【AWS】SAA [SAA-C03] 合格体験記 2023/01

この記事は個人ブログと同じ内容です


はじめに

2024年1月30日にAWS実務未経験で3ヶ月でAWS SAAに合格することができましたので、 その合格体験記を書きたいと思います。

私の背景

私のエンジニア歴は3年程度で、普段はWeb開発のフロントエンドとバックエンドをやっています。 ですので、AWSの実務経験はありません。 AWSは昔に、チュートリアルなどをみながらEC2やECSを使用してサーバーを建てた経験があるくらいです。

試験の傾向

オンプレからAWS移行するサービスの組み合わせや、コストを最適化するための問題が多かった印象です。 各サービスやタイプの特徴を理解して、使い分けできるような理解が必要と思います。

例えば、ストレージの種類(EBS,EFS,S3)、、Storage Gatewayのタイプ、EC2のインスタンスの種類などなど

勉強方法

1ヶ月目

Kindleで以下の 「AWS認定資格 ソリューションアーキテクトアソシエイトの教科書: 合格へ導く虎の巻」 を読んでインプットの学習をしました。こちらはなんと無料で読むことが可能です。

アウトプット中心の学習をした方がいいので、あまり時間をかけ過ぎない方がいいです。 さらに、情報が古いので重要なところだけをなんとなく理解する程度でいいと思います。

https://www.amazon.co.jp/AWS%E8%AA%8D%E5%AE%9A%E8%B3%87%E6%A0%BC-%E3%82%BD%E3%83%AA%E3%83%A5%E3%83%BC%E3%82%B7%E3%83%A7%E3%83%B3%E3%82%A2%E3%83%BC%E3%82%AD%E3%83%86%E3%82%AF%E3%83%88%E3%82%A2%E3%82%BD%E3%82%B7%E3%82%A8%E3%82%A4%E3%83%88%E3%81%AE%E6%95%99%E7%A7%91%E6%9B%B8-%E5%90%88%E6%A0%BC%E3%81%B8%E5%B0%8E%E3%81%8F%E8%99%8E%E3%81%AE%E5%B7%BB-CloudTech%E6%9B%B8%E7%B1%8D%E4%BD%9C%E6%88%90%E5%A7%94%E5%93%A1%E4%BC%9A-ebook/dp/B0BCPNZ9GJ

2ヶ月目

Ping-tという、無料のSAAの問題集を使用し、基礎的な問題を解いていきました。 Ping-tは、問題が充実しており、かつ復習などがやりやすいサービスになっているのでとても使用しやすかったです。

私自身は全ての問題を解くとこまでは終えることができたのですが、復習は時間がなくてできませんでした。 Ping-tも同様に情報が古いので、Ping-tでのサービス名と最新のAWSのサービス名が違うところが一部ありました。 ですので、最新の情報が載っているUdemyと併用して学習するのをお勧めします。

https://mondai.ping-t.com/g

3ヶ月目

Udemyの 「【SAA-C03版】AWS 認定ソリューションアーキテクト アソシエイト模擬試験問題集(6回分390問)」 を使用し学習しました。 本番と同様程度のレベルで、よく試験に出る問題を実際のテストと同じように受けられる教材になります。

私は時間がなく6回分あるうちの、4回分しかできませんでした。 どのテストも1回目は5割程度しか正解していませんでしたが、復習して2回目解く時には、4回分全てが720点を超えていました。 ほぼこのUdemyしか頭に入っていない状態で本番のテストに向かいました。

通常で買うと7600円とかなり高いので、セールで2000円程度で買うことをお勧めします。

https://www.udemy.com/course/aws-knan/

試験当日

試験自体は、リモート受験をしました。 30分前に入室したのですが、MACの操作権限やらのトラブルがあり、机のチェックなどがやり直しになり、試験時間が少し過ぎてからのスタートになってしまいました。 私が受けたのが平日だったので、試験監督の待ち時間がほぼありませんでしたが、土日にリモートで受験する方は注意してください。 また、試験監督はフィリピン人なので日本語が通じない時があります。 トラブルはあったが問題なく試験は終えられました。

結果

735点でギリギリの合格でした。 試験時間が足りず、見直しがほぼできておらず、自信はなくて確実に試験に落ちたと思っていたので、とても嬉しかったです。

反省・やっておくべきだったこと

復習に力を入れる

AWS SAAの学習は基本アウトプットを中心の学習にした方がいいのですが、やって終わりだと身につかないので、復讐をしましょう。 私は、Ping-tは問題を全て解きましたが復習はできなかったので、Udemyを解いた時に知識が身についていないと感じました。 試験本番はUdemyで解いたような問題も出ましたが、Ping-tで解いたような問題も出たのでPing-tはやっておいて損がないです。

ダラダラやらない

1ヶ月目と2ヶ月目は、ほぼ毎日学習はしていたものの、ダラけていて進捗がかなり遅かったので、目標を立ててしっかり逆算して計画を立てた方がいいです。 私は、試験の前の週だけ異常にに頑張ったので、皆さんは焦らないように常に一定の高い生産性を出せるようにした方がいいです。

本番と同じ環境でテストする

試験時間を測って本番と同じ環境でテストをした方がいいです。 試験本番の時に、時間をあまり気にせずに解いていたら、見直す時間がほぼなくて全くできませんでした。 Udemyのテストの時はいつもかなり時間が余っていたのですが、本番だと問題が違い、普段より時間がかかるので気をつけた方がいいです。

感想

何はともあれ1月中にAWS SAAに合格することができてとても嬉しかった。 このブログ記事が、AWS SAAを目指す方々の参考になれば幸いです。


現在 back check 開発チームでは一緒に働く仲間を募集中です。 herp.careers

瞬時に、柔軟に、そして簡単に。Amazon Athena で行うログ分析

この記事は個人ブログと同じ内容です

www.ritolab.com


ログの分析をどのような手段で実施するか

ログの多くはテキストファイルであることが多く、DB に格納されていないことがほとんどです。

しかも、ログファイルは大抵、複数の環境から吐き出されたり、量や時間で分割して出力されたりするため、複数のファイルにまたがっていることがほとんど。

そんな環境下でも、ログ分析の環境を簡単に構築し、柔軟に解析していけるのが、Amazon Athena です。

AWS Athena

AWS Athena は、Amazon S3 に保存されたデータを標準的な SQL を使って直接分析できるインタラクティブなクエリサービスです。サーバーの管理や設定は不要で、数秒でクエリを実行し、結果を取得できます。

aws.amazon.com

使いやすい上に瞬時にログ分析の環境を用意できる。

最初に結論からお伝えしておくと、ログ分析に Athena を用いるのはとてもオススメです。

特に調査や障害など、急にスポットでログ分析が発生した時などは瞬時に分析環境を構築できるため、初動が非常に早くなります。

前提とデータの用意

今回は、ログの分析を行ってみます。

以下のフォーマットを持つ、nginx ライクなログを 1000 行、擬似的に作成しました。APIアクセスログを模しています。

192.168.0.247 - - [2024-02-18 14:56:55] "POST /api/v1/tasks/24361 HTTP/1.1" 201 7101 "-" "Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/99.0.9876.54321 Safari/537.36"
192.168.0.142 - - [2024-02-18 14:56:56] "GET /api/v1/messages/20050 HTTP/1.1" 200 2942 "-" "Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/97.0.4692.99 Safari/537.36"
.
.
.

この access.log を、 S3 のバケットに配置してあります。

データベース作成

まずは、Athena でデータベースを作成します。初期は default になっています。データベースを作成せずに default のまま使うこともできます。

エディターにデータベース作成のクエリを入力し、実行ボタンを押下します。

CREATE DATABASE log_analysis;

実行が成功すると、左メニューのデータベースのセレクトボックスから、log_analysis を選択できるようになります。

テーブル作成

作成したデータベース log_analysis にテーブルを作成していきます。

作成テーブルは、最初に用意して S3 に配置したログのテーブルです。

左メニュー「テーブルとビュー」に「作成」というセレクトボックスがあります。そこから、「S3バケットデータ」を選択します。

「S3 バケットデータからテーブルを作成」という画面に遷移します。ここで必要な入力を行い、テーブルを作成します。

テーブルの詳細セクションでは、テーブル名とその説明を入力します。データベース選択セクションでは、先程作成した log_analysis を選択します。

データセットセクションでは、ログファイルが設定されている S3 のバケットディレクトリを指定します。

データ形式セクションでは、ファイル(今回でいうとログファイル)のパースに必要な情報を指定します。

今回、ログは正規表現でパースするため、このような指定になっています。

  • テーブルタイプ
  • ファイル形式
  • SerDe ライブラリ
  • SerDe プロパティ - オプション
    • 名前: input.regex
    • 値: ^(\\S+) - - \\[([^\\[]+)\\] "(\\S+) (\\S+) (\\S+)" (\\S+) (\\S+) "(\\S+)" "(.*)"

列の詳細セクションでは、作成するテーブルのカラムを定義します。

  • ip: string,
  • time: string,
  • method: string,
  • uri: string,
  • protocol: string,
  • status: int,
  • bytes_sent: int,
  • referer: string,
  • user_agent: string

注意点は、データ形式セクションでログのパースを正規表現で記載しましたが、正規表現によってキャプチャ(抽出)する数と、ここで指定するカラムの数は同じである必要があります。

このセクション以降はオプションなので今回は設定しません。最下部にある「テーブルを作成」ボタンを押下すると、テーブルが作成されます。

-- DDL
CREATE EXTERNAL TABLE IF NOT EXISTS `mydatabase`.`nginx_logs` (
  `ip` string, 
  `time` string,
  `method` string,
  `uri` string,
  `protocol` string,
  `status` int,
  `bytes_sent` string,
  `referer` string,
  `user_agent` string
)
ROW FORMAT SERDE 'org.apache.hadoop.hive.serde2.RegexSerDe'
WITH SERDEPROPERTIES (
  'input.regex' = '^(\\S+) - - \\[([^\\[]+)\\] "(\\S+) (\\S+) (\\S+)" (\\S+) (\\S+) "(\\S+)" "(.*)"'
)
STORED AS INPUTFORMAT 'org.apache.hadoop.mapred.TextInputFormat' OUTPUTFORMAT 'org.apache.hadoop.hive.ql.io.HiveIgnoreKeyTextOutputFormat'
LOCATION 's3://sample-logs/logs/'
;

クエリを実行しログデータを取得する

テーブルが作成されたので、実際にクエリを実行してログのデータを取得してみます。

SELECT 
*
FROM nginx_logs
;

取得できました。SQL を記述してログのデータが取得できるのはとても利便性が高いですね。

ip time method uri protocol status bytes_sent referer user_agent
192.168.0.247 2024-02-18 14:56:55 POST /api/v1/tasks/24361 HTTP/1.1 201 7101 - Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/99.0.9876.54321 Safari/537.36
192.168.0.142 2024-02-18 14:56:56 GET /api/v1/messages/20050 HTTP/1.1 200 2942 - Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/97.0.4692.99 Safari/537.36
192.168.0.17 2024-02-18 14:56:57 GET /api/v1/users/29621 HTTP/1.1 401 6058 - Mozilla/5.0 (Macintosh; Intel Mac OS X 1101) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/98.0.1234.5678 Safari/537.36
192.168.0.243 2024-02-18 14:56:58 PUT /api/v1/orders/49212 HTTP/1.1 204 6035 - Mozilla/5.0 (Macintosh; Intel Mac OS X 1152) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/99.0.9876.54321 Safari/537.36
192.168.0.176 2024-02-18 14:56:59 POST /api/v1/products/15518 HTTP/1.1 201 8669 - Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/98.0.1234.5678 Safari/537.36
192.168.0.144 2024-02-18 14:57:00 GET /api/v1/users/26990 HTTP/1.1 200 9565 - Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/99.0.9876.54321 Safari/537.36
192.168.0.70 2024-02-18 14:57:01 POST /api/v1/users/30991 HTTP/1.1 201 7781 - Mozilla/5.0 (Macintosh; Intel Mac OS X 1152) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/99.0.9876.54321 Safari/537.36
192.168.0.201 2024-02-18 14:57:02 PUT /api/v1/tasks/7064 HTTP/1.1 204 7816 - Mozilla/5.0 (Macintosh; Intel Mac OS X 1101) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/98.0.1234.5678 Safari/537.36
192.168.0.166 2024-02-18 14:57:03 GET /api/v1/users/12541 HTTP/1.1 200 6082 - Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/99.0.9876.54321 Safari/537.36
192.168.0.233 2024-02-18 14:57:04 DELETE /api/v1/products/1588 HTTP/1.1 204 5112 - Mozilla/5.0 (Macintosh; Intel Mac OS X 1152) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/99.0.9876.54321 Safari/537.36
192.168.0.47 2024-02-18 14:57:05 PUT /api/v1/orders/8586 HTTP/1.1 204 7961 - Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/97.0.4692.99 Safari/537.36

ログデータの分析

SQL でログデータを分析していけるということで、簡単な分析を行ってみます。

エンドポイントの成功率・エラー率

各エンドポイントの成功率やエラー率を算出してみます。

WITH log_with_endpoint_added AS (
    SELECT 
        method,
        regexp_extract(uri, '^/api/v1/\w+') as endpoint,
        status
    FROM nginx_logs
)
, log_with_call_count_added AS (
    SELECT
        method, 
        endpoint, 
        status,
        status / 100 as status_class,
        COUNT(endpoint) as call_count
    FROM log_with_endpoint_added
    GROUP BY method, endpoint, status
)
, number_results_per_endpoint AS (
    SELECT
        endpoint, 
        method, 
        SUM(call_count) as all_count,
        SUM(CASE WHEN status_class = 2 THEN call_count ELSE 0 END) as succeeded_count,
        SUM(CASE WHEN status_class = 4 THEN call_count ELSE 0 END) as client_error_count,
        SUM(CASE WHEN status_class = 5 THEN call_count ELSE 0 END) as server_error_count
    FROM log_with_call_count_added
    GROUP BY endpoint, method
)
SELECT 
    endpoint,
    method,
    all_count as count,
    CONCAT(CAST(ROUND((CAST(succeeded_count as DOUBLE) / all_count) * 100, 2) as VARCHAR), '%') as succeeded_rate,
    CONCAT(CAST(ROUND((CAST(client_error_count as DOUBLE) / all_count) * 100, 2) as VARCHAR), '%') as client_error_rate,
    CONCAT(CAST(ROUND((CAST(server_error_count as DOUBLE) / all_count) * 100, 2) as VARCHAR), '%') as server_error_rate
FROM number_results_per_endpoint
ORDER BY server_error_rate desc
;

endpoint method count succeeded_rate client_error_rate server_error_rate
/api/v1/users DELETE 15 73.33% 20.00% 6.67%
/api/v1/products DELETE 22 81.82% 13.64% 4.55%
/api/v1/tasks GET 96 84.38% 11.46% 4.17%
/api/v1/orders GET 88 85.23% 11.36% 3.41%
/api/v1/products POST 59 74.58% 22.03% 3.39%
/api/v1/users POST 59 83.05% 13.56% 3.39%
/api/v1/messages GET 95 88.42% 8.42% 3.16%
/api/v1/users PUT 32 87.50% 9.38% 3.13%
/api/v1/products PUT 40 80.00% 17.50% 2.50%
/api/v1/products GET 88 79.55% 18.18% 2.27%
/api/v1/users GET 91 78.02% 19.78% 2.20%
/api/v1/messages POST 49 83.67% 14.29% 2.04%
/api/v1/tasks POST 59 88.14% 10.17% 1.69%
/api/v1/messages PUT 29 82.76% 17.24% 0.00%
/api/v1/messages DELETE 17 100.00% 0.00% 0.00%
/api/v1/orders DELETE 18 94.44% 5.56% 0.00%
/api/v1/tasks DELETE 8 87.50% 12.50% 0.00%
/api/v1/tasks PUT 39 92.31% 7.69% 0.00%
/api/v1/orders PUT 40 82.50% 17.50% 0.00%
/api/v1/orders POST 56 89.29% 10.71% 0.00%

SQL でクエリを書けるので、集計などもこうして柔軟に分析を行っていけます。

複数リソースを用いた分析

Athena を使えば、ログファイルを柔軟に分析できることがわかりました。次に、ログファイルだけではなく、別のリソースを掛け合わせて使ってみます。

ログファイル × CSV ファイル

先ほどのログファイルのデータを使って、このアプリケーション API において使われていないエンドポイントを抽出してみます。

新たに、エンドポイントの一覧を収録した CSV ファイルを用意しました。

endpoints.csv

/api/v1/products
/api/v1/orders
/api/v1/users
/api/v1/messages
/api/v1/tasks
/api/v1/bookmarks
/api/v1/friends

表というよりは、エンドポイントの一覧が書き連ねてあるだけの CSV ファイルです。

これを S3 に配置します。

この CSV に対応するテーブルを Athena に作成します。(nginx_logs テーブルを作成したのと同じ要領で、CSV をパースする設定にてテーブルを作成します。)

-- DDL
CREATE EXTERNAL TABLE IF NOT EXISTS `mydatabase`.`endpoints` (`endpoint` string)
ROW FORMAT SERDE 'org.apache.hadoop.hive.serde2.lazy.LazySimpleSerDe'
WITH SERDEPROPERTIES ('field.delim' = ',')
STORED AS INPUTFORMAT 'org.apache.hadoop.mapred.TextInputFormat' OUTPUTFORMAT 'org.apache.hadoop.hive.ql.io.HiveIgnoreKeyTextOutputFormat'
LOCATION 's3://sample-logs/documents/'
TBLPROPERTIES ('classification' = 'csv');

作成した endpoints テーブルを SELECT してみます。

あとは、先程の nginx_logs テーブルのデータにこの endpoints テーブルのデータをぶつければ、使用されていないエンドポイントが抽出できます。

WITH log_with_endpoint_added AS (
    SELECT 
        method,
        regexp_extract(uri, '^/api/v1/\w+') as endpoint,
        status
    FROM nginx_logs
)
, endpoint_used AS (
    SELECT 
        DISTINCT endpoint
    FROM log_with_endpoint_added
)

SELECT 
*
FROM endpoints
WHERE not exists(
    SELECT * FROM endpoint_used WHERE endpoints.endpoint=endpoint_used.endpoint
)
;

こんな風に、様々な形式のデータを取り込み、分析できる柔軟性を Athena は持ち合わせています。

瞬時に、柔軟に、そして簡単に。

AWS Athena は、標準的な SQL を使用してデータにアクセスできるため、SQL のスキルを持つ多くのユーザーにとって親しみやすい環境です。今回は取り上げませんでしたが、notebook の実行も可能なため、SQL の範囲を超えるような分析も行える点も良いです。

また、データのフォーマットに対する柔軟性も高く、JSONCSV、Parquet、ORC などのさまざまな形式のデータに対応しています。これにより、さまざまなデータソースからのデータを統合して分析することができます。

個人的には、急なログ分析の必要が生じた際に瞬時に分析環境を構築できる点は、非常に心強く、利便性が高いと感じています。

ログなどの分析を行う際は、Amazon Athena を試してみてください。


現在 back check 開発チームでは一緒に働く仲間を募集中です。 herp.careers

テーブル結合は JOIN と FROM どちらで行うべきか。両者の違いと注意点

この記事は個人ブログと同じ内容です

www.ritolab.com


JOIN での結合式と、FROM での結合式

テーブル結合といえば JOIN 句が一般的ですが、FROM 句に複数テーブルを指定することでもテーブルを結合できます。例えば、以下 2 つのクエリは全く同じ結果を返します。

SELECT
  *
FROM books
INNER JOIN author_book ON books.id = author_book.book_id
INNER JOIN authors ON author_book.author_id = authors.id
;
SELECT
  *
FROM
  books,
  author_book,
  authors
WHERE books.id = author_book.book_id
  AND author_book.author_id = authors.id
;

リレーショナルデータベースにおいて上記のようなテーブル結合式は、製品に関わらず広く実装されていますが、例えばこれらは、MySQLオープンソース化された ver 3.23(2001年)の時点で、両者とも存在する伝統的な結合式です。

では、両者の違いはどこにあるのでしょうか?

(以下、クエリの実行は MySQL 8.0 です)

FROM 句での結合は用途が限定的

FROM 句に複数テーブルを指定して結合する場合、前提はデカルト積になります。つまり、クロス結合です。

SELECT id, name FROM tbl_a;
+----+--------------+
| id | name         |
+----+--------------+
|  1 | tbl a name 1 |
|  2 | tbl a name 2 |
|  3 | tbl a name 3 |
|  4 | tbl a name 4 |
|  5 | tbl a name 5 |
+----+--------------+

SELECT id, tbl_a_id, name FROM tbl_c;
+----+----------+--------------+
| id | tbl_a_id | name         |
+----+----------+--------------+
|  1 |        1 | tbl c name 1 |
|  2 |        1 | tbl c name 2 |
|  3 |        1 | tbl c name 3 |
|  4 |        1 | tbl c name 4 |
|  5 |        1 | tbl c name 5 |
+----+----------+--------------+

-- FROM 句でのテーブル結合
SELECT 
   tbl_a.id, 
   tbl_a.name,
   tbl_c.tbl_a_id,
   tbl_c.id, 
   tbl_c.name 
FROM 
   tbl_a, 
   tbl_c 
ORDER BY tbl_a.id, tbl_c.id;
+----+--------------+----------+----+--------------+
| id | name         | tbl_a_id | id | name         |
+----+--------------+----------+----+--------------+
|  1 | tbl a name 1 |        1 |  1 | tbl c name 1 |
|  1 | tbl a name 1 |        1 |  2 | tbl c name 2 |
|  1 | tbl a name 1 |        1 |  3 | tbl c name 3 |
|  1 | tbl a name 1 |        1 |  4 | tbl c name 4 |
|  1 | tbl a name 1 |        1 |  5 | tbl c name 5 |
|  2 | tbl a name 2 |        1 |  1 | tbl c name 1 |
|  2 | tbl a name 2 |        1 |  2 | tbl c name 2 |
|  2 | tbl a name 2 |        1 |  3 | tbl c name 3 |
|  2 | tbl a name 2 |        1 |  4 | tbl c name 4 |
|  2 | tbl a name 2 |        1 |  5 | tbl c name 5 |
|  3 | tbl a name 3 |        1 |  1 | tbl c name 1 |
|  3 | tbl a name 3 |        1 |  2 | tbl c name 2 |
|  3 | tbl a name 3 |        1 |  3 | tbl c name 3 |
|  3 | tbl a name 3 |        1 |  4 | tbl c name 4 |
|  3 | tbl a name 3 |        1 |  5 | tbl c name 5 |
|  4 | tbl a name 4 |        1 |  1 | tbl c name 1 |
|  4 | tbl a name 4 |        1 |  2 | tbl c name 2 |
|  4 | tbl a name 4 |        1 |  3 | tbl c name 3 |
|  4 | tbl a name 4 |        1 |  4 | tbl c name 4 |
|  4 | tbl a name 4 |        1 |  5 | tbl c name 5 |
|  5 | tbl a name 5 |        1 |  1 | tbl c name 1 |
|  5 | tbl a name 5 |        1 |  2 | tbl c name 2 |
|  5 | tbl a name 5 |        1 |  3 | tbl c name 3 |
|  5 | tbl a name 5 |        1 |  4 | tbl c name 4 |
|  5 | tbl a name 5 |        1 |  5 | tbl c name 5 |
+----+--------------+----------+----+--------------+

ここから更に、WHERE 句を指定することで、内部結合の状態を作り出します。

SELECT 
   tbl_a.id, 
   tbl_a.name,
   tbl_c.tbl_a_id,
   tbl_c.id, 
   tbl_c.name 
FROM 
   tbl_a, 
   tbl_c 
WHERE tbl_a.id = tbl_c.tbl_a_id
ORDER BY tbl_a.id, tbl_c.id;
+----+--------------+----------+----+--------------+
| id | name         | tbl_a_id | id | name         |
+----+--------------+----------+----+--------------+
|  1 | tbl a name 1 |        1 |  1 | tbl c name 1 |
|  1 | tbl a name 1 |        1 |  2 | tbl c name 2 |
|  1 | tbl a name 1 |        1 |  3 | tbl c name 3 |
|  1 | tbl a name 1 |        1 |  4 | tbl c name 4 |
|  1 | tbl a name 1 |        1 |  5 | tbl c name 5 |
+----+--------------+----------+----+--------------+

このように、FROM 句に複数テーブルを指定してテーブルを結合させるのは、クロス結合、ないし内部結合が前提となります。外部結合はできません。

JOIN 句は結合職人

JOIN 句を用いたテーブル結合は、内部結合、外部結合、そしてクロス結合と柔軟に結合式を組み立てられます。まさにテーブル結合のためのセンテンスです。

INNER JOIN tbl_b ON ... 
                     
LEFT JOIN tbl_b ON ...
                    
RIGHT JOIN tbl_b ON ...
                     
CROSS JOIN tbl_b

パフォーマンス比較

では、どちらの方がパフォーマンスが良いのでしょうか。

テーブルとデータを用意しました。本と出版の世界の一部を表現しています。

books テーブルには 100 万件のデータを収録し、あとはそれぞれ、1 対 1 または多対多のリレーションです。

books_count authors_count publishers_count managers_count author_book_count book_manager_count
1000000 100000 500 5000 1000000 1000000

実行クエリはそれぞれ以下になります。

-- JOIN 句で結合
SELECT
*
FROM books b
INNER JOIN author_book ab ON b.id=ab.book_id
INNER JOIN authors a ON ab.author_id=a.id
INNER JOIN publishers p on b.publisher_id=p.id
INNER JOIN book_manager bm ON b.id=bm.manager_id
INNER JOIN managers m ON bm.manager_id=m.id
;

-- FROM 句で結合
SELECT
  *
FROM
  books b,
  author_book ab,
  authors a,
  publishers p,
  book_manager bm,
  managers m
WHERE b.id=ab.book_id
  AND ab.author_id=a.id
  AND b.publisher_id=p.id
  AND b.id=bm.manager_id
  AND bm.manager_id=m.id
;

Explain

双方の実行計画を確認しましたが、両方とも全く同じ結果になりました。

id select_type table partitions type possible_keys key key_len ref rows filtered Extra
1 SIMPLE managers NULL ALL PRIMARY NULL NULL NULL 4845 100.00 NULL
1 SIMPLE books NULL eq_ref PRIMARY,books_publisher_id_foreign PRIMARY 8 sample.managers.id 1 100.00 NULL
1 SIMPLE publishers NULL eq_ref PRIMARY PRIMARY 8 sample.books.publisher_id 1 100.00 NULL
1 SIMPLE author_book NULL ref author_book_author_id_foreign,author_book_book_id_foreign author_book_book_id_foreign 8 sample.managers.id 1 100.00 NULL
1 SIMPLE authors NULL eq_ref PRIMARY PRIMARY 8 sample.author_book.author_id 1 100.00 NULL
1 SIMPLE book_manager NULL ref book_manager_manager_id_foreign book_manager_manager_id_foreign 8 sample.managers.id 190 100.00 NULL

JOIN 句は結合として明示的に意図を示せる句ですが、FROM 句に複数テーブルを指定した場合でも、結合として JOIN 句と同等に処理されることが確認できます。

実行時間

実行計画が同じではあったものの、念のため両者の実行時間を見てみます。

それぞれのクエリを 10 回実行し、その実行時間を確認します。

試行回数 JOIN: Execution time JOIN: Table lock wait time FROM: Execution time FROM: Table lock wait time
1 3.84146363 0.000132 3.70469371 0.000019
2 3.86136084 0.00015 3.85744995 0.000007
3 3.78419258 0.000018 3.77823229 0.000034
4 3.87785846 0.000184 3.80834454 0.00011
5 3.78526071 0.000016 3.81698154 0.000121
6 3.83468558 0.000057 3.79614567 0.000031
7 4.27876295 0.000013 3.76076388 0.000011
8 4.43237696 0.000016 3.82177008 0.000158
9 3.82524521 0.000123 3.98029196 0.000102
10 3.76645421 0.000033 3.88980984 0.000116
---------- ---------- ---------- ---------- ----------
AVERAGE 3.928766113 0.0000742 3.821448346 0.0000709

実行時間もほとんど差がないといえそうです。

どちらを使うべきか

パフォーマンスには差がないため、結果を抽出するということだけ考えたらどちらを使っても良いということになりますが、テーブル結合を行うならば、JOIN 句を使用しておくのが安全であると考えます。

なぜならば、FROM に複数のを指定した結合は、以下、可読性の面からヒューマンエラーが起こりやすいと感じているからです。

うっかりクロス結合

FROM 句で結合を行う際に最も注意しなければならないのは、結合条件の記述漏れです。

例えば、JOIN 句で結合を行う際は、JOIN 句に対して ON 句を記述することで、各 JOIN に対する結合条件を 1 行ずつ記述していくことができます。これは各テーブルの結合とその結合条件がセットで確認出来るため、条件やそれの指定漏れの確認も行いやすいです。

INNER JOIN author_book ab  ON b.id = author_book.book_id -- 結合テーブルと結合条件がセットで記述できる
INNER JOIN authors a       ON ab.author_id = a.id
INNER JOIN publishers p    ON b.publisher_id = p.id
INNER JOIN book_manager bm ON b.id = bm.manager_id
INNER JOIN managers m      ON bm.manager_id = m.id

対して、FROM 句で結合を行う際は、WHERE 句にて結合条件を記述していくことになります。

FROM
  -- 結合テーブル
  books b,
  author_book ab,
  authors a,
  publishers p,
  book_manager bm,
  managers m
WHERE b.id = ab.book_id     -- books, author_book の結合条件
  AND ab.author_id = a.id   -- author_book, authors の結合条件
  AND b.publisher_id = p.id -- books, publishers の結合条件
  AND b.id = bm.manager_id  -- books, book_manager の結合条件
  AND bm.manager_id = m.id  -- book_manager, managers の結合条件

この、結合したいテーブルと結合条件がセットにならない状況は、結合テーブルが増えた際に結合条件の記述漏れを引き起こす可能性が JOIN 句よりもかなり高いといえます。

前述の通り、結合条件を指定しない場合はクロス結合となるため、例えば今回用意したデータであれば、books, author_book の結合条件が未記述の場合、100 万件 × 100 万件 = 1 兆レコードの結果を返そうとすることになり、レコードの多いテーブルではなかなかリスクがあるなといえます。

(ちなみに JOIN 句であっても、結合条件を指定しなければクロス結合になるので注意)

不明瞭な境界線

JOIN 句では、結合後の絞り込みは、例えば以下のように記述するでしょう。

SELECT
    *
FROM tbl_a
INNER JOIN tbl_b ON tabl_a.id = tbl_b.tabl_a_id
WHERE tbl_b.score > 5 -- 結合後の絞り込み

結合テーブルとその条件があり、結合したデータに対して WHERE 句で絞り込みを行っています。

ON で使用される search_condition は、WHERE 句で使用できるフォームの条件式です。 通常、ON 句はテーブルの結合方法を指定する条件に使用され、WHERE 句は結果セットに含める行を制限します。

JOIN 句 - MySQL 8.0 リファレンスマニュアル

この表現を FROM 句での結合で行う場合は、以下のようなクエリになります。

SELECT
    *
FROM 
    tbl_a,
    tbl_b
WHERE tabl_a.id = tbl_b.tabl_a_id -- 結合条件
  AND tbl_b.score > 5  -- 結合後の絞り込み

結合条件と、結合後の絞り込みが同じ WHERE 句に混在することになります。シンプルなクエリなら良いかもしれませんが、結合数が多くなると可読性は一気に下がるはずです。

結合表現の一貫性

冒頭で述べた通り、FROM 句に複数テーブルを指定して結合する場合、クロス結合ないし内部結合が前提です。

外部結合のときだけ JOIN 句を用いるくらいなら、内部結合もクロス結合も JOIN を使っておけば、テーブル結合の表現は全て JOIN 句で統一されるため、メンテナンスもしやすいはずです。

SQL ステートメントは、意図の分かる作文でありたい。

どちらを使うにせよ、業務で SQL 文を作成するのであれば、欲しい結果を得る為だけでなく、意図が明確に伝わるようにクエリを組み立てていく意識は持ちたいものです。

いつかそのクエリをメンテナンスするであろう、チームの誰か、そして自分のために。

SQL 文は、パフォーマンスを大切にしながらも、生み出す自身の意図が簡単かつ適切に相手に伝わる作文でありたいですね。

参考:


現在 back check 開発チームでは一緒に働く仲間を募集中です。 herp.careers

Laravelを使用したStripeのCheckoutの決済済みかどうかの判定の実装

この記事は個人ブログと同じ内容です

Laravelを使用したStripeのCheckoutの決済済みかどうかの判定の実装


前回と前々回の記事の続きです。 https://zenn.dev/ota_rg/articles/5bb03b17198f58 https://zenn.dev/ota_rg/articles/836c891b481c23

概要

今回はCheckoutで決済した後の決済、未決済の判定について紹介していきたいと思います。 バックエンドはLaravelを使用しています。

決済、未決済の判定をする方法は以下のように2種類あり、今回はそれらについて解説しています。 ・StripeのPaymentIntentの検索APIを使用し判定する方法Webhookを使用して判定する方法

結論、2種類の方法のうちWebhookのほうが早いので、そちらをお勧めします。

StripeのPaymentIntentの検索APIを使用し判定する方法

PaymentIntentの検索APIを使用し、PaymentIntentのstatusがsucceededだと決済が完了していることがわかります。 https://stripe.com/docs/api/payment_intents/object#payment_intent_object-status

この方法は、Webhookに比べると遅いです。 決済して10秒くらい経ってもstatusがかわらないことがあるので、リトライ処理を作ったほうがいいです。

PaymentIntentとは

「支払いされたか、支払い方法はどうしたか」など支払いに関する情報が保存されているオブジェクトです。 これはCheckoutを使用した場合、決済をした後に作成されます。

https://stripe.com/docs/api/payment_intents

なぜPaymentIntentの検索APIを使用したか

Checkoutのセッションでも決済、未決済判定ができますが、 セッションIDをDBに保存しておくと考えたときに、 「決済前に2人セッションを作成したら、決済されるセッションと決済されないセッションができるので、どちらをDBに保存すればいいのかわからない」 という懸念点があります。

また、セッションの検索APIは、セッションIDでしか検索することができません。 よって、別の方法を考える必要がありました。

そこでMetadataという、自由に構造化情報を設定できるパラメーターを使用して検索できるPaymentIntentの検索APIを使用した。

https://stripe.com/docs/api/payment_intents/search

実装

metadataの設定

以下のようにCheckoutセッション作成時のpayment_intent_data['metadata'] のパラメーターに自由にmetadataを設定することができます。

$checkout = $stripe->checkout->sessions->create([
            'mode'                   => 'payment', // 支払いモード
            'payment_method_types'   => ['card'],
                ・
        ・
        ・
        ・
            // PaymentIntentのMetadata設定
            'payment_intent_data'    => [
                'metadata' => [
                    'reference_report_request_id' => 2,
            
                ],
            ],
        ],[
            'idempotency_key' => 'test',
        ]);

サンプルコード

支払いが完了していて、顧客とメタデータのitem_idで絞り込んだPaymentIntentを検索するコードです。 PaymentIntentのstatusがsucceededだと支払いが完了しています。

public function getSession(Request $request): JsonResponse
    {
        $config = config('define.stripe.token');
        $stripe = new StripeClient($config);
        //PaymentIntentsの検索
        $paymentIntents=$stripe->paymentIntents->search([
            'query' => 'status:\'succeeded\' AND customer:\'customer_id\' AND metadata[\'item_id\']:\'1\'',
        ]);
        return response()->json($paymentIntents);
}

重要なところの解説

PaymentIntentsの検索は$stripe->paymentIntents->search()で行っていますが、以下のクエリの書き方に注意する。

  • =が:になる
  • 'が「\'」になる

Webhookを使用した方法

StripeのWebhookを使用し、支払い完了イベントである「payment_intent.succeeded」を受け取ったら支払いが完了していることがわかります。

この方法はPaymentIntent検索APIを使う方法より、早く支払い完了であることがわかるのでお勧めです。

準備

ローカル環境 https://dashboard.stripe.com/test/webhooks/create?endpoint_location=local

  • 受信するイベントを絞り込む 本番または開発環境のみで可能で、エンドポイント作成の時の「リッスンするイベントの選択」で 「payment_intent.succeeded」を選択することで、支払い成功以外のイベントを受信しなくなる

実装

サンプルコード

public function webhook()
    {
        // 参考:https://stripe.com/docs/payments/checkout/fulfill-orders

        Stripe::setApiKey(env('STRIPE_SECRET'));

        // StripeのWebhookのendpoint_secret
        $endpoint_secret = 'endpoint_secret';

        $payload = @file_get_contents('php://input');
        $sig_header = $_SERVER['HTTP_STRIPE_SIGNATURE'];
        $event = null;

        try {
            $event = Webhook::constructEvent(
                $payload, $sig_header, $endpoint_secret
            );
        } catch(\UnexpectedValueException $e) {
            // Invalid payload
            http_response_code(400);
            exit();
        } catch(\Stripe\Exception\SignatureVerificationException $e) {
            // Invalid signature
            http_response_code(400);
            exit();
        }

        if($event->type==='payment_intent.succeeded'){
        $payment_intent = event.data.object
            //支払いが完了した後に行いたい処理をかく
        }

        http_response_code(200);
    }

重要なところの解説

以下のようにpayment_intent.succeededのイベントを受け取るとevent.data.objectでPaymentIntentを取得することができる。 PaymentIntentに事前にメタデータを設定することで、自前DBの商品IDやユーザーIDで誰のものかを特定することができる。

 if($event->type==='payment_intent.succeeded'){
        $payment_intent = event.data.object
            //支払いが完了した後に行いたい処理をかく
        }

終わりに

今回はLaravelを使用したStripeのCheckoutでの決済済みかどうかの判定の解説をしました。 PaymentIntentのステータスが変わる時間にはムラがあるということが、実装してから分かったので、その対応策を考えたり、リトライ処理を仕込むのが大変でした。 皆さんは最初からWebhookを使いましょう。


現在 back check 開発チームでは一緒に働く仲間を募集中です。 herp.careers

Nuxt.js+LaravelでStripeのCheckoutの実装をやってみた【決済フォーム埋め込み編】

この記事は個人ブログと同じ内容です

Nuxt.js+LaravelでStripeのCheckoutの実装をやってみた【決済フォーム埋め込み編】


前回の記事の続きです。 https://zenn.dev/ota_rg/articles/5bb03b17198f58

概要

LaravelとNuxt.jsでStripeの決済処理を実装しました。 今回はCheckoutの「自前のページに決済フォームを埋め込む方法」の紹介をしていきたいと思います。

https://stripe.com/docs/payments/checkout/how-checkout-works?payment-ui=embeddable-payment-form

実装したもの

モーダルにStripeの決済フォームを埋め込み、顧客が1つの商品を購入して完了するまでの実装をしました

https://youtu.be/Oz1fp_TcQZE

事前準備

前回説明したので、詳細は割愛しますが、以下の準備をしてください。

  • StripeのAPIキーの取得
  • 商品の追加
  • 顧客アカウントの作成

処理の簡単な流れ

  1. フロントがバックエンドのCheckoutセッション作成APIを叩く(フロント)
  2. バックエンドがstripeのセッション作成APIを叩き、その結果をフロントに返す(バックエンド)
  3. フロントがモーダルを表示する
  4. フロントがレスポンスのclient_secretを使用し、stripeの決済フォームを作成、モーダルにマウントする(フロント)
  5. 決済をし、任意のページにリダイレクト(フロント)

バックエンドの実装(Laravel)

前回同様にstripe-phpという、 Stripe公式のphp用のライブラリを使用して実装しました。 ライブラリを使用し、StripeのCheckoutセッション作成APIを叩き、その結果を返すAPIの作成をしました。

https://stripe.com/docs/api/checkout/sessions/create https://github.com/stripe/stripe-php

重要なところの解説

$stripe->checkout->sessions->create()でStripeのCheckoutセッション作成APIを叩いています。 前回も説明したので、今回は埋め込み式にする上で必要なパラメーターのみ解説します。

ui_mode

このパラメーターをembeddedにすることで、埋め込み式に変わります。 デフォルトはhostedとなっており、Stripeの決済ページに移動する方法になっています。

return_url

埋め込み式の場合のみ使用し、決済成功後のリダイレクト先のURLを指定する。 Stripeの決済ページに移動する方の時は、success_urlとcancel_urlとなっていて、少し違うので注意する

redirect_on_completion

if_requiredに設定することで、決済が成功した後にreturn_urlで指定したURLにリダイレクトしなくなり、 フロントエンドでstripe.initEmbeddedCheckoutの時のonCompleteに自由に処理を書くことができる。

https://stripe.com/docs/payments/checkout/custom-redirect-behavior

ソースコード

public function createCheckoutSession(): JsonResponse
    {
        /** @var string $config */

        $config = config('define.stripe.token');
        $stripe = new StripeClient($config);
        //Checkoutセッション作成
        $checkout = $stripe->checkout->sessions->create([
            // 商品
            'line_items'             => [[
                'price'    => 'price_id',//商品ID
                'quantity' => 1,//個数
            ],
            ],
            'mode'                   => 'payment', // 支払いモード
            'customer'               => 'customer_id', // 顧客ID 
            'ui_mode' => 'embedded',// 埋め込み式にするため
            //支払い成功時のリダイレクト先 ({CHECKOUT_SESSION_ID}とするとセッションIDが取得できる)
            'return_url' => 'http://localhost:8080/success',
            // 税金を自動徴収するかどうか(3万の商品だったら、決済ページで3万3千円になる)
            'automatic_tax'          => [
                'enabled' => true,
            ],
            //フロントのstripe.initEmbeddedCheckoutの時にonCompleteを使用することができる。ただし、return_urlには行かなくなる
            //参考:https://stripe.com/docs/js/embedded_checkout/init#embedded_checkout_init-options-onComplete
            'redirect_on_completion'=> 'if_required',
            // 支払い方法を保存するかどうか
            'payment_method_options' => [
                'card' => [
                    'setup_future_usage' => 'on_session',
                ],
            ],
            // 支払い方法
            'payment_method_types'   => ['card']
        ]);
        return response()->json($checkout);
    }

フロントエンドの実装

ボタンを押したら、バックエンドのセッション作成のAPIを叩き、モーダルを表示し、レスポンスのclientSecretを使用し、Stripeの決済フォームを作成し、モーダルにマウントするようになっています。

前回同様に、stripe-jsという、Stripeの公式のJavaScript用のライブラリを使用しました。 モーダルは、vue-js-modalというライブラリを使用しました。 https://www.npmjs.com/package/vue-js-modal

重要なところの解説

決済フォームの作成

バックエンドからもらったclientSecretを使用し、ライブラリのinitEmbeddedCheckout()を呼べば、 Stripeの決済フォームを作成します。

const checkout = await stripe?.initEmbeddedCheckout({
            clientSecret: res.data.client_secret,
          })
// Mount Checkout
checkout?.mount('#checkout')

マウントしたフォームの削除

Stripeの決済フォームを一度マウントした後に、そのままもう一度フォームを作成し、マウントしようとするとエラーになる。 なので、以下のようにモーダルを閉じるたびにマウントしたフォームを削除する必要があります。 vue-js-modalを使用しているので、hide()に処理を書くだけでモーダルが閉じた時の処理を書くことができる。

async hide() {
      // 一度destroyしないとcheckoutが残ってしまうので削除
      await this.checkout?.destroy()
      await this.$modal.hide('modal-content')
    },

ソースコード

<template>
  <div>
    <div>
      <button @click="submit">支払いをする</button>
    </div>

    <modal
      name="modal-content"
      height="auto"
      :scrollable="true"
      :click-to-close="false"
    >
      <button @click="hide">閉じる</button>
      <div id="checkout">
        <!-- Checkout will insert the payment form here -->
      </div>
    </modal>
  </div>
</template>

<script lang="ts">
import Vue from 'vue'
import { StripeEmbeddedCheckout, loadStripe } from '@stripe/stripe-js'
import axios from 'axios'

export default Vue.extend({
  name: 'StripeTest',
  head: () => ({
    title: 'StripeTest | back check',
  }),
  components: {},
  data() {
    return {
      publishableKey: process.env.STRIPE_PUBLISHABLE_KEY,
      loading: false,
      clientSecret: '',
      checkout: undefined as StripeEmbeddedCheckout | undefined,
    }
  },

  methods: {
    async submit() {
      const stripe = await loadStripe(
        process.env.STRIPE_PUBLISHABLE_KEY
          ? process.env.STRIPE_PUBLISHABLE_KEY
          : '',
      )

      await axios
        .post('http://localhost:8080/api/create_checkout_session')
        .then(async (res) => {
          this.$modal.show('modal-content')
          this.clientSecret = res.data.client_secret
          // StripeのCheckoutの作成
          const checkout = await stripe?.initEmbeddedCheckout({
            clientSecret: res.data.client_secret,
          })
          this.checkout = checkout

          // Mount Checkout
          checkout?.mount('#checkout')
        })
    },


    async hide() {
      // 一度destroyしないとcheckoutが残ってしまうので削除
      await this.checkout?.destroy()
      await this.$modal.hide('modal-content')
    },
  },
})
</script>

終わりに

今回は、StripeのCheckoutの実装で決済フォームを自前のページに埋め込む方法で行いました。 前回の決済フォームに遷移しての決済と比べると、モーダルなどを使用した分少し工数はかかりましたが、そこまで工数がかかるわけではないので、UIを重視したい方はこちらをお勧めします。 Stripeには、決済フォーム自体を自前で作る方法もあるので、さらにUIにこだわりたい方はそちらを使用してください。ただし工数がかなりかかると思います、、、


現在 back check 開発チームでは一緒に働く仲間を募集中です。 herp.careers

Nuxt.js+LaravelでStripeのCheckoutの実装をやってみた【Stripe決済ページ遷移編】

この記事は個人ブログと同じ内容です

Nuxt.js+LaravelでStripeのCheckoutの実装をやってみた【Stripe決済ページ遷移編】


概要

LaravelとNuxt.jsでStripeの決済処理を実装しました。 Stripeにはいくつか決済方法があるのですが今回はCheckoutと呼ばれる、Stripeが用意してくれた決済フォームを使用しました。 また、Checkoutにも2種類存在し、「決済時にStripeの決済ページに遷移する方法」と「自前のページに決済フォームを埋め込む方法」があるのですが、今回は「決済時にStripeの決済ページに遷移する方法」の紹介になります。

https://stripe.com/docs/payments/checkout

実装したもの

顧客が1つの商品を購入して完了するまでの実装をしました

https://youtu.be/ovSFHPwcZGY

事前準備

StripeのAPIキーの取得

以下を参考にAPIキーを取得してください。

https://stripe.com/docs/keys?locale=ja-JP#reveal-an-api-secret-key-for-test-mode

公開可能キーはフロントエンド側で使用するキー。 シークレットキーはバックエンド側で使用するAPIキー。

商品の追加

ダッシュボードの商品カタログから、購入してもらいたい商品を追加し、そのAPI IDをメモしておく。(バックエンドで使用する)

顧客アカウントの作成

誰が購入したかがわかるように、Stripeの顧客アカウントを作成する。 今回は記載していないが、Stripe APIを使用して顧客アカウントを作成も可能。

ダッシュボードの顧客より作成し、顧客IDをメモしておく。(バックエンドで使用する)

決済の簡単な流れ

  1. フロントがバックエンドのCheckoutセッション作成APIを叩く(フロント)
  2. バックエンドがstripeのセッション作成APIを叩き、その結果をフロントに返す(バックエンド)
  3. フロントがレスポンスのsessionIdを使用し、stripeの決済ページにリダイレクトする(フロント)
  4. 決済をし、任意のページにリダイレクト(フロント)

詳しくは以下をチェック↓

https://stripe.com/docs/payments/checkout/how-checkout-works?locale=ja-JP#lifecycle

バックエンドの実装(Laravel)

ライブラリを使用し、StripeのCheckoutセッション作成APIを叩き、その結果を返すAPIの作成をしました。

https://stripe.com/docs/api/checkout/sessions/create

ライブラリについて

stripe-phpという、 Stripe公式のphp用のライブラリを使用して実装しました。 採用理由としては、公式で信頼できるという点とドキュメントや使用例がたくさんあった点です。

https://github.com/stripe/stripe-php

Laravel CahierというLaravelが提供している別のライブラリも存在したのですが、こちらはドキュメントや使用例が少なく、サブスク支払がメイン機能そうだったので、今回は使用しませんでした。

https://readouble.com/laravel/9.x/ja/billing.html

重要なところの解説

$stripe->checkout->sessions->create()でStripeのCheckoutセッション作成APIを叩いています。以下にパラメーターについて解説します。

パラメーター 説明
line_items->price 顧客に購入してほしい商品のID。事前準備の商品IDを記載する
line_items->quantity 顧客に購入してもらう商品の個数
mode 今支払うか、後払いにするか、サブスクにするかを選択できる
customer Stripeの顧客アカウントID。誰が購入したかの把握ができる。事前準備の顧客IDを記載する
success_url 支払いに成功した時のリダイレクト先
cancel_url Stripeの決済ページの戻りボタンを押した時のリダイレクト先
automatic_tax->enable 税金の自動徴収をするかどうか。オンにすると価格1万円に設定した商品が、 税込の1万1千円で決済される
payment_method_options->card->setup_future_usage クレジットカードの情報を保存する。off_sessionにするとずっと保存してくれる
payment_method_types 支払い方法をどうするか。カード決済、ApplePayでの支払いなど選択できる
idempotency_key 多重決済を防ぐためのキー。 ユニークである必要がある。顧客が商品を購入する時の初回に作成し、それ以降はDBに保存されたこのキーを参照する

ソースコード

public function createCheckoutSession(): JsonResponse
    {
        /** @var string $config */
        $config = config('define.stripe.token');
        $stripe = new StripeClient($config);

        $checkout = $stripe->checkout->sessions->create([
            // 商品
            'line_items'             => [[
                'price'    => 'price_id',//商品ID
                'quantity' => 1,//個数
            ],
            ],
            'mode'                   => 'payment', // 支払いモード
            'customer'               => 'customer_id',//顧客ID 
            'success_url'            => 'http://localhost:8080/success,
            'cancel_url'             => 'http://localhost:8080/cancel', 
            // 税金を自動徴収するかどうか
            'automatic_tax'          => [
                'enabled' => true,
            ],
            // 支払い方法を保存するかどうか
            'payment_method_options' => [
                'card' => [
                    'setup_future_usage' => 'on_session',
                ],
            ],
            // 支払い方法
            'payment_method_types'   => ['card'],
        ],[
            'idempotency_key' => 'G5pobWIO8Q0ODYK1313333333',
        ]);
        return response()->json($checkout);
    }

フロントエンドの実装

ボタンを押したら、バックエンドのセッション作成のAPIを叩き、レスポンスのsessionIdを使用し、Stripeの決済ページにリダイレクトさせるような実装になっています。

stripe-jsでの実装

stripe-jsという、Stripeの公式のJavaScript用のライブラリを使用した実装方法の紹介です。

https://github.com/stripe/stripe-js

重要なところの解説

バックエンドからもらったclientSecretを使用し、ライブラリのredirectToCheckout()を呼べば、 Stripeの決済ページにリダイレクトします。

// Stripeのページにリダイレクトする
await stripe?.redirectToCheckout({
  sessionId: res.data.id,
})

ソースコード

<template>
  <div>
    <div>
      <button @click="submit">支払いをする</button>
    </div>
  </div>
</template>

<script lang="ts">
import Vue from 'vue'
import { StripeEmbeddedCheckout, loadStripe } from '@stripe/stripe-js'
import axios from 'axios'

export default Vue.extend({
  name: 'StripeTest',
  head: () => ({
    title: 'StripeTest | back check',
  }),
  components: {},
  data() {
    return {
      publishableKey: process.env.STRIPE_PUBLISHABLE_KEY,
      loading: false,
      sessionId: '',
      checkout: undefined as StripeEmbeddedCheckout | undefined,
    }
  },

  methods: {
    async submit() {
      const stripe = await loadStripe(
        process.env.STRIPE_PUBLISHABLE_KEY
          ? process.env.STRIPE_PUBLISHABLE_KEY
          : ''
      )

      await axios
        .post('http://localhost:8080/api/create_session')
        .then(async (res) => {
          // Stripeのページにリダイレクトする
          await stripe?.redirectToCheckout({
            sessionId: res.data.id,
          })
        })
    },
})
</script>

vue-stripeでの実装

vue-stripeとは、Stripeの認定パートナーのライブラリです。 ドキュメントが充実している。Checkoutで、Stripeの決済ページに遷移する方は実装できるが、自前のページに決済フォームを埋め込む方法は実装できません。 stripe-jsの方がコード量少ないので、個人的にはstripe-jsをお勧めします。

https://vuestripe.com

重要なところの解説

以下のように、ライブラリのコンポーネントを使用し、バックエンドからのsessionIdをコンポーネントに設定し、this.$refs.checkoutRef.redirectToCheckout();を呼び出すだけで、Stripeの決済ページに遷移する。

<stripe-checkout
      ref="checkoutRef"
      mode="payment"
      :pk="publishableKey"
      :session-id="sessionId"
      @loading="(v) => (loading = v)"
/>

ソースコード

<template>
  <div>
    <stripe-checkout
      ref="checkoutRef"
      mode="payment"
      :pk="publishableKey"
      :session-id="sessionId"
      @loading="(v) => (loading = v)"
    />
    <div>
      <button @click="submit">支払いをする</button>
    </div>
  </div>
</template>

<script lang="ts">
import Vue from 'vue'
import { StripeCheckout } from '@vue-stripe/vue-stripe'
import axios from 'axios'

export default Vue.extend({
  name: 'StripeTest',
  head: () => ({
    title: 'StripeTest | back check',
  }),
  components: {
    StripeCheckout,
  },
  data() {
    return {
      publishableKey: process.env.STRIPE_PUBLISHABLE_KEY,
      loading: false,
      sessionId: '',
    }
  },

  methods: {
    async submit() {
      await axios
        .post('http://localhost:8080/create_checkout_session')
        .then((res) => {
          this.sessionId = res.data.id
        })
        .then(() => {
          ;(this.$refs.checkoutRef as any).redirectToCheckout()
        })
    },
  },
})
</script>

終わりに

今回はStripeを使用し、決済ページに遷移するCheckoutの実装をやってみました。 Stripeの使用は初めてだったのですが、実装もそこまで難しなく、簡単に決済処理が作れて驚きでした。 ダッシュボードも使いやすかったので、決済の導入にはStripeをお勧めします。


現在 back check 開発チームでは一緒に働く仲間を募集中です。 herp.careers

Node.js サーバーアプリケーションにおけるトークンバケットを用いたレートリミットの実現

この記事は個人ブログと同じ内容です

Node.js サーバーアプリケーションにおけるトークンバケットを用いたレートリミットの実現

はじめに

こんにちは、株式会社 ROXX で back check というサービスを開発しているぐっきー(@Area029S)です。

リクエストを受け付けるごとに重い処理が実行される機能の実装などにおいては一定の時間内に大量に処理を実行するとシステムのリソースを圧迫してしまう可能性があるため注意が必要です。 このような処理をリソースを圧迫させずに処理する方法の1つとして、レートリミット(時間あたりの実行数の制御)の導入があげられます。

今回はネットワークのトラフィックシェービングなどに利用されることの多いトークバケットアルゴリズムを導入することで、サーバー側のアプリケーションでレートリミットを実現しようと思います。

レートリミットとは?

レートリミットとは主に時間あたりのネットワークトラフィックを制御するために用いられる戦略です。

一般的には下記のようなことに役立ちます。

  • アプリケーションリソースの枯渇を防ぐ
    • 特定のエンドポイントへのリクエスト数を制限する
  • 運用コストの超過を防ぐ
    • ChatGPT など利用ごとに課金される外部 API を利用したサービスのリクエスト数を制限することにより出費を管理する
  • 悪意のあるユーザーから API を保護する

参照: The Fundamentals of Rate Limiting: How it Works and Why You Need it

外部 API を利用したサービスのリクエスト数を制限する

今回のやりたいことであるリクエスト毎に重い処理を実行したい要件においては、上記の文脈でレートリミットを適用することでシステムリソースの負荷を分散させる用途に利用できそうです。

レートリミットを実装するための一般的なアルゴリズムには下記のようなものが存在します。

  • token bucket
  • leaky bucket
  • fixed window counter
  • sliding window log
  • sliding window counter

参照:様々な rate limit アルゴリズム

今回は高負荷の掛かるリクエストが大量に来たときは処理量を制限しつつ、制限の範囲内においては短期間に多数のリクエストを処理(バースト)することの可能な方式を利用したいため token bucket がよさそうです。そこで token bucket の特徴について説明します。

token bucketトークバケットアルゴリズム)とは?

token bucket

画像引用:様々な rate limit アルゴリズム より

概要

トークバケットアルゴリズムは、リクエストのレートを一定の制限内に保ちつつ、短期的なトラフィックの増加(バースト)も許容するための手法です。このアルゴリズムは、トークンの生成速度とバケットのサイズを利用してリクエストの平均処理量に制限をかけます。柔軟かつ効率的なレートリミットの手段として、ネットワーク環境からアプリケーションまで幅広く採用されています。

主要用語

トークバケットアルゴリズムを理解する上で重要な用語は以下の通りです。

  1. トーク
  2. バケット
  3. トークンの生成速度
  4. トークンの消費
  5. バースト
    • バーストは、短期間に多数のリクエストを許可する能力です。バケットが満杯の場合、短時間で多数のリクエストを処理できますが、その後トークンが不足するとトークンの再生成を待たなければなりません。

仕組み

トークバケットアルゴリズムでは、定められた速度でトークンがバケットに追加されます。バケットはこれらのトークンを保持し、そのサイズには上限があります。バケットが満杯の場合、新たに生成されるトークンは失われます。

リクエストが行われると、バケットからトークンが消費されます。処理するためには、必要な数のトークンがバケット内に存在する必要があります。十分なトークンがない場合、リクエストは待機状態に入ります。

このアルゴリズムによるレートリミットは、トークンの生成速度とバケットのサイズによって決定されます。これにより、長期的には一定のレートが保たれる一方で、バケットが満杯の状態では短期間のバーストが可能になります。

もっとわかりやすく説明してほしい!という方には下記のブログをおすすめします。

トークンバケットアルゴリズムではない、MP アルゴリズムと呼べ

Node.jsにおける実装例

Node.js での実装例を簡単にまとめます。

ライブラリの活用

トークバケットアルゴリズムを自前で実装するとそこそこの工数はかかりそうだということで、ライブラリがないか探してみます。 npm trends をみてみるとレートリミットを実装するものがいくつか見つかり、 limiter というライブラリが最も利用されてることがわかりました。 (比較的レートリミット系ライブラリ自体あまり出回っていないようです)

参照: npm trends - bottleneck vs leaky-bucket vs limiter vs tokenbucket

そこで今回は limiter を利用します。

実装例

コードの概要としては、リクエストを受け付けるごとに重い処理が非同期に実行される機能を想定しています。 そのため express でサーバーをたて、 bull というライブラリを用いて local 環境で動作するキューワーカーを立ち上げます。

キューに追加されたジョブを処理する箇所でレートリミットを設定することで、クライアントからのリクエストは受け取りつつ、ミッションクリティカルな処理の実行はトークバケットを利用したレートリミット制限下で実行できる実装となっています。

github.com

const express = require("express");
const Queue = require("bull");
const { TokenBucket } = require("limiter");

// トークンバケットによるレートリミッターを設定
const bucket = new TokenBucket({
  bucketSize: 2, // トークンの最大保持可能数
  tokensPerInterval: 1, // 1分間に生成されるトークン数
  interval: "minute", // トークン生成の間隔
});

// キューの作成
const myQueue = new Queue("myQueue");

const addJobToQueue = (data) => {
  myQueue.add(data);
}

const someProcess = () => {
  const currentTime = new Date().toLocaleString();
  console.log(`Processing at: ${currentTime}`);
};

// キューでのジョブの処理
myQueue.process(async function(job, done) {
  console.log("Processing job");
  await bucket.removeTokens(1);
  someProcess();
  done();
});

const app = express();
app.use(express.json());

app.post("/job", (req, res) => {
  addJobToQueue(req.body);
  res.send("Job added to queue");
});

const PORT = 3000;
app.listen(PORT, () => {
  console.log(`Server is running on port ${PORT}`);
});

それでは具体的に下記の設定でトークバケットを動かしてみます。

バケットサイズ: 2 トークンの生成速度: 1 個/分 処理実行時のトークンの消費数: 1 個

下記の振る舞いが確認できれば機能していることがわかります。

  1. 3分間待機してバケットトークンが溜まるのを待つ。バケットトークンを保持できる最大数(バケットサイズ)を2に設定しているため、3つめに生成されたトークンは破棄される。
  2. curlにて連続で3回リクエストを送信する。2個は即座に処理され、1分後にバケットに空きがある状態でトークンが生成されるため、生成されたトークンを使い残りの処理が実行される。

実行結果:

output

想定した動作をすることが確認できました。

おわりに

今回は受け付けたリクエストに対して非同期に処理を行う Node.js サーバーアプリケーションにおいてトークバケットを用いたレートリミットを実装してみました。 レートリミットのアルゴリズムはとっつきづらいイメージを持ちがちですが、1度理解できると便利なので読んでいただいた方の学習の促進に少しでも繋がると幸いです。 また、本記事内でリンクを貼らせていただいている関連の記事もわかりやすいのでぜひご覧ください。