Firebase Firestore|FunctionsでAPIサービスを迅速構築:テスト環境を即セットアップ
Firebase FirestoreとFunctionsを活用し、推播統計のテスト用APIサービスを即時構築。開発者が直面する複雑な連携問題を解決し、効率的な検証環境を実現します。
本記事は AI による翻訳をもとに作成されています。表現が不自然な箇所がありましたら、ぜひコメントでお知らせください。
記事一覧
Firebase Firestore + Functions を使ってテスト用の API サービスを素早く構築する
プッシュ通知の統計が Firebase Firestore + Functions に出会ったとき
Photo by Carlos Muza
はじめに
プッシュ通知の正確な統計機能
最近、APPに導入したい機能について、実装前はバックエンドからAPNS/FCMにデータを送信した成功可否をプッシュ通知の母数として扱い、クリックを記録して「クリック率」を計算していました。しかし、この方法は非常に不正確で、母数には多くの無効な端末や、APPが削除されていても(すぐには無効にならない場合がある)、プッシュ通知の権限をオフにしている端末も含まれており、バックエンドで送信成功の返答を得てしまいます。
iOS 10以降、Notification Service Extensionを実装することで、プッシュ通知のバナーが表示されるタイミングでこっそりAPIを呼び出して統計を取ることができます。利点は非常に正確で、ユーザーのプッシュ通知バナーが表示されたときだけAPIを呼び出します。アプリが削除されたり、通知がオフになっていたり、バナー表示が無効の場合は動作しません。バナーが表示されたことをプッシュ通知の母数とし、クリック数と組み合わせることで「正確なクリック率」を得ることができます。
詳細な原理および実装方法は、以前の記事「i OS ≥ 10 Notification Service Extension 応用 (Swift)」をご参照ください。
現在のテストでは、APPのロス率はおそらく0%です。実際の一般的な応用例としては、Lineのメッセージのピアツーピア暗号化(プッシュ通知のメッセージは暗号化されており、スマホで受信してから復号して表示されます)があります。
問題
APP 側の役割は実は大きくなく、iOS/Android は似たような機能を実装するだけです(ただし、Android は中国市場を考慮する場合、プッシュ通知フレームワークを別途実装する必要があり、やや複雑です)。より大きな負荷はバックエンドやサーバー側の処理にあります。プッシュ通知を一斉に送信すると同時に API 呼び出しで記録を返すため、サーバーの最大接続数が飽和する可能性があります。さらに RDBMS で記録を保存している場合は問題がより深刻になります。統計データのロスが発生する場合、多くはこの部分で起きています。
ここではログをファイルに書き込み、必要に応じて集計表示を行います。
また、後で考えたところ、一度に送信して同時に戻ってくる状況では、想像ほどの数にはならないかもしれません。なぜなら、プッシュ通知も一気に十万や百万件を送ることはなく、数件ずつバッチ送信するからです。バッチで送信して同時に戻ってくる数を耐えられれば十分です!
プロトタイプ
元の問題を考慮して、バックエンドは修正に時間がかかり、市場も成果にあまり関心がない可能性があります。そこで、まず使えるリソースでプロトタイプを作成して様子を見ようと思います。
ここでは、ほとんどのアプリが使用する Firebase サービスの中から、Functions と Firestore の機能を選択しています。
Firebase Functions
Functions は Google が提供するサーバーレスサービスで、プログラムのロジックを作成するだけで、Google が自動的にサーバーや実行環境を準備し、サーバーの拡張やトラフィックの管理を気にする必要がありません。
Firebase Functions は実際には Google Cloud Functions と同じですが、JavaScript (node.js) のみで作成できます。試したことはありませんが、Google Cloud Functions で他の言語を選んで Firebase サービスをインポートすれば、同様に使えると思います。
APIで使う場合は、node.jsファイルを書いて、実際のURL(例: my-project.cloudfunctions.net/getUser)を取得し、Request情報を取得して適切なResponseロジックを自分で作成できます。
以前に書いた Google Functions に関する記事「 Python+Google Cloud Platform+Line Bot を使って定期作業を自動化する 」
Firebase Functions は Blaze プラン(使用した分だけ支払う)を有効にする必要があります。
Firebase Firestore
Firebase Firestore は、NoSql データベースで、データの保存と管理に使用されます。
Firebase Functions と組み合わせることで、Request 時に Firestore を import してデータベースを操作し、Response をユーザーに返すことで、簡単な Restful API サービスを構築できます!
実践開始!
node.js 環境のインストール
こちらでは NVM、node.js のバージョン管理ツールを使ってインストールと管理を行うことをおすすめします(python の pyenv のように)。
NVM Github プロジェクトからインストール用シェルスクリプトをコピー:
1
curl -o- https://raw.githubusercontent.com/nvm-sh/nvm/v0.37.2/install.sh \\| bash
インストール中にエラーが発生した場合は、~/.bashrc または ~/.zshrc ファイルが存在するか確認してください。ファイルがない場合は、touch ~/.bashrc または touch ~/.zshrc でファイルを作成してから、再度インストールスクリプトを実行してください。
次に nvm install node を使って最新版の node.js をインストールできます。
npm のインストールが成功し、バージョンを確認するには、以下を実行してください:
npm --version
Firebase Functions のデプロイ
Firebase-tools のインストール:
1
npm install -g firebase-tools
インストールが成功したら、初回使用時に以下を入力してください:
1
firebase login
Firebase ログイン認証を完了する。
プロジェクトの起動:
1
firebase init
Firebase init のあるパスをメモしてください:
1
このディレクトリで Firebase プロジェクトを初期化しようとしています:
ここではインストールする Firebase CLI ツールを選択できます。「↑」「↓」キーで選択し、「スペースキー」で決定します。ここでは「Functions」のみ、または「Firestore」も含めて選択してインストールできます。
=== Functions セットアップ
言語選択「 JavaScript 」
「use ESLint to catch probable bugs and enforce style」に関する文法スタイルチェックは、はい / いいえ どちらでも可。
npmで依存関係をインストールしますか? はい
===エミュレーターのセットアップ
ローカル環境で Functions や Firestore の機能・設定をテストでき、使用量にカウントされず、デプロイして公開するまで待つ必要がありません。
個人の必要に応じてインストールしてください。私はインストールしましたが使っていません...小さな機能だけなので。
コーディング!
上記で記録したパスに移動し、functions フォルダを見つけて、その中の index.js ファイルをエディタで開いてください。
index.js:
1
2
3
4
5
6
7
8
9
10
11
12
const functions = require('firebase-functions');
const admin = require('firebase-admin');
admin.initializeApp();
exports.hello = functions.https.onRequest((req, res) => {
const targetID = req.query.targetID
const action = req.body.action
const name = req.body.name
res.send({"targetID": targetID, "action": action, "name": name});
return
})
上記の内容を貼り付けて、パスインターフェース /hello を定義しました。これにより、URLのクエリ ?targetID=、POSTの action、name パラメータ情報を返します。
修正&保存が完了したら、コンソールに戻ります:
1
firebase deploy
以降の変更は必ず
firebase deployコマンドを実行してから反映されます。
検証開始&Firebaseへのデプロイ…
少しお待ちください。Deploy complete! の後、最初のリクエスト&レスポンスのページが完成します!
この時点で Firebase → Functions ページに戻ることができます:
先ほど作成したインターフェースとURLの場所が表示されます。
以下のURLをコピーしてPostManでテストしてください:
POST Body は必ず
x-www-form-urlencodedを選択してください。
成功!
ログ
プログラム内で次のように使用できます:
1
functions.logger.log("ログ:", value);
ログ記録を行う。
Firebase -> Functions -> ログでログ結果を確認できます:
例の目標
記事の追加、編集、削除、取得、および「いいね」機能を持つAPIの作成
Restful API の機能設計を実現したいため、上記の純粋な Path 方式は使わず、Express フレームワークを利用して実装します。
POST 新しい記事を追加
index.js:
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
const functions = require('firebase-functions');
const admin = require('firebase-admin');
const express = require('express');
const cors = require('cors');
const app = express();
admin.initializeApp();
app.use(cors({ origin: true }));
// 挿入
app.post('/', async (req, res) => { // ここでの POST は HTTPメソッドのPOSTを指します
const title = req.body.title;
const content = req.body.content;
const author = req.body.author;
if (title == null \\|\\| content == null \\|\\| author == null) {
return res.status(400).send({"message":"パラメータエラー!"});
}
var post = {"title":title, "content":content, "author": author, "created_at": new Date()};
await admin.firestore().collection('posts').add(post);
res.status(201).send({"message":"追加成功!"});
});
exports.post= functions.https.onRequest(app); // ここでの POST は /post パスを指します
現在は Express を使ってネットワークリクエストを処理します。ここではまず、パス / の POST メソッドを追加します。最後の行は、すべてのパスが /post の下にあることを示しています。次に、更新・削除の API を追加します。
下 firebase deploy が成功したら、Post Man に戻ってテストします:
Post Man が成功した後、Firebase -> Firestore に移動してデータが正しく書き込まれているか確認できます:
PUT 記事の編集
index.js:
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
const functions = require('firebase-functions');
const admin = require('firebase-admin');
const express = require('express');
const cors = require('cors');
const app = express();
admin.initializeApp();
app.use(cors({ origin: true }));
// 更新
app.put("/:id", async (req, res) => {
const title = req.body.title;
const content = req.body.content;
const author = req.body.author;
const doc = await admin.firestore().collection('posts').doc(req.params.id).get();
if (!doc.exists) {
return res.status(404).send({"message":"記事が見つかりません!"});
} else if (title == null \\|\\| content == null \\|\\| author == null) {
return res.status(400).send({"message":"パラメーターエラー!"});
}
var post = {"title":title, "content":content, "author": author};
await admin.firestore().collection('posts').doc(req.params.id).update(post);
res.status(200).send({"message":"更新成功!"});
});
exports.post= functions.https.onRequest(app);
デプロイ&テスト方法は新規作成と同様で、Post ManのHTTPメソッドをPUTに変更することを忘れないでください。
DELETE 記事を削除する
index.js:
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
const functions = require('firebase-functions');
const admin = require('firebase-admin');
const express = require('express');
const cors = require('cors');
const app = express();
admin.initializeApp();
app.use(cors({ origin: true }));
// 削除
app.delete("/:id", async (req, res) => {
const doc = await admin.firestore().collection('posts').doc(req.params.id).get();
if (!doc.exists) {
return res.status(404).send({"message":"記事が見つかりません!"});
}
await admin.firestore().collection("posts").doc(req.params.id).delete();
res.status(200).send({"message":"記事が削除されました!"});
})
exports.post= functions.https.onRequest(app);
デプロイ&テスト方法は新規作成と同様で、Post ManのHTTPメソッドをDELETEに変更してください。
追加、変更、削除が終わったので、次は検索をしましょう!
SELECT で記事を取得する
index.js:
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
const functions = require('firebase-functions');
const admin = require('firebase-admin');
const express = require('express');
const cors = require('cors');
const app = express();
admin.initializeApp();
app.use(cors({ origin: true }));
// 一覧取得
app.get('/', async (req, res) => {
const posts = await admin.firestore().collection('posts').get();
var result = [];
posts.forEach(doc => {
let id = doc.id;
let data = doc.data();
result.push({"id":id, ...data})
});
res.status(200).send({"result":result});
});
// 詳細取得
app.get("/:id", async (req, res) => {
const doc = await admin.firestore().collection('posts').doc(req.params.id).get();
if (!doc.exists) {
return res.status(404).send({"message":"記事が見つかりません!"});
}
res.status(200).send({"result":{"id":doc.id, ...doc.data()}});
});
exports.post= functions.https.onRequest(app);
デプロイ&テスト方法は新規作成と同様で、PostmanのHTTPメソッドを GET に変更し、Body を none に戻してください。
InsertOrUpdate?
時々、値が存在する場合は更新し、存在しない場合は新規作成したいことがあります。その場合は set と merge: true を組み合わせて使います:
index.js:
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
const functions = require('firebase-functions');
const admin = require('firebase-admin');
const express = require('express');
const cors = require('cors');
const app = express();
admin.initializeApp();
app.use(cors({ origin: true }));
// 挿入または更新
app.post("/tag", async (req, res) => {
const name = req.body.name;
if (name == null) {
return res.status(400).send({"message":"パラメータエラー!"});
}
var tag = {"name":name};
await admin.firestore().collection('tags').doc(name).set({created_at: new Date()}, {merge: true});
res.status(201).send({"message":"追加成功!"});
});
exports.post= functions.https.onRequest(app);
ここではタグの追加を例に、デプロイ&テスト方法は追加と同様で、Firestoreが同じデータを繰り返し追加しないことが確認できます。
記事のいいねカウンター
もし記事データに「likeCount」フィールドが追加されて、いいね数を記録する場合、どうすればよいでしょうか?
index.js:
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
const functions = require('firebase-functions');
const admin = require('firebase-admin');
const express = require('express');
const cors = require('cors');
const app = express();
admin.initializeApp();
app.use(cors({ origin: true }));
// 投稿に「いいね」
app.post("/like/:id", async (req, res) => {
const doc = await admin.firestore().collection('posts').doc(req.params.id).get();
const increment = admin.firestore.FieldValue.increment(1)
if (!doc.exists) {
return res.status(404).send({"message":"記事が見つかりません!"});
}
await admin.firestore().collection('posts').doc(req.params.id).set({likeCount: increment}, {merge: true});
res.status(201).send({"message":"いいね成功!"});
});
exports.post= functions.https.onRequest(app);
increment という変数を使うと、値を取り出して +1 する操作を直接行えます。
大量トラフィックの記事いいねカウンター
Firestore には 書き込み速度制限 があるため:
1つのドキュメントには1秒に1回しか書き込めません。そのため、いいねをする人が増えると、同時リクエストで遅くなる可能性があります。
公式が提供する解決方法「 Distributed counters 」は特に高度な技術ではなく、複数の分散された likeCount フィールドを使って集計し、読み込み時に合計を計算するだけです。
index.js:
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
const functions = require('firebase-functions');
const admin = require('firebase-admin');
const express = require('express');
const cors = require('cors');
const app = express();
admin.initializeApp();
app.use(cors({ origin: true }));
// 分散カウンター いいねポスト
app.post("/like2/:id", async (req, res) => {
const doc = await admin.firestore().collection('posts').doc(req.params.id).get();
const increment = admin.firestore.FieldValue.increment(1)
if (!doc.exists) {
return res.status(404).send({"message":"記事が見つかりません!"});
}
//1~10
await admin.firestore().collection('posts').doc(req.params.id).collection("likeCounter").doc("likeCount_"+(Math.floor(Math.random()*10)+1).toString())
.set({count: increment}, {merge: true});
res.status(201).send({"message":"いいね成功!"});
});
exports.post= functions.https.onRequest(app);
以上はカウントを分散したフィールドで記録し、書き込みの遅延を防ぐ方法です。しかし、分散フィールドが多すぎると読み取りコスト($$)が増えますが、いいねのたびに新しいレコードを追加するよりは安価でしょう。
Siege ツールを使った負荷テスト
brew を使って siege をインストールする
1
brew install siege
p.s brew: command not found が表示された場合は、まず brew パッケージ管理ツールをインストールしてください :
1
/usr/bin/ruby -e "$(curl -fsSL https://raw.githubusercontent.com/Homebrew/install/master/install)"
インストールが完了したら、以下を実行してください:
1
siege -c 100 -r 1 -H 'Content-Type: application/json' 'https://us-central1-project.cloudfunctions.net/post/like/id POST {}'
負荷テストを実施する:
-c 100:100個のタスクを同時に実行-r 1:各タスクが1回リクエストを実行する-H ‘Content-Type: application/json’:POSTの場合に追加が必要です‘https://us-central1-project.cloudfunctions.net/post/like/id POST {}’:POSTのURL、Post Body(例:{“name”:”1234”})
実行完了後、実行結果が表示されます:
successful_transactions: 100 は 100 回すべて成功したことを示します。
Firebase -> Firestore で結果にデータ損失がないか確認できます:
成功しました!
完全なサンプルコード
index.js:
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
const functions = require('firebase-functions');
const admin = require('firebase-admin');
const express = require('express');
const cors = require('cors');
const app = express();
admin.initializeApp();
app.use(cors({ origin: true }));
// 挿入
app.post('/', async (req, res) => {
const title = req.body.title;
const content = req.body.content;
const author = req.body.author;
if (title == null \\|\\| content == null \\|\\| author == null) {
return res.status(400).send({"message":"パラメータエラー!"});
}
var post = {"title":title, "content":content, "author": author, "created_at": new Date()};
await admin.firestore().collection('posts').add(post);
res.status(201).send({"message":"追加成功!"});
});
// 更新
app.put("/:id", async (req, res) => {
const title = req.body.title;
const content = req.body.content;
const author = req.body.author;
const doc = await admin.firestore().collection('posts').doc(req.params.id).get();
if (!doc.exists) {
return res.status(404).send({"message":"記事が見つかりません!"});
} else if (title == null \\|\\| content == null \\|\\| author == null) {
return res.status(400).send({"message":"パラメータエラー!"});
}
var post = {"title":title, "content":content, "author": author};
await admin.firestore().collection('posts').doc(req.params.id).update(post);
res.status(200).send({"message":"更新成功!"});
});
// 削除
app.delete("/:id", async (req, res) => {
const doc = await admin.firestore().collection('posts').doc(req.params.id).get();
if (!doc.exists) {
return res.status(404).send({"message":"記事が見つかりません!"});
}
await admin.firestore().collection("posts").doc(req.params.id).delete();
res.status(200).send({"message":"記事削除成功!"});
});
// 一覧取得
app.get('/', async (req, res) => {
const posts = await admin.firestore().collection('posts').get();
var result = [];
posts.forEach(doc => {
let id = doc.id;
let data = doc.data();
result.push({"id":id, ...data})
});
res.status(200).send({"result":result});
});
// 単一取得
app.get("/:id", async (req, res) => {
const doc = await admin.firestore().collection('posts').doc(req.params.id).get();
if (!doc.exists) {
return res.status(404).send({"message":"記事が見つかりません!"});
}
res.status(200).send({"result":{"id":doc.id, ...doc.data()}});
});
// 挿入または更新
app.post("/tag", async (req, res) => {
const name = req.body.name;
if (name == null) {
return res.status(400).send({"message":"パラメータエラー!"});
}
var tag = {"name":name};
await admin.firestore().collection('tags').doc(name).set({created_at: new Date()}, {merge: true});
res.status(201).send({"message":"追加成功!"});
});
// いいね
app.post("/like/:id", async (req, res) => {
const doc = await admin.firestore().collection('posts').doc(req.params.id).get();
const increment = admin.firestore.FieldValue.increment(1)
if (!doc.exists) {
return res.status(404).send({"message":"記事が見つかりません!"});
}
await admin.firestore().collection('posts').doc(req.params.id).set({likeCount: increment}, {merge: true});
res.status(201).send({"message":"いいね成功!"});
});
// 分散カウンターいいね
app.post("/like2/:id", async (req, res) => {
const doc = await admin.firestore().collection('posts').doc(req.params.id).get();
const increment = admin.firestore.FieldValue.increment(1)
if (!doc.exists) {
return res.status(404).send({"message":"記事が見つかりません!"});
}
//1~10
await admin.firestore().collection('posts').doc(req.params.id).collection("likeCounter").doc("likeCount_"+(Math.floor(Math.random()*10)+1).toString())
.set({count: increment}, {merge: true});
res.status(201).send({"message":"いいね成功!"});
});
exports.post= functions.https.onRequest(app);
本題に戻って、プッシュ通知の統計
最初にやりたかったこと、プッシュ通知の統計機能に戻りましょう。
index.js:
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
const functions = require('firebase-functions');
const admin = require('firebase-admin');
const express = require('express');
const cors = require('cors');
const app = express();
admin.initializeApp();
app.use(cors({ origin: true }));
const vaildPlatformTypes = ["iOS","Android"]
const vaildActionTypes = ["clicked","received"]
// ログを挿入
app.post('/', async (req, res) => {
const increment = admin.firestore.FieldValue.increment(1);
const platformType = req.body.platformType;
const pushID = req.body.pushID;
const actionType = req.body.actionType;
if (!vaildPlatformTypes.includes(platformType) \\|\\| pushID == undefined \\|\\| !vaildActionTypes.includes(actionType)) {
return res.status(400).send({"message":"參數錯誤!"});
} else {
await admin.firestore().collection(platformType).doc(actionType+"_"+pushID).collection("shards").doc((Math.floor(Math.random()*10)+1).toString())
.set({count: increment}, {merge: true})
res.status(201).send({"message":"紀錄成功!"});
}
});
// ログを表示
app.get('/:type/:id', async (req, res) => {
// received
const receivedDocs = await admin.firestore().collection(req.params.type).doc("received_"+req.params.id).collection("shards").get();
var received = 0;
receivedDocs.forEach(doc => {
received += doc.data().count;
});
// clicked
const clickedDocs = await admin.firestore().collection(req.params.type).doc("clicked_"+req.params.id).collection("shards").get();
var clicked = 0;
clickedDocs.forEach(doc => {
clicked += doc.data().count;
});
res.status(200).send({"received":received,"clicked":clicked});
});
exports.notification = functions.https.onRequest(app);
プッシュ通知記録の追加
プッシュ通知の統計数値を確認する
1
https://us-centra1-xxx.cloudfunctions.net/notification/iOS/1
また、プッシュ通知の数を統計するインターフェースも作成しました。
トラブルシューティング
node.js の使い方に慣れていなかったため、最初にデータを追加する際に
awaitを付け忘れ、さらに書き込み速度制限もあり、大量トラフィック時にデータロスが発生しました…
価格設定
Firebase Functions と Firestore の料金プランも忘れずに参照してください。
Functions
運算時間
ネットワーク
Cloud Functions は計算時間リソースに対して永久無料プランを提供しており、GB/秒および GHz/秒の計算時間が含まれます。200万回の呼び出しに加えて、無料プランでは 400,000 GB/秒 と 200,000 GHz/秒 の計算時間、さらに毎月 5 GB のインターネットアウトバウンドトラフィックも提供されます。
Firestore
価格は予告なく変更されることがありますので、公式サイトの最新情報をご確認ください。
結論
タイトルにあるように「テスト用」「テスト用」「テスト用」として、上記のサービスを本番環境や製品のコアとして使用することはあまり推奨しません。
料金が高く、移行が難しい
以前、ある大きなサービスがFirebaseを使って立ち上げられたと聞きましたが、後にデータやトラフィックが増えて料金が非常に高くなりました。移行も難しく、コードは問題ありませんがデータの移行は非常に困難でした。初期に少しコストを節約しただけで、後に大きな損失を招く結果となり、あまり価値がなかったと言えます。
テスト用のみ
以上の理由から、Firebase Functions + Firestore を使って構築した API サービスは、個人的にはテストやプロトタイプの製品デモにのみ使用することをお勧めします。
もっと機能
Functions は Authentication(認証)や Storage(ファイルアップロード)とも連携できますが、この部分は調査していません。
参考資料
https://firebase.google.com/docs/firestore/query-data/queries
https://firebase.google.com/docs/firestore/solutions/counters#node.js_1
関連記事
ご質問やご意見がございましたら、こちらからご連絡ください 。
Post は Medium から ZMediumToMarkdown によって変換されました。
本記事は Medium にて初公開されました(こちらからオリジナル版を確認)。ZMediumToMarkdown による自動変換・同期技術を使用しています。



























