ISUCON(2)基礎的な負荷試験

ISUCON(2)基礎的な負荷試験

今回から本格的にISUCONトレーニングの記録を綴っていきます。 まずは書籍「達人が教えるWebパフォーマンスチューニング」を読みながら 実際に手を動かして体験していこうと思います。 今回は「Chapter3 基礎的な負荷試験」までで学習したことを綴っていきます。 書籍の内容すべてを書き残すわけでなく個人的に残しておきたいノウハウに ついてのみまとめます。 また、私の環境ではDockerを用いて検証するため、その点での差異になども 書き残していきたいと思います。

検証環境

まず前提となる私の検証環境ですがPCはインテルCPUのMac Book Proです。 「達人が教えるWebパフォーマンスチューニング」に記載の実際にパフォーマンス改善を 行うWebアプリケーション、private-isuはMac内にDocker環境を構築し検証していきます。

検証用のPCと検証対象のWebアプリケーションが同じホスト上で動くと互いにリソースを 共有し、環境としては理想的ではないため、いずれ検証対象のアプリケーションを 別で動かすためのサーバーを用意したいと思っていますが、一旦はこの構成で進めます。

基本的な用語

計測やパフォーマンスチューニングを始める前に基本的な用語の整理をします。 チューニングにおいて頻出の2つの単語「レイテンシ」と「スループット」について整理します。 ここでの単語の扱いはWebの文脈においてとなります。

レイテンシ

クライアントがリクストを送信開始しレスポンスを受信完了するまでにかかる時間です。 レスポンスタイムともいいます。 レイテンシの単位はmsec、μsecが多く、この値が低ければ低いほど良いということになります。

スループット

単位時間あたりに処理できるリクエスト数を示す値です。 同時並行処理性能を表します。 1秒間にどれだけのリクエストを処理できるかを表したrps(request per second)を単位とします。 この値が多ければ多いほど良いということになります。

計測ツール

Chapter3では以下の2つのツールを主に使用し計測します。

  • alp
  • ab

alp

Webサーバーのログを入力に読みやすく加工してくれるCLIツールです。 正式名称はAccess Log Profiler。 実行イメージは以下です。

tail -n 50 ./logs/nginx/access.log  | alp json -o count,method,uri,min,avg,max

+-------+--------+---------------------------------------------------+-------+-------+-------+
| COUNT | METHOD |                        URI                        |  MIN  |  AVG  |  MAX  |
+-------+--------+---------------------------------------------------+-------+-------+-------+
| 1     | GET    | /favicon.ico                                      | 0.004 | 0.004 | 0.004 |
| 2     | GET    | /css/style.css                                    | 0.004 | 0.050 | 0.095 |
| 2     | GET    | /img/ajax-loader.gif                              | 0.029 | 0.037 | 0.046 |
| 2     | GET    | /js/main.js                                       | 0.074 | 0.079 | 0.084 |
| 2     | GET    | /js/timeago.min.js                                | 0.069 | 0.082 | 0.095 |
| 6     | GET    | /.well-known/appspecific/com.chrome.devtools.json | 0.002 | 0.033 | 0.076 |

ab

外部から任意のURLに負荷検証するためにリクエストを送信するCLIツールです。 正式名称はApache Bench。 リクエスト後にレイテンシやスループットなどの情報をまとめて表示します。 コマンド実行イメージは以下です。

ab -c 1 -t 30 http://localhost/

計測の下準備

フォーマットの変更

nginxのアクセスログなど、デフォルトでは解析しづらいフォーマットになっているため、 jsonなどの処理しやすいフォーマットに変更します。 設定はwebapp/etc/nginx/conf.d/default.confに追加します。

ローテートの準備

ログは計測ごとに分けておきたいですが、デフォルトのnginxのアクセスログでは一つの ログにどんどん追記されていきます。そのため、計測ごとにログを別名にし分けておきます。 この作業をローテートといいます。 以下のように日時を付与して都度ログを分けます。

mv ./access.log ./access.`date +%Y%m%d-%H%M%S`

nginx の access.log はファイル名を変えても書き込み先が変化しません。 変更するには色々な方法がありますが、nginxのmasterプロセスにシグナル送信するのが手軽です。

# nginxコンテナで実行
/usr/sbin/nginx -s reopen

マウントする(Dockerの場合)

Docker環境の場合はホストでログを操作しやすいよう、access.logをマウントすると良いです。

手始めのあたりの付け方

手始めに特定のエンドポイントに対してabで負荷を与えます。 このとき、topコマンドでサーバーのCPU負荷状況を観測し、リソースの使用が激しいプロセス を調べます。リソースの消費が激しいということはボトルネックとなっている可能性が高いということになります。

今回はDocker環境で構築しているため、Webサーバー、アプリケーションサーバー、データベース それぞれのコンテナでのリソース消費状況を観測することで負荷の高いコンテナを見極めます。 docker statsコマンドで各コンテナのリソース消費状況を観測できます。

以上を用いてhttp://localhost/に負荷を与えるとデータベースコンテナ(mysql)が 著しくCPUを使用していることがわかります。 手始めにデータベースが改善対象にということになりました。

データベース改善

データベースが怪しいとなった場合、次にやることとしてはデータベースのボトルネックを 発見する準備です。

スロークエリを設定する

まずはスロークエリログを設定します。 ここで重要なのがクエリログ出力対象とするしきい値に0秒を設定することです。 これは1つ1つのクエリの実行時間が短くてもクエリ回数が多いことによって、 時間がかかっているクエリを発見するためです。

取得したスロークエリログはそのままでは数が多く見づらいため、 mysqldumpshowコマンドを使用することで要約された見やすい出力となります。

mysqldumpslow mysql-slow.log

MySQLのログローテート

MySQLのログもローテートする場合は以下のコマンドが必要です。

mysqladmin flush-logs

データベース解析ポイント

ログが取得できたらデータベースの問題箇所を洗い出します。 主な観点は以下になります。

①スロークエリを解析する

  • Rows_sent : 実際にクライアントに返した行数
  • Rows_examined : 内部で処理した行数

この差が大きいと、非効率な可能性が高い。

②テーブルの構成を確認する

インデックスが適切に設定されているか等確認。

SHOW CREATE TABLE table_name;

③ EXPLAIN でクエリの実行計画を確認する

インデックスが使われているか? rows で走査される行数は多くないか?

ワーカープロセス数改善

次に書籍ではアプリケーションのワーカープロセス数を上げる改善を施しますが、 その際の、Docker環境での調査方法の覚書を残します。 まず、dstatコマンド。これはdocker statsを用いて以下のシェルスクリプトを 作成することにより代替しました。

# 1秒ごとに一度、データを取得し、ファイルに追記(ログとして記録)
while true; do
  docker stats --no-stream --format "{{.Name}}\t{{.CPUPerc}}\t{{.MemUsage}}" | sed 's/\r//g' >> docker_cpu_log.txt
  sleep 1
done
 

また、systemctl status isu-rubyもコンテナ環境にはないためdocker compose top appで代用しました。

$ docker compose top app
SERVICE  #   UID   PID  PPID  C   STIME  TTY  TIME      CMD
app      1   root  751  728   0   08:47  ?    00:00:00  /sbin/docker-init -- bundle exec unicorn -c unicorn_config.rb
app      1   root  774  751   0   08:47  ?    00:00:01  unicorn master -c unicorn_config.rb
app      1   root  945  774   0   08:47  ?    00:01:06  unicorn worker[0] -c unicorn_config.rb

docker環境でunicornのワーカープロセス数を増やすにはdocker-compose.ymlの appサービスにある deploy.resources.limits.cpu を "4" に設定してから、 unicorn_config.rb の worker_processes を4に増やす必要もあります。

まとめ

第二回は書籍の「基礎的な負荷試験」までの内容について、 Docker環境での代替方法と個人的な重要ポイントをまとめました。 次回は続きの「Chapter4 シナリオを持った負荷試験」からまた続けていきたいと思います。

ISUCON(1)ISUCON トレーニング開始

ISUCON(1)ISUCON トレーニング開始

1年ぶりらいにブログ更新します。 言い訳をするとこの1年勉強をサボっていたわけではなくインプットを頑張ったり、 語学勉強に比重をおいたり、そんな感じで過ごしておりました。

さて突然ですが、最近ふつふつとISUCONへの興味がわき出し、この度本格的に トレーニングを始めようと決意しました。

2025年はISUCONが開催されないようで、おそらくは昨今、盛り上がりを見せる AIに対応したレギュレーション策定に難航しているのではないかと勝手に推測しております。

AIが必須であろうこれからの時代に、完全にAIを禁止にするというのはナンセンスですし、 かといって完全にOKとしてしまうと、課金をもりもりできる選手が有利となってしまい、 貧乏サラリーマンや学生さんに不利というのもあり、非常に落とし所が難しい問題な気がします。

それはさておき、2026年の開催を念頭に、私もトレーニングをしていこうと思います。 そして、トレーニングの進捗をまたこうしてブログにまとめていこうと考えています。

手始めに有名なISUCON本、「達人が教えるWebパフォーマンスチューニング」を読みながら手を動かしていこうと思います。 最近、読書ばかりでしたが、初心に返り、本を読みながら手を動かす、というのをまたやっていきたいですね。 https://gihyo.jp/book/2022/978-4-297-12846-3

はじめはこの書籍の内容を一通り実践し、その後、過去問などをどんどんこなしていこうと考えています。 また、並行して改善対象となるWebアプリケーションを動かすためのサーバーもこしらえたいなと思っております。 (現状、ホスト環境上のDockerで動かしていますが、やはりベンチマーカーとかとは別で動かしたいので)

ひとまず、第一回ISUCON日記はこのくらいとし、次回以降はISUCONトレーニングの中であった学びやノウハウを 進捗と共にまとめていけたらなあ、と考えています。

LaravelでSPA認証を実装する

LaravelでSPA認証を実装する

概要

LaravelでのSPA認証の実装方法の覚書。 使用技術は以下の通り。

  • 手動による認証+SanctumによるSPA認証
  • セッション+クッキー認証

環境

  • PHPのバージョン
    • 8.1.27
  • Laravelのバージョン
    • v9.52.16
  • Sanctumのバージョン
    • v3.3.3

参照ドキュメント

構成

今回はログイン画面はSSRで実装しログイン後はSPAとなる構成としている。

実装手順

  1. ログインの実装
    1. ログイン画面はSSRで実装する
  2. ログアウトの実装
  3. ルートを認証で保護する
  4. フロントの実装

ログインの実装

ログインAPIの実装

ログイン用のコントローラを実装する。実装コードはほぼ以下URLの「ユーザーを手作業で認証する」の通り実装。コード中の詳細についてもリンク先を参照。

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

<?php

namespace App\Http\Controllers;

use Illuminate\Contracts\View\View;
use Illuminate\Http\Request;
use Illuminate\Http\RedirectResponse;
use Illuminate\Support\Facades\Auth;

class LoginController extends Controller
{
    /**
     * 認証の試行を処理
     */
    public function authenticate(Request $request): RedirectResponse
    {
        $credentials = $request->validate([
            'email' => ['required', 'email'],
            'password' => ['required'],
        ]);

        if (Auth::attempt($credentials)) {
            $request->session()->regenerate();

            return redirect()->intended('/');
        }

        return back()->withErrors([
            'email' => 'The provided credentials do not match our records.',
        ])->onlyInput('email');
    }
}

web.phpにルーティングを追加する。

Route::post('/login', [LoginController::class, 'authenticate']);

ログイン画面の実装

ログイン画面はSSRで実現するためLaravelのBladeを使用する。

resources/views/login.blade.phpを作成する

<!DOCTYPE html>
<html lang="{{ str_replace('_', '-', app()->getLocale()) }}">

<head>
    <meta charset="utf-8" />
    <meta name="viewport" content="width=device-width, initial-scale=1" />

    <title>Login</title>
</head>

<body>
    <section>
        <h1>Login</h1>

        <div>
            <form action="{{ url('/login') }}" method="POST">
                {{ csrf_field() }}
                <div>
                    <p>email</p>
                    <input type="email" name="email" value="{{ old('email') }}" required autofocus>

                    @error('email')
                        <div>
                            {{ $message }}
                        </div>
                    @enderror

                </div>
                <div>
                    <p>password</p>
                    <input type="text" name="password">
                </div>
                <div>
                    <!-- 送信ボタン -->
                    <input type="submit" value="送信">
                </div>
            </form>
        </div>

    </section>

</body>

</html>
  • formタグで先ほど作成した認証ルートにCSRFトークン付きでPOST
  • 認証がエラーした場合はフラッシュメッセージとして@error('email')部分を表示

ログイン画面のコントローラを作る。LoginController.phpに以下のメソッドを追加する。

    public function index(): View
    {
        return view('login');
    }

web.phpにルーティングを追加する。

Route::get('/login', [LoginController::class, 'index'])->name('login');

ログイン先の画面を作る

ログイン成功時の遷移先の画面を作る。resources/views/index.blade.phpを作る。後ほどSPAログインに変更する際にまた書きかえるので一旦は遷移したことがわかる程度の内容にする。

<! DOCTYPE html>
<html>
<head></head>
<body>
    <div>ログインしました</div>
</body>
</html>

web.phpにルーティングを追加する。

Route::get('/', function () {
    return view('index');
});

ログアウトの実装

ログアウトの処理を作成する

コントローラの作成。 これも基本的には以下の参照先に記載の「ログアウト」の項目の通り実装する。

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

<?php

namespace App\Http\Controllers;

use Illuminate\Http\Request;
use Illuminate\Http\RedirectResponse;
use Illuminate\Support\Facades\Auth;

class LogoutController extends Controller
{
    /**
     * ユーザーをアプリケーションからログアウトさせる
     */
    public function logout(Request $request): RedirectResponse
    {
        Auth::logout();

        $request->session()->invalidate();

        $request->session()->regenerateToken();

        return redirect('/login');
    }
}
  • セッションを無効にする
  • CSRFトークンを初期化する
  • ログアウト後の画面にリダイレクトする

コントローラが完成したらweb.phpにルーティングを追加する。

Route::post('/logout', [LogoutController::class, 'logout']);

ログアウトボタンを画面に追加する

ログアウトするためのボタンをindex.blade.phpに追加する。

<! DOCTYPE html>
<html>
<head></head>
<body>
    <div>ログインしました</div>
    <div>
        <form action="{{ url('/logout') }}" method="POST">
            {{ csrf_field() }}
            <div>
                <input type="submit" value="ログアウト">
            </div>
        </form>
    </div>
</body>
</html>

ログイン後、ログアウトボタンを押すとログイン画面にリダイレクトされればOK。

ルートを認証で保護する

ログイン状態でないと/にはアクセスできないようにweb.php実装する。参照先の「ルートの保護」の通り実装する。

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

Route::get('/', function () {
    // 認証済みユーザーのみがこのルートにアクセス可能
    return view('index');
})->middleware('auth');

->middleware()の追加でこのルートには認証済みのユーザしかアクセスできないようになる。

フロントの実装

ここまででSSRによる認証の機構は完成したので、最終のゴール地点である「ログインまでSSRで行いログイン後はSPAとして動かす」 ところまでを実装する。なお、フロントのフレームワークとしてはVue.jsを使用するが、こちらの細かい実装内容については割愛する。

index.blade.phpにVueを埋め込めるよう変更した上、resources/js/components/App.vueを以下の通り実装する。

<template>
    <div>
        <p>Vueのページ</p>
    </div>
    <div>
        <button type="button" v-on:click="logout"> ログアウト </button>
    </div>
</template>

<script lang="ts" setup>
import axios from "axios";

const logout = (): void => {
    axios.post("logout");
    window.location.href = "/login";
};
</script>

ログアウトをリクエスト後、window.location.hrefを更新してフロント側で自前でロケーションを切り替える必要がある。

Sanctumについて

フロント側で認証する場合、Sanctumによる以下の手順が必要だが。

  1. Sanctumのミドルウェアapp/Http/Kernel.phpに追加
  2. ログイン前にsanctum/csrf-cookieにアクセスしCSRFトークンを初期化
    • XSRF-TOKENを取得する
  3. リクエスト時にXSRF-TOKENをヘッダに付与する
  4. ログイン後にアクセスするルーティングをSanctumミドルウェアで保護する

1に関しては自分の環境ではLaravelにデフォルトで設定されていた。 throttle:apiが該当。

app/Http/Kernel.php

        'api' => [
            // \Laravel\Sanctum\Http\Middleware\EnsureFrontendRequestsAreStateful::class,
            'throttle:api',
            \Illuminate\Routing\Middleware\SubstituteBindings::class,
        ],

2のsanctum/csrf-cookieのルーティングもデフォルトで設定されている。 また、初めのSSRの認証時にCSRFトークンを取得するため、特に以下へのアクセスは本手順では不要。

php artisan route:list

  GET|HEAD   sanctum/csrf-cookie ............................................... sanctum.csrf-cookie › Laravel\Sanctum › CsrfCookieController@show

3のトークンの付与はaxiosなどのライブラリを使用した場合自動で付与してくれる。

4のログイン後にアクセスするルーティングの保護はroutes/api.phpにデフォルトで参考となるソースがあるためこちらを元に実装する。

Route::middleware('auth:sanctum')->get('/user', function (Request $request) {
    $user = $request->user();
    return $user->toRistrictedArray();
});

以上でLaravelによるSPA認証の実装が完了。

Laravelで自作の認証を作成する

Laravelで自作の認証を作成する

Laravelデフォルトのusersプロバイダーではなく、自作で認証機構を作成する方法をまとめる。

環境

  • PHPのバージョン
    • 8.1.27
  • Laravelのバージョン
    • v9.52.16

参照ドキュメント

Laravelでの認証方法の概要

Laravelで認証機構を自作する場合は自作のユーザープロパイダーを作成する必要がある。 また、そのユーザープロバイダーの一部のメソッドで返すクラスはAuthenticatableインタフェースを実装する必要がある。 ユーザープロバイダーを作成したら、認証で使用しているガードにそのプロバイダーを設定する。

自作で認証機構を作る方法

大まかな手順は以下の通り。

  1. Authenticatableインタフェースを実装した認証済ユーザーの情報を持つクラスを作成する
  2. 自作のユーザープロバイダーを作成する
  3. サービスプロバイダーに自作のユーザープロバイダーを登録する
  4. 認証で使用しているガードに作成したユーザープロバイダーを設定する

作成手順

Authenticatableを実装したクラスを作成する

Illuminate\Contracts\Auth\Authenticatableインタフェースを実装したクラスを作成する。 implementsするには以下のメソッドの実装が必要。 具体的な実装例はEloquentモデルのUserクラスなどを参考にするとよい。

  • getAuthIdentifierName
    • ユーザをユニークに識別できるIDのキー名を返す
    • RDBMSなどではテーブルのカラム名にだいたいはなる
  • getAuthIdentifier
    • ユーザのIDの値を返す
  • getAuthPassword
    • ユーザのパスワードを返す
  • getRememberToken
    • remember tokenの値を返す
  • setRememberToken
    • remember tokenを設定する
  • getRememberTokenName

自作のユーザープロバイダーを作成する

Illuminate\Contracts\Auth\UserProviderインタフェースを実装した自作のユーザープロバイダーを作成する。 既存のEloquentUserProviderクラスなどを参考に実装する。実装が必要なメソッドは以下。

  • retrieveById($identifier);
    • IDから該当するユーザを取得する
    • 返すクラスは先程実装したAuthenticatableを実装した自作ユーザクラスを返す
  • retrieveByToken($identifier, $token);
    • IDとremember me tokenからユーザを取得する
    • こちらも戻り値として自作ユーザクラスを返す
  • updateRememberToken(Authenticatable $user, $token);
    • 自作ユーザクラスとremember me tokenを引数で受け取りremember me tokenを更新する
  • retrieveByCredentials(array $credentials);
    • 引数でemailやパスワードなどの認証情報を受け取り自作ユーザを取得する
    • 戻り値は自作ユーザ
  • validateCredentials(Authenticatable $user, array $credentials);
    • 引数で自作ユーザとログイン時に入力された認証情報を受け取り合致するか検証し結果を返す

サービスプロバイダーに自作のユーザープロバイダーを登録する

config/auth.phpで自作プロバイダーを使用できるようAuthServiceProviderに設定を追加する。 \Auth::providerメソッドの第一引数にconfig/auth.phpで使用する名前を任意で指定する。 第二引数で自作プロバイダーを生成するメソッドを指定する。

    public function boot()
    {
        $this->registerPolicies();

        //カスタムプロバイダの名前を定義
        \Auth::provider(
            // config/auth.php には、この名称で設定を行う。
            'my_user',
            function ($app, array $config) {
                // MyUserProviderは自作プロバイダー
                return new MyUserProvider($app['hash']);
            }
        );
    }

認証で使用しているガードに作成したユーザープロバイダーを設定する

作成したユーザープロバイダーを使用するためconfig/auth.phpの設定を変更する。 現在使用しているガードが使用しているユーザープロバイダーを自作したユーザープロバイダーに変更する。

例えばデフォルトで使用しているガードが以下のwebガードで使用しているproviderusersの場合、

    'guards' => [
        'web' => [
            'driver' => 'session',
            'provider' => 'users',
        ],
    ],

providers.usersに以下のように自作ユーザープロバイダーを設定する。ここで設定する値が先程AuthServiceProviderで設定した文字列になる。

    'providers' => [
        'users' => [
            'driver' => 'my_user',
        ],
    ],

おわり

以上で自作ユーザープロバイダーと自作ユーザーを使用した認証の作成は完了。 Auth::user()メソッドやテスト時のTestCase::actingAsメソッドなどで自作のAuthenticatableを実装したユーザクラスが使用できるようになる。

はてなブログの WEB API を使う

はてなブログの WEB API を使う

やりたいこと

はてなブログが公開している WEB API, Atom Pub API を使用した 記事の作成, 更新をするスクリプトを作成したため覚書記事を作成します.

本記事では Atom Pub API を使用した記事の取得, 新規作成, 更新の方法についてまとめます.

今回作成したスクリプト(TypeScript製)は以下にあります. こちらは GitHub で管理している記事をはてなブログ 側にアップロードするスクリプトになります. 詳細は README.md を参照してください.

基本事項

API 使用のために以下の情報が必要になります.

  • user ID
  • ルートエンドポイントのURL
    • https://blog.hatena.ne.jp/userId/userId.hatenablog.com/atom みたいなやつ
  • API KEY

user IDはてなブログの管理画面(ダッシュボード)のアカウントから確認できます. ルートエンドポイントと API KEY は「管理画面」→「設定」→「詳細設定」から確認できます.

認証情報

認証に使用する手段は複数あるようですが, 今回は一番手っ取り早い Basic 認証を 用いた方法を使用します.

ユーザー名を user ID, パスワードを API KEY としてコロンでつなげて Base64 エンコードした文字列を使用します.

TypeScript では以下のように auth オブジェクトに usernamepassword を設定します.

axios.get(url, {
            headers: {
                'Content-Type': 'application/xml',
            },
            auth: {
                username: userId,
                password: apiKey,
            },
        })

もしくは自前でユーザーID, API KEYをコロンで連結した文字列を Base64 エンコード し, Authorization ヘッダにBasic認証として設定します.

import { Buffer } from 'buffer';

const basicAuth = Buffer.from(`${userId}:${apiKey}`).toString('base64');

axios.get(url, {
            headers: {
                'Authorization': 'Basic ${basicAuth}',
                'Content-Type': 'application/xml',
            },
        })

curl なら以下のような感じです.

curl -u userId:apiKey https://blog.hatena.ne.jp/userId/userId.hatenablog.com/atom/entry

記事一覧の取得

記事一覧の取得はルートの URL + /entry のエンドポイントに対して GET リクエスト で取得できます. Basic認証も忘れずに.

TypeScript では以下のような雰囲気になります. (実際に動かしていないので動くかわからないです. あくまで雰囲気です. 以降のコードサンプルも動作確認はしていませんのでご了承ください.)

const url = 'https://blog.hatena.ne.jp/userId/userId.hatenablog.com/atom/entry';

response = await axios.get(url, {
            headers: {
                'Content-Type': 'application/xml',
            },
            auth: {
                username: USER_ID,
                password: API_KEY,
            },
        })

console.log(response.data);

レスポンスは以下のような xml で返ってきます.

<?xml version="1.0" encoding="utf-8"?>
<feed xmlns="http://www.w3.org/2005/Atom"
      xmlns:app="http://www.w3.org/2007/app">

  <link rel="first" href="https://blog.hatena.ne.jp/userId/userId.hatenablog.com/atom/entry" />

  
  <link rel="next" href="https://blog.hatena.ne.jp/userId/userId.hatenablog.com/atom/entry?page=1658475002" />
  

  <title>userIdのブログ</title>
  
  <link rel="alternate" href="https://userId.hatenablog.com/"/>
  <updated>2023-05-12T00:56:57+09:00</updated>
  <author>
    <name>userId</name>
  </author>
  <generator uri="https://blog.hatena.ne.jp/" version="xxxxxxxxxxxxxxxxxxxxxx">Hatena::Blog</generator>
  <id>hatenablog://blog/yyyyyyyyyyyyyyyy</id>

一覧取得のエンドポイントはデフォルトで7件ほどしか記事情報を返しません. 続きを取得する場合は <link rel="next" のタグの href 属性に 続きの記事を取得するための URL が設定されているため, そこにリクエストすれば続きを取得できます.

こんな感じ

  <link rel="next" href="https://blog.hatena.ne.jp/userId/userId.hatenablog.com/atom/entry?page=nnnnnnnnnn" />

全件記事情報を取得する場合は, <link rel="next" がなくなるまで繰り返す感じになります.

記事の新規作成

記事の新規作成はルートの URL + /entry に対して記事の情報を xml で作成したデータ とともに POST メソッドリクエストで行います.
TypeScript の例では以下のような感じになります.

function create(title: string, contents: string): void {
    const url = 'https://blog.hatena.ne.jp/userId/userId.hatenablog.com/atom/entry';
    const xmlData = `<?xml version="1.0" encoding="utf-8"?>
    <entry xmlns="http://www.w3.org/2005/Atom">
      <title>${title}</title>
      <content>${contents}</content>
      <updated>${new Date().toISOString()}</updated>
    </entry>`;

    // POSTリクエストを送信
    axios.post(url, xmlData, {
        headers: {
            'Content-Type': 'application/xml',
        },
        auth: {
            username: USER_ID,
            password: API_KEY,
        },
    }).then((response) => {
        if (response.status !== 201) {
            throw new Error(`HTTPステータスコード ${response.status}`)
        }
    }).catch((error) => {
        throw new Error(`${error}`);
    });
}

記事の更新

既存の記事の更新はルートの URL + /entry/記事のID に対して更新する記事の情報を xml で作成したデータ とともに PUT メソッドリクエストで行います.
ここで「記事のID」の取得方法についてですが, 記事一覧で取得した xml に含まれる <link rel="edit" タグの href がそれに該当します.

こんな感じ.

<link rel="edit" href="https://blog.hatena.ne.jp/userId/userId.hatenablog.com/atom/entry/3824838384928"/>

この URL に対して PUT します.

TypeScript の例では以下のような感じになります.

function update(title: string, contents: string): void {
    const url = 'https://blog.hatena.ne.jp/userId/userId.hatenablog.com/atom/entry/3824838384928';
    const xmlData = `<?xml version="1.0" encoding="utf-8"?>
    <entry xmlns="http://www.w3.org/2005/Atom">
      <title>${title}</title>
      <content>${contents}</content>
      <updated>${new Date().toISOString()}</updated>
    </entry>`;

    // POSTリクエストを送信
    axios.put(url, xmlData, {
        headers: {
            'Content-Type': 'application/xml',
        },
        auth: {
            username: USER_ID,
            password: API_KEY,
        },
    }).then((response) => {
        if (response.status !== 201) {
            throw new Error(`HTTPステータスコード ${response.status}`)
        }
    }).catch((error) => {
        throw new Error(`${error}`);
    });
}

注意事項

記事のコンテンツは HTML エスケープすること

<content> タグ内のテキストに HTML のタグがあるとサーバ側に怒られるため, 記事の内容はエスケープすること.

import * as he from 'he';

    const escaped = he.escape(contents);

    // 更新するためのXMLデータを作成
    const xmlData = `<?xml version="1.0" encoding="utf-8"?>
    <entry xmlns="http://www.w3.org/2005/Atom">
      <title>${entry.title}</title>
      <content>${escaped}</content>
      <updated>${new Date().toISOString()}</updated>
    </entry>`;

Web API 作成において心得たいことまとめ

Web API 作成において心得たいことまとめ

最近読んだ書籍「Web API The Good Parts」について個人的に心に留めておきたいことをまとめます. なお, 本書籍は発行年2014年と今となっては若干古いため, 現在では古い情報もあることを念頭において読み進めました.

Web API The Good Parts

そのため, 本記事のまとめも今では古いノウハウがあるかもしれません. そういった情報に対してはツッコミをいただけると幸いです.

だいたい, リクエスト, レスポンス, その他といった感じで大まかに3つのセクションにまとめます.

2.2.1 などの数値はそのノウハウが書籍に記載されている章番号です.

リクエストとエンドポイント関連

主に2章の内容.

URI でなんとなく使い方の想像がつく

ドキュメントとかいちいち見たくないため. http://example.com/v1/user/123 だったら ID 123 のユーザーを取得するみたいに容易に 想像がつく URI が理想.

URI に大文字小文字が混ざっていない

基本はすべて小文字を使用する. ホスト名が大文字小文字を無視するため, ホスト部のルールに足並みを揃える.

エンドポイントには複数形の名詞

2.4.1.1

  • users, friends, updates など
  • 集合を表しているものは複数形のほうが適切であるという考え
  • そもそもなぜ名詞なのか?
    • URIがそもそもリソースを表すものであるため
    • そして HTTP のメソッドが動詞を表す

エンドポイントの単語のつなぎは「ハイフン」

2.4.4

  • 区切り候補
    • キャメルケース
    • スネークケース
    • スパイナルケース
  • Google がハイフンを推奨しており SEO 的によいっぽい
  • アンダースコアだとリンクアドレスに下線が引かれて重なって見づらい
  • URIホスト部はハイフンは許可されているがアンダースコアは使えず、大文字小文字の区別がないためホスト部とルールを統一するとハイフンとなる
  • 実際のところ最も良いのは極力単語を繋げないこと
    • パスで区切る
    • 短い表現を目指す
    • クエリパラメータにする
  • しかし割と好みなので、別にアンスコ、キャメルケースでもよい

一括取得系は絞り込みも対応

2.5

GET: /users/ とか

一括取得系のエンドポイントはクエリパラメータを利用して絞り込み 取得などできるとよいかも.

クエリパラメータとパスの使い分け

2.5.5

パラメータをクエリパラメータにするかパスに入れるかの使い分け.

クエリパラメータは以下のような感じのパスの後に?hoge=123などがつく形式.

http://example.com/v1/user/123?hoge=123&fuga=xyz

パスに入れるとは ID 123 のユーザーを取得したい場合は以下のように ID をパスに入れる形式.

http://example.com/v1/user/123

リソースを指定する場合にどちらを採用するかの判断基準は以下.

① 一意なリソースを表すのに必要な情報かどうか

URI がそもそもリソースを表すものであるため, 参照したい情報が一意に決まる場合はパスにしたほうがよい.

② 省略しても良いようなパラメータはクエリにする

メソッド

PUT と PATHCH はどちらも更新系のメソッドだが具体的な使い分けについて.

  • PUT
    • 2.3.3
    • リソースを完全上書きする場合に使用
    • 送信するデータでもともとのリソースを完全に上書きする
  • PATCH
    • 2.3.5
    • リソースの一部を修正する場合に使用

エンドポイントの共通部分にバージョンを付与する

2.7

以下の v1 のような感じ.

http://example.com/v1/xxx

レスポンス関連

レスポンスの内容をユーザーが選べるようにする

3.3.1

ユーザーがほしい情報だけを選べるような作りにする. (名前がほしいだけなら名前だけとか) ほしくない情報が含まれるとデータサイズ的にも無駄になってしまう.

クエリパラメータで指定できるようにする.

http://example.com/v1/users/123?fields=name,age

レスポンスデータは配列ではなくなるべくオブジェクトで?

3.3.4

レスポンスデータに配列を使用するかオブジェクトで配列を包むかで 迷った場合はオブジェクトに統一するほうがややオススメ.

  • レスポンスデータが何を示しているのかわかりやすくなる
  • レスポンスデータをオブジェクトに統一できる
    • クライアント側で処理を共通化できる
  • JSONインジェンクションのリスクが軽減する

読み込んだJSONファイルがオブジェクトの場合, トップレベルにブレースがきて JavaScriptの構文としておかしいためJSONインジェクションを防ぐ可能性がやや上がる.

単/複数形に気をつける

3.4.1

データが複数(配列)になる可能性があるなら「複数形」, 1つだけなら単数形というかたちできちんと使い分ける.

日付はRFC3339を使用する

3.4.3

RFC3339 とは以下のようなフォーマットの日付.

2015-10-15T11:30:22+09:00

  • 数ある日時を表すフォーマットの問題を解決したフォーマットだから
  • 読みやすく使いやすいを目指したインターネット上で用いる標準形式だから
  • 冗長な表現もない
    • 他であるような日付と曜日両方を含むなど

ただし, HTTPヘッダの Date や Expires などには使用できない...

エラー時のステータスコードは適切に

3.6.1

ちゃんとステータスコードでエラーの状態を示す. リクエストが成功していないのに200番台を返すなどはしない.

クライアントライブラリとかでステータスコードを見て処理を分岐するものなども あるため, ちゃんと作法に従う.

データをエンベロープ(封筒)で包まない

3.3.2

エンベロープとはAPI的にはすべてのデータを同じ構造でくるむこと.

「HTTPの仕様をフル活用する」にも書いているがそもそもHTTPヘッダがエンベロープなので冗長になる.

その他

HTTPの仕様をフル活用する

4

  • HTTPヘッダ
  • ステータスコード
  • キャッシュ
    • クライアント側にキャッシュをさせたい/させたくないなどHTTPヘッダである程度コントロールできる
    • Cache-Contorol
      • キャッシュしてほしくない場合は no-cache, no-store
    • Vary
      • URI以外にデータを一意に特定するリクエストヘッダを指定する
      • URIが同じでもVaryで指定したヘッダに変化があった場合キャッシュではなくデータをとりに行く
  • メディアタイプ

Laravel : Docker + VSCode でデバッガ環境構築

Laravel : Docker + VSCode でデバッガ環境構築

目的

Docker コンテナ上で動作してる Laravel を xdebugデバッグできる環境を構築する.

環境

  • macOS
  • Docker
    • 20.10.12
  • Laravel
    • 9.21.5
  • PHP
    • 8.1.8
  • Composer
    • 2.3.10
  • MySQL
    • 8.0.29

手順

Docker コンテナを使用した環境構築は完了している前提で記事を書きます. Docker を使ったサーバー構築手順は以下の記事にまとめています.

https://kita127.hatenablog.com/entry/2022/10/02/145614

編集するファイルは以下の4ファイル.

  • Dockerfile
  • php.ini
  • docker-compose.yaml
  • .vscode/launch.json

Dockerfile

サーバー用のコンテナの Dockerfile に xdebug をインストールする記述を追加する.
pecl install xdebugxdebug をインストールする.
docker-php-ext-enable xdebugxdebug を有効化する.

# xdebug のインストール
RUN pecl install xdebug \
  && docker-php-ext-enable xdebug

php.ini

コンテナサーバーに配置する php.ini に以下を追加する. 9012 は xdebug のポート

[xdebug]
xdebug.mode = debug
xdebug.start_with_request = yes
xdebug.client_host = "host.docker.internal"
xdebug.client_port = 9012
xdebug.log = "/var/log/xdebug.log"

docker-compose.yaml

サーバーコンテナの volumes に xdebug のログをマウントするよう- ./log:/var/log を追加する. 左側はローカルのログ置き場のパス, 右側は php.ini で設定した xdebug のログのディレクトリ.

    volumes:
      - ./webapp:/var/www/html
      - ./docker/apache/php.ini:/usr/local/etc/php/php.ini
      - ./log:/var/log

launch.json

portxdebug のポート 9012 を設定する
pathMappings は左に Docker コンテナ上に配置している Laravel アプリケーションのパス
右にローカルで作業している Laravel アプリケーションのパスを設定する.

{
    "version": "0.2.0",
    "configurations": [
        {
            "name": "Listen for XDebug",
            "type": "php",
            "request": "launch",
            "port": 9012,
            "pathMappings": {
                "/var/www/html": "${workspaceFolder}/webapp"
            }
        }
    ]
}

試す

以上で xdebug を使用する環境構築は完了. 任意の箇所にブレイクポイントを設定し, start debugging をすればステップ実行や変数の状態の確認などができるようになる.

参考記事

以下の記事を参考にさせていただきました.

https://maasaablog.com/development/backend/php/laravel/2308/