2021年12月28日火曜日
C# List内の要素同士で一致するものがあるか確認方法。
プログラム実装時に、配列やList同士で一致するものがあるかどうかチェックする、というような場合が多々あると思います。
今回はC#でこのチェックをさっときれいに書く方法がないか調べてみて、良さそうな書き方を見つけたので、ご参考程度にちょっと共有したいと思います。
まず単純に、2つのList内に同一のものがあるかどうかチェックするする場合、下記のようなFindAllメソッド等を使えば簡単に確認ができます。
string[] fruitsAry1 = new string[] { "Apple", "Banana", "Orange", "Grape" }; string[] fruitsAry2 = new string[] { "Apple", "Grape", "Grapefruit", "Strawberry" }; List<string> list1 = new List<string>(fruitsAry1); List<string> list2 = new List<string>(fruitsAry2); List<string> fruitsList = list1.FindAll(list2.Contains);結果上記のfruitsListは{ "Apple", "Grape" }になります。
単純なList同士であれば上記のような方法でチェックできますが、例えばListに複数要素が含まれている場合で、ある要素だけ、一致するかどうかをチェックしたい場合は、ちょっと工夫が必要になります。
例えば
■Fruits.cs
namespace Fruits { public class FruitsData { public string id { get; set; } public string name { get; set; } } }
■CheckFruits.cs
List<FruitsData> fruitsList1 = new List<FruitsData>() { new FruitsData {Id = "1", Name = "Apple" }, new FruitsData {Id = "2", Name = "Banana" } new FruitsData {Id = "3", Name = "Orange" } new FruitsData {Id = "4", Name = "Grape" } }; List<FruitsData> fruitsList2 = new List<FruitsData>() { new FruitsData {Id = "1", Name = "Apple" }, new FruitsData {Id = "2", Name = "Grape" } new FruitsData {Id = "3", Name = "Grapefruit" } new FruitsData {Id = "4", Name = "Strawberry" } };上記のような場合でNameが一致するものがあるか、チェックする場合、素直にやると以下のような感じでしょうか
bool isSame = false; foreach(var fruits1 in fruitsList1) { if(isSame) { break; } foreach (var fruits2 in fruitsList2) { if(fruits2.Name.Equals(fruits1.Name)) { isSame = true; break; } } }2つのリストを回して一致するものがあったら抜けるという、すごい単純なものですが、一致するものがあるかどうかのチェックだけのためにこれを書くのはちょっと長いですよね。
もうちょっとだけ簡略化すると、Existsメソッドを使えばこのようにも書けると思います。
bool isSame = false; foreach(var fruits1 in fruitsList1) { if(fruitsList2.Exists(fruitsItem2 => fruitsItem2.Name.Equals(fruits1.Name))) { isSame = true; break; } }結構すっきりしましたが、fruitsList1をとりあえず回さないといけないので、ここまできたらループさせずにいけそうな気がします。
で、上記を踏まえて、さらにFindメソッドを使って、最終的にはこんな感じに簡略化できました。
var machedFruits = fruitsList1.Find(fruitsItem1 => fruitsList2.Exists(fruitsItem2 => fruitsItem2.Name.Equals(fruitsItem1.Name)) );FindメソッドとExistsメソッドを使ってループを使わずに一致する要素の確認ができました。
ちなみにこのFindメソッドの返り値は、全体の中で最もインデックス番号の小さい要素が返ってきます。
一致するものが無い場合はnullが返ってきますので、単純に一致するかどうかのチェックとしては、nullが返ってこなかったら一致しているということになります。
この他にも、Linqを使ったり等いろいろと方法があると思いますが(もっと簡略化もできそう)、方法の1つとしてご参考いただければと思います。
【Laravel】条件によって、RateLimitterの回数制限のカウント処理を分ける方法。
オフィス狛 技術部のmmm(むー)です。
LaravelでAPIのリクエストをある回数以上行うと、「Too Many Attempts」のエラーが発生して一定時間経過するまでAPIを実行できなくなります。
このエラー文言の表示条件は、RateLimiterにて設定されています。
条件によって、この制限回数のカウント方法を変更する修正がしたかったのですが、対応方法がなかなか見つけられず時間がかかったため記事に残します。
・ユーザー情報を取得できない場合:1時間に10回連続でAPIをリクエストすると、「Too Many Attempts」のエラーを発生させる
$ php artisan --version
Laravel Framework 8.27.0
対処方法
早速ですが、下記のように記載すると実現できました。RateLimiter::for('login', function (Request $request) {
return $request->user()
? Limit::perMinute(5)->by($request->user()->id) // ユーザー情報を取得できた場合のリクエスト回数制限
: Limit::perHour(10)->by($request->email.$request->ip()); // ユーザー情報を取得できない場合のリクエスト回数制限
}
・制限回数のチェックをする際は、by()でどの単位でカウントするかを指定します。
・ユーザー情報を取得できた場合:ユーザーID単位($request->user()->id)でカウント
・ユーザー情報を取得できない場合:リクエストのメールアドレス+IPアドレス($request->email.$request->ip())でカウント
・1分間での回数制限を設定する場合は、perMinute()を使用します。
・1時間での回数制限を設定する場合は、perHour()を使用します。
参考:https://laravel.com/docs/8.x/routing#segmenting-rate-limits (公式)
$ php artisan cache:clear
またRateLimiterの修正が反映されているか、レスポンスヘッダーから確認することができます。
・X-RateLimit-Limit:全何回リクエストできるか
・X-RateLimit-Remaining:あと何回リクエストできるか
・Retry-After:何秒後にロックが解除されるか
以上となります、参考になりましたら幸いです。
2021年12月27日月曜日
【Python】loggingでログをカスタマイズする【Flask】
狛ログをご覧のみなさん、はじめまして。
2021年にオフィス狛に入社いたしました、nago(なご)です。
技術に関するブログ記事を書くのは初めてですが、有益な情報を投稿できるように頑張ります。
よろしくおねがいします☺
さて、今回はPython(Flask)で、お試し開発をしていた時に苦戦したログ出力についてまとめたいと思います。
作ろうとしていたのは、外部のREST APIにリクエストを投げて、何かしらの結果を受け取り、画面に表示させる機能です。
まず、先輩社員が投稿してくださっていたこちらの記事を参考にして、AWS API GatewayでAPIのMockを作成しました。
正しい合言葉をidに設定してリクエストすると、ステータスコードが200となり、とってもタメになる情報を受け取れるAPIにしました。
合言葉を間違えていたらステータスコードが401となりエラーが返ってきます。
アプリケーションは以下のような構成で作成しました。
api-test/
├ static/
│ └ styles.css
├ templates/
│ └ api_test.html
├ views/
│ ├ __init__.py
│ └ api_test.py
├ __init__.py
├ app.py
├ get-pip.py
├ Pipfile
└ Pipfile.lock
コードはこちら。
・api_test.html
<!doctype html>
<html lang="ja">
<head>
<meta charset="utf-8">
<title>APIテスト</title>
<link rel="stylesheet" href="{{ url_for('static', filename='css/styles.css') }}">
</head>
<body>
<form action="{{ url_for('api_test.call') }}" method="POST">
<input type="text" name="id">
<input type="submit" value="API CALL">
</form>
<input type="button" onclick="location.href='{{ url_for('api_test.show_api_test') }}'" value="RESET">
<p>{{ message }}</p>
</body>
・views/app.py
from flask import Flask, redirect, url_for
from views.api_test import api_test
app = Flask(__name__)
app.register_blueprint(api_test)
# 最初の画面へリダイレクト
@app.route("/")
def redirect_to_api_test():
return redirect(url_for("api_test.show_api_test"))
if __name__ == "__main__":
app.run(host="0.0.0.0", port=8080)
・api_test.py
import requests, json
from flask import Blueprint
from flask import render_template, request
api_test = Blueprint("api_test", __name__)
# 画面表示(初期 & リセット時)
@api_test.route("/")
def show_api_test():
message = ""
return render_template(
"api_test.html",
message=message
)
# APIリクエスト & レスポンス受け取り & 画面表示
@api_test.route("/call", methods=["POST"])
def call():
# APIGatewayでMock作成時に設定されたURL
url = "https://xxxx.amazonaws.com/api-test/my/info"
# クエリ文字列を作成
payload = { "id": request.form.get("id") }
# リクエストヘッダを作成
headers = { "Content-Type": "application/json" }
# リクエスト & レスポンス受け取り
response = requests.post(url, data=json.dumps(payload), headers=headers)
# レスポンスからメッセージを取り出す
message = response.json()["message"]
# 画面遷移 & メッセージ表示
return render_template(
"api_test.html",
message=message
)
これで、APIから返ってきたメッセージをそのまま画面に表示することができました。
次に、APIがコールされるたびに、そのリクエスト内容と結果のログを出力する仕組みを作ろうと思います。
Pythonでログを出力するには、Pythonで用意されているloggingというモジュールを使います。
今回は設定ファイルを既存のファイルとは別に作成して、ログの設定をしてみます。
私はプロジェクトルートに「config」という名前のディレクトリを作り、その中にログの設定ファイルを作りました。
また、ログファイルを出力したかったので、ログファイルを置くための「log」ディレクトリも作成しました。
以下が作成したディレクトリと設定ファイルです。
api-test/
├ config/
│ ├ __init__.py
│ └ logging_config.py
├ log/
├ static/
│ └ styles.css
├ templates/
│ └ api_test.html
├ views/
│ ├ __init__.py
│ └ api_test.py
├ __init__.py
├ app.py
├ get-pip.py
├ Pipfile
└ Pipfile.lock
※赤字が追加分・config/logging_config.py
from logging import DEBUG, INFO, config
import datetime
# ファイル名に入れる日付フォーマット
today = datetime.date.today().strftime("%Y-%m-%d");
config.dictConfig(
{
"version": 1,
# ログフォーマット設定
"formatters": {
"formatter": {
"format": "%(levelname)s %(asctime)s [%(module)s] %(message)s"
}
},
# ハンドラー設定
"handlers": {
# 標準出力ハンドラー
"streamHandler": {
"class": "logging.StreamHandler",
"formatter": "formatter",
"level": INFO
},
# ファイル出力ハンドラー
"fileHandler": {
"class": "logging.FileHandler",
"formatter": "formatter",
"level": INFO,
# ログファイルを出力したい場所のパスとファイル名を指定
"filename":f"xxxx/api-test/log/{today}_api-test.log",
"encoding": "utf-8"
}
},
# ロガー設定
"loggers": {
"basicLogger": {
"handlers": [
"streamHandler",
"fileHandler"
],
"level": INFO,
"propagate": 0
}
}
}
)
ログの設定は、logging.config.dictConfigに辞書(Dictionary型の設定値)を渡すか、
logging.config.fileConfigにconfigparser形式ファイルを渡すかで設定できますが、
私は今回はdictConfigで設定してみました。
ログを出力するには、まずLogger(ロガー)の取得が必要なのですが、そこにHandler(ハンドラー)を指定することで出力形式の指定をプラスできるようになっています。
また、出力フォーマットも設定したい場合は、ハンドラーに対してFormatter(フォーマッター)を指定します。
ロガー、ハンドラー、フォーマッターの名前は自由につけることが出来ます。
私はロガーは「basicLogger」を作り、そこに標準出力ハンドラーの「streamHandler」と、ファイル出力の「fileHandler」をくっつけました。フォーマッターは「formatter」にしました(そのまんま)
"varsion" : 1 ってなんぞやと思い調べてみたのですが、辞書スキーマのバージョンのことでした。
現在は1しかないので、1を入れておけばいいそうです。こちらはdictConfigに辞書を渡す場合は必須のため、入れないと怒られます。
さて、ようやくapi_test.pyで設定ファイルを読み込んで、ログ出力してみます。
・views/api_test.py
import requests, json
from logging import getLogger
from flask import Blueprint
from flask import render_template, request
from config import logging_config
api_test = Blueprint("api_test", __name__)
# ログ設定ファイル読込
logging_config
# ロガー取得
logger = getLogger("basicLogger")
# 画面初期表示
@api_test.route("/")
def show_api_test():
message = ""
return render_template(
"api_test.html",
message=message
)
# APIリクエスト & レスポンス受け取り & 画面表示
@api_test.route("/call", methods=["POST"])
def call():
# APIGatewayでMock作成時に設定されたURL
url = "https://xxxx.amazonaws.com/api-test/my/info"
# クエリ文字列を作成
payload = { "id": request.form.get("id") }
# リクエストヘッダを作成
headers = { "Content-Type": "application/json" }
# リクエストログ出力
logger.info("request : url = " + url + ", payload = " + str(payload))
# リクエスト & レスポンス受け取り
response = requests.post(url, data=json.dumps(payload), headers=headers)
# レスポンスからステータスコードとメッセージを取り出す
status_code = response.status_code
message = response.json()["message"]
# レスポンスログ出力
if status_code == 200:
logger.info("response : status_code = " + str(status_code) + ", message = " + message)
else:
logger.error("response : status_code = " + str(status_code) + ", message = " + message)
# 画面遷移 & メッセージ表示
return render_template(
"api_test.html",
message=message
)
※赤字は追加分・log/2021-12-15_api-test.log (2021/12/15にログが作成された場合のファイル名)
INFO 2021-12-15 14:23:53,701 [api_test] request : url = https://xxxx.amazonaws.com/api-test/my/info, payload = {'id': 'sushi'}
INFO 2021-12-15 14:23:53,778 [api_test] response : status_code = 200, message = お寿司はおいしい。猫はかわいい。ポメラニアンもかわいい。
INFO 2021-12-15 14:24:46,184 [api_test] request : url = https://xxxx.amazonaws.com/api-test/my/info, payload = {'id': ''}
ERROR 2021-12-15 14:24:46,254 [api_test] response : status_code = 401, message = 合言葉が違うようです・・・
正常パターンとエラーパターンを実行してみました。フォーマッターで設定した「%(levelname)s %(asctime)s [%(module)s] %(message)s」の通りに表示されています。
出力時にapi_test.pyで設定したメッセージが「%(message)s」に代入されている形です。
調べてみると、loggingは思ったより奥が深くびっくりしました。
覚えておくと出したいログを見やすく出すことができて便利だと思います。
今回はこれでおしまいです。
ここまでお読みいただきありがとうございました。
参考リファレンス:
・Logging HOWTO
・logging.config --- ロギングの環境設定
Angular・ngx-scannerでインカメラが使われるのを制御する方法。
こんにちは、オフィス狛 技術部のpinoです。
Angularで、バーコードやQRコード読み取りに使用するライブラリに、『ngx-scanner』があります。
テンプレートに<zxing-scanner></zxing-scanner>
を埋め込むだけで、読み取り用のカメラを自動で起動してくれる便利なライブラリです。
当社のプロジェクトでも採用しているのですが、先日、通常なら背面のカメラが起動するところで、iOS12でのみインカメラが起動する事象が発生しました。
今回は、その時のトラブルシューティングとして、インカメラが使われるのを制御する方法について紹介します。
バージョン情報
- Angular:12.0.3
- ngx-scanner:3.0.1
なぜインカメラが起動する?
まず、インカメラが起動してしまう理由について見ていきます。
カメラが起動するまでの流れは、node_modules/@zxing/ngx-scanner/__ivy_ngcc__/fesm2015/zxing-ngx-scanner.js
で確認できます。
① ngAfterViewInit()
/**
* Executed after the view initialization.
*/
ngAfterViewInit() {
// makes torch availability information available to user
this.getCodeReader().isTorchAvailable.subscribe(x => this.torchCompatible.emit(x));
if (!this.autostart) {
console.warn('New feature \'autostart\' disabled, be careful. Permissions and devices recovery has to be run manually.');
// does the necessary configuration without autostarting
this.initAutostartOff();
return;
}
// configurates the component and starts the scanner
this.initAutostartOn();
}
this.autostart
はtrue固定なのでif (!this.autostart)
の分岐に入りません。
this.initAutostartOn()
に続きます。
② initAutostartOn()
/**
* Initializes the component and starts the scanner.
* Permissions are asked to accomplish that.
*/
initAutostartOn() {
return __awaiter(this, void 0, void 0, function* () {
this.isAutostarting = true;
let hasPermission;
try {
// Asks for permission before enumerating devices so it can get all the device's info
hasPermission = yield this.askForPermission();
}
catch (e) {
console.error('Exception occurred while asking for permission:', e);
return;
}
// from this point, things gonna need permissions
if (hasPermission) {
const devices = yield this.updateVideoInputDevices();
this.autostartScanner([...devices]);
}
});
}
カメラアクセスの権限周りをチェックしています。
許可された状態だと、最終的にthis.autostartScanner([...devices])
が実行されます。
③ autostartScanner()
/**
* Starts the scanner with the back camera otherwise take the last
* available device.
*/
autostartScanner(devices) {
const matcher = ({ label }) => /back|trás|rear|traseira|environment|ambiente/gi.test(label);
// select the rear camera by default, otherwise take the last camera.
const device = devices.find(matcher) || devices.pop();
if (!device) {
throw new Error('Impossible to autostart, no input devices available.');
}
this.device = device;
// @note when listening to this change, callback code will sometimes run before the previous line.
this.deviceChange.emit(device);
this.isAutostarting = false;
this.autostarted.next();
}
まず、カメラのlabel
プロパティにback|trás|rear|traseira|environment|ambiente
が含まれるかを見ていますね。
(調べてみたところ、ポルトガル語やスペイン語などで「後方」「環境」といった意味の単語が含まれているようです。)
devices
の中で上記に当てはまる最初のカメラか、当てはまるものがない場合はdevices
の最後尾のカメラが起動される仕組みになっています。
ここまでで、カメラが起動するまでの流れを追うことができました。
次は、iOS12と、比較用に背面カメラが起動するiOS15のカメラ情報を、デモページを使って確認してみます。
デモページでカメラアクセスを許可すると、使用できるカメラのリストが表示されます。
iOS12とiOS15で試した結果、以下のようになりました。
『〇〇カメラ』となっているのがlabel
プロパティに当たり、並び順がそのままdevices
の要素の並びになっています。
どちらのOSも、label
にback|trás|rear|traseira|environment|ambiente
は含まれていません。
その場合、配列の最後尾のカメラが起動されるわけですが、iOS15は背面カメラなのに対し、iOS12は前面側カメラが最後尾になっています。
iOS12でのみインカメラが起動するのは、ここに原因があったんですね。
デモのコードは、公式のGithubか下記からも確認できます。
ちなみに、iPhoneの設定から『iPhoneの使用言語』をEnglishに変更したところ、
-
前面カメラ ->
Front Camera
-
背面カメラ ->
Back Camera
という風に文言が変わりました。
label
は使用言語に基づくようなので、使用言語を変更することで回避できる、ということになりますが、あまり現実的ではなさそうです...。
インカメラが使われるのを避けるには?
自動起動されるカメラを変更するのは、先ほど確認したような処理の流れ自体を変更しないといけないので、難しいです。
ただ、デモページで確認できるように、カメラの切り替えは可能になっています。
だとしたら、自動起動が終わった後に、背面カメラを起動するよう設定し直すのはどうでしょうか?
ということで、今度は「自動起動が完了したことを検知できるか」を確認してみます。
ngx-scannerでは、いくつかのイベントが用意されています。
今回使用しているVer.3.0.1では、以下のイベントが定義されていました。
-
autostarted
-
autostarting
-
torchCompatible
-
scanSuccess
-
scanFailure
-
scanError
-
scanComplete
-
camerasFound
-
camerasNotFound
-
permissionResponse
-
hasDevices
-
deviceChange
名前からなんとなくどんなイベントか予想がつくかと思います。
例えば、「使用できるカメラが存在していない場合」と「読み取りに成功した場合」にイベントハンドラを設けたい時は、以下のようにします。
テンプレート側
<zxing-scanner
(camerasNotFound)="onCamerasNotFound()"
(scanSuccess)="onScanSuccess($event)">
</zxing-scanner>
ロジック側
onCamerasNotFound(): void {
console.log('cameras not found.')
}
onScanSuccess(result: string): void {
console.log('scan success:', result)
}
ここで、カメラが起動するまでの流れの最後の処理(autostartScanner()
)をもう一度見てみます。
/**
* Starts the scanner with the back camera otherwise take the last
* available device.
*/
autostartScanner(devices) {
const matcher = ({ label }) => /back|trás|rear|traseira|environment|ambiente/gi.test(label);
// select the rear camera by default, otherwise take the last camera.
const device = devices.find(matcher) || devices.pop();
if (!device) {
throw new Error('Impossible to autostart, no input devices available.');
}
this.device = device;
// @note when listening to this change, callback code will sometimes run before the previous line.
this.deviceChange.emit(device);
this.isAutostarting = false;
this.autostarted.next(); // <- ここ!
}
最後に、autostarted
イベントが発火していますね。
これを、コンポーネント側で検知するようにしてあげればよさそうです。
テンプレート側
<zxing-scanner
[(device)]="currentDevice"
(camerasFound)="onCamerasFound($event)"
(autostarted)="onAutoStarted()">
</zxing-scanner>
ロジック側
availableDevices: MediaDeviceInfo[];
currentDevice: MediaDeviceInfo = null;
...
onCamerasFound(devices: MediaDeviceInfo[]): void {
// 使用できるカメラを取得
this.availableDevices = devices;
}
onAutoStarted(result: string): void {
const userAgent = navigator.userAgent.toLowerCase();
let iosVersionInfo = userAgent.match(/iphone os ([\d]+)_([\d]+)_([\d]+)/);
if (!iosVersionInfo) {
iosVersionInfo = userAgent.match(/iphone os ([\d]+)_([\d]+)/);
}
const iosVersion = iosVersionInfo?.[1];
if (iosVersion === '12') {
// iOS12の場合は、配列の最初のカメラを使用する
this.currentDevice = this.availableDevices?.[0];
}
}
実際に、iOS12の場合は背面カメラを起動するよう設定し直すサンプルにしてみました。
まず、camerasFound
で使用できるカメラの情報が渡ってきますので、変数に控えます。
テンプレート側にある[(device)]
プロパティに使用したいカメラを設定してあげるとカメラが切り替えられるので、UserAgentからiOS12と判断できる場合は配列の最初のカメラを設定するようにしました。
iOS12ではない場合はライブラリ側で自動起動したカメラが設定されますので、ピンポイントな修正が可能です。
最新版(Ver.3.2.0)では、this.autostarted.next()
はinitAutostartOn()
内に処理が移っていました。
紹介した処理の流れは大きく変わっていないと思うので、お使いのバージョンと比較してみてください。
以上が、今回のトラブルシューティングの内容でした。
どなたかの参考になれば嬉しいです🦓
iOS15版のSafariから目立つようになったステータスバーの背景色を指定したい。
こんにちは、オフィス狛 デザイン部のSatoです。
iOS15版のSafariで以前私がHTMLなどを作成したWebシステムを確認したところ、ステータスバーの色が変わっていることに気づきました。
自動的に選ばれた色とヘッダーの色味が少し違う色でしたので、逆に浮いて見えて困っていました。
ステータスバーをWebシステムのメインカラーの色に合わせられたら違和感を感じないのにと考え、色を制御する方法を探していたところ、色を指定できるようなので忘れないよう備忘録も兼ねてブログに書いてみようと思います。
ステータスバーの背景色を指定する方法、それは「theme-color」というmetaタグを書くだけです。
headタグの中に下記を記述することで色を変えることができます。
<meta name="theme-color" content="任意の色">
任意の色の部分にカラーコードを指定するだけで、ステータスバーやアドレスバーの背景色が変わります。
<meta name="theme-color" content="#dcdcdc">をheadタグの中に記述しました。
「theme-color」を指定した場合も、明度の高いペールカラーのような色を指定した場合にはダークモードでは色が変わらないこともあるようですが、それはダークモードの仕様上仕方ないかと思います。
余談ですが、iOS15版のSafariから画面下部にアドレスバーが表示される関係だと思うのですが、要素が少ないページの背景色がアドレスバーの下には回り込まないで下に「theme-color」で指定した色が表示されてしまったりします。
上記のような状態になった場合は、全体を囲んでいるタグにheight: 100vh;を指定すると背景が下まで表示されるようになりました。(余白が空いてしまった例のCSS指定は height: 100%; にしていました)
簡単なことなのですが、少しつまづいてしまったので、似たような現象に悩まれた方は試してみてはいかがでしょうか?
2022/11/01日 height:100vhを設定するよりも綺麗に背景色設定する方法ができた為、新しくブログ記事を書きました!(この方法だと、余分なスクロールができません)
iOS15版のSafariはレイアウト含めさまざまな変更点があり、技術者はもちろん普通に使っているユーザーも覚えることが多くて大変ですね……。
今後もiOS版Safariの仕様変更には気をつけていきたいです。
2021年4月30日金曜日
【AWS】API Gatewayで超手軽にMockを作成する。
オフィス狛 技術部のyuckieeeです。
Androidアプリの開発中に動作確認をしようとしたとき、データ取得先のAPIが未完成だったり、特定のレスポンスやエラー(メンテナンス画面にするなど)を返すように制御するのが難しい...
など、もやもやすることがありました。
で、、それならシミュレータとかMockとか呼ばれてるものを自分で用意しちゃえばいいじゃん!?
と、思い立ち、AWSのAPI Gatewayが提供する機能を使用して作成してみました。
これが意外と使える子だったので、今回は一番シンプルに使える「統合Mock」というサービスの使い方を紹介します!
Step1:API GatewayにAPIを登録する
何はともあれ、APIの外枠を作らないことには始まりません。
AWSの管理コンソールからAPI Gateway画面を開き[API作成]をクリックして、APIを作成していきます!
最初に、作成するAPIタイプの選択画面が表示されますので"REST API"の[構築]を選択してください。
この値はアクセスURLに反映はされませんが、変更が出来ないようなので、ご注意ください。 これでAPIの新規作成は完了です。
Step2:リソースとメソッドを作成する
APIの作成が完了するとリソース管理画面に遷移します。この時点では、下図のとおり何も設定されていませんので、ここから設定を追加していきます。 作成開始前にイメージ共有しておいた方が分かりやすいと思うので、まずはMock化したいAPIの概要を決めておきます。
今回は以下のAPIレスポンスを代替する統合Mockを作ろうと思います。
GET / https://www.yuckieee.com/book/search
上記APIの下線部がAWS Gatewayが自動発行するパスで置き換わる部分です。
そのため、book以降のパス部分を作成対象として進めていきます。
まずは[アクション]メニューから[リソースの作成]を選択し、上記に沿ってリソースを定義します。
ここで"リソース"と言っているのがアクセスするURLのパスとなります。
リソースは、ディレクトリ 構造での管理となっており、最上位を
/(ルート)
リソースと呼びます。このルートリソースに子リソースを追加していく形でリソースを作成していきます。
今回は
①
/(ルート)
リソースにbook
子リソースを追加②
book
リソースにsearch
子リソースを追加の順でリソースを作成します。
リソースの作成後、レスポンス返却させたいリソース(=今回は
search
リソース)を選択し、[アクション]メニューから[メソッドの作成]を選択します。[メソッドの作成]クリック後、下図のように作成したいHTTPメソッドが選択可能になるため、今回は"GET"を選択し、右側の確定チェックボタンをクリックします。 確定チェッククリック後、メソッドのセットアップ画面が表示されます。
今回はAWSの統合Mock機能を使用するため、統合タイプから"Mock"を選択し[保存]をクリックします。
これでリソースとメソッドの作成は完了です。
Step3:マッピングテーブルを定義する
統合Mockが作成されると、下図のような定義画面が表示されます。それぞれ、私視点での概要を軽く説明すると下記のとおりです。
タイプ | 説明 |
---|---|
メソッド リクエスト |
実際に受信したAPIリクエストに対して、入力値チェックやAPIキー指定などの設定が可能です。 |
統合 リクエスト |
統合Mockエンドポイントに引き渡す値の設定を行います。 統合Mockの場合、ここで設定された値が統合レスポンスに引き渡されます。 |
統合 レスポンス |
統合Mockエンドポイント(統合リクエスト)から受け取った値を元にレスポンスを定義します。ここでHTTPステータスやヘッダ、レスポンスBodyなどを設定します。 |
メソッド レスポンス |
統合Mockの場合、レスポンスに必要なHTTPステータスをここで設定しておく必要があるようです。 |
色々触ってみながら試していただくのが良いですが、 最もシンプルに使いたい場合は、統合レスポンスのマッピングテーブルを作成するだけ出来ちゃいます!
ということで、[統合レスポンス]を開きます。
統合レスポンスでは、統合リクエストから受信するHTTPステータスごとにマッピングテンプレートの設定が可能です。 新規作成段階では、統合レスポンスがデフォルトで1つ用意されており、統合リクエストで指定されたHTTPステータスに対応する統合レスポンスが存在しない場合、 この統合レスポンスのマッピングテンプレートがレスポンスされます。
※ただし、API Gateway自体がエラーとなった場合を除きます。
今回マッピングテンプレートに指定したい内容は以下のとおりです。
[
{
"id": "1001",
"title": "オフィス狛ものがたり",
"Author": "yuckieee"
},
{
"id": "1002",
"title": "狛ちゃん冒険譚",
"Author": "yuckieee"
}
]
デフォルトの統合レスポンスを順番に展開し、マッピングテンプレートの
Content-Type:application/json
にレスポンスしたい内容を入力します。入力完了後、右下の[保存]をクリックして保存します。
👉 統合リクエストと統合レスポンスのマッピングテンプレートの記述方法は
「おまけ:リクエストによってレスポンス内容を切り替える」の章を参照してください
マッピングテンプレートの設定は以上で完了です。
Step4:動作確認(テスト)を行う
これまでの設定が問題ないか、デプロイまでにテストできる機能がありますので、これを使って動作確認してみます。 リソースの管理画面左にある[テスト⚡️]をクリックしてください。 メソッドのテスト画面が表示されますので、画面中央下部の[⚡️テスト]クリックで、先ほど定義した内容でAPIが呼び出されます。 [⚡️テスト]クリック後、結果が画面右側に表示されます。この内容が実際にMockにアクセスした際に返却される値と同様の内容となっていますので、この段階でレスポンスに問題がないかを確認します。
もし、
Internal Error
などが返却されている場合は、設定をもう一度見直すか、ログ領域を参照しエラー内容を確認してください。
Step5:デプロイ(外部公開)する
テストで動作に問題がないことを確認したあとは、実際にデプロイしてみます。[アクション]メニューの[APIのデプロイ]をクリックしてください。
APIのデプロイダイアログが表示されますので、デプロイするステージを新規作成します。
デプロイステージ名は、URLにも組み込まれますので分かりやすい名前にしておきましょう。
今回は
dev
としておきます。問題なくデプロイが完了すると、新規作成したステージのエディター画面に遷移します。 遷移先の画面に呼び出し用のURLが記載されていますので、このURLを使用してMockにアクセスが可能になります。
Step6:実行確認する
最後にPOSTMANを使用してリクエストが可能かを確認してみます。POSTMANを起動し、該当のURLを指定して実行してみてください。下図のようにマッピングテンプレートに定義した値が想定通り返却されればOKです!
※APIクライアントツールが入っていない場合は、GETであればブラウザ直接指定でも確認可能です!
デプロイが出来ていない場合、下図のように
Missing Authentication Token
エラーが返ってきますので再度デプロイしてみてください。
これでMockとして使用できるようになりました!あとは参照元のAndroidアプリ側URLをMockに切り替えてLet's テスト〜♪♬♩
おまけ:リクエストによってレスポンス内容を切り替える
テストをしていると、HTTPステータスやレスポンスBODYの値をリクエストによって切り替えたくなりますよね。私もそうでした!!!!(最終的にはLambdaを使用しましたが)簡単な切り替えであれば、統合Mock機能でも可能です。幾つか切り替え方法をご紹介しますので参考にしてみてください。
💡 マッピングテンプレート記述言語
マッピングテンプレートではApache Velocity Template Language (VTL)という言語が採用されているようです。 この言語仕様に沿って変数の定義やIF文、FOR文などの記述が可能です。今回は、これらを使ってリクエストによるレスポンス内容の切り替えを行っていきたいと思います。
なお、ここでは言語自体の説明を省きますので、詳細は公式リファレンスを参照ください。
パターン1:HTTPステータスコードを切り替える
① 統合リクエストのマッピングテンプレート編集HTTPステータスを切り替えたい場合は、統合リクエストのマッピングテンプレートを編集します。
デフォルトでは
{statusCode: 200}
が指定されていますが、リクエスト値によって切り替えたい場合は以下の要領で設定します。👉 GETメソッド(クエリ文字使用)の場合
クエリ文字に"id"というパラメータを指定していたと仮定して記述しています。
例:
/book/search?id=1001
#set($id = $input.params("id"))
#if($id == "1001")
{
"statusCode": 200
}
#elseif($id == "1002")
{
"statusCode": 400
}
#else
{
"statusCode": 500
}
#end
👉 POSTメソッド(リクエストBODY使用)の場合
リクエストBODYにidというパラメータを指定していたと仮定して記述しています。
例:
{ "id": "1001" }
#set($id = $util.parseJson($input.body).id)
#if( $id == "1001")
{
"statusCode": 200
}
#elseif($id == "1002")
{
"statusCode": 400
}
#else
{
"statusCode": 500
}
#end
② メソッドレスポンスの追加
レスポンスのHTTPステータスにバリエーションを持たせたい場合、最初にメソッドレスポンスを追加する必要があります。メソッドレスポンス画面を開き[➕ レスポンスの追加]をクリックし、追加したいHTTPステータスコードを入力し、確定してください。
今回は400と500を追加してみました。
③ 統合レスポンスの追加
続いて、HTTPステータスごとの統合レスポンスを作成します。
デフォルトをHTTPステータス200で使用することにして、新しくHTTPステータス400と500用の統合レスポンスを作成します。統合レスポンス画面を開き[➕ 統合レスポンスの追加]をクリックして下記の要領で統合レスポンスを追加してください。
HTTPステータスの正規表現:
統合リクエストから引き渡されるStatusCodeのうち、対象とする値を指定します。
この値は正規表現での指定も可能なため、
4\d{2}
と書くことでステータスコード400番台全てを対象とすることも出来ます。メソッドレスポンスのステータス:
②で作成したメソッドレスポンスを指定します。
※先にメソッドレスポンスを作成したのは、ここで指定させるためでした。
コンテンツの処理:
デフォルト値であるパススルーのままで!
この段階で一度[保存]をして開き直すと、マッピングテンプレートの追加が可能になりますので、適当なレスポンス内容を設定してみてください。
その後、HTTPステータス500についても同様に設定します。
全ての設定が完了したあとに、再度STEP4~5で紹介した手順を実施いただけば完了です...!
【補足】動作確認時のクエリ文字/リクエストBODYの指定について
リソースに指定されたHTTPメソッドがGETなどクエリ文字指定可能なものである場合、メソッドテスト画面にクエリ文字の入力エリアが表示されます。
同様にPOSTなど、リクエストBODY指定可能なメソッドの場合は、リクエスト本文の入力エリアが表示されます。(注:リクエスト本文は、GETメソッドの場合は非表示となります)
パターン2:レスポンスBODYを切り替える
HTTPステータスは200のままで、リクエスト内容によってレスポンス内容を変えたい!!!というパターンのご紹介です。
なお、クエリ文字を使用する場合は、パターン1のHTTPステータスでご紹介した
$input.params("id")
が、統合レスポンスでも使用できるため説明を省略します。① 統合リクエストのマッピングテンプレート編集
この手順はリクエストBODYを統合レスポンスで使用したいときだけ使用します。
👉 POSTメソッド(リクエストBODY使用)の場合
デフォルトでは、リクエストBODYは統合レスポンスに渡されません。そのため、統合リクエストで明示的にオーバーライドしてあげる必要があります。
なお、
statusCode
は必ず統合レスポンスに送る必要があるため、削除しないようご注意ください。
#set($context.requestOverride.path.body = $input.body)
{
"statusCode": 200
}
② 統合レスポンスのマッピングテンプレート編集
👉 POSTメソッド(リクエストBODY使用)の場合
リクエストBODYにidというパラメータを指定していたと仮定して記述しています。
例:
{ "id": "1001" }
#set($body = $context.requestOverride.path.body)
#set($id = $util.parseJson($body).id)
#if($id == "1001")
{
"id": "1001",
"title": "オフィス狛ものがたり",
"Author": "yuckieee"
}
#elseif($id == "1002")
{
"id": "1002",
"title": "狛ちゃん冒険譚",
"Author": "yuckieee"
}
#else
{
"message":"該当の書籍情報が存在しません"
}
#end
なお、この辺りの話はAWS公式のデベロッパーガイドに記述されていますので、こちらも参考にしていただければと思います。
AWS公式:API Gateway マッピングテンプレートとアクセスのログ記録の変数リファレンス
おまけ:APIGatewayの料金
今回使用したREST APIでは、受信したAPIコールと、転送データ量に対してのみ料金が発生します。
APIコール料金:
2021/4/13時点で、月間のリクエスト数が3億3,300万件まで100万リクエストにつき4.25USD(アジアパシフィック(東京))のようです。
※なお、キャッシュを利用する場合は、更に料金がかかりますが、テスト・開発で使用する分には必要のない機能かと思います。
データ転送料金:
2021/4/13時点で、10TBまで1GBあたり0.09USDのようです。
なお、対象はAPIGatewayから、インターネット方向のデータ(レスポンスデータ)のみが対象となります。
どちらもリクエスト数、転送データ量が多い場合は、レンジによって割引されます。
ただ、ある程度使い倒したとしても、テスト利用の範囲であれば数百円程度でしょうか。
Mockやシミュレータってどうすりゃいいのか...と、小1時間頭を抱えている工数分で解決できるなら、試してみるのも良さげです!
AWS公式:Amazon API Gateway 料金
まとめ
APIGatewayの統合Mock機能で手軽にMockやシミュレータが作成できるのが分かりました!次回は、APIGatewayをLambdaやS3と連携して一歩進んだMock作成をご紹介できればと思います!
2021年4月26日月曜日
express-requests-loggerでリクエストログの特定の項目をマスクする方法。
オフィス狛 技術部のJoeです。
担当しているプロジェクトで、Node.js と Express でAPIを作成して、
リクエストのログを出力しているのですが、
セキュリティの観点から、一部リクエストの値をマスクする必要がありました。
express-requests-loggerで、マスクする方法をまとめました。
【バージョン情報】
・node:v12.14.0
・express-requests-logger:v3.0.3
■express-requests-logger のインストール
express-requests-loggerをインストールします。npm install express-requests-logger
■ログ出力設定
app.jsにexpress-requests-loggerの設定を追加します。※loggerで指定している「httpLogger」ついては、こちらの記事を参考に設定いただければと思います
Node.js + Express + log4jsで、アクセスログとリクエストログを取る。
const audit = require('express-requests-logger'); // ログ設定 app.use(audit({ logger: httpLogger, request: { maskBody: ['password'], }, response: { maskBody: ['token'], }, }));
上記のように設定しますと、リクエストとレスポンスの内容がログに出力されますが、
リクエストBodyの「password」と、レスポンスBodyの「token」の値が「XXXXX」にマスクされます。
【ログ出力結果】
[2021-04-16T11:04:56.407] [INFO] http - { response: { status_code: 200, (中略) body: '{"token":"XXXXX"}' }, request: { method: 'POST', (中略) body: '{"loginId":"test","password":"XXXXX"}' }, 'millis-timestamp': 1618538696407, 'utc-timestamp': '2021-04-16T02:04:56.407Z', stage: 'end' } Inbound Transaction
他にも、項目自体を除外するオプションなどもありますので、一部ご紹介します。
詳細は「express-requests-logger」のホームページをご覧ください。
express-request-logger
【オプション】
■doubleAudit
・true:リクエストを受けたタイミングで出力(リクエスト)、
レスポンスを送信したタイミングで出力(リクエストとレスポンス)します。
・false:レスポンスを送信したタイミングで出力(リクエストとレスポンス)します。
■excludeURLs
配列で指定した文字列が、リクエストURLの一部と一致する場合は、リクエストもレスポンスも出力しません。
【オプション(リクエスト)】
■audit
・true:リクエストを出力します。
・false:リクエストは出力しません。
■excludeBody
配列で指定した文字列と一致する項目は、bodyに出力しません。
※「*」を指定すると、bodyの全ての項目を出力しません。
■maskQuery
配列で指定した文字列と一致する項目は、queryにマスクして出力します。
■excludeHeaders
配列で指定した文字列と一致する項目は、headersに出力しません。
※「*」を指定すると、headersの全ての項目を出力しません。
■maskHeaders
配列で指定した文字列と一致する項目は、headersにマスクして出力します。
■maxBodyLength
bodyに出力される最大文字数を指定できます。
【オプション(レスポンス)】
レスポンスもリクエストと同様のオプションで、
Body、Headersの項目について、マスクや除外することができます。
少ない設定でマスクすることができますので、機会があればお試しください。