MySQLマルチカラムインデックスについて整理

きっかけ

「理論から学ぶデータベース実践入門 ~リレーショナルモデルによる効率的なSQL」の11章を読みました。

対象テーブル

users.sql

CREATE TABLE `users` (
  `id` bigint unsigned NOT NULL AUTO_INCREMENT,
  `age` tinyint unsigned NOT NULL,
  `name` varchar(255) CHARACTER SET utf8mb4 COLLATE utf8mb4_0900_ai_ci NOT NULL,
  `name_reverse` varchar(255) CHARACTER SET utf8mb4 COLLATE utf8mb4_0900_ai_ci NOT NULL,
  `pref_id` tinyint unsigned NOT NULL,
  `gender` varchar(5) NOT NULL,
  PRIMARY KEY (`id`),
  KEY `age_name_prefid` (`age`,`name`,`pref_id`) USING BTREE
) ENGINE=InnoDB AUTO_INCREMENT=12 DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_0900_ai_ci;;

INSERT INTO `users` (`id`, `age`, `name`, `name_reverse`, `pref_id`, `gender`) VALUES
(1, 20, 'Aaron', 'noraA', 9, 'men'),
(2, 20, 'Bob', 'boB', 34, 'men'),
(3, 20, 'Cammy', 'ymmaC', 24, 'women'),
(4, 40, 'Danny', 'ynnaD', 44, 'men'),
(5, 40, 'Eric', 'cirE', 21, 'men'),
(6, 40, 'Faker', 'rekaF', 15, 'men'),
(7, 27, 'George', 'egroeG', 45, 'men'),
(8, 27, 'Henry', 'yrneH', 33, 'women'),
(9, 60, 'Ivry', 'yrvI', 20, 'women'),
(10, 60, 'Johny', 'ynhoJ', 8, 'men'),
(11, 60, 'Karen', 'neraK', 4, 'women');

前置き

以降に書いている「Indexが効く」というのは今回のusersテーブルの場合のみです。
もちろん他のデータではカーディナリティや検索結果数などによって結果が変わります。

実践

Indexが効く場合

Indexの全てのカラムまたは左側のカラムを優先的に指定する

EXPLAIN SELECT * FROM `users` WHERE age=20 AND name='Aaron' AND pref_id=9;

| id | select_type | table   | partitions | type | possible_keys | key  | key_len | ref  | rows | filtered | Extra|
1  SIMPLE  users   NULL    ref age_name_prefid age_name_prefid 1024   const,const,const   1  100.00    NULL
EXPLAIN SELECT * FROM `users` WHERE age=20;

| id | select_type | table   | partitions | type | possible_keys | key  | key_len | ref  | rows | filtered | Extra|
1  SIMPLE  users   NULL    ref age_name_prefid age_name_prefid 1  const   3  100.00    NULL

Indexが効かない場合

Indexの項目を間抜けして指定する

EXPLAIN SELECT * FROM `users` WHERE age=20 order by pref_id;

| id | select_type | table   | partitions | type | possible_keys | key  | key_len | ref  | rows | filtered | Extra|
1  SIMPLE  users   NULL    ref age_name    age_name    1  const   3  100.00    Using filesort

Indexの一番左側のカラムを使用しない

EXPLAIN SELECT * FROM `users` WHERE name='Aaron' AND pref_id=9;

| id | select_type | table   | partitions | type | possible_keys | key  | key_len | ref  | rows | filtered | Extra|
1  SIMPLE  users   NULL    ALL   NULL    NULL    NULL    NULL    11 9.09  Using where

nippondanji.blogspot.com

なぜIndexが効かないのか

マルチカラムインデックスを設定したとき、Indexは以下のように並べられます。
まずcol1カラムの値でソートされた後、順にcol2,col3とソートされていくため、Indexにcol1からアクセスしないと適切な順序でレコードを取得することができません。
そのため間抜けでアクセスしたりcol1(一番左)項目を使用しないと、Indexが効きません。

col1 col2 col3
99 abc 1
99 xyz 1
100 aaa 1
100 aaa 2
100 abc 1
100 abc 2
100 abc 3
100 abc 4
100 xyz 1
101 abc 1
101 abc 2

カラムの順番

カーディナリティの高いカラムを先頭に(左に)指定する

カーディナリティの高いカラムを先頭に指定しておくことでIndexから取得するレコード数を少なくすることができます。
Explainのfilteredの値をみるとnameを先に指定しているIndexの方が高くなっている
※直感的に20歳以下のユーザ群からAaronを探すより、名前がAaronのユーザ群から20歳以下を探す方が総取得レコード数が少なそうじゃないですか?(DBのデータによりますが...)

ALTER TABLE `users` ADD KEY `age_name` (`age`,`name`);  
EXPLAIN SELECT * FROM `users` WHERE age<=20 AND name='Aaron';  

| id | select_type | table   | partitions | type | possible_keys | key  | key_len | ref  | rows | filtered | Extra|
1  SIMPLE  users   NULL    range   age_name    age_name    1023   NULL    1  10.00 Using index condition
ALTER TABLE `users` ADD KEY `name_age` (`name`,`age`);  
EXPLAIN SELECT * FROM `users` WHERE age<=20 AND name='Aaron';  

| id | select_type | table   | partitions | type | possible_keys | key  | key_len | ref  | rows | filtered | Extra|
1  SIMPLE  users   NULL    range   name_age    name_age    1023   NULL    1  100.00    Using index condition

scrapbox.io

OR検索 (番外編)

OR検索も複数カラムを使用しているのでマルチカラムインデックスを使うかもと思いましたが ORは各カラムにそれぞれIndexが必要で、それぞれの結果の集合和を結果として返します。( インデックスマージ)
key項目には複数のIndexが表示されており
Extraにはインデックスマージ時の特定のアルゴリズムが表示されています。

ALTER TABLE `users` ADD KEY `name` (`name`);
ALTER TABLE `users` ADD KEY `pref_id` (`pref_id`);
EXPLAIN SELECT * FROM `users` WHERE name='Aaron' OR pref_id=9;  

| id | select_type | table   | partitions | type | possible_keys | key  | key_len | ref  | rows | filtered | Extra|
1  SIMPLE  users   NULL    index_merge name,pref_id    name,pref_id    1022,1    NULL    2  100.00    Using union(name,pref_id); Using where

参考

dev.mysql.com

nippondanji.blogspot.com

2023年に読んだ本

こんにちはyukと申します。
個人的に昨年は人生初めての転職ということで余暇も長く、動きも大きい年でした。
なので年末年始をダラダラと過ごしていましたが、何か記録せねばと思い去年読んだ本について書いてみます。

技術書

単体テストの考え方/使い方

転職のきっかけになった本とも言えるかもしれません。
当時、自身のヒューマンエラーや設計の甘さによるバグに苦しみ
どうすればミスを減らすことができるのかを考えていた中でドメイン駆動設計やテスト・保守性の高いコードに興味を持ち始めていた頃
業務ロジックとは密な関係を持たず、客観的に自分の書いたコードを評価できることで簡単なミスを減らすことができました。
テストコードを書いていなかった環境にいた私はこの本でテストの書き方の指針を知ることができ、このような取り組みをしている企業で働いてみたいと思いました。

AWSコンテナ設計・構築[本格]入門

転職活動中にAWSの資格を取得しましたが、実務経験がないためこの本を教材としてハンズオン形式でWebアプリケーションの環境構築を行いました。
図も多く使用されており、2023年8月時点では特に問題なく環境構築ができました。

技術書でない本?

エンジニアリング組織論への招待 ~不確実性に向き合う思考と組織のリファクタリング

この本は「エンジニアリング組織論への招待」というタイトルですが
私は特に業務における不安との向き合い方について非常に参考になりました。
というのも転職後は職場環境・人間関係が変化し
会社が自身に求めているレベルがわからなくなったり、新しい人間関係によって「認知の歪み」と呼ばれる過剰な思い込みが発生し自身にストレスがかかっていたことからメンタルが疲弊していた時期がありました。

そのような時に
・なぜ自分は不安なのか
・不安の根源は何なのか
などが言語化されていて、自分の不安の言語化や今後の対処法を考えるヒントが多く書かれており非常に助けになりました。

SOFT SKILLS ソフトウェア開発者の人生マニュアル

今後のキャリアについて改めて考える本でした。
「どこか」で専門的なキャリアパスを選んだ方が良いと思ってはいましたが
何も興味がある分野がない=フルスタックエンジニアと都合の良い脳内変換をして
トレンドから外れたスキルに学習時間を費やしてしまい、時間を無駄にしてしまうのではないかと選択を先延ばしにしていました。
もちろんそんなことはなく専門性は自身のブランディングになりますし、どんな知識やその過程も無駄になることはありません。

その他

ソフトウェアテスト技法練習帳 ~知識を経験に変える40問
良いコード/悪いコードで学ぶ設計入門 ―保守しやすい 成長し続けるコードの書き方
テスト駆動開発
システム設計の面接試験
世界一流エンジニアの思考法
ChatGPT/LangChainによるチャットシステム構築
これからはじめるReact実践入門 コンポーネントの基本からNext.jsによるアプリ開発まで
ここは今から倫理です。
ひらやすみ

終わりに

今年はデータベースに力を入れて勉強してみようかな・・・

DDDが好きなPHPerがFastAPIを触ってみる

きっかけ

偉大なWeb+DB Pressの隔月最終号でFastAPIを特集していたので、触ってみました。

gihyo.jp

PHPを4年ほど書いており、動的型付け言語ですがDDDに影響を受けて
・変数・戻り値に型を必ず定義する
・イミュータブルなクラスを使用する
・「依存性の逆転」を利用してDBや外部サービスに対して疎結合にする
などを意識しており、FastAPI(Python)でも可能なのかと思い触ってみました。

GitHub - yuktake/expenditure-management-api: web+db sample fastapi application

Pythonの印象

型が指定できない

型ヒントによって変数や戻り値の型を明示することができるが、異なる型を代入してもエラーが起こらない。

インターフェースがない

「依存性の逆転」ができない?

試したいこと

型エラーを起こす

明示した型ヒントと異なる型を代入した場合にエラーを起こす。

イミュータブルなクラス

インスタンス生成後、内部状態を変えられないようにする。

「依存性の逆転」の実現

任意の条件に応じてDIするクラスを変更する
インターフェースの代替になるものを使用して「依存性の逆転」を実現する

結果

型エラーを起こす→Pydanticの使用

PydanticのBaseModelを継承したクラスを実装することで、異なった型が入力された場合ステータスコード422を返してくれます。
またFastAPIではエンドポイントのRequest/Responseクラスを指定することができ、Pydanticのクラスを指定することでパラメータの型を自動でバリデーションしてくれます。

api/wallets/view.py

@router.get("", response_model=GetWalletsResponse)
async def get_wallets(
    use_case: Annotated[ListWallets, Depends(ListWallets)],
) -> GetWalletsResponse:
    """Walletの一覧取得API"""
    return GetWalletsResponse(
        wallets=[Wallet.model_validate(w) for w in await use_case.execute()]
    )

イミュータブルなクラス→dataclassesモジュールの使用

dataclassesモジュールの@dataclassをクラスの先頭に追記することで、クラスをイミュータブルにすることができます。
これによって一度生成されたインスタンスの変数を変更しようとするとエラーが起きるようになります。
またコンストラクタでself.model_validate(self)を呼び出すことで、不完全なインスタンスを生成できないようにします。

models.py

from datetime import datetime
from enum import StrEnum
from typing import Annotated
from pydantic import BaseModel as _BaseModel
from pydantic import ConfigDict, PositiveInt, Field, validator
from pydantic import WrapSerializer
from utils.datetime import to_utc
from typing import Any
from dataclasses import dataclass

UTCDatetime = Annotated[datetime, WrapSerializer(to_utc)]

class BaseModel(_BaseModel):
    # 変数名を揃えることでORMインスタンスからPydanticインスタンスに変換できる?
    model_config = ConfigDict(from_attributes=True)

class HistoryType(StrEnum):
    INCOME = "INCOME"
    OUTCOME = "OUTCOME"

@dataclass(frozen=True)
class History(BaseModel):
    def __init__(self, **kwargs):
        super().__init__(**kwargs)
        self.model_validate(self)

    history_id: int|None
    name: str
    amount: PositiveInt
    type: HistoryType
    history_at: UTCDatetime
    wallet_id: int

@dataclass(frozen=True)
class Wallet(BaseModel):
    def __init__(self, **kwargs):
        super().__init__(**kwargs)
        self.model_validate(self)

    wallet_id: int|None
    name: str = Field(..., min_length=4, max_length=10)
    histories: list[History]

    @property
    def balance(self) -> int:
        return sum(
            h.amount
            if h.type == HistoryType.INCOME
            else -h.amount
            for h in self.histories
        )

    # 独自関数でのValidationも可能
    # @validator("name", pre=True)
    # def test(cls, v: Any) -> Any:
    #     raise NotImplementedError()

「依存性の逆転」の実現→抽象クラスの使用

Pythonにはインターフェースはありませんが、代わりに抽象クラスで代替します。
抽象クラスの関数でNotImplementedErrorを書いておくことで、継承クラスでの実装を強制できます。

抽象クラス(repositories/abstract_wallet_repository.py)

class AbstractWalletRepository(ABC):

    @abstractmethod
    async def add(self, session: AsyncSession, name: str) -> Wallet:
        raise NotImplementedError()

継承したクラス(repositories/wallet_repository.py)

class WalletRepository(AbstractWalletRepository):

    async def add(self, session: ass, wallet: Wallet) -> Wallet:
        wallet_orm = WalletORM(name=wallet.name, histories=[])
        session.add(wallet_orm)
        await session.flush()
        return wallet_orm.to_entity()

またDependency InjectionもFastAPIのDependsを使用することで設定が可能です。

DIの設定(dependencies/repository.py)

settings = Settings()

if settings.status == "testing":
    WalletRepositoryInterface = Annotated[
        AbstractWalletRepository, Depends(TestWalletRepository)
    ]
else:
    WalletRepositoryInterface = Annotated[
        AbstractWalletRepository, Depends(WalletRepository)
    ]

※PydanticのSettingsConfigDictを使用することで、.envファイルの値によってDIするクラスを変更しています。

DIの使用(api/wallets/use_case.py)

class ListWallets:

    def __init__(
        self,
        session: SessionInterface,
        repo: WalletRepositoryInterface,
    ) -> None:
        self.session = session
        self.repo = repo

    async def execute(self) -> list[Wallet]:
        sess = self.session.get_session()
        async with sess() as s:
            wallets = await self.repo.get_all(s)
        return wallets

参考記事

github.com

WebSocketでLINEに送った画像を複数ブラウザに共有する

構成

LineBotに画像を送信すると、WebSocket接続しているクライアントのブラウザの画像がリロードせずに切り替わるというものです。

https://drive.google.com/file/d/1Jy9BZB0Dy-7wDY6M_3gtuX0C8h_fIaUR/view

フォルダ構成

今回はサイトをホスティングするS3以外はSAMを使用して作成します。

.
├── src
│   ├── onconnect
│   │   └── lambda_function.py
│   ├── ondisconnect
│   │   └── lambda_function.py
│   ├── sendimage
│   │   └── lambda_function.py
│   └── sentfromline
│       └── lambda_function.py
├── template.yaml
└── samconfig.yaml

github.com

LINEBotの作成

LINEBotを作成し、アクセストークンを発行してください。
発行したアクセストークンはsamconfig.yamlのLINECHANNELACCESSTOKENに記入してください。

Requestsレイヤーの作成

LINEからメッセージ情報をWebhook経由で受け取った際、Lambdaでメッセージ情報を元に画像を取得するためrequestsモジュールを使用します。
現在のLambda Python3.11ではサポートされていないので、作成したLayerをImportして使用しています。
作成したLayerのARNもsamconfig.yamlのRequestsLayerArnに記入してください。
(Layerの作成方法については省略します)

サービスの作成

templateファイルだけ指定すれば、設定等はsamconfig.yamlを参照してくれます。

sam deploy --template ./template.yaml

ホスティング用のS3の作成

LINEから送信した画像を保存するS3バケットと同様にPublicなS3をもう1つ作成し、シェルスクリプトでindex.htmlをアップロードすることは可能だと思いますが
index.htmlに記載しているWebSocketのURLを記入する必要があるので、今回は手動で作成しています。
・アクセス許可はブロックパブリックアクセスを全て無効
バケットポリシーはフルアクセス
・静的Webサイトホスティングを有効化
しています。
※レポジトリにあるindex.htmlをアップロードしておいてください

{
    "Version": "2012-10-17",
    "Statement": [
        {
            "Effect": "Allow",
            "Principal": "*",
            "Action": "s3:GetObject",
            "Resource": "arn:aws:s3:::{your-bucket-name}/*"
        }
    ]
}

LINEのMessageing API設定でWebhookURLを設定する

作成された「photo-share」API GatewayのURLをWebhook URLに設定します。

https://--------.execute-api.{your-region}.amazonaws.com/saveImageFromLine

index.htmlのWebSocket URLを記入する

作成された「wsApiTest」API GatewayのURLを記入します。

const socket = new WebSocket("wss://----.execute-api.{your-region}.amazonaws.com/{your-stage}");

動作確認

S3のページをブラウザで開き「WebSocket接続中」と表示されている状態でLINEBotに画像を送信すると、画像が反映されます。

参考記事

ChatGPTで簡単! 余興に使えるAWSサーバーレスアプリ開発入門 - Speaker Deck

AWSでWebSocketを使って画像共有 - NIFTY engineering

Next.js/microCMS/GithubActions/GithubPages で静的ブログを作る

はじめに

コーポレートサイトやブログ等、動的コンテンツが一部しかない場合
サーバ管理の必要がなくヘッドレスCMSから内容を編集するだけでサイトに反映されるためエンジニアでない方でも運用が簡単になると思い、試してみました。

yuktake.github.io

構成

・mainブランチのコードが変更された時
・microCMSのコンテンツが変更された時
にGithubActionsが実行され、出力された静的ファイル群がGithubPagesにデプロイされます。

開発環境

Node.js v18.16.1
next.js 13.4.10
※Node.jsはnodebrewを使用しローカルで開発しました。

アプリケーション

SSG対応

・next.config.jsにoutput項目を追加

/** @type {import('next').NextConfig} */
const nextConfig = {
    output: "export",
}

module.exports = nextConfig

・Imageタグは使用しないため削除
.eslintrc.json

{
  "extends": "next/core-web-vitals",
  "rules": {
      "@next/next/no-img-element": "off"
  }
}

環境ファイルの作成

.envファイルを作成して、API _KEYとサービスポイントを記入

NEXT_PUBLIC_API_KEY=xxxxxxxxxxx
NEXT_PUBLIC_SERVICE_DOMAIN=xxxxxxxxxxx

ライブラリの追加

・@types/marked 5.0.1
・microcms-js-sdk 2.5.0

Github

レポジトリの作成

レポジトリ名を必ず[user].github.ioにしてください

Tokenの取得

microCMSにWebhookを設定するためのトークンを生成します。
https://github.com/settings/tokens

microCMS

microCMSのアカウントを無料枠で作成します
APIは無料枠で3つまで作成できますが、今回は1つの一覧用のAPIを作成して一覧と詳細機能が実装できます。

Webhookの設定

Githubで作成したトークンを入力して、microCMSで任意の操作を行った際にGithubActionsを実行するよう設定します。

環境変数の設定

レポジトリ内にAPI情報を持たせたくないので
microCMSで作成したAPIキーとサービスポイントを
・アプリケーションの.envファイル
・レポジトリの「Settings」→「Secrets and variables」→「Actions」
に保存します。

GithubActions

GithubPages

「Settings」→「Pages」のSource項目をGithubActionsに選択します。
theme項目はコードから自動的にNext.jsに判定されます。

nextjs.ymlに認証情報の追加

デフォルトで生成されているGithubActionsのyamlファイルをコミットする前に認証情報の項目を追記します。
またmicroCMSの動作に応じてGithubActionsが実行されるようにrepository_dispatchも追記します。
追記後、コミットするとビルドが走るようになります。

nextjs.yml

# Sample workflow for building and deploying a Next.js site to GitHub Pages
#
# To get started with Next.js see: https://nextjs.org/docs/getting-started
#
name: Deploy Next.js site to Pages

on:
  repository_dispatch:
    types: [microCMSで命名したWebhook名]

  # Runs on pushes targeting the default branch
  push:
    branches: ["main"]

  # Allows you to run this workflow manually from the Actions tab
  workflow_dispatch:

# Sets permissions of the GITHUB_TOKEN to allow deployment to GitHub Pages
permissions:
  contents: read
  pages: write
  id-token: write

# Allow only one concurrent deployment, skipping runs queued between the run in-progress and latest queued.
# However, do NOT cancel in-progress runs as we want to allow these production deployments to complete.
concurrency:
  group: "pages"
  cancel-in-progress: false

jobs:
  # Build job
  build:
    runs-on: ubuntu-latest
    steps:
      - name: Checkout
        uses: actions/checkout@v3
      - name: Detect package manager
        id: detect-package-manager
        run: |
          if [ -f "${{ github.workspace }}/yarn.lock" ]; then
            echo "manager=yarn" >> $GITHUB_OUTPUT
            echo "command=install" >> $GITHUB_OUTPUT
            echo "runner=yarn" >> $GITHUB_OUTPUT
            exit 0
          elif [ -f "${{ github.workspace }}/package.json" ]; then
            echo "manager=npm" >> $GITHUB_OUTPUT
            echo "command=ci" >> $GITHUB_OUTPUT
            echo "runner=npx --no-install" >> $GITHUB_OUTPUT
            exit 0
          else
            echo "Unable to determine package manager"
            exit 1
          fi
      - name: Setup Node
        uses: actions/setup-node@v3
        with:
          node-version: "16"
          cache: ${{ steps.detect-package-manager.outputs.manager }}
      - name: Setup Pages
        uses: actions/configure-pages@v3
        with:
          # Automatically inject basePath in your Next.js configuration file and disable
          # server side image optimization (https://nextjs.org/docs/api-reference/next/image#unoptimized).
          #
          # You may remove this line if you want to manage the configuration yourself.
          static_site_generator: next
      - name: Restore cache
        uses: actions/cache@v3
        with:
          path: |
            .next/cache
          # Generate a new cache whenever packages or source files change.
          key: ${{ runner.os }}-nextjs-${{ hashFiles('**/package-lock.json', '**/yarn.lock') }}-${{ hashFiles('**.[jt]s', '**.[jt]sx') }}
          # If source files changed but packages didn't, rebuild from a prior cache.
          restore-keys: |
            ${{ runner.os }}-nextjs-${{ hashFiles('**/package-lock.json', '**/yarn.lock') }}-
      - name: Install dependencies
        run: ${{ steps.detect-package-manager.outputs.manager }} ${{ steps.detect-package-manager.outputs.command }}
      - name: Build with Next.js
        run: ${{ steps.detect-package-manager.outputs.runner }} npm run build
        env:
          NEXT_PUBLIC_API_KEY: ${{ secrets.NEXT_PUBLIC_API_KEY }}
          NEXT_PUBLIC_SERVICE_DOMAIN: ${{ secrets.NEXT_PUBLIC_SERVICE_DOMAIN }}
      - name: Upload artifact
        uses: actions/upload-pages-artifact@v2
        with:
          path: ./out

  # Deployment job
  deploy:
    environment:
      name: github-pages
      url: ${{ steps.deployment.outputs.page_url }}
    runs-on: ubuntu-latest
    needs: build
    steps:
      - name: Deploy to GitHub Pages
        id: deployment
        uses: actions/deploy-pages@v2

デプロイ

デプロイに成功後[user].github.ioにアクセスするとサイトが表示されます。

microCMSからのデータ取得

・/src/app/post/[id]/page.tsxにファイルを作成することでlocalhost:3000/post/****へのリクエストは全てこのファイルにルーティングされます。
・ビルド時に各記事のファイルを動的に生成するためにgenerateStaticParams関数を実装しています。
・microCMSの管理画面では記事の内容をマークダウンで記載できますがAPIで取得した際にHTMLに変換されてしまうので
マークダウンの文字列を保存しておき、markedというライブラリを使って文字列をマークダウンに変換して表示しています。

記事詳細画面

import { createClient } from "microcms-js-sdk";
import { notFound } from "next/navigation";
import { marked } from 'marked';
import type {
  MicroCMSQueries,
  MicroCMSImage,
  MicroCMSDate,
 } from "microcms-js-sdk";

export const client = createClient({
  serviceDomain: process.env.NEXT_PUBLIC_SERVICE_DOMAIN || "",
  apiKey: process.env.NEXT_PUBLIC_API_KEY || "",
  customFetch: (input, init) => {
    if (typeof input === 'string') {
      const newInput = new URL(input)
      const time = new Date()
      newInput.searchParams.set('cacheclearparam', `${time.getMinutes()}`)
      return fetch(newInput.href, init)
    }
    return fetch(input, init)
  },
});

//ブログの型定義
export type Blog = {
  id: string;
  title: string;
  body: string;
  markdown: string;
  eyecatch?: MicroCMSImage;
 } & MicroCMSDate;

 // ブログ一覧を取得
export const getList = async (queries?: MicroCMSQueries) => {
  const listData = await client.getList<Blog>({
   endpoint: "blogs",
   queries,
  });
 
  return listData;
 };

export async function generateStaticParams() {
  const posts = await getList();

  return posts.contents.map((post:Blog) => ({
    id: post.id,
  }))
}

  // ブログ詳細を取得
export const getObject = async (contentId: string, queries?: MicroCMSQueries) => {

  const objectData = await client.get({
   endpoint: "blogs",
   contentId: contentId,
   queries,
  })
  .then((res) => {
    // console.log(res)
    return res;
  })
  .catch((res) => {
    return {
      notFound: true,
    };
  });
 
  return objectData;
 };

export default async function Post({
  params: { id },
}: {
  params: { id: string };
}) {
  const blog = await getObject(id);

  if(blog.id == undefined) {
    notFound();
  }

  return (
    <main className="flex min-h-screen flex-col items-center justify-start p-6 w-full">
      <div className="w-full place-items-center z-10 max-w-5xl justify-center font-mono flex">
        <p className="text-2xl font-bold font-mono flex justify-center border-b border-gray-300 w-auto rounded-xl border bg-gray-200 p-4 dark:bg-zinc-800/30">
          {blog.title}
        </p>
      </div>

      <div className="flex flex-col items-center justify-start ">
        <div className="mt-2">
          <p>{blog.revisedAt}</p>
        </div>
      </div>

      <div className="w-full sm:w-full md:w-5/6 lg:w-1/2">
        <div className="prose">
          <div className="py-5" dangerouslySetInnerHTML={{__html: marked(blog.markdown ?? "", {mangle:false})}}/>
        </div>
      </div>
    </main>
  );
};

AppRunnerでLaravel+RDB

きっかけ

この動画を見て、実際に動かしてみようと思いました。

www.youtube.com

筆者レベル

・実務でのサーバ構築経験なし
・UdemyのAWS入門動画で手を動かしたことがある
(EC2,RDS,IAM,VPC,S3などを少しずつ)

今回使用するサービス

・App Runner
・RDS
VPC
・IAM
・Secret Manager

AppRunnerでLaravelを動かし、RDSとの接続を確認するところまでを行いました

前提

ソースコードGithubのレポジトリを使用する
・構築設定はレポジトリに含めているapprunner.yamlを使用する

ソースおよびデプロイ

AWS App Runnerの画面に行き、サービスの作成を行います。
今回はGithubのレポジトリを使用するので、Githubと連携しレポジトリを選択できるようにしてください。
今回はデプロイ設定を手動にしておきます。

構築を設定

構築設定には設定ファイルを使用します。

サービスを設定

今回は動作確認を行うだけなので、任意のサービス名を指定後
「セキュリティ」と「ネットワーキング」のみ設定を変更します

セキュリティ

AppRunnerがRDSにアクセスするためにデータベースの認証情報が必要ですが、レポジトリに載せることはできないためSecretManagerの値を参照するようにします。
そのため、AppRunnerにSecretManagerの値を取得できるようなロールを与える必要があります。

IAMロールの作成

カスタム信頼ポリシーと許可を以下のように設定したロールを作成し、AppRunnerのインスタンスロールに指定します。

カスタム信頼ポリシー

{
  "Version": "2012-10-17",
  "Statement": [
    {
      "Effect": "Allow",
      "Principal": {
        "Service": "tasks.apprunner.amazonaws.com"
      },
      "Action": "sts:AssumeRole"
    }
  ]
}

許可

{
    "Statement": [
        {
            "Action": "secretsmanager:GetSecretValue",
            "Effect": "Allow",
            "Resource": [
                "arn:aws:secretsmanager:<region><account>:~~~",
                "arn:aws:secretsmanager:<region>:<account>:~~~"
            ]
        }
    ],
    "Version": "2012-10-17"
}

参考記事
[アップデート] AWS App Runner の環境変数ソースで Secrets Manager と SSM パラメータストアがサポートされました | DevelopersIO

ネットワーキング

AppRunnerがプライベートなRDSに接続するにはVPCコネクタというものを作成し、AppRunnerとRDSが置かれている別のVPNを繋げる必要があります。

VPC・セキュリティグループの作成

VPCを作成し、プライベートサブネットを作成します。
・AppRunner,RDSに設定する用のセキュリティグループをそれぞれ作成します。
VPCコネクタ作成時にAppRunner用のセキュリティグループを指定します。
・RDS作成時にRDS用のセキュリティグループを指定します。

参考記事
App Runner新機能でVPCリソースを利用してみた | クラウド・AWSのIT技術者向けブログ SKYARCH BROADCASTING

確認および作成

設定内容を確認し、デプロイが開始されます。

設定ファイルについて

apprunner.yaml

version: 1.0 
runtime: php81
build:
  commands:
    pre-build:
      - scripts/pre-build.sh
    build:
      - scripts/build.sh
    post-build:
      - scripts/post-build.sh
run:
  command: scripts/entrypoint.sh
  network:
    port: 8080
    env: APP_PORT
  secrets:
    - name: DB_USERNAME
      value-from: "arn:aws:secretsmanager:<region>:<account>:~~~:username::"
    - name: DB_PASSWORD
      value-from: "arn:aws:secretsmanager:<region>:<account>:~~~:password::"
    - name: DB_DATABASE
      value-from: "arn:aws:secretsmanager:<region>:<account>:~~~:database::"
    - name: DB_HOST
      value-from: "arn:aws:secretsmanager:<region>:<account>:~~~:host::"

build.sh(ファイルのパーミッションは自己責任で確認してください)

#!/bin/bash

# Install Composer
EXPECTED_CHECKSUM="$(php -r 'copy("https://composer.github.io/installer.sig", "php://stdout");')"
php -r "copy('https://getcomposer.org/installer', 'composer-setup.php');"
ACTUAL_CHECKSUM="$(php -r "echo hash_file('sha384', 'composer-setup.php');")"

if [ "$EXPECTED_CHECKSUM" != "$ACTUAL_CHECKSUM" ]
then
    >&2 echo 'ERROR: Invalid installer checksum'
    rm composer-setup.php
    exit 1
fi

php composer-setup.php
rm composer-setup.php

# Install dependencies

composer --version
yum install php-mbstring php-xml wget tar -y
amazon-linux-extras install nginx1 -y
php composer.phar install
cp -p .env.example .env
php artisan key:generate

# ディレクトリの権限設定
chown -R :nginx ./storage
chown -R :nginx ./bootstrap/cache
chown -R :nginx ./public

chmod -R 777 ./storage

find ./bootstrap/cache -type d -exec chmod 775 {} \;
find ./bootstrap/cache -type f -exec chmod 664 {} \;

find ./bootstrap/cache -type d -exec chmod g+s {} \;

setfacl -R -d -m g::rwx ./bootstrap/cache

entrypoint.sh

#!/usr/bin/env bash

php artisan config:clear
php artisan migrate

set -o monitor

trap exit SIGCHLD

# Start nginx 
nginx -g 'daemon off;' &

# Start php-fpm
php-fpm -F &

wait

※pre-build.sh,post-build.shでは特に何も実行していません。
php artisan migrateをbuild.shに書いていて、数時間つまずきました。

課題:RDSの確認方法

DBを接続することはできましたが、ローカルからDBのデータを確認したい場合にどのようにするべきか悩んでいます。

候補1/ AppRunner内にphpmyadminなどを設置してAWS WAFでアクセス制限をする

SecretManagerの認証情報をphpmyadminのconfigファイルに適用する方法がわからず断念しました。

候補2/ DB側VPCのパブリックサブネット上に踏み台サーバを設置して、セキュリティグループでアクセス制限をする

こちらであればできる気がします。

他に方法があれば試してみたいです。

参考記事

docs.aws.amazon.com
AWS Lambda PHPのProduction利用を続ける僕がAWS App Runnerの可能性を探る - Speaker Deck
AWS App RunnerのPHPマネージドランタイムをApache + PHP-FPMの構成で動作させる|AWS|開発ブログ|株式会社Nextat(ネクスタット)

「ソフトウェアテスト技法練習帳」40本ノック(3)/1杯目のビールの価格

この記事について

ソフトウェアテスト技法練習帳の問題をピックアップしてテストコードを書いています。
※コードは全てGithubにあげる予定です。
指摘・アドバイス大歓迎です!!!

仕様

・通常1杯490円で提供される
・16:00から17:59まではハッピーアワーのため、1杯290円で提供される
・クーポンを使うと利用時間に関わらず、100円で提供される
・ハッピーアワーでもクーポンは利用可能
・最も安い価格で提供される
※今回は問題の仕様とは別に一般的なクーポンの仕様も加えています
・クーポンの効果は店舗側が自由に設定できる
・クーポンは複数種類使用可能

テストコード

BeerTest.php

<?php

use App\Beer\Domain\Coupon\AvailablePeriod;
use App\Beer\Domain\Coupon\CouponConditions;
use App\Beer\Domain\Coupons;
use App\Beer\Domain\Customer;
use App\Beer\Domain\DeliveryFee;
use App\Beer\DomainService\OrderDomainService;
use App\Beer\Domain\Coupon\CouponInterface;
use App\Beer\Domain\Coupon\CouponSpecifications;
use App\Beer\Domain\Order;
use App\Beer\Domain\OrderDetail;
use App\Beer\Domain\OrderDetails;
use App\Beer\Domain\Product;
use App\Beer\Domain\Coupon\Coupon;
use App\Beer\Domain\CouponCondition\SpecificProductExistCondition;
use App\Beer\Domain\CouponSpecification\SpecificProductDiscountSpecification;
use App\Beer\DomainService\OrderDetailDomainService;
use PHPUnit\Framework\TestCase;

class BeerTest extends TestCase{

    protected function setUp() :void {
        
    }

    /**
     * @test
     * @dataProvider クーポンを使うまたはハッピーアワーだと割引になる用データ
     */
    public function クーポンを使うまたはハッピーアワーだと割引になる(
        \DateTimeImmutable $orderAt,
        bool $useCoupon,
        int $expectedPrice,
    ) {
        $orderDomainService = new OrderDomainService();
        $orderDetailDomainService = new OrderDetailDomainService();

        $beer = new Product('Beer',490,'beer-S',290);
        $happyHourPrice = $orderDetailDomainService->getHappyHourPrice($beer, $orderAt);
        $orderDetail = new OrderDetail(null,$happyHourPrice,$beer);
        $orderDetails = new OrderDetails([$orderDetail]);
        $customer = new Customer('Bob',new \DateTimeImmutable());

        $order = new Order(
            null,
            $orderDetails->getTotalPrice(),
            $customer,
            $orderDetails,
            new Coupons([]),
            new DeliveryFee(0),
        );

        if ($useCoupon) {
            $beerCoupon = $this->createBeerCoupon();
            if (!$orderDomainService->couponIsApplicable($order, $beerCoupon)) {
                throw new \Exception('Error');
            }

            $order = $orderDomainService->applyCoupon($order, $beerCoupon);
        }

        $this->assertEquals($expectedPrice, $order->getTotalPrice());
    }

    private function createBeerCoupon():CouponInterface {
        $beerCondition = new SpecificProductExistCondition("beer-S");
        $couponConditions = new CouponConditions([$beerCondition]);

        $beerSpecification = new SpecificProductDiscountSpecification('beer-S', 100);
        $couponSpecifications = new CouponSpecifications([$beerSpecification]);

        $beerCoupon = new Coupon(
            1,
            'beer',
            $couponConditions,
            $couponSpecifications,
            null,
            null,
            new AvailablePeriod(
                \DateTimeImmutable::createFromFormat('Y-m-d H:i:s', '2000-01-01 00:00:00'),
                \DateTimeImmutable::createFromFormat('Y-m-d H:i:s', '2100-01-31 23:59:59'),
            )
        );

        return $beerCoupon;
    }

    public static function クーポンを使うまたはハッピーアワーだと割引になる用データ() {
        return [
            'ハッピーアワーではなくクーポンは使わない' => [
                \DateTimeImmutable::createFromFormat('Y-m-d H:i:s', date('Y-m-d').' 20:00:00'),
                false,
                490
            ],
            'ハッピーアワーではなくクーポンを使用する' => [
                \DateTimeImmutable::createFromFormat('Y-m-d H:i:s', date('Y-m-d').' 20:00:00'),
                true,
                100
            ],
            'ハッピーアワーかつクーポンは使わない' => [
                \DateTimeImmutable::createFromFormat('Y-m-d H:i:s', date('Y-m-d').' 17:00:00'),
                false,
                290
            ],
            'ハッピーアワーかつクーポンを使用する' => [
                \DateTimeImmutable::createFromFormat('Y-m-d H:i:s', date('Y-m-d').' 17:00:00'),
                true,
                100
            ],
        ];
    }
}

Couponクラス

Coupon.php

<?php
namespace App\Beer\Domain\Coupon;

use App\Beer\Domain\Order;

class Coupon implements CouponInterface {

    private ?int $id;

    private String $couponCode;

    private CouponConditions $conditions;

    private CouponSpecifications $specifications;

    private ?int $useLimit;

    private ?int $useLimitByCustomer;

    private AvailablePeriod $availablePeriod;

    public function __construct(
        ?int $id,
        String $couponCode,
        CouponConditions $conditions,
        CouponSpecifications $specifications,
        ?int $useLimit,
        ?int $useLimitByCustomer,
        AvailablePeriod $availablePeriod,
    ) {
        $this->id = $id;
        $this->couponCode = $couponCode;
        $this->conditions = $conditions;
        $this->specifications = $specifications;
        $this->useLimit = $useLimit;
        $this->useLimitByCustomer = $useLimitByCustomer;
        $this->availablePeriod = $availablePeriod;
    }

    public function getId(): ?int {
        return $this->id;
    }

    public function isApplicable(Order $order): bool {
        return $this->conditions->appliable($order);
    }

    public function apply(Order $order):Order {
        $appliedOrder = $this->specifications->apply($order);

        return $appliedOrder;
    }

    public function getUseLimit(): ?int
    {
        return $this->useLimit;
    }

    public function getUseLimitByCustomer(): ?int
    {
        return $this->useLimitByCustomer;
    }

    public function isAvailable(): bool
    {
        return $this->availablePeriod->isAvailable(new \DateTimeImmutable());
    }
}

OrderDomainServiceクラス

OrderDomainService.php

<?php
namespace App\Beer\DomainService;

use App\Beer\Domain\Coupons;
use App\Beer\Domain\Order;
use App\Beer\Domain\Coupon\CouponInterface;

class OrderDomainService {

    public function __construct(
        
    ) {
        
    }

    public function couponIsApplicable(Order $order, CouponInterface $coupon):bool {
        if (!$coupon->isAvailable()) {
            return false;
        }

        // Couponの使用回数が決まっている場合
        if (!is_null($coupon->getUseLimit())) {
            return false;
        }

        // ユーザごとにCouponの使用回数が決まっている場合
        if (!is_null($coupon->getUseLimitByCustomer())) {
            return false;
        }
    
        return $coupon->isApplicable($order);
    }

    public function applyCoupon(Order $order, CouponInterface $coupon): Order {
        $order->addCoupon($coupon);

        $appliedOrder = $this->calculateCouponsOrder($order);

        return $appliedOrder;
    }

    private function calculateCouponsOrder(Order $order):Order {
        $orders = [];
        $coupons = $order->getCoupons()->asArray();

        $combinations = $this->permutation($coupons, count($coupons));
        // クーポンの適用順で最も最小金額になる組み合わせを探す
        foreach ($combinations as $coupons) {
            $couponCollection = new Coupons($coupons);
            $appliedOrder = $couponCollection->apply($order);
            $orders[] = $appliedOrder;
        }
        // 最小注文金額のOrderを返す
        $minTotalPriceOrder = $this->getMinTotalPriceOrder($orders);

        return $minTotalPriceOrder;
    }

    private function getMinTotalPriceOrder(Array $orders):Order {
        $totalPrices = [];
        foreach($orders as $order) {
            $totalPrices[] = $order->getTotalPrice();
        }

        $minTotalPriceIndex = array_keys($totalPrices, min($totalPrices))[0];

        return $orders[$minTotalPriceIndex];
    }

    // クーポンの順列を返す
    private function permutation(array $arr, int $r): ?array {
        $n = count($arr);
        $result = []; // 最終的に二次元配列にして返す

        // nPr の条件に一致していなければ null を返す
        if($n < 1 || $n < $r){ return null; }

        if($r === 1){
            foreach($arr as $item){
                $result[] = [$item];
            }
        }

        if($r > 1){
            // $item が先頭になる順列を算出する
            foreach($arr as $key => $item){
                // $item を除いた配列を作成
                $newArr = array_filter($arr, function($k) use($key) {
                    return $k !== $key;
                }, ARRAY_FILTER_USE_KEY);
                // 再帰処理 二次元配列が返ってくる
                $recursion = self::permutation($newArr, $r - 1);
                foreach($recursion as $one_set){
                    array_unshift($one_set, $item);
                    $result[] = $one_set;
                }
            }
        }

        return $result;
    }
}

ポイント

・注文へのクーポンの適用はOrderDomainServiceで行う
・Couponの適用可能条件と適用効果を分けて実装する
・クーポンの適用順によって金額が異なるため、順列を計算して最安値を探す

注文へのクーポンの適用はOrderDomainServiceで行う

Orderクラスにクーポンの適用処理関数を書くと
OrderオブジェクトがOrderオブジェクト(自分自身)を返すようになり動作が不自然に感じたためOrderDomainServiceに処理を書いています。

Couponの適用可能条件と適用効果を分けて実装する

今回の問題は居酒屋のビールクーポンについてですが
一般的にECサイトのクーポンなどでは、店舗がある程度自由にクーポンの内容を決めることができるはずです。
その内容はさまざまで
・一定金額以上購入すると注文全体への割引
・注文に特定の商品が含まれていれば特定の商品が500円になる
・注文に特定のカテゴリ商品が含まれていれば特定のカテゴリ商品が2割引
・送料無料
などがあります。

これらの適用可能条件と適用効果の組み合わせはとても多いため
それぞれInterfaceを分けてCouponクラスに持たせることで、機能が整理されて分かりやすくなりました。

クーポンの適用順によって金額が異なるため、順列を計算して最安値を探す

実装をしているとクーポンの適用順で注文の合計金額が変わってしまうことがわかりました。
なので、順列を計算してクーポン配列の組み合わせを全て試して最小金額の組み合わせを採用するようにしています。

終わりに

問題自体は複雑ではなかったですが、クーポンのモデリングがとても難しく
非常に勉強になりました。

参考資料

ドメイン駆動設計で保守性をあげたリニューアル事例 〜 ショッピングクーポンの設計紹介