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
なぜ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
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
参考
2023年に読んだ本
こんにちはyukと申します。
個人的に昨年は人生初めての転職ということで余暇も長く、動きも大きい年でした。
なので年末年始をダラダラと過ごしていましたが、何か記録せねばと思い去年読んだ本について書いてみます。
技術書
単体テストの考え方/使い方
転職のきっかけになった本とも言えるかもしれません。
当時、自身のヒューマンエラーや設計の甘さによるバグに苦しみ
どうすればミスを減らすことができるのかを考えていた中でドメイン駆動設計やテスト・保守性の高いコードに興味を持ち始めていた頃
業務ロジックとは密な関係を持たず、客観的に自分の書いたコードを評価できることで簡単なミスを減らすことができました。
テストコードを書いていなかった環境にいた私はこの本でテストの書き方の指針を知ることができ、このような取り組みをしている企業で働いてみたいと思いました。
AWSコンテナ設計・構築[本格]入門
転職活動中にAWSの資格を取得しましたが、実務経験がないためこの本を教材としてハンズオン形式でWebアプリケーションの環境構築を行いました。
図も多く使用されており、2023年8月時点では特に問題なく環境構築ができました。
技術書でない本?
エンジニアリング組織論への招待 ~不確実性に向き合う思考と組織のリファクタリング
この本は「エンジニアリング組織論への招待」というタイトルですが
私は特に業務における不安との向き合い方について非常に参考になりました。
というのも転職後は職場環境・人間関係が変化し
会社が自身に求めているレベルがわからなくなったり、新しい人間関係によって「認知の歪み」と呼ばれる過剰な思い込みが発生し自身にストレスがかかっていたことからメンタルが疲弊していた時期がありました。
そのような時に
・なぜ自分は不安なのか
・不安の根源は何なのか
などが言語化されていて、自分の不安の言語化や今後の対処法を考えるヒントが多く書かれており非常に助けになりました。
SOFT SKILLS ソフトウェア開発者の人生マニュアル
今後のキャリアについて改めて考える本でした。
「どこか」で専門的なキャリアパスを選んだ方が良いと思ってはいましたが
何も興味がある分野がない=フルスタックエンジニアと都合の良い脳内変換をして
トレンドから外れたスキルに学習時間を費やしてしまい、時間を無駄にしてしまうのではないかと選択を先延ばしにしていました。
もちろんそんなことはなく専門性は自身のブランディングになりますし、どんな知識やその過程も無駄になることはありません。
その他
ソフトウェアテスト技法練習帳 ~知識を経験に変える40問
良いコード/悪いコードで学ぶ設計入門 ―保守しやすい 成長し続けるコードの書き方
テスト駆動開発
システム設計の面接試験
世界一流エンジニアの思考法
ChatGPT/LangChainによるチャットシステム構築
これからはじめるReact実践入門 コンポーネントの基本からNext.jsによるアプリ開発まで
ここは今から倫理です。
ひらやすみ
終わりに
今年はデータベースに力を入れて勉強してみようかな・・・
DDDが好きなPHPerがFastAPIを触ってみる
きっかけ
偉大なWeb+DB Pressの隔月最終号でFastAPIを特集していたので、触ってみました。
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
参考記事
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
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に画像を送信すると、画像が反映されます。
参考記事
Next.js/microCMS/GithubActions/GithubPages で静的ブログを作る
はじめに
コーポレートサイトやブログ等、動的コンテンツが一部しかない場合
サーバ管理の必要がなくヘッドレスCMSから内容を編集するだけでサイトに反映されるためエンジニアでない方でも運用が簡単になると思い、試してみました。
構成
・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
きっかけ
この動画を見て、実際に動かしてみようと思いました。
筆者レベル
・実務でのサーバ構築経験なし
・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クラスに持たせることで、機能が整理されて分かりやすくなりました。
クーポンの適用順によって金額が異なるため、順列を計算して最安値を探す
実装をしているとクーポンの適用順で注文の合計金額が変わってしまうことがわかりました。
なので、順列を計算してクーポン配列の組み合わせを全て試して最小金額の組み合わせを採用するようにしています。
終わりに
問題自体は複雑ではなかったですが、クーポンのモデリングがとても難しく
非常に勉強になりました。