[{"content":"EDINET は金融庁が運営する電子開示システムで、上場企業が提出した有価証券報告書・四半期報告書などを無償で取得できる API を提供している。\n個人開発の財務分析ツールを作るにあたって、この API を Node.js / TypeScript で叩いた際のポイントをまとめる。\nエンドポイント概要 ベース URL：https://api.edinet-fsa.go.jp/api/v2\n用途 エンドポイント 書類一覧 GET /documents.json?date=YYYY-MM-DD\u0026amp;type=2 PDF取得 GET /documents/{docID}?type=2 XBRL取得 GET /documents/{docID}?type=1（ZIP） API キーはクエリパラメータ Subscription-Key で渡す。EDINET のサイト からアカウント登録すると発行される。ハードコードせず process.env.EDINET_API_KEY から読むのはもはや最低限のマナーといえる。\nレスポンスの型定義 まず API レスポンスに型をつける。docID（大文字）が EDINET 公式の表記：\ninterface EdinetDocumentResponse { docID: string // EDINET が振る書類ID docTypeCode: string | null secCode: string | null // 証券コード。上場企業以外は null edinetCode: string filerName: string docDescription: string | null submitDateTime: string } クライアントクラスで型を明示しておくと、後続のフィルタリングや保存ロジックで補完が効いて安全になる。\n書類一覧の取得とフィルタリング /documents.json は指定日に提出されたすべての書類を返す。有価証券報告書だけを絞り込むには docTypeCode を見る：\n120：有価証券報告書 130：訂正有価証券報告書 140：四半期報告書 また secCode が null の書類は上場企業以外なのでスキップする。\nconst filteredResults = results.filter((r: EdinetDocumentResponse) =\u0026gt; { const typeCode = (r.docTypeCode ?? \u0026#39;\u0026#39;).replace(/[\u0026#39;\u0026#34;]/g, \u0026#39;\u0026#39;) return ( r.secCode != null \u0026amp;\u0026amp; (typeCode === \u0026#39;120\u0026#39; || typeCode === \u0026#39;130\u0026#39; || typeCode === \u0026#39;140\u0026#39;) ) }) docTypeCode に余分なクォートが混入することがある（\u0026quot;120\u0026quot; のように入ってくる）ので replace で除去している。実際にハマった。\nPDF ダウンロードと 50KB スキップ type=2 で PDF が取得できる。ただし極端に小さい PDF（50KB 未満）が稀に存在し、そのままパース処理に渡すとエラーになる。\n原因は諸説あるが、提出後の取り下げ・差し替えによって実質的に中身がない状態になっているケースや、EDINET 側の生成エラーで空のファイルが返るケースが確認されている。サイズチェックを挟んでスキップするのが現実的：\ntry { const pdfBuffer = await this.edinetClient.downloadPdf(doc.docID) if (pdfBuffer.length \u0026lt; 50 * 1024) { logger.warn(`[FetchPdf] Skipping ${doc.docID}: too small (${pdfBuffer.length} bytes)`) // pdfFetched = true にして次回スキップ対象から外す await this.documentRepo.save({ ...doc, pdfFetched: true }) return } await fs.writeFile(path.join(pdfDir, `${doc.docID}.pdf`), pdfBuffer) await this.documentRepo.save({ ...doc, pdfFetched: true }) logger.info(`[FetchPdf] Saved ${doc.docID}`) } catch (error) { // 失敗した docID をログに残し、次のサイクルで再試行させる logger.error(`[FetchPdf] Failed to download ${doc.docID}:`, error) throw error // JobRunner 側で status=\u0026#39;error\u0026#39; に更新される } エラーを throw することで JobRunner 側がジョブを error 状態にし、次のサイクルで再試行できる。失敗した docID はログに残るので、後から手動で再実行することも可能。\nレート制限対策 EDINET API には明示的な RPS 上限は公開されていないが、連続リクエストすると弾かれる。 スロットルキューを持たせるのが堅牢だが、シンプルにはループ内スリープでも十分：\nconst apiKey = process.env.EDINET_API_KEY if (!apiKey) throw new Error(\u0026#39;EDINET_API_KEY is not set\u0026#39;) for (const doc of filteredDocs) { await new Promise\u0026lt;void\u0026gt;((r) =\u0026gt; setTimeout(r, 3000)) // 最低 3 秒 await downloadAndSave(doc, apiKey) } 一応今の実装時点ではもっと大きな値を入れてる。\n実運用では DB の settings テーブルにレート制限秒数を持たせ、UI から動的に変更できるようにしている。重い処理の日は10秒に上げるといった使い方ができる 。\nまとめ API キーは必ず環境変数から読む レスポンスに EdinetDocumentResponse 型をつけてコンパイル時に安全にする docTypeCode の余分なクォートに注意（replace で除去） secCode が null の書類は上場企業以外なのでフィルタ 50KB 未満の PDF は取り下げ等で空になっているケースがあるのでスキップ PDF ダウンロードは try-catch で囲み、失敗した docID をログに残して再試行できるようにする ","permalink":"https://techblog.wasutech.dev/posts/edinet-api-nodejs-typescript/","summary":"\u003cp\u003e\u003ca href=\"https://disclosure2.edinet-fsa.go.jp/WEEK0010.aspx\"\u003eEDINET\u003c/a\u003e は金融庁が運営する電子開示システムで、上場企業が提出した有価証券報告書・四半期報告書などを無償で取得できる API を提供している。\u003c/p\u003e\n\u003cp\u003e個人開発の財務分析ツールを作るにあたって、この API を Node.js / TypeScript で叩いた際のポイントをまとめる。\u003c/p\u003e\n\u003ch2 id=\"エンドポイント概要\"\u003eエンドポイント概要\u003c/h2\u003e\n\u003cp\u003eベース URL：\u003ccode\u003ehttps://api.edinet-fsa.go.jp/api/v2\u003c/code\u003e\u003c/p\u003e\n\u003ctable\u003e\n  \u003cthead\u003e\n      \u003ctr\u003e\n          \u003cth\u003e用途\u003c/th\u003e\n          \u003cth\u003eエンドポイント\u003c/th\u003e\n      \u003c/tr\u003e\n  \u003c/thead\u003e\n  \u003ctbody\u003e\n      \u003ctr\u003e\n          \u003ctd\u003e書類一覧\u003c/td\u003e\n          \u003ctd\u003e\u003ccode\u003eGET /documents.json?date=YYYY-MM-DD\u0026amp;type=2\u003c/code\u003e\u003c/td\u003e\n      \u003c/tr\u003e\n      \u003ctr\u003e\n          \u003ctd\u003ePDF取得\u003c/td\u003e\n          \u003ctd\u003e\u003ccode\u003eGET /documents/{docID}?type=2\u003c/code\u003e\u003c/td\u003e\n      \u003c/tr\u003e\n      \u003ctr\u003e\n          \u003ctd\u003eXBRL取得\u003c/td\u003e\n          \u003ctd\u003e\u003ccode\u003eGET /documents/{docID}?type=1\u003c/code\u003e（ZIP）\u003c/td\u003e\n      \u003c/tr\u003e\n  \u003c/tbody\u003e\n\u003c/table\u003e\n\u003cp\u003eAPI キーはクエリパラメータ \u003ccode\u003eSubscription-Key\u003c/code\u003e で渡す。\u003ca href=\"https://api.edinet-fsa.go.jp/api/auth/index.aspx?mode=1\"\u003eEDINET のサイト\u003c/a\u003e からアカウント登録すると発行される。\u003cstrong\u003eハードコードせず \u003ccode\u003eprocess.env.EDINET_API_KEY\u003c/code\u003e から読む\u003c/strong\u003eのはもはや最低限のマナーといえる。\u003c/p\u003e\n\u003ch2 id=\"レスポンスの型定義\"\u003eレスポンスの型定義\u003c/h2\u003e\n\u003cp\u003eまず API レスポンスに型をつける。\u003ccode\u003edocID\u003c/code\u003e（大文字）が EDINET 公式の表記：\u003c/p\u003e\n\u003cdiv class=\"highlight\"\u003e\u003cpre tabindex=\"0\" style=\"color:#f8f8f2;background-color:#272822;-moz-tab-size:4;-o-tab-size:4;tab-size:4;\"\u003e\u003ccode class=\"language-typescript\" data-lang=\"typescript\"\u003e\u003cspan style=\"display:flex;\"\u003e\u003cspan\u003e\u003cspan style=\"color:#66d9ef\"\u003einterface\u003c/span\u003e \u003cspan style=\"color:#a6e22e\"\u003eEdinetDocumentResponse\u003c/span\u003e {\n\u003c/span\u003e\u003c/span\u003e\u003cspan style=\"display:flex;\"\u003e\u003cspan\u003e  \u003cspan style=\"color:#a6e22e\"\u003edocID\u003c/span\u003e: \u003cspan style=\"color:#66d9ef\"\u003estring\u003c/span\u003e           \u003cspan style=\"color:#75715e\"\u003e// EDINET が振る書類ID\n\u003c/span\u003e\u003c/span\u003e\u003c/span\u003e\u003cspan style=\"display:flex;\"\u003e\u003cspan\u003e\u003cspan style=\"color:#75715e\"\u003e\u003c/span\u003e  \u003cspan style=\"color:#a6e22e\"\u003edocTypeCode\u003c/span\u003e: \u003cspan style=\"color:#66d9ef\"\u003estring\u003c/span\u003e \u003cspan style=\"color:#f92672\"\u003e|\u003c/span\u003e \u003cspan style=\"color:#66d9ef\"\u003enull\u003c/span\u003e\n\u003c/span\u003e\u003c/span\u003e\u003cspan style=\"display:flex;\"\u003e\u003cspan\u003e  \u003cspan style=\"color:#a6e22e\"\u003esecCode\u003c/span\u003e: \u003cspan style=\"color:#66d9ef\"\u003estring\u003c/span\u003e \u003cspan style=\"color:#f92672\"\u003e|\u003c/span\u003e \u003cspan style=\"color:#66d9ef\"\u003enull\u003c/span\u003e  \u003cspan style=\"color:#75715e\"\u003e// 証券コード。上場企業以外は null\n\u003c/span\u003e\u003c/span\u003e\u003c/span\u003e\u003cspan style=\"display:flex;\"\u003e\u003cspan\u003e\u003cspan style=\"color:#75715e\"\u003e\u003c/span\u003e  \u003cspan style=\"color:#a6e22e\"\u003eedinetCode\u003c/span\u003e: \u003cspan style=\"color:#66d9ef\"\u003estring\u003c/span\u003e\n\u003c/span\u003e\u003c/span\u003e\u003cspan style=\"display:flex;\"\u003e\u003cspan\u003e  \u003cspan style=\"color:#a6e22e\"\u003efilerName\u003c/span\u003e: \u003cspan style=\"color:#66d9ef\"\u003estring\u003c/span\u003e\n\u003c/span\u003e\u003c/span\u003e\u003cspan style=\"display:flex;\"\u003e\u003cspan\u003e  \u003cspan style=\"color:#a6e22e\"\u003edocDescription\u003c/span\u003e: \u003cspan style=\"color:#66d9ef\"\u003estring\u003c/span\u003e \u003cspan style=\"color:#f92672\"\u003e|\u003c/span\u003e \u003cspan style=\"color:#66d9ef\"\u003enull\u003c/span\u003e\n\u003c/span\u003e\u003c/span\u003e\u003cspan style=\"display:flex;\"\u003e\u003cspan\u003e  \u003cspan style=\"color:#a6e22e\"\u003esubmitDateTime\u003c/span\u003e: \u003cspan style=\"color:#66d9ef\"\u003estring\u003c/span\u003e\n\u003c/span\u003e\u003c/span\u003e\u003cspan style=\"display:flex;\"\u003e\u003cspan\u003e}\n\u003c/span\u003e\u003c/span\u003e\u003c/code\u003e\u003c/pre\u003e\u003c/div\u003e\u003cp\u003eクライアントクラスで型を明示しておくと、後続のフィルタリングや保存ロジックで補完が効いて安全になる。\u003c/p\u003e\n\u003ch2 id=\"書類一覧の取得とフィルタリング\"\u003e書類一覧の取得とフィルタリング\u003c/h2\u003e\n\u003cp\u003e\u003ccode\u003e/documents.json\u003c/code\u003e は指定日に提出されたすべての書類を返す。有価証券報告書だけを絞り込むには \u003ccode\u003edocTypeCode\u003c/code\u003e を見る：\u003c/p\u003e\n\u003cul\u003e\n\u003cli\u003e\u003ccode\u003e120\u003c/code\u003e：有価証券報告書\u003c/li\u003e\n\u003cli\u003e\u003ccode\u003e130\u003c/code\u003e：訂正有価証券報告書\u003c/li\u003e\n\u003cli\u003e\u003ccode\u003e140\u003c/code\u003e：四半期報告書\u003c/li\u003e\n\u003c/ul\u003e\n\u003cp\u003eまた \u003ccode\u003esecCode\u003c/code\u003e が \u003ccode\u003enull\u003c/code\u003e の書類は上場企業以外なのでスキップする。\u003c/p\u003e\n\u003cdiv class=\"highlight\"\u003e\u003cpre tabindex=\"0\" style=\"color:#f8f8f2;background-color:#272822;-moz-tab-size:4;-o-tab-size:4;tab-size:4;\"\u003e\u003ccode class=\"language-typescript\" data-lang=\"typescript\"\u003e\u003cspan style=\"display:flex;\"\u003e\u003cspan\u003e\u003cspan style=\"color:#66d9ef\"\u003econst\u003c/span\u003e \u003cspan style=\"color:#a6e22e\"\u003efilteredResults\u003c/span\u003e \u003cspan style=\"color:#f92672\"\u003e=\u003c/span\u003e \u003cspan style=\"color:#a6e22e\"\u003eresults\u003c/span\u003e.\u003cspan style=\"color:#a6e22e\"\u003efilter\u003c/span\u003e((\u003cspan style=\"color:#a6e22e\"\u003er\u003c/span\u003e: \u003cspan style=\"color:#66d9ef\"\u003eEdinetDocumentResponse\u003c/span\u003e) \u003cspan style=\"color:#f92672\"\u003e=\u0026gt;\u003c/span\u003e {\n\u003c/span\u003e\u003c/span\u003e\u003cspan style=\"display:flex;\"\u003e\u003cspan\u003e  \u003cspan style=\"color:#66d9ef\"\u003econst\u003c/span\u003e \u003cspan style=\"color:#a6e22e\"\u003etypeCode\u003c/span\u003e \u003cspan style=\"color:#f92672\"\u003e=\u003c/span\u003e (\u003cspan style=\"color:#a6e22e\"\u003er\u003c/span\u003e.\u003cspan style=\"color:#a6e22e\"\u003edocTypeCode\u003c/span\u003e \u003cspan style=\"color:#f92672\"\u003e??\u003c/span\u003e \u003cspan style=\"color:#e6db74\"\u003e\u0026#39;\u0026#39;\u003c/span\u003e).\u003cspan style=\"color:#a6e22e\"\u003ereplace\u003c/span\u003e(\u003cspan style=\"color:#e6db74\"\u003e/[\u0026#39;\u0026#34;]/g\u003c/span\u003e, \u003cspan style=\"color:#e6db74\"\u003e\u0026#39;\u0026#39;\u003c/span\u003e)\n\u003c/span\u003e\u003c/span\u003e\u003cspan style=\"display:flex;\"\u003e\u003cspan\u003e  \u003cspan style=\"color:#66d9ef\"\u003ereturn\u003c/span\u003e (\n\u003c/span\u003e\u003c/span\u003e\u003cspan style=\"display:flex;\"\u003e\u003cspan\u003e    \u003cspan style=\"color:#a6e22e\"\u003er\u003c/span\u003e.\u003cspan style=\"color:#a6e22e\"\u003esecCode\u003c/span\u003e \u003cspan style=\"color:#f92672\"\u003e!=\u003c/span\u003e \u003cspan style=\"color:#66d9ef\"\u003enull\u003c/span\u003e \u003cspan style=\"color:#f92672\"\u003e\u0026amp;\u0026amp;\u003c/span\u003e\n\u003c/span\u003e\u003c/span\u003e\u003cspan style=\"display:flex;\"\u003e\u003cspan\u003e    (\u003cspan style=\"color:#a6e22e\"\u003etypeCode\u003c/span\u003e \u003cspan style=\"color:#f92672\"\u003e===\u003c/span\u003e \u003cspan style=\"color:#e6db74\"\u003e\u0026#39;120\u0026#39;\u003c/span\u003e \u003cspan style=\"color:#f92672\"\u003e||\u003c/span\u003e \u003cspan style=\"color:#a6e22e\"\u003etypeCode\u003c/span\u003e \u003cspan style=\"color:#f92672\"\u003e===\u003c/span\u003e \u003cspan style=\"color:#e6db74\"\u003e\u0026#39;130\u0026#39;\u003c/span\u003e \u003cspan style=\"color:#f92672\"\u003e||\u003c/span\u003e \u003cspan style=\"color:#a6e22e\"\u003etypeCode\u003c/span\u003e \u003cspan style=\"color:#f92672\"\u003e===\u003c/span\u003e \u003cspan style=\"color:#e6db74\"\u003e\u0026#39;140\u0026#39;\u003c/span\u003e)\n\u003c/span\u003e\u003c/span\u003e\u003cspan style=\"display:flex;\"\u003e\u003cspan\u003e  )\n\u003c/span\u003e\u003c/span\u003e\u003cspan style=\"display:flex;\"\u003e\u003cspan\u003e})\n\u003c/span\u003e\u003c/span\u003e\u003c/code\u003e\u003c/pre\u003e\u003c/div\u003e\u003cp\u003e\u003ccode\u003edocTypeCode\u003c/code\u003e に余分なクォートが混入することがある（\u003ccode\u003e\u0026quot;120\u0026quot;\u003c/code\u003e のように入ってくる）ので \u003ccode\u003ereplace\u003c/code\u003e で除去している。実際にハマった。\u003c/p\u003e","title":"EDINET API v2 で有価証券報告書を自動取得する（Node.js / TypeScript）"},{"content":"個人開発の EDINET 分析ツールでは、取得した有価証券報告書の PDF を Gemini に渡して「怪しさ判定」をさせている。\n単なる要約ではなく、3段階のスコア（normal / caution / danger） を返させる設計にしたので、その仕組みをまとめる。\nなぜスコアが必要か 毎日数十〜数百件の書類が提出される。全部読むのは無理なので、AI に「これは要注意」かどうかを仕分けさせたい。\nスコアが danger の書類だけ Discord 通知を飛ばす、といった使い方ができる。\nプロンプト設計 プロンプトの末尾に必ずスコアを出力させるよう指示する：\n分析の最後に必ず以下の形式でスコアを出力してください： SCORE:normal # 特に問題なし SCORE:caution # 気になる点あり・要確認 SCORE:danger # 重大なリスクの可能性 Gemini はマークダウン形式で分析テキストを返した後、最終行に SCORE:danger のような文字列を出力する。\nPDF を渡す方法 @google/generative-ai SDK では PDF を base64 で渡せる：\nconst model = genAI.getGenerativeModel({ model: \u0026#39;gemini-2.5-flash\u0026#39; }) const result = await model.generateContent([ { inlineData: { mimeType: \u0026#39;application/pdf\u0026#39;, data: pdfBuffer.toString(\u0026#39;base64\u0026#39;), }, }, { text: prompt }, ]) 最大 50MB まで渡せるが、大きすぎるとトークン消費が跳ね上がるので注意。\nスコアのパース 正規表現で SCORE: 以降を抽出：\nfunction parseScore(text: string): \u0026#39;normal\u0026#39; | \u0026#39;caution\u0026#39; | \u0026#39;danger\u0026#39; { const match = text.match(/SCORE:(normal|caution|danger)/i) return (match?.[1] ?? \u0026#39;normal\u0026#39;) as \u0026#39;normal\u0026#39; | \u0026#39;caution\u0026#39; | \u0026#39;danger\u0026#39; } この1行で取れる。テキスト全体で最初にマッチした SCORE:xxx を使うので、プロンプトの中に SCORE: を書いてしまうと誤マッチする。プロンプト側では SCORE:normal という形式を「例として」書かずに指示だけにするか、出力セクションを明示的に区切るとよい。\nDB への保存 analysis_results テーブルの score カラムに CHECK 制約を入れている：\nscore VARCHAR(10) CHECK (score IN (\u0026#39;normal\u0026#39;, \u0026#39;caution\u0026#39;, \u0026#39;danger\u0026#39;)) これで不正な値が入らない。プロンプトの出力がブレてもDB側で弾ける。\nまとめ プロンプト末尾に SCORE:xxx を出力させる設計はシンプルで使いやすい PDF は base64 で inlineData として渡す スコアのパースは正規表現1行 DB の CHECK 制約でバリデーションを二重にかける ","permalink":"https://techblog.wasutech.dev/posts/gemini-api-financial-document-scoring/","summary":"\u003cp\u003e個人開発の EDINET 分析ツールでは、取得した有価証券報告書の PDF を Gemini に渡して「怪しさ判定」をさせている。\u003cbr\u003e\n単なる要約ではなく、\u003cstrong\u003e3段階のスコア（normal / caution / danger）\u003c/strong\u003e を返させる設計にしたので、その仕組みをまとめる。\u003c/p\u003e\n\u003ch2 id=\"なぜスコアが必要か\"\u003eなぜスコアが必要か\u003c/h2\u003e\n\u003cp\u003e毎日数十〜数百件の書類が提出される。全部読むのは無理なので、AI に「これは要注意」かどうかを仕分けさせたい。\u003cbr\u003e\nスコアが \u003ccode\u003edanger\u003c/code\u003e の書類だけ Discord 通知を飛ばす、といった使い方ができる。\u003c/p\u003e\n\u003ch2 id=\"プロンプト設計\"\u003eプロンプト設計\u003c/h2\u003e\n\u003cp\u003eプロンプトの末尾に必ずスコアを出力させるよう指示する：\u003c/p\u003e\n\u003cpre tabindex=\"0\"\u003e\u003ccode\u003e分析の最後に必ず以下の形式でスコアを出力してください：\n\nSCORE:normal   # 特に問題なし\nSCORE:caution  # 気になる点あり・要確認\nSCORE:danger   # 重大なリスクの可能性\n\u003c/code\u003e\u003c/pre\u003e\u003cp\u003eGemini はマークダウン形式で分析テキストを返した後、最終行に \u003ccode\u003eSCORE:danger\u003c/code\u003e のような文字列を出力する。\u003c/p\u003e\n\u003ch2 id=\"pdf-を渡す方法\"\u003ePDF を渡す方法\u003c/h2\u003e\n\u003cp\u003e\u003ccode\u003e@google/generative-ai\u003c/code\u003e SDK では PDF を base64 で渡せる：\u003c/p\u003e\n\u003cdiv class=\"highlight\"\u003e\u003cpre tabindex=\"0\" style=\"color:#f8f8f2;background-color:#272822;-moz-tab-size:4;-o-tab-size:4;tab-size:4;\"\u003e\u003ccode class=\"language-typescript\" data-lang=\"typescript\"\u003e\u003cspan style=\"display:flex;\"\u003e\u003cspan\u003e\u003cspan style=\"color:#66d9ef\"\u003econst\u003c/span\u003e \u003cspan style=\"color:#a6e22e\"\u003emodel\u003c/span\u003e \u003cspan style=\"color:#f92672\"\u003e=\u003c/span\u003e \u003cspan style=\"color:#a6e22e\"\u003egenAI\u003c/span\u003e.\u003cspan style=\"color:#a6e22e\"\u003egetGenerativeModel\u003c/span\u003e({ \u003cspan style=\"color:#a6e22e\"\u003emodel\u003c/span\u003e\u003cspan style=\"color:#f92672\"\u003e:\u003c/span\u003e \u003cspan style=\"color:#e6db74\"\u003e\u0026#39;gemini-2.5-flash\u0026#39;\u003c/span\u003e })\n\u003c/span\u003e\u003c/span\u003e\u003cspan style=\"display:flex;\"\u003e\u003cspan\u003e\n\u003c/span\u003e\u003c/span\u003e\u003cspan style=\"display:flex;\"\u003e\u003cspan\u003e\u003cspan style=\"color:#66d9ef\"\u003econst\u003c/span\u003e \u003cspan style=\"color:#a6e22e\"\u003eresult\u003c/span\u003e \u003cspan style=\"color:#f92672\"\u003e=\u003c/span\u003e \u003cspan style=\"color:#66d9ef\"\u003eawait\u003c/span\u003e \u003cspan style=\"color:#a6e22e\"\u003emodel\u003c/span\u003e.\u003cspan style=\"color:#a6e22e\"\u003egenerateContent\u003c/span\u003e([\n\u003c/span\u003e\u003c/span\u003e\u003cspan style=\"display:flex;\"\u003e\u003cspan\u003e  {\n\u003c/span\u003e\u003c/span\u003e\u003cspan style=\"display:flex;\"\u003e\u003cspan\u003e    \u003cspan style=\"color:#a6e22e\"\u003einlineData\u003c/span\u003e\u003cspan style=\"color:#f92672\"\u003e:\u003c/span\u003e {\n\u003c/span\u003e\u003c/span\u003e\u003cspan style=\"display:flex;\"\u003e\u003cspan\u003e      \u003cspan style=\"color:#a6e22e\"\u003emimeType\u003c/span\u003e\u003cspan style=\"color:#f92672\"\u003e:\u003c/span\u003e \u003cspan style=\"color:#e6db74\"\u003e\u0026#39;application/pdf\u0026#39;\u003c/span\u003e,\n\u003c/span\u003e\u003c/span\u003e\u003cspan style=\"display:flex;\"\u003e\u003cspan\u003e      \u003cspan style=\"color:#a6e22e\"\u003edata\u003c/span\u003e: \u003cspan style=\"color:#66d9ef\"\u003epdfBuffer.toString\u003c/span\u003e(\u003cspan style=\"color:#e6db74\"\u003e\u0026#39;base64\u0026#39;\u003c/span\u003e),\n\u003c/span\u003e\u003c/span\u003e\u003cspan style=\"display:flex;\"\u003e\u003cspan\u003e    },\n\u003c/span\u003e\u003c/span\u003e\u003cspan style=\"display:flex;\"\u003e\u003cspan\u003e  },\n\u003c/span\u003e\u003c/span\u003e\u003cspan style=\"display:flex;\"\u003e\u003cspan\u003e  { \u003cspan style=\"color:#a6e22e\"\u003etext\u003c/span\u003e: \u003cspan style=\"color:#66d9ef\"\u003eprompt\u003c/span\u003e },\n\u003c/span\u003e\u003c/span\u003e\u003cspan style=\"display:flex;\"\u003e\u003cspan\u003e])\n\u003c/span\u003e\u003c/span\u003e\u003c/code\u003e\u003c/pre\u003e\u003c/div\u003e\u003cp\u003e最大 50MB まで渡せるが、大きすぎるとトークン消費が跳ね上がるので注意。\u003c/p\u003e\n\u003ch2 id=\"スコアのパース\"\u003eスコアのパース\u003c/h2\u003e\n\u003cp\u003e正規表現で \u003ccode\u003eSCORE:\u003c/code\u003e 以降を抽出：\u003c/p\u003e","title":"Gemini API で財務書類を「怪しさ判定」する：スコア付き出力の設計"},{"content":"個人開発に「クリーンアーキテクチャ」は過剰では？という気持ちはある。\nただ実際にやってみたら、テストが書きやすい・外部APIの差し替えが楽という恩恵がちゃんとあった。\nHono + TypeScript (ESM) でどう組んだかをメモしておく。\nディレクトリ構成 backend/src/ ├── domain/ # エンティティ・リポジトリ Interface │ ├── entity/ │ └── repository/ ├── usecase/ # ビジネスロジック ├── infrastructure/ # DB・外部API の実装 │ ├── postgres/ │ ├── edinet/ │ └── gemini/ ├── api/ # Hono ルーター └── job/ # JobRunner 依存の方向 api / job ↓ usecase ← domain (Interface) ↓ infrastructure → domain (Interface を実装) usecase は domain の Interface にしか依存しない。\ninfrastructure が Interface を実装する。これだけ守れば十分。\nRepository Interface の例 // domain/repository/documentRepository.ts export interface DocumentRepository { save(document: Document): Promise\u0026lt;void\u0026gt; exists(docId: string): Promise\u0026lt;boolean\u0026gt; findUnanalyzedOne(): Promise\u0026lt;Document | null\u0026gt; findById(docId: string): Promise\u0026lt;Document | null\u0026gt; } PostgreSQL 実装 // infrastructure/postgres/postgresDocumentRepository.ts export class PostgresDocumentRepository implements DocumentRepository { constructor(private pool: Pool) {} async exists(docId: string): Promise\u0026lt;boolean\u0026gt; { const res = await this.pool.query( \u0026#39;SELECT 1 FROM documents WHERE doc_id = $1\u0026#39;, [docId] ) return (res.rowCount ?? 0) \u0026gt; 0 } // ... } UseCase でビジネスロジックを書く // usecase/fetchDocuments.ts export class FetchDocumentsUseCase { constructor( private documentRepository: DocumentRepository, private edinetClient: EdinetClient ) {} async execute(date: string): Promise\u0026lt;void\u0026gt; { const results = await this.edinetClient.listDocuments(date) for (const res of results) { if (await this.documentRepository.exists(res.docID)) continue await this.documentRepository.save(/* ... */) } } } UseCase は DocumentRepository の Interface しか知らない。\nテストでは vi.fn() でモックしたオブジェクトを渡すだけ。\nテストが書きやすい describe(\u0026#39;FetchDocumentsUseCase\u0026#39;, () =\u0026gt; { it(\u0026#39;already existing docs are skipped\u0026#39;, async () =\u0026gt; { const mockRepo = { exists: vi.fn().mockResolvedValue(true), save: vi.fn(), // ... } const useCase = new FetchDocumentsUseCase(mockRepo, mockEdinetClient) await useCase.execute(\u0026#39;2026-01-01\u0026#39;) expect(mockRepo.save).not.toHaveBeenCalled() }) }) DB を立ち上げなくてもロジックをテストできる。\nHono ルーターとの繋ぎ方 // api/routes/documents.ts const router = new Hono() router.get(\u0026#39;/\u0026#39;, async (c) =\u0026gt; { const docs = await documentRepo.findAll() return c.json(docs) }) router.post(\u0026#39;/:docId/analyze\u0026#39;, async (c) =\u0026gt; { const { docId } = c.req.param() const jobId = await jobRunner.createAndRun(\u0026#39;analyze\u0026#39;, { docId }) return c.json({ jobId }) }) ルーターは Repository・JobRunner を直接受け取る。DI コンテナは使わず、server.ts でインスタンスを組み立てて渡している。個人開発ならこれで十分。\nまとめ Interface → UseCase → Infrastructure の依存方向を守るだけでかなり効果がある DI コンテナは不要。server.ts で手で組み立てる テストが書きやすくなるのは本当 Hono は軽くて Bun / Node.js 両方で動くので個人開発に向いている ","permalink":"https://techblog.wasutech.dev/posts/hono-typescript-clean-architecture/","summary":"\u003cp\u003e個人開発に「クリーンアーキテクチャ」は過剰では？という気持ちはある。\u003cbr\u003e\nただ実際にやってみたら、\u003cstrong\u003eテストが書きやすい・外部APIの差し替えが楽\u003c/strong\u003eという恩恵がちゃんとあった。\u003cbr\u003e\nHono + TypeScript (ESM) でどう組んだかをメモしておく。\u003c/p\u003e\n\u003ch2 id=\"ディレクトリ構成\"\u003eディレクトリ構成\u003c/h2\u003e\n\u003cpre tabindex=\"0\"\u003e\u003ccode\u003ebackend/src/\n├── domain/           # エンティティ・リポジトリ Interface\n│   ├── entity/\n│   └── repository/\n├── usecase/          # ビジネスロジック\n├── infrastructure/   # DB・外部API の実装\n│   ├── postgres/\n│   ├── edinet/\n│   └── gemini/\n├── api/              # Hono ルーター\n└── job/              # JobRunner\n\u003c/code\u003e\u003c/pre\u003e\u003ch2 id=\"依存の方向\"\u003e依存の方向\u003c/h2\u003e\n\u003cpre tabindex=\"0\"\u003e\u003ccode\u003eapi / job\n    ↓\nusecase          ← domain (Interface)\n    ↓\ninfrastructure   → domain (Interface を実装)\n\u003c/code\u003e\u003c/pre\u003e\u003cp\u003e\u003ccode\u003eusecase\u003c/code\u003e は \u003ccode\u003edomain\u003c/code\u003e の Interface にしか依存しない。\u003cbr\u003e\n\u003ccode\u003einfrastructure\u003c/code\u003e が Interface を実装する。これだけ守れば十分。\u003c/p\u003e","title":"Hono + TypeScript でクリーンアーキテクチャもどきを個人開発に持ち込む"},{"content":"何が起きたか useState の初期値で Date.now() を直接呼んでいたら、こんなエラーが出た。\nerror Error: Cannot call impure function during render `Date.now` is an impure function. コード的にはこういうやつ。\nconst [stockPriceFrom, setStockPriceFrom] = useState\u0026lt;string\u0026gt;( new Date(Date.now() - 90 * 86400000).toISOString().split(\u0026#39;T\u0026#39;)[0] ) なぜエラーになるか React のルールとして、レンダー中はコンポーネントが pure でなければならない。\nDate.now() や Math.random() はレンダーのたびに異なる値を返す「impure な関数」なので、直接渡すと React（特に Strict Mode）に怒られる。\nStrict Mode ではレンダーを意図的に 2 回実行するため、こういった副作用が顕在化しやすい。\n解決策：lazy initialization useState に関数を渡すと、初回マウント時に一度だけ実行される。これが lazy initialization パターン。\nconst [stockPriceFrom, setStockPriceFrom] = useState\u0026lt;string\u0026gt;( () =\u0026gt; new Date(Date.now() - 90 * 86400000).toISOString().split(\u0026#39;T\u0026#39;)[0] ) const [stockPriceTo, setStockPriceTo] = useState\u0026lt;string\u0026gt;( () =\u0026gt; new Date(Date.now() - 84 * 86400000).toISOString().split(\u0026#39;T\u0026#39;)[0] ) () =\u0026gt; で包むだけ。それだけ。\nまとめ パターン 評価タイミング useState(Date.now()) レンダーごとに評価される useState(() =\u0026gt; Date.now()) 初回マウント時のみ new Date() も同様なので、日付系の初期値を useState に渡すときはアロー関数で包む癖をつけておくと良い。\n参考：React 公式 — Components and Hooks must be pure\n","permalink":"https://techblog.wasutech.dev/posts/react-use-state-date-now/","summary":"\u003ch2 id=\"何が起きたか\"\u003e何が起きたか\u003c/h2\u003e\n\u003cp\u003e\u003ccode\u003euseState\u003c/code\u003e の初期値で \u003ccode\u003eDate.now()\u003c/code\u003e を直接呼んでいたら、こんなエラーが出た。\u003c/p\u003e\n\u003cpre tabindex=\"0\"\u003e\u003ccode\u003eerror  Error: Cannot call impure function during render\n`Date.now` is an impure function.\n\u003c/code\u003e\u003c/pre\u003e\u003cp\u003eコード的にはこういうやつ。\u003c/p\u003e\n\u003cdiv class=\"highlight\"\u003e\u003cpre tabindex=\"0\" style=\"color:#f8f8f2;background-color:#272822;-moz-tab-size:4;-o-tab-size:4;tab-size:4;\"\u003e\u003ccode class=\"language-tsx\" data-lang=\"tsx\"\u003e\u003cspan style=\"display:flex;\"\u003e\u003cspan\u003e\u003cspan style=\"color:#66d9ef\"\u003econst\u003c/span\u003e [\u003cspan style=\"color:#a6e22e\"\u003estockPriceFrom\u003c/span\u003e, \u003cspan style=\"color:#a6e22e\"\u003esetStockPriceFrom\u003c/span\u003e] \u003cspan style=\"color:#f92672\"\u003e=\u003c/span\u003e \u003cspan style=\"color:#a6e22e\"\u003euseState\u003c/span\u003e\u0026lt;\u003cspan style=\"color:#f92672\"\u003estring\u003c/span\u003e\u0026gt;(\n\u003c/span\u003e\u003c/span\u003e\u003cspan style=\"display:flex;\"\u003e\u003cspan\u003e  \u003cspan style=\"color:#66d9ef\"\u003enew\u003c/span\u003e Date(Date.\u003cspan style=\"color:#a6e22e\"\u003enow\u003c/span\u003e() \u003cspan style=\"color:#f92672\"\u003e-\u003c/span\u003e \u003cspan style=\"color:#ae81ff\"\u003e90\u003c/span\u003e \u003cspan style=\"color:#f92672\"\u003e*\u003c/span\u003e \u003cspan style=\"color:#ae81ff\"\u003e86400000\u003c/span\u003e).\u003cspan style=\"color:#a6e22e\"\u003etoISOString\u003c/span\u003e().\u003cspan style=\"color:#a6e22e\"\u003esplit\u003c/span\u003e(\u003cspan style=\"color:#e6db74\"\u003e\u0026#39;T\u0026#39;\u003c/span\u003e)[\u003cspan style=\"color:#ae81ff\"\u003e0\u003c/span\u003e]\n\u003c/span\u003e\u003c/span\u003e\u003cspan style=\"display:flex;\"\u003e\u003cspan\u003e)\n\u003c/span\u003e\u003c/span\u003e\u003c/code\u003e\u003c/pre\u003e\u003c/div\u003e\u003ch2 id=\"なぜエラーになるか\"\u003eなぜエラーになるか\u003c/h2\u003e\n\u003cp\u003eReact のルールとして、\u003cstrong\u003eレンダー中はコンポーネントが pure でなければならない\u003c/strong\u003e。\u003c/p\u003e\n\u003cp\u003e\u003ccode\u003eDate.now()\u003c/code\u003e や \u003ccode\u003eMath.random()\u003c/code\u003e はレンダーのたびに異なる値を返す「impure な関数」なので、直接渡すと React（特に Strict Mode）に怒られる。\u003c/p\u003e\n\u003cp\u003eStrict Mode ではレンダーを意図的に 2 回実行するため、こういった副作用が顕在化しやすい。\u003c/p\u003e\n\u003ch2 id=\"解決策lazy-initialization\"\u003e解決策：lazy initialization\u003c/h2\u003e\n\u003cp\u003e\u003ccode\u003euseState\u003c/code\u003e に関数を渡すと、\u003cstrong\u003e初回マウント時に一度だけ実行\u003c/strong\u003eされる。これが lazy initialization パターン。\u003c/p\u003e\n\u003cdiv class=\"highlight\"\u003e\u003cpre tabindex=\"0\" style=\"color:#f8f8f2;background-color:#272822;-moz-tab-size:4;-o-tab-size:4;tab-size:4;\"\u003e\u003ccode class=\"language-tsx\" data-lang=\"tsx\"\u003e\u003cspan style=\"display:flex;\"\u003e\u003cspan\u003e\u003cspan style=\"color:#66d9ef\"\u003econst\u003c/span\u003e [\u003cspan style=\"color:#a6e22e\"\u003estockPriceFrom\u003c/span\u003e, \u003cspan style=\"color:#a6e22e\"\u003esetStockPriceFrom\u003c/span\u003e] \u003cspan style=\"color:#f92672\"\u003e=\u003c/span\u003e \u003cspan style=\"color:#a6e22e\"\u003euseState\u003c/span\u003e\u0026lt;\u003cspan style=\"color:#f92672\"\u003estring\u003c/span\u003e\u0026gt;(\n\u003c/span\u003e\u003c/span\u003e\u003cspan style=\"display:flex;\"\u003e\u003cspan\u003e  () \u003cspan style=\"color:#f92672\"\u003e=\u0026gt;\u003c/span\u003e \u003cspan style=\"color:#66d9ef\"\u003enew\u003c/span\u003e Date(Date.\u003cspan style=\"color:#a6e22e\"\u003enow\u003c/span\u003e() \u003cspan style=\"color:#f92672\"\u003e-\u003c/span\u003e \u003cspan style=\"color:#ae81ff\"\u003e90\u003c/span\u003e \u003cspan style=\"color:#f92672\"\u003e*\u003c/span\u003e \u003cspan style=\"color:#ae81ff\"\u003e86400000\u003c/span\u003e).\u003cspan style=\"color:#a6e22e\"\u003etoISOString\u003c/span\u003e().\u003cspan style=\"color:#a6e22e\"\u003esplit\u003c/span\u003e(\u003cspan style=\"color:#e6db74\"\u003e\u0026#39;T\u0026#39;\u003c/span\u003e)[\u003cspan style=\"color:#ae81ff\"\u003e0\u003c/span\u003e]\n\u003c/span\u003e\u003c/span\u003e\u003cspan style=\"display:flex;\"\u003e\u003cspan\u003e)\n\u003c/span\u003e\u003c/span\u003e\u003cspan style=\"display:flex;\"\u003e\u003cspan\u003e\u003cspan style=\"color:#66d9ef\"\u003econst\u003c/span\u003e [\u003cspan style=\"color:#a6e22e\"\u003estockPriceTo\u003c/span\u003e, \u003cspan style=\"color:#a6e22e\"\u003esetStockPriceTo\u003c/span\u003e] \u003cspan style=\"color:#f92672\"\u003e=\u003c/span\u003e \u003cspan style=\"color:#a6e22e\"\u003euseState\u003c/span\u003e\u0026lt;\u003cspan style=\"color:#f92672\"\u003estring\u003c/span\u003e\u0026gt;(\n\u003c/span\u003e\u003c/span\u003e\u003cspan style=\"display:flex;\"\u003e\u003cspan\u003e  () \u003cspan style=\"color:#f92672\"\u003e=\u0026gt;\u003c/span\u003e \u003cspan style=\"color:#66d9ef\"\u003enew\u003c/span\u003e Date(Date.\u003cspan style=\"color:#a6e22e\"\u003enow\u003c/span\u003e() \u003cspan style=\"color:#f92672\"\u003e-\u003c/span\u003e \u003cspan style=\"color:#ae81ff\"\u003e84\u003c/span\u003e \u003cspan style=\"color:#f92672\"\u003e*\u003c/span\u003e \u003cspan style=\"color:#ae81ff\"\u003e86400000\u003c/span\u003e).\u003cspan style=\"color:#a6e22e\"\u003etoISOString\u003c/span\u003e().\u003cspan style=\"color:#a6e22e\"\u003esplit\u003c/span\u003e(\u003cspan style=\"color:#e6db74\"\u003e\u0026#39;T\u0026#39;\u003c/span\u003e)[\u003cspan style=\"color:#ae81ff\"\u003e0\u003c/span\u003e]\n\u003c/span\u003e\u003c/span\u003e\u003cspan style=\"display:flex;\"\u003e\u003cspan\u003e)\n\u003c/span\u003e\u003c/span\u003e\u003c/code\u003e\u003c/pre\u003e\u003c/div\u003e\u003cp\u003e\u003ccode\u003e() =\u0026gt;\u003c/code\u003e で包むだけ。それだけ。\u003c/p\u003e\n\u003ch2 id=\"まとめ\"\u003eまとめ\u003c/h2\u003e\n\u003ctable\u003e\n  \u003cthead\u003e\n      \u003ctr\u003e\n          \u003cth\u003eパターン\u003c/th\u003e\n          \u003cth\u003e評価タイミング\u003c/th\u003e\n      \u003c/tr\u003e\n  \u003c/thead\u003e\n  \u003ctbody\u003e\n      \u003ctr\u003e\n          \u003ctd\u003e\u003ccode\u003euseState(Date.now())\u003c/code\u003e\u003c/td\u003e\n          \u003ctd\u003eレンダーごとに評価される\u003c/td\u003e\n      \u003c/tr\u003e\n      \u003ctr\u003e\n          \u003ctd\u003e\u003ccode\u003euseState(() =\u0026gt; Date.now())\u003c/code\u003e\u003c/td\u003e\n          \u003ctd\u003e初回マウント時のみ\u003c/td\u003e\n      \u003c/tr\u003e\n  \u003c/tbody\u003e\n\u003c/table\u003e\n\u003cp\u003e\u003ccode\u003enew Date()\u003c/code\u003e も同様なので、日付系の初期値を \u003ccode\u003euseState\u003c/code\u003e に渡すときはアロー関数で包む癖をつけておくと良い。\u003c/p\u003e","title":"ReactのuseStateでDate.now()を使うとlintエラーになる話"},{"content":"モダンオペレーティングシステム 第5版 上\n第3章中盤（3.3後半〜3.5）。ページテーブルの実装とTLB、ページ置き換えアルゴリズム。\n3.3後半 ページテーブルの実装 多段ページテーブル 仮想アドレス空間が64bitの場合、単純なページテーブルは現実的じゃない。\n理論上の最大：2^64 = 18,446,744,073,709,551,616 バイト 1ページ = 4KB = 4096バイト ページ数 = 2^64 / 2^12 = 2^52 個 1エントリ = 8バイトとして ページテーブルのサイズ = 2^52 × 8 = 32ペタバイト プロセス1個のページテーブルだけで32PB。話にならない。\n実際のx86_64は64bitフルを使わず48bitに妥協している（一部の最新CPUは57bit）。64bitフルを使うと6〜7段の階層が必要になりメモリアクセスのオーバーヘッドが耐えられなくなるから。「理論上の最大は64bitだけど、現実のCPUは48bitに妥協している」という設計判断。\nLinuxはこれを多段ページテーブルで解決してる。x86_64では4段構成。\n仮想アドレス（48bit有効） ┌──────┬──────┬──────┬──────┬────────────┐ │ PGD │ PUD │ PMD │ PTE │ offset │ │ 9bit │ 9bit │ 9bit │ 9bit │ 12bit │ └──────┴──────┴──────┴──────┴────────────┘ 名前は覚えなくていい。住所の階層構造だと思えばいい。\n東京都 → PGD（一番大きい区分） 渋谷区 → PUD 代々木1丁目 → PMD 1番地 → PTE 101号室 → offset（ページ内の位置） ポイントは使っていない部分のテーブルを作らないこと。\n「渋谷区に建物がない」 → 渋谷区のPUDを作らない → その下のPMD/PTEも全部省略 プロセスが実際に使う仮想アドレスはスカスカなので、使っていないエントリに対応するテーブルは丸ごと省略できる。結果として数MB〜数十MBで収まる。\nDockerとの絡み\n# コンテナのプロセスの仮想アドレス空間を見る cat /proc/$(pgrep nginx)/maps コンテナ内のnginxも普通のLinuxプロセスなので、同じ多段ページテーブルで管理されてる。\n逆引きページテーブル 発想を逆にしたアプローチ。\n【普通のページテーブル】 仮想ページ番号をインデックスにして物理フレームを引く 仮想[0] → 物理フレーム52 仮想[1] → 物理フレーム7 【逆引きページテーブル】 物理フレーム番号をインデックスにして「誰が使ってるか」を引く 物理フレーム[0] → (プロセスA, 仮想ページ52) 物理フレーム[1] → (プロセスB, 仮想ページ7) 「逆」ってのはインデックスが仮想→物理の逆になってるという意味。物理メモリに1対1対応で直置きしてるだけとも言える。\nメリット\n普通のページテーブル → プロセスごとに1個必要 → プロセス100個 × 仮想空間分 = 巨大 逆引きページテーブル → 物理メモリ全体で1個だけ → 物理メモリ16GB ÷ 4KB = 約400万エントリ（固定） プロセスが何個増えてもテーブルのサイズが変わらないのが利点。\nデメリット\n「この仮想アドレスはどの物理フレーム？」を調べるのに全エントリを線形探索しないといけない。実用上はハッシュテーブルと組み合わせて使う。IBMのPOWERアーキテクチャ等で採用されてたがx86では使われていない。\nTLB（Translation Lookaside Buffer） 多段ページテーブルで仮想→物理変換をするたびに4回メモリアクセスが必要になる。\n仮想アドレス → PGD読む（1回目） → PUD読む（2回目） → PMD読む（3回目） → PTE読む（4回目） → やっと目的のデータを読む（5回目） これを解決するのがTLB。CPUチップの中にある「最近使ったアドレス変換」のキャッシュ。\n仮想アドレス → まずTLBを見る ↓ TLBヒット（あった！）→ 物理アドレスが即わかる（1サイクル以下） ↓ TLBミス（なかった）→ ページテーブルを4段たどる → TLBに登録 なぜ多段ページテーブルとTLBが別々に存在するか\n置き場所が根本的に違うから。\n多段ページテーブル → メインメモリ（RAM）の上にある → 100ns〜 TLB → CPUチップの中（L1キャッシュと同レベル） → 1ns以下 コンピュータ全体が「速いけど小さい層」を「遅いけど大きい層」で仮想的に拡張する構造になっている。\nレジスタ（CPU内） 1サイクル 数十バイト L1キャッシュ（CPU内） 4サイクル 32KB L2キャッシュ（CPU内） 12サイクル 256KB L3キャッシュ（CPU内） 40サイクル 数十MB RAM 200サイクル 数十GB ディスク（SSD） 数万サイクル 数TB TLBもページテーブルもこの妥協の塔の中でどこに置くかという話。「速くて小さい層を、遅くて大きい層があるように見せる詐欺」の繰り返しがコンピュータアーキテクチャの歴史。\nTLBとコンテキストスイッチ\nプロセスAからプロセスBに切り替わる時、TLBの中身はプロセスAのもの。対処法は2つ。\n方式 方法 特徴 TLBフラッシュ 切り替え時にTLBを全部消す シンプル。切り替えのたびにTLBが冷える ASID（Address Space ID） TLBエントリにプロセスIDを付ける フラッシュ不要。複数プロセスのエントリが共存 LinuxはASIDを使ってる（x86_64ではPCID: Process Context Identifier）。\nk3sで大量のPodが動いている時、コンテキストスイッチが多発する。TLBフラッシュが多いと全体的なスループットが落ちる。\n# コンテキストスイッチの回数を見る vmstat 1 # cs列がコンテキストスイッチ数/秒 3.4 ページ置き換えアルゴリズム 問題設定 物理メモリが全部埋まった時に新しいページが必要になったら、誰かを追い出す必要がある。この「誰を追い出すか」がページ置き換えアルゴリズム。\n追い出し方を間違えるとすぐまた必要になって読み戻す羽目になる。これが頻発する状態をスラッシングと呼ぶ。Proxmoxでメモリ割り当てが少ないVMがディスクアクセスしまくって重くなるあの状態。\nOPT（最適アルゴリズム） 「将来一番長く使われないページを追い出す」 理論上の最適解。未来は見えないので実装不可能。ベンチマークの基準として使う。\nFIFO（First In First Out） 一番古く入ったページを追い出す シンプルだが問題がある。\n古いページ ≠ 使われていないページ ずっと使い続けている古いページを追い出して、すぐ読み戻す羽目になる。\nNRU（Not Recently Used） 各ページに2つのビットを持つ。\n参照ビット（R）: アクセスがあったら1 更新ビット（M）: 書き込みがあったら1 これで4クラスに分類する。\nクラス0: R=0, M=0 → 最近アクセスも書き込みもない ← 最優先で追い出す クラス1: R=0, M=1 → 最近アクセスはないが書き込みあり クラス2: R=1, M=0 → 最近アクセスあり、書き込みなし クラス3: R=1, M=1 → 最近アクセスも書き込みもあり ← 最後まで残す 更新ビット（M）がなぜ重要か\nMが1のページを追い出す → ディスクに書き戻す必要がある（ダーティページ）→ 遅い Mが0のページを追い出す → ディスクに書き戻し不要（元のデータそのまま）→ 速い R同じならMが0のクラスを優先して追い出す。\n問題はスケール\nページが大量にある場合、全ページをスキャンして4クラスに分類する処理と定期的なRビットリセットが重くなる。\nLRU（Least Recently Used） 一番最近使われていないページを追い出す 「最近使っていないなら、しばらく使わないだろう」という局所性の原理に基づく。精度は高いがメモリアクセスのたびに「最後にアクセスした時刻」を更新する必要があり、ハードウェアコストが重い。\nClock（時計）アルゴリズム LRUの近似。全ページを時計の文字盤のように円形に並べ、各ページに参照ビット（0か1）を持つ。\nページにアクセスがあった → 参照ビットを1にする 置き換えが必要になったら時計の針を進める ├── 参照ビットが1 → 0にして次へ（チャンスを1回あげる） └── 参照ビットが0 → こいつを追い出す NRUより精度は落ちるが、スケールする。針を進めながら探すので参照ビット0を見つけた時点で終わり。\nワーキングセット プロセスはある時間帯に「よく使うページの集合」が決まってくる。この集合をワーキングセットと呼ぶ。\nW(k) = 直近k回のメモリアクセスで使われたページの集合 スラッシングとの関係\nワーキングセットが物理メモリに収まってる → ページフォルトがほぼ発生しない → 快適 ワーキングセットが物理メモリより大きい → 常にページフォルトが発生 → スラッシング k3sでのmemoryRequestはワーキングセットの見積もり。kubectl top podで見えるメモリ使用量は実質的にワーキングセットのサイズ。\nWSClock ワーキングセットとClockを組み合わせた実用最強版。各ページに参照ビット（R）、更新ビット（M）、最後にアクセスした時刻（τ）を持つ。\n針が進んだ時の判断 Rが1 → ワーキングセット内 → Rを0にして時刻を更新、次へ Rが0、かつ (現在時刻 - τ) \u0026lt; しきい値 → ワーキングセット内 → 次へ Rが0、かつ (現在時刻 - τ) \u0026gt;= しきい値 → ワーキングセット外 → Mが0 → 即追い出す → Mが1 → ディスクに書き戻し予約して次へ Clockの軽さを保ちながらワーキングセットの概念を取り込んでいる。\nLinuxの実際の実装\nLinuxはアクティブリストと非アクティブリストを使ったLRU/Clock混合のマルチリストシステムを採用している。\nアクティブリスト → 最近使ったページ 非アクティブリスト → しばらく使っていないページ ページへのアクセスがあればアクティブリストへ昇格、しばらく使われなければ非アクティブリストへ降格、置き換えが必要なら非アクティブリストの末尾から追い出す。WSClockのワーキングセット概念をリストの昇格/降格で近似している。\nアルゴリズムまとめ OPT → 最適だが未来が見えないので実装不可、ベンチマーク用 FIFO → シンプル、古い≠使っていないので性能悪い NRU → R/Mビットで4クラス分類、シンプルで実用的、スケールしない LRU → 精度高い、ハードウェアコストが重い Clock → LRUの近似、軽い WSClock → Clockにワーキングセット概念を追加、実用最強 理論と実装の間のギャップを埋めるのが近似アルゴリズムで、OSもDBもそこだらけ。Proxmoxのスケジューラもk3sのリソース管理も「完璧じゃないけど十分に良い」を積み重ねてシステムが動いている。\nローカル vs グローバル置き換え 複数プロセスが動いている時、誰かのページを追い出す必要が出た場合の方針。\nローカル置き換え → 追い出すのは自分のページだけ → 他のプロセスに影響しない → でも自分のワーキングセットが増えた時に対応できない グローバル置き換え → 誰のページでも追い出せる → ワーキングセットの変化に追従できる → でも暴走プロセスが他を圧迫できる k3sとの絡み：\nローカル ≈ PodのmemoryLimit（そのPodの中だけで完結、超えたらOOMKiller） グローバル ≈ Nodeのメモリ管理（全Pod込みでLinuxカーネルが全体を見て判断） Linuxはcgroupでプロセスグループごとに上限を設けることでローカルっぽい制御もできる。それがDockerの--memoryフラグの正体。\nマイナーフォルトとメジャーフォルト ページフォルトには2種類ある。\nマイナーフォルト（Minor Page Fault） → 物理メモリにはある → ページテーブルに登録されていないだけ → ディスクアクセスなし・速い → CoWの発動時もこれ メジャーフォルト（Major Page Fault） → 物理メモリにない → ディスクから読み込む必要がある → 遅い・I/O発生 # プロセスのフォルト数を見る /usr/bin/time -v ./your_program # Podのフォルト数を見る cat /proc/$(pgrep nginx)/stat | awk \u0026#39;{print \u0026#34;minor:\u0026#34;, $10, \u0026#34;major:\u0026#34;, $12}\u0026#39; メジャーフォルトが多いPodはスラッシング候補。kubectl top podでメモリ使用量がrequestを常に超えているPodは要注意。\n3.5 ページングシステムの設計問題 ページフォルトの正確なフロー プロセスが仮想アドレスにアクセス ↓ TLBミス → ページテーブルを見る ↓ 「メモリにない」フラグ → ページフォルト割り込み発生 ↓ OSのページフォルトハンドラが起動 ↓ 1. アドレスが有効か確認（無効 → セグフォ SIGSEGV） 2. 物理フレームを確保（空きなし → 置き換えアルゴリズム発動） 3. ディスクからページを読み込む 4. ページテーブルを更新 5. TLBに登録 ↓ フォルトした命令を再実行 → 成功 Dockerコンテナ内でセグフォが起きるとコンテナが落ちる。kubectl logsで「Segmentation fault」が見える。\nページサイズの設計問題 小さいページサイズ（4KB）\nメリット: 内部断片化が小さい、ワーキングセットを細かく管理できる デメリット: ページテーブルのエントリ数が増える、TLBミスが増える 大きいページサイズ（HugePage: 2MB or 1GB）\nメリット: TLBエントリ1個で広い範囲をカバー、TLBミスが減る デメリット: 内部断片化が増える # HugePage確認 cat /proc/meminfo | grep Huge # Proxmoxで設定 echo 512 \u0026gt; /proc/sys/vm/nr_hugepages # 512 × 2MB = 1GB分のHugePage確保 KVMのVMに大量メモリを割り当てる時はHugePageを使うと速い。\nI空間とD空間（NX bit） I空間（Instruction Space）：コード（実行命令）を置く空間 D空間（Data Space）：データを置く空間\n分離することでセキュリティが向上する。\nI空間を書き込み禁止にする → コードを改ざんできない D空間を実行禁止にする（NX bit）→ データ領域に埋め込んだコードを実行できない → シェルコード攻撃を防ぐ Dockerコンテナでも有効で、カーネルレベルで保護されている。\n共有ページとCopy-on-Write（CoW） 複数プロセスが同じライブラリ（例：libc）を使う時、メモリを共有する仕組み。\n共有しない場合 プロセスA: libc 2MB + プロセスB: libc 2MB + プロセスC: libc 2MB = 6MB 共有する場合 物理メモリ: libc 2MB（1個だけ） プロセスA/B/C: 全員同じ物理フレームを参照 = 2MB 各プロセスの仮想アドレスは別々でも、物理アドレスは同じ場所を指す。\nCoWの仕組み\n共有ページには書き込み禁止フラグを意図的に設定しておく。\nプロセスAが共有ページに書き込もうとする ↓ ページフォルト発生（「書き込み禁止です」） ↓ OSが「CoWのページか」と判断 ↓ そのページをコピーして別の物理フレームに置く ↓ プロセスAだけ新しいフレームを指すように更新 ↓ BとCは元のフレームのまま 「書けないから別の場所に」じゃなくて「書かせないようにしておいて、書こうとした瞬間にコピーを作る」が正確。\nfork()との絡み\nfork()でプロセスをコピーする時 → 親プロセスのメモリを全部コピーしたら重い → CoWなら「書き込みが発生するまでコピーしない」 → 実際に書き込んだページだけコピーが発生 DockerのCoWはレイヤーが違う\nDockerでよく「CoWのおかげでイメージが軽い」と言われるが、あれはファイルシステム（OverlayFS）レベルのCoWであり、ここで説明したメモリ（RAM）上のCoWとは動いているレイヤーが違う。\nメモリのCoW（本章の話） → RAM上の物理ページを共有 → fork()時のプロセス間でのメモリ節約 ストレージのCoW（OverlayFS） → ディスク上のファイルレイヤーを共有 → Dockerイメージレイヤーの差分管理 思想は同じ「書き込みが発生するまでコピーしない」だが、動いている場所が違う。\ndocker images のサイズが小さく見える → OverlayFSのCoW（ストレージ層） 実際の物理使用量を見るには → docker system df 共有メモリのコスト\nプロセス間で意図的にメモリを共有する場合（multiprocessing.Value等）は複数のコストが発生する。\n① CoWでページのコピーが走る ② 複数プロセスが同じメモリを触るのでロック（排他制御）が必要 ③ キャッシュコヒーレンシ（別CPUコアのキャッシュを無効化する必要がある） これが「書き込みを減らせ」「イミュータブルにしろ」の物理的な根拠。Goのchannel、RustのMPSC、KafkaのMessage Queue、k8sのPod間通信、全部「データを共有するな、コピーを渡せ」という同じ思想。\nマップトファイル（Memory-mapped file） ファイルを仮想アドレス空間に直接マッピングする仕組み。\n通常のread() ディスク → カーネルバッファ（コピー1回）→ ユーザーバッファ（コピー1回） mmap() ディスク → 仮想アドレス空間に直接見える（コピーなし） アクセスした瞬間にページフォルトが発生して、その部分だけディスクから読む。デマンドページングと同じ仕組み。\ntmpfsとの違い\nmmap() → ディスクのファイルをメモリに見せる（ディスクが実体） tmpfs → メモリをディスクに見せる（メモリが実体） 方向が逆。docker run --tmpfs /tmpや、k3sのemptyDir: medium: Memoryがtmpfs。Node再起動で消える。\nDBのバッファプールとの違い\nmmap() → OSがページ単位で管理 → どのページをメモリに乗せるかOSが決める → DBの文脈（このテーブルはフルスキャンだからキャッシュ不要等）を知らない バッファプール（MySQLのinnodb_buffer_pool等） → DBが自前でLRU管理 → トランザクションの文脈を知っている → パフォーマンスが予測可能 MySQLもPostgreSQLも自前バッファプール派。SQLiteはmmap()派。\nクリーニングポリシー ダーティページ（書き込みが発生してディスクと内容が違うページ）をいつディスクに書き戻すか。\nオンデマンド（置き換え時） → 追い出す時に初めて書き戻す → 普段は速い、置き換え時に遅延が発生 先読み書き戻し（ページデーモン） → 定期的にダーティページをバックグラウンドで書き戻す → 現代のLinuxではカーネルスレッド（bdi-writeback）がバックグラウンドで処理 → 古いカーネルではpdflushと呼ばれていた ライトバック競合\n書き戻し中に同じページへ追加書き込みが来た場合、Linuxは書き戻しを止めない。\n書き戻し中のページに追加書き込み → 書き戻しはそのまま完走させる → 追加書き込みは新しいダーティとして記録 → 次のpdflushサイクルで再度書き戻す 途中で止めると中途半端な状態がディスクに残り、最悪ファイルシステム破損につながる。\nこれがジャーナリング（ext4、XFS）の存在理由。書き込み前に「これから何を書くか」をジャーナルに記録しておき、クラッシュしても再起動時にやり直せる。\n# ダーティページの状況確認 cat /proc/meminfo | grep Dirty # Dirty: まだ書き戻していないページ # Writeback: 今書き戻し中のページ # 強制的に書き戻す sync Proxmoxのストレージグラフで定期的にI/Oスパイクが見える場合、bdi-writebackカーネルスレッドが原因のことが多い。\nまとめ 第3章中盤 ├── 多段ページテーブル（住所の階層構造、使っている部分だけ作る） ├── 逆引きページテーブル（物理メモリに直置き、プロセス数に依存しない） ├── TLB（妥協の塔におけるCPU内アドレス変換キャッシュ） │ └── コンテキストスイッチ時はフラッシュ or ASID ├── ページ置き換えアルゴリズム │ ├── OPT（最適、実装不可） │ ├── FIFO（シンプル、性能悪い） │ ├── NRU（4クラス分類、スケールしない） │ ├── LRU（精度高い、コスト重い） │ ├── Clock（軽い近似） │ └── WSClock（Clock + ワーキングセット、実用最強） ├── ローカル vs グローバル置き換え（k8sはmemoryLimitとcgroupで両方使う） ├── マイナーフォルト vs メジャーフォルト ├── ページサイズのトレードオフ（4KB vs HugePage） ├── NX bit（D空間を実行禁止にしてシェルコード攻撃を防ぐ） ├── 共有ページ + CoW（書こうとした瞬間だけコピー） │ └── メモリのCoW（RAM上）とストレージのCoW（OverlayFS）は別レイヤー ├── マップトファイル（コピーなしでファイルを仮想空間に直接マッピング） └── クリーニングポリシー（ダーティページをいつ書き戻すか） 次回は3.6〜3.8（実装上の課題、セグメンテーション、メルトダウン・スペクター）。\n","permalink":"https://techblog.wasutech.dev/posts/tanehon-3-2/","summary":"\u003cp\u003e\u003ca href=\"https://www.maruzenjunkudo.co.jp/products/9784296070992\"\u003eモダンオペレーティングシステム 第5版 上\u003c/a\u003e\u003c/p\u003e\n\u003cp\u003e第3章中盤（3.3後半〜3.5）。ページテーブルの実装とTLB、ページ置き換えアルゴリズム。\u003c/p\u003e\n\u003chr\u003e\n\u003ch2 id=\"33後半-ページテーブルの実装\"\u003e3.3後半 ページテーブルの実装\u003c/h2\u003e\n\u003ch3 id=\"多段ページテーブル\"\u003e多段ページテーブル\u003c/h3\u003e\n\u003cp\u003e仮想アドレス空間が64bitの場合、単純なページテーブルは現実的じゃない。\u003c/p\u003e\n\u003cpre tabindex=\"0\"\u003e\u003ccode\u003e理論上の最大：2^64 = 18,446,744,073,709,551,616 バイト\n1ページ = 4KB = 4096バイト\n\nページ数 = 2^64 / 2^12 = 2^52 個\n\n1エントリ = 8バイトとして\nページテーブルのサイズ = 2^52 × 8 = 32ペタバイト\n\u003c/code\u003e\u003c/pre\u003e\u003cp\u003eプロセス1個のページテーブルだけで32PB。話にならない。\u003c/p\u003e\n\u003cp\u003e実際のx86_64は64bitフルを使わず\u003cstrong\u003e48bit\u003c/strong\u003eに妥協している（一部の最新CPUは57bit）。64bitフルを使うと6〜7段の階層が必要になりメモリアクセスのオーバーヘッドが耐えられなくなるから。「理論上の最大は64bitだけど、現実のCPUは48bitに妥協している」という設計判断。\u003c/p\u003e\n\u003cp\u003eLinuxはこれを\u003cstrong\u003e多段ページテーブル\u003c/strong\u003eで解決してる。x86_64では4段構成。\u003c/p\u003e\n\u003cpre tabindex=\"0\"\u003e\u003ccode\u003e仮想アドレス（48bit有効）\n┌──────┬──────┬──────┬──────┬────────────┐\n│ PGD  │ PUD  │ PMD  │ PTE  │  offset    │\n│ 9bit │ 9bit │ 9bit │ 9bit │   12bit    │\n└──────┴──────┴──────┴──────┴────────────┘\n\u003c/code\u003e\u003c/pre\u003e\u003cp\u003e名前は覚えなくていい。\u003cstrong\u003e住所の階層構造\u003c/strong\u003eだと思えばいい。\u003c/p\u003e\n\u003cpre tabindex=\"0\"\u003e\u003ccode\u003e東京都    → PGD（一番大きい区分）\n渋谷区    → PUD\n代々木1丁目 → PMD\n1番地     → PTE\n101号室   → offset（ページ内の位置）\n\u003c/code\u003e\u003c/pre\u003e\u003cp\u003eポイントは\u003cstrong\u003e使っていない部分のテーブルを作らないこと\u003c/strong\u003e。\u003c/p\u003e","title":"モダンオペレーティングシステム 第3章中盤メモ"},{"content":"モダンオペレーティングシステム 第5版 上\n第3章後半（3.6〜3.8）。実装上の課題、セグメンテーション、そしてCPU脆弱性とOSの関係。\n3.6 実装上の課題 OSがページングに関わるタイミング ページング処理はいつ動くか。\n【プロセス生成時】 → 新しいページテーブルを作る → ディスクからプログラムをロードする準備 【プロセス実行時】 → TLBミス → ページテーブルをたどる → ページフォルト → ページを物理メモリに載せる 【プロセス終了時】 → ページテーブルを解放 → 物理フレームを解放 → ディスク上のスワップ領域を解放 【コンテキストスイッチ時】 → ページテーブルの切り替え → TLBフラッシュ or ASID切り替え k3sでPodが終了した時：\nコンテナプロセス終了 → カーネルがページテーブル解放 → 物理フレーム解放 → 次のPodがそのフレームを使える 命令の再実行問題 ページフォルトが起きた時、フォルトした命令を再実行する。でもここに厄介な問題がある。\nメモリのコピー命令（100番地→200番地にコピー） 実行途中の150番地でページフォルト ↓ 100〜149はすでにコピー済み ↓ 命令を再実行 ↓ 100〜149を二重にコピー → 壊れる 対処法\n方法1: 命令実行前に全ページが存在するか先読みチェック → なければ先にページフォルトを起こしておく → 全部揃ってから命令実行（x86寄りの方式） 方法2: CPUが内部状態を保存 → どこまで実行したか記録 → 再開時はその続きから（一部のRISCの方式） ページのロック（ピン留め） I/O処理中のページは絶対に追い出せない。\nDMA転送中 → デバイスが直接メモリに書き込んでる → このページを追い出したら → デバイスが存在しないメモリに書き込む → 最悪システムクラッシュ だからI/O中のページには**ロック（ピン留め）**をかける。置き換えアルゴリズムの対象外になり、I/O完了後にロック解除。\nProxmoxとの絡み\nVMのディスクI/O中 → そのI/Oバッファのページはロックされてる → ホストのメモリが逼迫しても追い出せない → VM数が増えるとI/Oが詰まりやすい理由の一つ ページングとI/Oの連鎖 ページフォルトとI/Oが組み合わさると厄介な連鎖が起きる。\nページフォルト発生 → ディスクからページを読み込む（I/O） → I/O待ちの間プロセスはブロック → 別のプロセスにCPUを渡す → そのプロセスもページフォルト → さらにI/O待ち → I/O待ちのプロセスが積み重なる → スラッシング k3sでの対処：\nPodのmemoryRequestを正しく設定 → ワーキングセットをメモリに収める → ページフォルトを最小化 → I/O待ちの連鎖を防ぐ kubectl top podでメモリ使用量がrequestを常に超えているPodはスラッシング候補。\nマップトファイル（Memory-mapped file） ファイルを仮想アドレス空間に直接マッピングする仕組み。\n通常のread() ディスク → カーネルバッファ（コピー1回）→ ユーザーバッファ（コピー1回） mmap() ディスク → 仮想アドレス空間に直接見える（コピーなし） アクセスした瞬間にページフォルトが発生して、その部分だけディスクから読む。デマンドページングと同じ仕組み。\ntmpfsとの違い\nmmap() → ディスクのファイルをメモリに見せる（ディスクが実体） tmpfs → メモリをディスクに見せる（メモリが実体） 方向が逆。docker run --tmpfs /tmpや、k3sのemptyDir: medium: Memoryがtmpfs。Node再起動で消える。\nDBのバッファプールとの違い\nmmap() → OSがページ単位で管理 → どのページをメモリに乗せるかOSが決める → DBの文脈（このテーブルはフルスキャンだからキャッシュ不要等）を知らない バッファプール（MySQLのinnodb_buffer_pool等） → DBが自前でLRU管理 → トランザクションの文脈を知っている → パフォーマンスが予測可能 MySQLもPostgreSQLも自前バッファプール派。SQLiteはmmap()派。用途の違い。\n3.7 セグメンテーション 発想 ページングは4KB単位でメモリを管理する。でもプログラマから見たメモリは4KB単位じゃない。\nプログラムの構造 ├── コード（テキスト）セグメント → 実行命令が入る ├── データセグメント → グローバル変数等 ├── スタックセグメント → 関数呼び出しの管理 └── ヒープセグメント → malloc()で動的確保 これを全部4KBのページに押し込めるのは不自然。「意味のある単位で分けて管理したい」という発想がセグメンテーション。\n仕組み 各セグメントはベースアドレスとリミットを持つ。\nセグメントテーブル ┌────────┬──────────┬────────┐ │セグメント│ ベース │ リミット │ ├────────┼──────────┼────────┤ │ コード │ 0x000000 │ 10KB │ │ データ │ 0x010000 │ 5KB │ │ スタック │ 0x020000 │ 8KB │ └────────┴──────────┴────────┘ アドレス変換：\n仮想アドレス = セグメント番号 + オフセット ↓ セグメントテーブルでベースアドレスを引く ↓ 物理アドレス = ベース + オフセット ↓ リミットを超えてないか確認（超えたらセグフォ） ページングとの違い ページング → 固定サイズ（4KB）で分割 → プログラマはページを意識しない → ハードウェア寄りの管理 セグメンテーション → 意味のある単位で分割（可変長） → セグメントごとに別々の保護属性をつけられる → プログラマ寄りの管理 セグメントごとに保護属性を設定できるのが強み。\nコードセグメント → 実行可能・書き込み禁止 データセグメント → 書き込み可能・実行禁止 断片化の整理 ここで断片化の種類を整理しておく。\n外部断片化 → プロセス間の「穴」問題 → セグメンテーション時代に発生 → ページングで解決（固定サイズなので穴が生まれない） 内部断片化 → ページ内の「余り」問題 → ページングが生む副作用 → 1バイトしか使わなくても4KB丸ごと占有してしまう セグメンテーションは可変長なので、ページングが解決した外部断片化が復活する。\nセグメントA（10KB）終了 → 10KBの穴 セグメントB（5KB）終了 → 5KBの穴 [空き10KB][使用中][空き5KB][使用中] 15KBのセグメントを入れたい → 連続した15KBの空きがない → 外部断片化 ページドセグメンテーション 両方の弱点を補う組み合わせ。\nセグメント単位で意味的に分割（保護属性も設定） ↓ 各セグメントをページに分割 ↓ ページ単位で物理メモリに配置 ↓ 外部断片化なし（ページ固定サイズのおかげ） + セグメントの保護属性あり ※ 内部断片化は引き続き許容する 現代x86_64での扱い x86は歴史的にセグメンテーションとページングの両方を持っていた。\nx86（32bit時代） → セグメンテーション + ページングの両方を使用 x86_64（64bit） → セグメンテーションをほぼ廃止、ページングだけ セグメンテーションの役割はページの保護属性（NX bit、書き込み禁止）が吸収した。\n# セグメントの痕跡を確認 cat /proc/$(pgrep nginx)/maps # こんな感じで見える 7f1234000000-7f1234001000 r-xp # コード（実行可能・書き込み禁止） 7f1234001000-7f1234002000 rw-p # データ（書き込み可能・実行禁止） 7fff00000000-7fff00001000 rwxp # スタック r-xpやrw-pがページの保護属性。セグメンテーションの役割をページ属性で代替している。\n「セグメントを意識してプログラムを書け」の意味 教科書に「セグメントはプログラマが意識する必要がある」と書いてある。現代での意味はこれ。\n普段は意識しなくていい（コンパイラが自動でやる） でも以下の場面では知らないと詰む ├── スタックオーバーフロー（スタックセグメントの限界を超えた） ├── NX bit違反（データ領域のコードを実行しようとした） └── バッファオーバーフロー（セグメントの境界を超えた書き込み） 「普段は意識しなくていいけど、バグった時に知らないと原因がわからない」が正確。\n基本情報技術者試験でページングはガッツリ出てセグメンテーションは概念だけなのも同じ理由。現代のOSで実際に使われているのはページングで、セグメンテーションはほぼ廃止されているから。\n3.8 メモリ管理の研究：メルトダウンとスペクター CPUの脆弱性をOSが対処した話 2018年に発覚した脆弱性。CPUの投機的実行（先読み最適化）を突いた攻撃で、OSのページテーブル管理と直結している。\n投機的実行とは CPUの先読み最適化 → 「たぶんこの命令を実行するだろう」と先読みして実行 → 外れたら結果を捨てる → 当たれば速い これ自体は正常な最適化。問題はその副作用にある。\nメルトダウン 投機的実行中にカーネルのメモリを読む → 本来アクセス禁止のはず → でもCPUが先読みでキャッシュに乗せてしまう → 後から「権限ないじゃん」と気づく → 結果は捨てる → でもキャッシュには残ってる ← ここが問題 キャッシュに残ったデータを直接読もうとしても権限チェックが入って読めない。でもキャッシュのタイミング差は読める。\n攻撃の流れ（サイドチャネル攻撃） 投機的実行で秘密データをキャッシュに乗せる ↓ 「アドレスAへのアクセスが速いか遅いか」を測定 ↓ 速い → キャッシュに乗ってる → そのbitは1 遅い → キャッシュに乗ってない → そのbitは0 ↓ 1bitずつ秘密データを復元 直接読むんじゃなくてタイミングを観測して間接的に推測する。「横から覗く」のでサイドチャネル攻撃と呼ぶ。\nこれによりユーザープロセスからカーネルのパスワードや暗号鍵が読める。Proxmox上では別VMのメモリまで読めてしまう。\nOSの対処：KPTI（カーネルページテーブル分離） 対処前 → カーネルとユーザーが同じページテーブルを共有 → カーネルのアドレスがユーザーから見えてしまう 対処後（KPTI） → カーネル用とユーザー用でページテーブルを完全分離 → ユーザーモードではカーネルのアドレスが見えない → 投機的実行でキャッシュに乗せられない 代償\nKPTIを有効にする → コンテキストスイッチのたびにページテーブルを切り替える → TLBフラッシュが発生 → パフォーマンスが5〜30%低下 # KPTIの状態確認 cat /sys/devices/system/cpu/vulnerabilities/meltdown スペクター メルトダウンより巧妙。\nメルトダウン → カーネルとユーザーの境界を突破 → 権限の壁を越える攻撃 → KPTIで対処できた スペクター → 分岐予測を意図的に誤らせる → 同じ権限レベルの中での攻撃 → ブラウザのJavaScriptから同じプロセス内の別メモリを読む → サンドボックスの壁を越える攻撃 分岐予測を騙す\n通常 → CPUが「たぶんこっちの分岐だろう」と予測して先読み スペクター → 攻撃者が意図的に分岐予測を誤らせる → 「自分が読みたいメモリにアクセスする分岐」を先読みさせる → キャッシュに乗ったらサイドチャネルで読む 2つの攻撃の共通構造 共通の経路（サイドチャネル攻撃） 投機的実行でキャッシュに乗せる ↓ キャッシュのタイミング差を観測する ↓ メモリの中身を推測する 入口の違い メルトダウン → 投機的実行中に権限チェックをスキップ スペクター → 分岐予測を騙して意図した場所を先読みさせる CPUの設計判断のツケ 投機的実行が実装されたのは1990年代 → 「権限チェックより先に先読みした方が速い」 → 「どうせ結果を捨てるから安全だろう」 → 「キャッシュのタイミング差が観測されるとは思わなかった」 2018年に発覚 → 約30年間誰も気づかなかった セキュリティよりパフォーマンスを優先した設計のツケが30年後に回ってきた。\n対処が最悪だった理由\nソフトウェアで完全に直せない → CPUのハードウェアレベルの問題 → OSのKPTIは「被害を減らす」だけ → 根本解決はCPUを作り直すしかない 新しいCPU世代でやっと対処 → Intelは何世代かかけて段階的に修正 → 古いCPUは永遠に脆弱なまま # Proxmoxで脆弱性の状態確認 cat /sys/devices/system/cpu/vulnerabilities/meltdown cat /sys/devices/system/cpu/vulnerabilities/spectre_v2 # Vulnerable と出たら未対処 今日やった内容が全部繋がる メルトダウン/スペクターの話は、第3章で学んだ内容の総決算になっている。\nページテーブル → カーネルとユーザーのアドレス空間を分離している → KPTIはこの分離をさらに強化した TLB → KPTIでページテーブルを切り替えるたびにTLBフラッシュが必要 → これがパフォーマンス低下の原因 コンテキストスイッチ → KPTIにより切り替えコストが増大 → k3sで大量のPodが動くと影響が出る 投機的実行（先読み最適化） → CPUの「妥協の塔」における速度最適化 → セキュリティとのトレードオフが30年後に爆発した まとめ 第3章後半 ├── 3.6 実装上の課題 │ ├── OSがページングに関わるタイミング（生成・実行・終了・スイッチ） │ ├── 命令の再実行問題（途中でフォルトしたら二重実行の危険） │ ├── ページのロック（I/O中は絶対に追い出せない） │ ├── ページングとI/Oの連鎖（スラッシングの発生メカニズム） │ └── マップトファイル（コピーなしでファイルを仮想空間に直接マッピング） │ ├── 3.7 セグメンテーション │ ├── 意味のある単位（コード・データ・スタック）でメモリを分割 │ ├── 各セグメントに保護属性をつけられる │ ├── 外部断片化が発生する（ページングで解決） │ ├── ページドセグメンテーション（両方組み合わせ） │ └── 現代x86_64ではほぼ廃止、ページの保護属性で代替 │ └── 3.8 メルトダウン・スペクター ├── 投機的実行（先読み最適化）の副作用を突いた攻撃 ├── サイドチャネル攻撃（タイミング差でキャッシュの中身を推測） ├── メルトダウン（権限の壁を越える）→ KPTIで対処（5〜30%低下） ├── スペクター（分岐予測を騙す）→ 完全対処は困難 └── CPUの30年前の設計判断のツケ、OSが割を食った 第3章完了。次回は第4章（ファイルシステム）。\n","permalink":"https://techblog.wasutech.dev/posts/tanehon-3-3/","summary":"\u003cp\u003e\u003ca href=\"https://www.maruzenjunkudo.co.jp/products/9784296070992\"\u003eモダンオペレーティングシステム 第5版 上\u003c/a\u003e\u003c/p\u003e\n\u003cp\u003e第3章後半（3.6〜3.8）。実装上の課題、セグメンテーション、そしてCPU脆弱性とOSの関係。\u003c/p\u003e\n\u003chr\u003e\n\u003ch2 id=\"36-実装上の課題\"\u003e3.6 実装上の課題\u003c/h2\u003e\n\u003ch3 id=\"osがページングに関わるタイミング\"\u003eOSがページングに関わるタイミング\u003c/h3\u003e\n\u003cp\u003eページング処理はいつ動くか。\u003c/p\u003e\n\u003cpre tabindex=\"0\"\u003e\u003ccode\u003e【プロセス生成時】\n→ 新しいページテーブルを作る\n→ ディスクからプログラムをロードする準備\n\n【プロセス実行時】\n→ TLBミス → ページテーブルをたどる\n→ ページフォルト → ページを物理メモリに載せる\n\n【プロセス終了時】\n→ ページテーブルを解放\n→ 物理フレームを解放\n→ ディスク上のスワップ領域を解放\n\n【コンテキストスイッチ時】\n→ ページテーブルの切り替え\n→ TLBフラッシュ or ASID切り替え\n\u003c/code\u003e\u003c/pre\u003e\u003cp\u003ek3sでPodが終了した時：\u003c/p\u003e\n\u003cpre tabindex=\"0\"\u003e\u003ccode\u003eコンテナプロセス終了\n→ カーネルがページテーブル解放\n→ 物理フレーム解放\n→ 次のPodがそのフレームを使える\n\u003c/code\u003e\u003c/pre\u003e\u003chr\u003e\n\u003ch3 id=\"命令の再実行問題\"\u003e命令の再実行問題\u003c/h3\u003e\n\u003cp\u003eページフォルトが起きた時、フォルトした命令を再実行する。でもここに厄介な問題がある。\u003c/p\u003e\n\u003cpre tabindex=\"0\"\u003e\u003ccode\u003eメモリのコピー命令（100番地→200番地にコピー）\n実行途中の150番地でページフォルト\n        ↓\n100〜149はすでにコピー済み\n        ↓\n命令を再実行\n        ↓\n100〜149を二重にコピー → 壊れる\n\u003c/code\u003e\u003c/pre\u003e\u003cp\u003e\u003cstrong\u003e対処法\u003c/strong\u003e\u003c/p\u003e\n\u003cpre tabindex=\"0\"\u003e\u003ccode\u003e方法1: 命令実行前に全ページが存在するか先読みチェック\n→ なければ先にページフォルトを起こしておく\n→ 全部揃ってから命令実行（x86寄りの方式）\n\n方法2: CPUが内部状態を保存\n→ どこまで実行したか記録\n→ 再開時はその続きから（一部のRISCの方式）\n\u003c/code\u003e\u003c/pre\u003e\u003chr\u003e\n\u003ch3 id=\"ページのロックピン留め\"\u003eページのロック（ピン留め）\u003c/h3\u003e\n\u003cp\u003eI/O処理中のページは絶対に追い出せない。\u003c/p\u003e\n\u003cpre tabindex=\"0\"\u003e\u003ccode\u003eDMA転送中\n→ デバイスが直接メモリに書き込んでる\n→ このページを追い出したら\n→ デバイスが存在しないメモリに書き込む\n→ 最悪システムクラッシュ\n\u003c/code\u003e\u003c/pre\u003e\u003cp\u003eだからI/O中のページには**ロック（ピン留め）**をかける。置き換えアルゴリズムの対象外になり、I/O完了後にロック解除。\u003c/p\u003e","title":"モダンオペレーティングシステム 第3章後半メモ"},{"content":"構成 自宅NWの冗長化目的で Raspberry Pi を2台使い、WiFiアクセスポイントを組んでいる。\nホスト名 チャンネル SSID pi-1 1 your-ssid pi-2 11 your-ssid 同一SSIDで別チャンネルにする構成は ESS（Extended Service Set） と呼ばれ、クライアントが自動的に電波の強い方に接続する。WPA2-PSKで普通に成立する冗長化構成。\nブリッジ構成で bridge interface（br0）にWiFiインタフェース（wlan0）を参加させ、hostapdで電波を飛ばしている。\n問題 OS再起動後、SSIDが片方または両方スキャンに出ない。\nsystemctl status hostapd は active (running) エラーログも一見出ていない hostapdを手動で再起動すると出る 調査 チャンネルの干渉を疑う 最初にhostapd.confのチャンネル設定を確認した。\n2.4GHz帯で実際に干渉しないチャンネルは 1、6、11 の3つだけ。元の設定は6と7だったため、ほぼ完全に干渉していた。\nch6 --|-- ch7 --|-- ← 隣接していてほぼ被る チャンネルを1と11に変更した。\n# pi-1 channel=1 # pi-2 channel=11 BSSIDレベルで電波を確認 sudo iw dev wlan0 scan | grep -E \u0026#34;BSS |SSID|freq|signal\u0026#34; BSS xx:xx:xx:xx:xx:xx(on wlan0) freq: 2462 signal: -15.00 dBm SSID: your-ssid BSS xx:xx:xx:xx:xx:xx(on wlan0) freq: 2412 signal: -68.00 dBm SSID: your-ssid 両台ともビーコンは出ていた。ESSとして動作は成立している。\npower_saveを疑う APなのにpower_saveが有効になっている可能性を確認した。\nsudo iw dev wlan0 get power_save hostapdが wlan0 を掴んでいるため、設定変更にはhostapd停止が必要：\nsudo systemctl stop hostapd sudo iw dev wlan0 set power_save off sudo systemctl start hostapd ただし再起動で戻るため、永続化はsystemdのoverride.confで対応：\n# /etc/systemd/system/hostapd.service.d/override.conf [Service] ExecStartPost=/sbin/iw dev wlan0 set power_save off これだけでは問題は解決しなかった。\nbeacon_intを疑う 元の設定は beacon_int=200（デフォルトは100ms）。ビーコン間隔が長いと、起動直後のスキャンでSSIDを見逃す可能性がある。\nbeacon_int=100 に戻したが、問題は解決しなかった。\njournalctlで起動ログを確認 sudo journalctl -u hostapd -b 0 pi-2のログに以下が出ていた：\nhostapd: nl80211: Failed to add the bridge interface br0: File exists hostapd: nl80211 driver initialization failed. hostapd: wlan0: interface state UNINITIALIZED-\u0026gt;DISABLED hostapd: wlan0: AP-DISABLED hostapd: wlan0: CTRL-EVENT-TERMINATING hostapd.service: Failed with result \u0026#39;exit-code\u0026#39;. その後：\nhostapd.service: Scheduled restart job, restart counter is at 1. hostapd: wlan0: AP-ENABLED 原因はここにあった。 Restart=on-failure がセーフティネットになっていただけで、初回起動は失敗していた。\n起動順序を確認 sudo systemctl show hostapd | grep -E \u0026#34;After|Wants|Requires\u0026#34; pi-1とpi-2で差異があった：\n# pi-1 After=sys-subsystem-net-devices-wlan0.device network.target networking.service ... # pi-2 After=network.target ← wlan0.deviceもnetworking.serviceも待っていない pi-2は network.target しか待っていなかった。\nブリッジの管理主体を確認 ls /etc/NetworkManager/system-connections/ # br0.nmconnection bridge-slave-eth0.nmconnection br0は NetworkManager が管理していた。\n原因 起動フローが以下の順序になっていた：\n1. OS起動 2. NetworkManager が br0 を作成（非同期） 3. hostapd が起動 → br0 がまだ中途半端な状態 4. \u0026#34;Failed to add the bridge interface br0: File exists\u0026#34; で失敗 5. Restart=on-failure で2秒後に再起動 6. 今度はbr0が正常になっているので成功 File exists エラーは「br0が存在しない」ではなく「br0が中途半端な状態で既に存在している」ことを示している。hostapdが wlan0 をbr0に追加しようとした時に競合が発生していた。\nsystemctl status が active に見えるのは Restart=on-failure が自動回復しているためで、SSIDが出るのが遅延していた。\n解決策 両台に systemd の override.conf を作成し、network-online.target を待つようにする。\nsudo mkdir -p /etc/systemd/system/hostapd.service.d/ sudo nano /etc/systemd/system/hostapd.service.d/override.conf [Unit] Wants=sys-subsystem-net-devices-wlan0.device network-online.target After=sys-subsystem-net-devices-wlan0.device network-online.target sudo systemctl daemon-reload sudo reboot network-online.targetが有効か確認 sudo systemctl is-enabled NetworkManager-wait-online.service enabled になっていること。disabled の場合は network-online.target が機能しないので有効化する：\nsudo systemctl enable NetworkManager-wait-online.service network-online.target と network.target の違い target 意味 network.target ネットワークスタックの起動完了。インターフェースがオンラインかは問わない network-online.target 少なくとも1つのインターフェースがオンラインになった状態 NetworkManagerが管理するbr0のような仮想インターフェースを依存先にする場合は network-online.target が適切。\n補足：hostapd.confの注意点 TKIPはCCMPに統一する # 非推奨 wpa_pairwise=TKIP # 推奨 wpa_pairwise=CCMP rsn_pairwise=CCMP TKIPはWPA2と組み合わせた場合に一部クライアントから拒否されることがある。\nチャンネルは1・6・11を使う 2.4GHz帯で干渉しないチャンネルの組み合わせは 1と6、1と11、6と11 のみ。隣接チャンネルは必ず干渉する。\nまとめ 項目 内容 原因 NetworkManagerによるbr0初期化完了前にhostapdが起動 症状 Restart=on-failure で自動回復するため active に見えるが遅延あり 対処 network-online.target をAfter/Wantsに追加 対象 NetworkManagerがブリッジを管理している環境全般 systemctl status が active でも、journalctl -u hostapd -b 0 で起動ログを必ず確認すること。\n","permalink":"https://techblog.wasutech.dev/posts/raspi-hostapd-nw-fix/","summary":"\u003ch2 id=\"構成\"\u003e構成\u003c/h2\u003e\n\u003cp\u003e自宅NWの冗長化目的で Raspberry Pi を2台使い、WiFiアクセスポイントを組んでいる。\u003c/p\u003e\n\u003ctable\u003e\n  \u003cthead\u003e\n      \u003ctr\u003e\n          \u003cth\u003eホスト名\u003c/th\u003e\n          \u003cth\u003eチャンネル\u003c/th\u003e\n          \u003cth\u003eSSID\u003c/th\u003e\n      \u003c/tr\u003e\n  \u003c/thead\u003e\n  \u003ctbody\u003e\n      \u003ctr\u003e\n          \u003ctd\u003epi-1\u003c/td\u003e\n          \u003ctd\u003e1\u003c/td\u003e\n          \u003ctd\u003eyour-ssid\u003c/td\u003e\n      \u003c/tr\u003e\n      \u003ctr\u003e\n          \u003ctd\u003epi-2\u003c/td\u003e\n          \u003ctd\u003e11\u003c/td\u003e\n          \u003ctd\u003eyour-ssid\u003c/td\u003e\n      \u003c/tr\u003e\n  \u003c/tbody\u003e\n\u003c/table\u003e\n\u003cp\u003e同一SSIDで別チャンネルにする構成は \u003cstrong\u003eESS（Extended Service Set）\u003c/strong\u003e と呼ばれ、クライアントが自動的に電波の強い方に接続する。WPA2-PSKで普通に成立する冗長化構成。\u003c/p\u003e\n\u003cp\u003eブリッジ構成で bridge interface（\u003ccode\u003ebr0\u003c/code\u003e）にWiFiインタフェース（\u003ccode\u003ewlan0\u003c/code\u003e）を参加させ、hostapdで電波を飛ばしている。\u003c/p\u003e\n\u003chr\u003e\n\u003ch2 id=\"問題\"\u003e問題\u003c/h2\u003e\n\u003cp\u003eOS再起動後、SSIDが片方または両方スキャンに出ない。\u003c/p\u003e\n\u003cul\u003e\n\u003cli\u003e\u003ccode\u003esystemctl status hostapd\u003c/code\u003e は \u003ccode\u003eactive (running)\u003c/code\u003e\u003c/li\u003e\n\u003cli\u003eエラーログも一見出ていない\u003c/li\u003e\n\u003cli\u003ehostapdを手動で再起動すると出る\u003c/li\u003e\n\u003c/ul\u003e\n\u003chr\u003e\n\u003ch2 id=\"調査\"\u003e調査\u003c/h2\u003e\n\u003ch3 id=\"チャンネルの干渉を疑う\"\u003eチャンネルの干渉を疑う\u003c/h3\u003e\n\u003cp\u003e最初にhostapd.confのチャンネル設定を確認した。\u003c/p\u003e\n\u003cp\u003e2.4GHz帯で実際に干渉しないチャンネルは \u003cstrong\u003e1、6、11\u003c/strong\u003e の3つだけ。元の設定は6と7だったため、ほぼ完全に干渉していた。\u003c/p\u003e\n\u003cpre tabindex=\"0\"\u003e\u003ccode\u003ech6  --|--\nch7    --|--   ← 隣接していてほぼ被る\n\u003c/code\u003e\u003c/pre\u003e\u003cp\u003eチャンネルを1と11に変更した。\u003c/p\u003e\n\u003cdiv class=\"highlight\"\u003e\u003cpre tabindex=\"0\" style=\"color:#f8f8f2;background-color:#272822;-moz-tab-size:4;-o-tab-size:4;tab-size:4;\"\u003e\u003ccode class=\"language-ini\" data-lang=\"ini\"\u003e\u003cspan style=\"display:flex;\"\u003e\u003cspan\u003e\u003cspan style=\"color:#75715e\"\u003e# pi-1\u003c/span\u003e\n\u003c/span\u003e\u003c/span\u003e\u003cspan style=\"display:flex;\"\u003e\u003cspan\u003e\u003cspan style=\"color:#a6e22e\"\u003echannel\u003c/span\u003e\u003cspan style=\"color:#f92672\"\u003e=\u003c/span\u003e\u003cspan style=\"color:#e6db74\"\u003e1\u003c/span\u003e\n\u003c/span\u003e\u003c/span\u003e\u003cspan style=\"display:flex;\"\u003e\u003cspan\u003e\n\u003c/span\u003e\u003c/span\u003e\u003cspan style=\"display:flex;\"\u003e\u003cspan\u003e\u003cspan style=\"color:#75715e\"\u003e# pi-2\u003c/span\u003e\n\u003c/span\u003e\u003c/span\u003e\u003cspan style=\"display:flex;\"\u003e\u003cspan\u003e\u003cspan style=\"color:#a6e22e\"\u003echannel\u003c/span\u003e\u003cspan style=\"color:#f92672\"\u003e=\u003c/span\u003e\u003cspan style=\"color:#e6db74\"\u003e11\u003c/span\u003e\n\u003c/span\u003e\u003c/span\u003e\u003c/code\u003e\u003c/pre\u003e\u003c/div\u003e\u003ch3 id=\"bssidレベルで電波を確認\"\u003eBSSIDレベルで電波を確認\u003c/h3\u003e\n\u003cdiv class=\"highlight\"\u003e\u003cpre tabindex=\"0\" style=\"color:#f8f8f2;background-color:#272822;-moz-tab-size:4;-o-tab-size:4;tab-size:4;\"\u003e\u003ccode class=\"language-bash\" data-lang=\"bash\"\u003e\u003cspan style=\"display:flex;\"\u003e\u003cspan\u003esudo iw dev wlan0 scan | grep -E \u003cspan style=\"color:#e6db74\"\u003e\u0026#34;BSS |SSID|freq|signal\u0026#34;\u003c/span\u003e\n\u003c/span\u003e\u003c/span\u003e\u003c/code\u003e\u003c/pre\u003e\u003c/div\u003e\u003cpre tabindex=\"0\"\u003e\u003ccode\u003eBSS xx:xx:xx:xx:xx:xx(on wlan0)\n        freq: 2462\n        signal: -15.00 dBm\n        SSID: your-ssid\nBSS xx:xx:xx:xx:xx:xx(on wlan0)\n        freq: 2412\n        signal: -68.00 dBm\n        SSID: your-ssid\n\u003c/code\u003e\u003c/pre\u003e\u003cp\u003e両台ともビーコンは出ていた。ESSとして動作は成立している。\u003c/p\u003e","title":"Raspberry Pi 2台構成のWiFi APでSSIDが起動時に出ない問題を解決した"},{"content":"モダンオペレーティングシステム 第5版 上\n第3章前半（3.1〜3.3）。メモリ管理の基本。\n第3章前半 メモリ管理の基礎 ベースレジスタとリミットレジスタ CPUのハードウェアレジスタを使ったシンプルなメモリ保護の仕組み。\nプログラムがメモリにロードされる ↓ ベースレジスタ ← プログラムの開始物理アドレス リミットレジスタ ← プログラムの長さ ↓ プロセスがメモリにアクセスするたびに ↓ CPUがアクセスアドレス + ベース値を自動加算 ↓ リミットを超えてないか同時チェック ↓ 超えてたらフォールト（アクセス中断） これによってプロセスAはプロセスBのメモリに触れない。CPUレベルで物理的にブロックしてる。\nProxmoxのVM（KVM）との絡み\nKVMの場合はベース/リミットだけじゃなく、**EPT（Extended Page Table）**というIntel VT-xの機能でさらに一段上の分離をしてる。\nゲストの仮想アドレス ↓ ゲストのページテーブル ↓ ゲストの物理アドレス（実はまだ仮想） ↓ EPT（ここがKVMの追加レイヤー） ↓ 本当の物理アドレス これをネストされたページングと呼ぶ。ゲストOSごとに完全に別の物理アドレス空間にマッピングされるので、VM同士はお互いのメモリに絶対アクセスできない。\nEPTはソフトウェアじゃなくてCPUがハードウェアで変換するので速い。\nKVMに必要なCPU機能 KVMは以下のCPU機能が必須：\nCPU 仮想化支援機能 Intel VT-x AMD AMD-V（SVM） ARM（ラズパイ4等） ARMv8 Virtualization Extensions BIOSでVT-xが無効になってるとKVMが動かない。ProxmoxでVM作れないエラーの原因の大半がこれ。\nMacのDockerが遅かった理由もここ\nIntel Mac時代 → HyperKitでソフトウェア仮想化 → 遅い・重い Apple Silicon（M1〜） → ARMのVirtualization Extensions使える → ハードウェア仮想化 → 速い M1でDockerが速くなったのはApple SiliconがARMの仮想化支援機能をちゃんと使えるようになったから。ただしARMなのでx86イメージは--platform=linux/amd64でエミュレーションになり遅い。\nスワップ（Swapping） メモリが足りなくなった時にプロセスをディスクに退避する仕組み。\nメモリ逼迫 ↓ OSが使っていないプロセスを選ぶ ↓ そのプロセスのメモリをディスクに書き出す（スワップアウト） ↓ 空いたメモリを別のプロセスに使わせる ↓ スワップアウトしたプロセスが必要になったら ↓ ディスクからメモリに読み戻す（スワップイン） ディスクはメモリに比べて桁違いに遅いので、スワップが多発するとシステム全体が重くなる。スワップ領域がMaxになると逃がす場所もなくなり詰む。\nk3sとの比較\nk3sはデフォルトでswapを無効にすることを要求する（--fail-swap-on）。\n理由はシンプルで、swapがあるとスケジューラのリソース計算が狂うから。\nPodのメモリ制限 = 512MB と設定 ↓ swapがあると実際には512MB以上使える ↓ スケジューラが「このNodeはまだ余裕ある」と誤判断 ↓ Podを詰め込みすぎる ↓ 全体が遅くなる メモリ逼迫時はswapに逃がすんじゃなくOOMKillerがPodを強制終了する。これはKubernetesの設計思想「Podは死ぬもの、死んだら再起動すればいい」に基づいてる。\n複数台前提の設計なので、OOMKillerで殺されても別のNodeで再起動されればユーザーは気づかない。\n仮想アドレスと物理アドレス 物理アドレス：実際のRAMの場所。\n仮想アドレス：各プロセスが「自分のメモリ」として認識する仮想的なアドレス空間。\n物理メモリ（実際のRAM） ┌────┬────┬────┬────┬────┐ │ 使用│ 空き│ 使用│ 空き│ 使用│ │プロA│ │プロB│ │プロC│ └────┴────┴────┴────┴────┘ ↑ バラバラに散らばってる 仮想アドレス空間（各プロセスから見える世界） ┌────────────────────────────┐ │ 0番地から連続してるように見える │ │ 実際は物理メモリのバラバラな場所 │ │ にマッピングされてる │ └────────────────────────────┘ プロセスは「自分のメモリは0番地から連続してる」と思ってる。実際の物理メモリはバラバラ。その変換をページテーブルがやってる。\nこれで外部断片化（穴問題）を隠蔽できる。\nmalloc()した時に何が起きるか Dockerコンテナ内でmalloc()を呼んでも、コンテナはただのLinuxプロセスなので動きは同じ。\nmalloc(1GB)を呼ぶ ↓ OSは「わかった、仮想アドレス空間に1GB分確保したよ」と返す ↓ でもこの時点で物理メモリは割り当てていない ↓ 実際にそのアドレスに書き込もうとする ↓ ページフォルト発生（「あ、物理メモリまだ割り当ててなかった」） ↓ ここで初めて物理メモリを割り当てる これをデマンドページングと呼ぶ。要求された時に初めてページを割り当てる。\ndocker run --memory=512mでコンテナ起動直後にメモリを512MB食わない理由がこれ。\nページフォルト：仮想アドレスを参照した時に物理アドレス側にまだ何も割り当てられていない時に発火するエラー。エラーといっても正常系の処理の一部。\n外部断片化（穴問題） ページングが登場する前の話。プロセスをメモリに連続して配置していた時代の問題。\nプロセスA開始 → 終了 プロセスB開始 → 終了 プロセスC開始 → 終了 メモリの状態： [プロA][ 空き ][プロC] ↑ 穴（使えない空白地帯） プロセスが終了するとその場所が空くが、断片化して小さな穴だらけになる。大きなプロセスを入れたくても穴が小さすぎて入らない。\n穴を埋める3つの戦略\n戦略 方法 特徴 ファーストフィット 最初に見つかった空きに入れる シンプル・速い ベストフィット 全部走査して一番小さい穴に入れる 小さい残骸が増えて断片化悪化 ワーストフィット 全部走査して一番大きい穴に入れる 残った穴をなるべく大きく保つ ベストフィットは一見効率よさそうだが、「ぴったりサイズを狙う」ので使えない小さい残骸が増えて結局断片化が悪化する。\n現代のOSがこの問題を解決した方法\nページングで4KBに分割することで外部断片化自体が発生しなくなった。\n【断片化時代】 「100KBの連続した空きを探す」→ サイズを比較しながら走査（フィット必要） 【ページング以降】 「空きページを1個見つける」→ サイズ比較なし・最初の空きで終わり 連続したメモリを探す必要がなくなったので、フィット戦略自体が不要になった。ページテーブルのビットマップで「空き[0]か使用中[1]か」を管理するだけでいい。\nクソ長いので一旦ここで切る\nまとめ 第3章前半 ├── ベースレジスタ・リミットレジスタ（シンプルなメモリ保護） ├── EPT・ネストされたページング（VMレベルのメモリ分離） ├── KVMに必要なCPU仮想化支援機能（VT-x / AMD-V） ├── スワップ（メモリ逼迫時にディスクに退避） │ └── k3sはswap無効・OOMKillerで対処 ├── 仮想アドレスと物理アドレス（ページテーブルで変換） ├── デマンドページング（malloc→仮想確保→アクセス→ページフォルト→物理割当） └── 外部断片化（穴問題）→ ページングで解決 次回はページテーブルとTLBの話（3.4〜3.5）。\n","permalink":"https://techblog.wasutech.dev/posts/tanehon-3-1/","summary":"\u003cp\u003e\u003ca href=\"https://www.maruzenjunkudo.co.jp/products/9784296070992\"\u003eモダンオペレーティングシステム 第5版 上\u003c/a\u003e\u003c/p\u003e\n\u003cp\u003e第3章前半（3.1〜3.3）。メモリ管理の基本。\u003c/p\u003e\n\u003chr\u003e\n\u003ch2 id=\"第3章前半-メモリ管理の基礎\"\u003e第3章前半 メモリ管理の基礎\u003c/h2\u003e\n\u003ch3 id=\"ベースレジスタとリミットレジスタ\"\u003eベースレジスタとリミットレジスタ\u003c/h3\u003e\n\u003cp\u003eCPUのハードウェアレジスタを使ったシンプルなメモリ保護の仕組み。\u003c/p\u003e\n\u003cpre tabindex=\"0\"\u003e\u003ccode\u003eプログラムがメモリにロードされる\n    ↓\nベースレジスタ ← プログラムの開始物理アドレス\nリミットレジスタ ← プログラムの長さ\n    ↓\nプロセスがメモリにアクセスするたびに\n    ↓\nCPUがアクセスアドレス + ベース値を自動加算\n    ↓\nリミットを超えてないか同時チェック\n    ↓\n超えてたらフォールト（アクセス中断）\n\u003c/code\u003e\u003c/pre\u003e\u003cp\u003eこれによってプロセスAはプロセスBのメモリに触れない。CPUレベルで物理的にブロックしてる。\u003c/p\u003e\n\u003cp\u003e\u003cstrong\u003eProxmoxのVM（KVM）との絡み\u003c/strong\u003e\u003c/p\u003e\n\u003cp\u003eKVMの場合はベース/リミットだけじゃなく、**EPT（Extended Page Table）**というIntel VT-xの機能でさらに一段上の分離をしてる。\u003c/p\u003e\n\u003cpre tabindex=\"0\"\u003e\u003ccode\u003eゲストの仮想アドレス\n    ↓\nゲストのページテーブル\n    ↓\nゲストの物理アドレス（実はまだ仮想）\n    ↓\nEPT（ここがKVMの追加レイヤー）\n    ↓\n本当の物理アドレス\n\u003c/code\u003e\u003c/pre\u003e\u003cp\u003eこれを\u003cstrong\u003eネストされたページング\u003c/strong\u003eと呼ぶ。ゲストOSごとに完全に別の物理アドレス空間にマッピングされるので、VM同士はお互いのメモリに絶対アクセスできない。\u003c/p\u003e\n\u003cp\u003eEPTはソフトウェアじゃなくてCPUがハードウェアで変換するので速い。\u003c/p\u003e\n\u003chr\u003e\n\u003ch3 id=\"kvmに必要なcpu機能\"\u003eKVMに必要なCPU機能\u003c/h3\u003e\n\u003cp\u003eKVMは以下のCPU機能が必須：\u003c/p\u003e\n\u003ctable\u003e\n  \u003cthead\u003e\n      \u003ctr\u003e\n          \u003cth\u003eCPU\u003c/th\u003e\n          \u003cth\u003e仮想化支援機能\u003c/th\u003e\n      \u003c/tr\u003e\n  \u003c/thead\u003e\n  \u003ctbody\u003e\n      \u003ctr\u003e\n          \u003ctd\u003eIntel\u003c/td\u003e\n          \u003ctd\u003eVT-x\u003c/td\u003e\n      \u003c/tr\u003e\n      \u003ctr\u003e\n          \u003ctd\u003eAMD\u003c/td\u003e\n          \u003ctd\u003eAMD-V（SVM）\u003c/td\u003e\n      \u003c/tr\u003e\n      \u003ctr\u003e\n          \u003ctd\u003eARM（ラズパイ4等）\u003c/td\u003e\n          \u003ctd\u003eARMv8 Virtualization Extensions\u003c/td\u003e\n      \u003c/tr\u003e\n  \u003c/tbody\u003e\n\u003c/table\u003e\n\u003cp\u003eBIOSでVT-xが無効になってるとKVMが動かない。ProxmoxでVM作れないエラーの原因の大半がこれ。\u003c/p\u003e\n\u003cp\u003e\u003cstrong\u003eMacのDockerが遅かった理由もここ\u003c/strong\u003e\u003c/p\u003e\n\u003cpre tabindex=\"0\"\u003e\u003ccode\u003eIntel Mac時代\n→ HyperKitでソフトウェア仮想化\n→ 遅い・重い\n\nApple Silicon（M1〜）\n→ ARMのVirtualization Extensions使える\n→ ハードウェア仮想化\n→ 速い\n\u003c/code\u003e\u003c/pre\u003e\u003cp\u003eM1でDockerが速くなったのはApple SiliconがARMの仮想化支援機能をちゃんと使えるようになったから。ただしARMなのでx86イメージは\u003ccode\u003e--platform=linux/amd64\u003c/code\u003eでエミュレーションになり遅い。\u003c/p\u003e","title":"モダンオペレーティングシステム 第3章前半メモ"},{"content":"モダンオペレーティングシステム 第5版 上\n読んでる。\n読み進めた記事というよりは範囲の中でわからんとこを生成AIと相談しながら文章課題とか出してもらいつつ進めたものをまとめた記事。\n第1章 序論 OSの2つの役割 OSには2つの顔がある。\n1. 拡張マシン（Extended Machine）\nハードウェアの複雑さを隠蔽する。アプリ開発者はディスクの物理構造を知らなくてもファイルを読み書きできる。Docker APIがLinuxの複雑さを隠すのと同じ発想。\n2. リソースマネージャー（Resource Manager）\nCPU・メモリ・ディスク・ネットワークを複数プロセスに割り当てる。k3s schedulerがNodeのリソースをPodに割り当てるのと同じ役割。\nk3s scheduler → OSのリソースマネージャーと同じ役割 Docker API → OSの拡張マシンと同じ役割 OSがやってることをk3sやDockerが上位レイヤーで再現している。\nユーザー空間とカーネル空間 ┌─────────────────────────────┐ │ ユーザー空間 │ │ Docker、アプリ、k3s、etc... │ │ → 直接ハードウェアは触れない │ └──────────┬──────────────────┘ │ システムコール（唯一の通路） ┌──────────▼──────────────────┐ │ カーネル空間 │ │ スケジューラ │ │ メモリ管理 │ │ ファイルシステム │ │ デバイスドライバ │ │ → ハードウェアを直接触れる │ └─────────────────────────────┘ アプリが直接ハードウェアを触れたら危険。カーネルが仲介することで安全性を保証する。\nシステムコール docker runした時の実際の流れ：\ndocker run ubuntu ↓ Dockerデーモン ↓ clone() ← プロセス生成 unshare() ← namespaceを作る mount() ← ファイルシステムをマウント ↓ Linuxカーネル Dockerはシステムコールの集合体。魔法じゃなくてLinuxのシステムコールをうまく組み合わせてるだけ。\nファイルリードの流れ：\nDocker（ユーザー空間） ↓ fread() ← libcの関数（ユーザー空間） ↓ read() ← システムコール（ここでカーネル空間へ） ↓ VFS ← どのFSか判断する仮想ファイルシステム ↓ デバイスドライバ ← ディスクからデータ取得 ↓ データをユーザー空間に返す VFS（仮想ファイルシステム）：「このインターフェースを守れば何でもいい」のレイヤー。VFSに準拠していればext4でもBtrfsでもZFSでも作れる。\nOSの構造：モノリシック vs マイクロカーネル 【モノリシックカーネル（Linux）】 ┌─────────────────────────────┐ │ ユーザー空間 │ ├─────────────────────────────┤ │ カーネル空間 │ │ ファイルシステム │ │ メモリ管理 │ │ プロセス管理 │ │ デバイスドライバ ← 全部一塊 │ └─────────────────────────────┘ 【マイクロカーネル】 ┌─────────────────────────────┐ │ ユーザー空間 │ │ ファイルサーバー │ │ デバイスドライバ ← バラバラ │ ├─────────────────────────────┤ │ カーネル（最小限のコアのみ） │ └─────────────────────────────┘ モノリシック マイクロカーネル 速度 ✅ 速い ❌ 遅い 安全性 ❌ バグが全体に影響 ✅ 障害が局所化 代表例 Linux、Windows QNX、Minix LinuxはモノリシックだがProxmox CTのようにfork + namespace + cgroupの組み合わせでマイクロカーネル的な障害分離ができる。\nProxmoxのVM vs CT ┌─────────────────┬──────────────────┐ │ KVM（VM） │ LXC（CT） │ ├─────────────────┼──────────────────┤ │ 独自カーネルを持つ│ ホストカーネル共有 │ │ 完全な仮想化 │ namespace/cgroup │ │ オーバーヘッド大 │ オーバーヘッド小 │ │ Windowsも動く │ Linuxのみ │ └─────────────────┴──────────────────┘ 使い分け：セキュリティ要件が高い・別OSが必要 → VM、軽量・環境分離だけでいい → CT\n第2章 プロセスとスレッド プロセスとは docker psで見えるコンテナ = Linuxから見たらただのプロセス。\nnginx（バイナリファイル） ← ただのデータ ↓ 実行 nginxプロセス ← メモリ上に展開、CPUが実行中 プロセスが持つもの：\n┌─────────────────────────┐ │ コード（命令列） │ ← 読み取り専用 │ データ（グローバル変数） │ │ ヒープ（動的メモリ） │ ← malloc()で確保 │ スタック（関数呼び出し） │ ← ローカル変数 │ PID │ │ 状態 │ └─────────────────────────┘ プロセスの3つの状態 ┌──────────┐ スケジューラが選ぶ ┌──────────┐ │ Ready │ ─────────────────▶ │ Running │ │ 実行待ち │ ◀───────────────── │ 実行中 │ └──────────┘ タイムアップ └──────────┘ │ I/O待ち等 ▼ ┌──────────┐ │ Blocked │ │ 待機中 │ └──────────┘ DockerコンテナがDBにクエリ投げて結果待ちの間 → Blocked。その間CPUは別のコンテナの処理へ。\nゾンビプロセスとtini 子プロセスが終了 ↓ 親プロセスが終了を受け取っていない ↓ カーネルがプロセステーブルから消せない ↓ ゾンビ状態（死んでるけど消えられない） DockerはPID 1にtiniを使うことでゾンビプロセスを回収する。\nスレッド プロセス ├── コード ← スレッド間で共有 ├── ヒープ ← スレッド間で共有（← レースコンディションの原因） ├── データ ← スレッド間で共有 │ ├── スレッドA │ └── スタック ← 独自（関数呼び出し履歴） │ └── レジスタ ← 独自 │ └── スレッドB └── スタック ← 独自 └── レジスタ ← 独自 nginxとApacheの違い 【Apache：スレッド/プロセスモデル】 リクエスト1万 → スレッド1万 → メモリ死亡（C10K問題） 【nginx：イベント駆動モデル】 リクエスト1万 → ワーカー数個 → イベントループで捌く nginxはスレッド間でヒープを共有するのでメモリ効率が良い。\nレースコンディション スレッドA: 残高読む → 1000円 スレッドB: 残高読む → 1000円 スレッドA: 1000 + 500 = 1500円に書き込む スレッドB: 1000 + 500 = 1500円に書き込む ↓ 本来2000円のはずが1500円になる → BON💥 ミューテックス：1個だけ通れる\nセマフォ：N個まで同時に通れる（DBコネクションプール等）\nDBの場合はトランザクション分離レベルで制御するのが正しい。\nレベル 使いどころ READ COMMITTED 一般的なWebアプリ REPEATABLE READ 集計バッチ SERIALIZABLE 金融、在庫管理 スケジューラ 誰がプロセスの順番を決めるか → スケジューラ。\nLinuxはかつて**CFS（Completely Fair Scheduler）**が赤黒木でプロセスを管理していた。赤黒木はO(log n)で「一番CPUを使っていないプロセス」を効率よく取り出せる。\nLinux 6.6から**EEVDF（Earliest Eligible Virtual Deadline First）**に移行。CFSはレイテンシが不安定だったが、EEVDFは期限を考慮してより賢く順番を決める。\n割り込み キーボード1ストローク ≈ 50ms の間にCPUは約1億6000万命令を実行できる。CPUからすると人間は永遠に近い暇な時間を作ってくれる存在。\nプロセスA: ファイル書き込み開始 ↓ CPUは待たない → 別プロセスBを実行 ↓ ディスクへの書き込み完了 ↓ 「終わったぞ」と割り込み信号 ↓ プロセスAがReady状態に戻る 組み込みOSの世界（余談） デバイス OS Arduino なし（ベアメタル） Raspberry Pi Linux 家電・車 FreeRTOS、VxWorks、QNX 医療機器・信号機 RTOS RTOSは「この処理は1ms以内に必ず終わらせる」をリアルタイムで保証する。車のブレーキ制御が「ちょっと待って」では困るから。\nまとめ 第1章 ├── OSの2つの役割（拡張マシン・リソースマネージャー） ├── システムコール（ユーザー空間とカーネル空間の橋） ├── VFS（FSの抽象化レイヤー） └── モノリシック vs マイクロカーネル 第2章 ├── プロセス（実行中のプログラム） ├── プロセスの3状態（Running・Ready・Blocked） ├── スレッド（ヒープ共有、スタック独自） ├── レースコンディション → ミューテックス・セマフォ・トランザクション ├── スケジューラ（CFS→EEVDF、赤黒木） └── 割り込み（I/O待ちの間に別プロセスを動かす） ","permalink":"https://techblog.wasutech.dev/posts/tane-book-1/","summary":"\u003cp\u003e\u003ca href=\"https://www.maruzenjunkudo.co.jp/products/9784296070992\"\u003eモダンオペレーティングシステム 第5版 上\u003c/a\u003e\u003c/p\u003e\n\u003cp\u003e読んでる。\u003c/p\u003e\n\u003cp\u003e読み進めた記事というよりは範囲の中でわからんとこを生成AIと相談しながら文章課題とか出してもらいつつ進めたものをまとめた記事。\u003c/p\u003e\n\u003ch2 id=\"第1章-序論\"\u003e第1章 序論\u003c/h2\u003e\n\u003ch3 id=\"osの2つの役割\"\u003eOSの2つの役割\u003c/h3\u003e\n\u003cp\u003eOSには2つの顔がある。\u003c/p\u003e\n\u003cp\u003e\u003cstrong\u003e1. 拡張マシン（Extended Machine）\u003c/strong\u003e\u003c/p\u003e\n\u003cp\u003eハードウェアの複雑さを隠蔽する。アプリ開発者はディスクの物理構造を知らなくてもファイルを読み書きできる。Docker APIがLinuxの複雑さを隠すのと同じ発想。\u003c/p\u003e\n\u003cp\u003e\u003cstrong\u003e2. リソースマネージャー（Resource Manager）\u003c/strong\u003e\u003c/p\u003e\n\u003cp\u003eCPU・メモリ・ディスク・ネットワークを複数プロセスに割り当てる。k3s schedulerがNodeのリソースをPodに割り当てるのと同じ役割。\u003c/p\u003e\n\u003cpre tabindex=\"0\"\u003e\u003ccode\u003ek3s scheduler  →  OSのリソースマネージャーと同じ役割\nDocker API     →  OSの拡張マシンと同じ役割\n\u003c/code\u003e\u003c/pre\u003e\u003cp\u003eOSがやってることをk3sやDockerが上位レイヤーで再現している。\u003c/p\u003e\n\u003chr\u003e\n\u003ch3 id=\"ユーザー空間とカーネル空間\"\u003eユーザー空間とカーネル空間\u003c/h3\u003e\n\u003cpre tabindex=\"0\"\u003e\u003ccode\u003e┌─────────────────────────────┐\n│        ユーザー空間           │\n│  Docker、アプリ、k3s、etc... │\n│  → 直接ハードウェアは触れない │\n└──────────┬──────────────────┘\n           │ システムコール（唯一の通路）\n┌──────────▼──────────────────┐\n│        カーネル空間           │\n│  スケジューラ                │\n│  メモリ管理                  │\n│  ファイルシステム             │\n│  デバイスドライバ             │\n│  → ハードウェアを直接触れる   │\n└─────────────────────────────┘\n\u003c/code\u003e\u003c/pre\u003e\u003cp\u003eアプリが直接ハードウェアを触れたら危険。カーネルが仲介することで安全性を保証する。\u003c/p\u003e\n\u003chr\u003e\n\u003ch3 id=\"システムコール\"\u003eシステムコール\u003c/h3\u003e\n\u003cp\u003e\u003ccode\u003edocker run\u003c/code\u003eした時の実際の流れ：\u003c/p\u003e\n\u003cpre tabindex=\"0\"\u003e\u003ccode\u003edocker run ubuntu\n    ↓\nDockerデーモン\n    ↓\nclone()    ← プロセス生成\nunshare()  ← namespaceを作る\nmount()    ← ファイルシステムをマウント\n    ↓\nLinuxカーネル\n\u003c/code\u003e\u003c/pre\u003e\u003cp\u003eDockerは\u003cstrong\u003eシステムコールの集合体\u003c/strong\u003e。魔法じゃなくてLinuxのシステムコールをうまく組み合わせてるだけ。\u003c/p\u003e","title":"モダンオペレーティングシステム 第1章・第2章メモ"},{"content":"結論 Mike Olson - Fixing typescript-ts-mode in Emacs 30.2\n上記のブログで解説と対応スクリプトが公開されてる。\nEmacs 30.2 + libtree-sitter 0.26 の組み合わせで go-ts-mode や typescript-ts-mode などの *-ts-mode が動作しない場合、現時点（2026年4月）では以下のワークアラウンドで動作させることができる。\n現時点ではこれが最適解と思う。おそらくEmacs 31 安定版がリリースされるまでこのスクリプトを使い続けることになる。\nmkdir -p ~/.emacs.d/init curl -o ~/.emacs.d/init/treesit-predicate-rewrite.el \\ https://raw.githubusercontent.com/mwolson/emacs-shared/master/init/treesit-predicate-rewrite.el init.el の早い段階（他の *-ts-mode の設定より前）に追加する。\n(load \u0026#34;~/.emacs.d/init/treesit-predicate-rewrite\u0026#34; nil nil nil t) 経緯 go-ts-mode を開いたとき *Messages* バッファに treesit-query-error が出てシンタックスハイライトが死んだ。\n最初はグラマーの .so ファイルが原因だと思い、treesit-install-language-grammar で入れ直したり、~/.emacs.d/tree-sitter/ 以下のファイルを削除して再インストールしたりと試行錯誤した。しかし何をやっても症状が変わらず、グラマー側の問題ではないと判断した。\n調べたところ Emacs bug#79687 に行き着いた。グラマーの問題ではなく、Emacs 30.2 と libtree-sitter 0.26 の組み合わせ自体が壊れていた。\n症状 Emacs 30.2 で Go や TypeScript などのファイルを開くと、*Messages* バッファに以下のようなエラーが表示され、シンタックスハイライトが一切効かなくなる。\nError during redisplay: (jit-lock-function 35) signaled (treesit-query-error \u0026#34;Syntax error at\u0026#34; 73 \u0026#34;(call_expression function: ((identifier) @font-lock-builtin-face (#match \\\u0026#34;...\\\u0026#34; @font-lock-builtin-face)))\u0026#34; \u0026#34;Debug the query with `treesit-query-validate\u0026#39;\u0026#34;) go-ts-mode だけでなく typescript-ts-mode、python-ts-mode、rust-ts-mode など、tree-sitter ベースの全モードで同様の問題が発生する。\n原因 Emacs bug#79687 を読んだところ、tree-sitter 側の仕様変更が発端だった。\ntree-sitter のクエリには #match や #equal のような述語を使うことができる。ある時点から tree-sitter はこれらの述語の末尾に ? または ! がない場合に構文エラーを返す仕様に変更した。\nEmacs はもともと意図的に #match（末尾 ? なし）を使っていた。他のエディタ（Neovim 等）との互換性より「Emacs らしい慣用表現」を優先した設計判断だった。それが tree-sitter 側の変更で突然壊れた形になる。\ntree-sitter 0.26 → 述語に ? を強制 Emacs 30.2 → #match のまま → 拒否される ❌ Emacs 31 (master) → #match? に対応済み ✅ Emacs の master ブランチでは 2025年11月に対応が完了しているが（bug#79687 クローズ）、emacs-30 ブランチへのバックポートは 30.2 時点では行われていない。\n文字列レベルで #match → #match? に書き換えることもできない。Emacs 30.2 側の述語ディスパッチャが match? を拒否するため、tree-sitter を満足させると Emacs が壊れ、Emacs を満足させると tree-sitter が壊れるというジレンマがある。\n対処 現時点（2026年4月）でシンプルにバージョンを上げるといった単純な解決策は無さそう。Emacs 31 の master ブランチには修正が入っているが、31 系の安定版リリースはまだ行われておらず、30 系へのバックポートも確認できていない。現行の安定版である Emacs 30.2 を使い続ける限り、以下のワークアラウンドが現状の最適解だ。\ntreesit-predicate-rewrite.el Mike Olson が公開している treesit-predicate-rewrite.el を使う。\nこのファイルは述語をクエリから丸ごと除去し、その述語ロジックを capture-name-as-function fontifier に移すことで、tree-sitter と Emacs 30.2 の両方を満足させる。go-ts-mode、typescript-ts-mode、python-ts-mode、rust-ts-mode など主要なモードで動作確認済みだ。\nmkdir -p ~/.emacs.d/init curl -o ~/.emacs.d/init/treesit-predicate-rewrite.el \\ https://raw.githubusercontent.com/mwolson/emacs-shared/master/init/treesit-predicate-rewrite.el init.el に追加する。他の *-ts-mode の設定より前に読み込むこと。\n(load \u0026#34;~/.emacs.d/init/treesit-predicate-rewrite\u0026#34; nil nil nil t) use-package で treesit を管理している場合は :init に書くと確実だ。\n(use-package treesit :straight (:type built-in) :init (load \u0026#34;~/.emacs.d/init/treesit-predicate-rewrite\u0026#34; nil nil nil t) :config (setq treesit-font-lock-level 3)) やっても意味がなかったこと グラマーの .so ファイルを疑って以下を試したが、どれも効果がなかった。\ntreesit-install-language-grammar で再インストール ~/.emacs.d/tree-sitter/ 以下を削除して入れ直し tree-sitter パッケージ自体の再インストール 症状はグラマー側ではなく Emacs と libtree-sitter の互換性の問題なので、グラマーをいじっても解決しない。\n並行してググっていたが冒頭のサイトが全然見つからず、生成AIも具体的な解決策を出せなかった。最終的にPlanet Emacslifeでそれらしき内容を見つけて対応できた。\nEmacs 関連でハマったらPlanet Emacslifeを見るのがよさそうだ。\nPlanet Emacslife 運営者と対策スクリプトを書いて公開してくださった方々へ感謝。\nスクリプトの除去タイミング 30 系へのバックポートが行われるか、Emacs 31 安定版がリリースされ bug#79687 の修正が含まれていることを確認したタイミングで treesit-predicate-rewrite.el の読み込みを削除する。\ntreesit-predicate-rewrite.el は述語を含まないクエリには何もしないため、修正済みの Emacs でロードしても副作用はない。削除を忘れても即壊れるわけではないが、不要なコードは消しておくのが無難。\n参考 Emacs bug#79687 Fixing typescript-ts-mode in Emacs 30.2 - Mike Olson treesit-predicate-rewrite.el Arch Linux GitLab issue #13 ","permalink":"https://techblog.wasutech.dev/posts/broken-emacs-treesitter/","summary":"\u003ch2 id=\"結論\"\u003e結論\u003c/h2\u003e\n\u003cp\u003e\u003ca href=\"https://mwolson.org/blog/2026-04-20-fixing-typescript-ts-mode-in-emacs-30-2/\"\u003eMike Olson - Fixing typescript-ts-mode in Emacs 30.2\u003c/a\u003e\u003c/p\u003e\n\u003cp\u003e上記のブログで解説と対応スクリプトが公開されてる。\u003c/p\u003e\n\u003chr\u003e\n\u003cp\u003eEmacs 30.2 + libtree-sitter 0.26 の組み合わせで \u003ccode\u003ego-ts-mode\u003c/code\u003e や \u003ccode\u003etypescript-ts-mode\u003c/code\u003e などの \u003ccode\u003e*-ts-mode\u003c/code\u003e が動作しない場合、現時点（2026年4月）では以下のワークアラウンドで動作させることができる。\u003c/p\u003e\n\u003cp\u003e\u003cstrong\u003e現時点ではこれが最適解と思う。おそらくEmacs 31 安定版がリリースされるまでこのスクリプトを使い続けることになる。\u003c/strong\u003e\u003c/p\u003e\n\u003cdiv class=\"highlight\"\u003e\u003cpre tabindex=\"0\" style=\"color:#f8f8f2;background-color:#272822;-moz-tab-size:4;-o-tab-size:4;tab-size:4;\"\u003e\u003ccode class=\"language-bash\" data-lang=\"bash\"\u003e\u003cspan style=\"display:flex;\"\u003e\u003cspan\u003emkdir -p ~/.emacs.d/init\n\u003c/span\u003e\u003c/span\u003e\u003cspan style=\"display:flex;\"\u003e\u003cspan\u003ecurl -o ~/.emacs.d/init/treesit-predicate-rewrite.el \u003cspan style=\"color:#ae81ff\"\u003e\\\n\u003c/span\u003e\u003c/span\u003e\u003c/span\u003e\u003cspan style=\"display:flex;\"\u003e\u003cspan\u003e\u003cspan style=\"color:#ae81ff\"\u003e\u003c/span\u003e  https://raw.githubusercontent.com/mwolson/emacs-shared/master/init/treesit-predicate-rewrite.el\n\u003c/span\u003e\u003c/span\u003e\u003c/code\u003e\u003c/pre\u003e\u003c/div\u003e\u003cp\u003e\u003ccode\u003einit.el\u003c/code\u003e の早い段階（他の \u003ccode\u003e*-ts-mode\u003c/code\u003e の設定より前）に追加する。\u003c/p\u003e\n\u003cdiv class=\"highlight\"\u003e\u003cpre tabindex=\"0\" style=\"color:#f8f8f2;background-color:#272822;-moz-tab-size:4;-o-tab-size:4;tab-size:4;\"\u003e\u003ccode class=\"language-elisp\" data-lang=\"elisp\"\u003e\u003cspan style=\"display:flex;\"\u003e\u003cspan\u003e(\u003cspan style=\"color:#a6e22e\"\u003eload\u003c/span\u003e \u003cspan style=\"color:#e6db74\"\u003e\u0026#34;~/.emacs.d/init/treesit-predicate-rewrite\u0026#34;\u003c/span\u003e \u003cspan style=\"color:#66d9ef\"\u003enil\u003c/span\u003e \u003cspan style=\"color:#66d9ef\"\u003enil\u003c/span\u003e \u003cspan style=\"color:#66d9ef\"\u003enil\u003c/span\u003e \u003cspan style=\"color:#66d9ef\"\u003et\u003c/span\u003e)\n\u003c/span\u003e\u003c/span\u003e\u003c/code\u003e\u003c/pre\u003e\u003c/div\u003e\u003ch2 id=\"経緯\"\u003e経緯\u003c/h2\u003e\n\u003cp\u003e\u003ccode\u003ego-ts-mode\u003c/code\u003e を開いたとき \u003ccode\u003e*Messages*\u003c/code\u003e バッファに \u003ccode\u003etreesit-query-error\u003c/code\u003e が出てシンタックスハイライトが死んだ。\u003c/p\u003e\n\u003cp\u003e最初はグラマーの \u003ccode\u003e.so\u003c/code\u003e ファイルが原因だと思い、\u003ccode\u003etreesit-install-language-grammar\u003c/code\u003e で入れ直したり、\u003ccode\u003e~/.emacs.d/tree-sitter/\u003c/code\u003e 以下のファイルを削除して再インストールしたりと試行錯誤した。しかし何をやっても症状が変わらず、グラマー側の問題ではないと判断した。\u003c/p\u003e\n\u003cp\u003e調べたところ \u003cstrong\u003e\u003ca href=\"https://debbugs.gnu.org/cgi/bugreport.cgi?bug=79687\"\u003eEmacs bug#79687\u003c/a\u003e\u003c/strong\u003e に行き着いた。グラマーの問題ではなく、Emacs 30.2 と libtree-sitter 0.26 の組み合わせ自体が壊れていた。\u003c/p\u003e\n\u003ch2 id=\"症状\"\u003e症状\u003c/h2\u003e\n\u003cp\u003eEmacs 30.2 で Go や TypeScript などのファイルを開くと、\u003ccode\u003e*Messages*\u003c/code\u003e バッファに以下のようなエラーが表示され、シンタックスハイライトが一切効かなくなる。\u003c/p\u003e\n\u003cpre tabindex=\"0\"\u003e\u003ccode\u003eError during redisplay: (jit-lock-function 35) signaled\n(treesit-query-error \u0026#34;Syntax error at\u0026#34; 73\n\u0026#34;(call_expression function: ((identifier) @font-lock-builtin-face\n(#match \\\u0026#34;...\\\u0026#34; @font-lock-builtin-face)))\u0026#34;\n\u0026#34;Debug the query with `treesit-query-validate\u0026#39;\u0026#34;)\n\u003c/code\u003e\u003c/pre\u003e\u003cp\u003e\u003ccode\u003ego-ts-mode\u003c/code\u003e だけでなく \u003ccode\u003etypescript-ts-mode\u003c/code\u003e、\u003ccode\u003epython-ts-mode\u003c/code\u003e、\u003ccode\u003erust-ts-mode\u003c/code\u003e など、tree-sitter ベースの全モードで同様の問題が発生する。\u003c/p\u003e","title":"Emacs 30.2 で *-ts-mode が壊れる問題と対処"},{"content":"背景 自宅のネットワーク構成が辛かった。\n具体的には以下の問題があった。\nルータが1台構成で、死んだら自宅NW全滅 メンテナンスで定期的に落としたいとかあっても、コストが大きい DNS/DHCPがルータに同居していて、役割が混在している WireGuardで外部接続していたが、外部からの入り方がだるい。 特に「冗長化したい」という気持ちは何年も前からあったが、DNSとDHCPの同期という難問の前に何度も挫折してきた。\n結論からいえば、DNS,DHCPの冗長化は諦めた。\n諦めた上で出口のルータとDNS、DHCPは役割を分けた上で出口のルータとWIFIルータのみ冗長化を実施した。\n設計方針 過去の失敗から学んだ一番の教訓はやはり「DNS/DHCPの冗長化は難しい」ということだ。\n理論上可能ではあるが、私は万年初級自宅インフラエンジニアでいつまで経ってもできる目処が立たない。\nそれでいて、生成AIという過ぎた兵器を持ってしても、この問題は解決しなかったので、難しいというよりは不可能とした。\n2台のサーバでDHCPを冗長化しようとすると、リースの同期問題が必ず発生する。同期ツールを使ったりしたが、どうしても同期しきれなかった。\nそこで今回は思い切って諦めた上で役割を分離する方針にした。\nルータ（冗長化） → GWとTailscaleだけ担当 AP → hostapdのみ DNS/DHCP → 専用機1台に集約（冗長化しない） DNSとDHCPは単一障害点になるが、自宅ラボであれば許容範囲だと判断した。\nてかどうしようもない。\n構成 使用機材はすべてRaspberry Pi。封印していたタワーからも引っ張り出した。\nホスト 役割 ルータA (MASTER) Keepalived + Tailscale ルータB (BACKUP) Keepalived + Tailscale DNS/DHCP機 dnsmasq専用 AP x2 WIFI冗長化 ネットワークセグメントはひとつ。VIPをデフォルトゲートウェイ兼DNSとして各クライアントに配布する。\nKeepalivedの設定 まずルータ2台にKeepalivedを入れる。\nsudo apt install -y keepalived MASTER側の設定:\nvrrp_instance VI_1 { state MASTER interface lan-if virtual_router_id 51 priority 100 advert_int 1 authentication { auth_type PASS auth_pass xxxxxxxx } virtual_ipaddress { 192.168.xx.1/24 } } BACKUP側は state BACKUP と priority 90 に変えるだけ。シンプルだ。\n起動後、MASTERがVIPを持ち、BACKUPが待機状態になることを確認した。\niptablesの設定 ルータとしての役割を果たすにはMASQUERADEとDNS転送が必要だ。\n# 外部への通信 (uplink-if経由で上流ルータへ) sudo iptables -t nat -A POSTROUTING -o uplink-if -j MASQUERADE # VIPへのDNSクエリをdnsmasq専用機へ転送 sudo iptables -t nat -A PREROUTING -d 192.168.xx.1 -p udp --dport 53 -j DNAT --to-destination 192.168.xx.5:53 sudo iptables -t nat -A PREROUTING -d 192.168.xx.1 -p tcp --dport 53 -j DNAT --to-destination 192.168.xx.5:53 # 永続化 sudo apt install -y iptables-persistent sudo netfilter-persistent save VIPに来たDNSクエリをdnsmasq専用機（192.168.xx.5）に転送する設計にした。こうすることで、クライアント側のDNS設定はVIPのまま変えなくて済む。\ndnsmasqの設定 DNS/DHCP専用機にdnsmasqを入れる。\nsudo apt install -y dnsmasq 設定ファイル /etc/dnsmasq.conf:\nport=53 interface=lan-if domain-needed domain=home.local expand-hosts server=8.8.8.8 server=8.8.4.4 dhcp-authoritative dhcp-range=192.168.xx.20,192.168.xx.99,12h dhcp-option=option:router,192.168.xx.1 dhcp-option=option:dns-server,192.168.xx.1 dhcp-option=option:domain-search,home.local dhcp-leasefile=/var/lib/misc/dnsmasq.leases ハマりポイント1: bind-interfaces 最初 bind-interfaces を入れていたら、dnsmasqが 127.0.0.1:53 だけで待ち受けてしまい外部から到達できなかった。コメントアウトすることで全インターフェースで待ち受けるようになった。\nハマりポイント2: avahi-daemon .local ドメインはmDNS予約済みのため、avahi-daemonが干渉して名前解決ができなかった。全ノードでavahi-daemonを無効化して解決した。\nsudo systemctl stop avahi-daemon sudo systemctl disable avahi-daemon ハマりポイント3: expand-hosts /etc/hosts に 192.168.xx.5 myhost と書いても myhost.home.local で解決できなかった。expand-hosts ディレクティブを追加することで、hostsのエントリにドメインが自動付与されるようになった。\nTailscaleのSplit DNS設定 全ノードにTailscaleが入っているため、Tailscaleがresolv.confを上書きしてしまう問題があった。\nTailscaleの管理画面でSplit DNSを設定することで解決した。\nDNS → Nameservers → Add nameserver → Custom IPアドレス: 192.168.xx.5 Restrict to domain: home.local これにより home.local のクエリだけdnsmasq専用機に向けられ、それ以外はTailscale DNSが処理する。\nWIFIの冗長化 APは2台用意し、hostapdでそれぞれをアクセスポイントとして動かす。\nsudo apt install -y hostapd 設定ファイル /etc/hostapd/hostapd.conf:\ninterface=wlan0 bridge=br0 driver=nl80211 ssid=home-wifi hw_mode=g wmm_enabled=1 auth_algs=1 wpa=2 wpa_passphrase=xxxxxxxx wpa_key_mgmt=WPA-PSK wpa_pairwise=TKIP rsn_pairwise=CCMP ieee80211n=1 max_num_sta=15 beacon_int=200 2台はチャンネルだけ変える（例: ch6とch7）。同一チャンネルだと干渉するためだ。SSIDとパスワードは同一にする。\nBridgeモード（br0）で動かすことで、WIFI経由で繋いできたクライアントが有線側の192.168.xx.0/24セグメントにそのまま収容される。DHCPもdnsmasq専用機から取得できる。\n# br0を作成してeth0をスレーブに sudo nmcli con add type bridge ifname br0 sudo nmcli con add type bridge-slave ifname eth0 master br0 # hostapd有効化 sudo systemctl enable --now hostapd クライアントは自動的に電波の強い方のAPに繋がる。DHCPの同期問題もなく、これが一番シンプルな冗長化だった。\nちなみにここらへんは全部前に試したときの設定残ってたのでちょちょいとなおしたら普通に動くようになった。\n結果 フェイルオーバーテストとしてMASTERの電源を抜いたところ、数秒でBACKUPがVIPを引き継ぎ通信が復旧した。DNSもDHCPもVIP経由で継続して機能した。\n構成のポイントをまとめると:\nルータの冗長化: Keepalivedでシンプルに実現 DNS/DHCPは分離・単一化: 同期問題を回避 DNATでVIPへの透過性を確保: クライアント設定変更不要 avahi無効化とexpand-hosts: .localドメイン運用の必須設定 WIFI冗長化: 同一SSID・チャンネル分け・Bridgeモードでシンプルに実現 今後の課題 あくまでも今のNWとは別のSwitchなどを介して構築しているため、使っていない開発PCなどをこちらに移行するか考え中。 外形監視の仕組みを作りたい VPSにHeadscaleを構築してTailscaleをセルフホスト化したい 上２つは多分やる。最後のは多分やらないと思う。やる気がマックスファイアーならやる。\n","permalink":"https://techblog.wasutech.dev/posts/home-nw-redundancy/","summary":"\u003ch2 id=\"背景\"\u003e背景\u003c/h2\u003e\n\u003cp\u003e自宅のネットワーク構成が辛かった。\u003c/p\u003e\n\u003cp\u003e具体的には以下の問題があった。\u003c/p\u003e\n\u003cul\u003e\n\u003cli\u003eルータが1台構成で、死んだら自宅NW全滅\u003c/li\u003e\n\u003cli\u003eメンテナンスで定期的に落としたいとかあっても、コストが大きい\u003c/li\u003e\n\u003cli\u003eDNS/DHCPがルータに同居していて、役割が混在している\u003c/li\u003e\n\u003cli\u003eWireGuardで外部接続していたが、外部からの入り方がだるい。\u003c/li\u003e\n\u003c/ul\u003e\n\u003cp\u003e特に「冗長化したい」という気持ちは何年も前からあったが、DNSとDHCPの同期という難問の前に何度も挫折してきた。\u003c/p\u003e\n\u003cp\u003e結論からいえば、DNS,DHCPの冗長化は諦めた。\u003c/p\u003e\n\u003cp\u003e諦めた上で出口のルータとDNS、DHCPは役割を分けた上で出口のルータとWIFIルータのみ冗長化を実施した。\u003c/p\u003e\n\u003ch2 id=\"設計方針\"\u003e設計方針\u003c/h2\u003e\n\u003cp\u003e過去の失敗から学んだ一番の教訓はやはり「DNS/DHCPの冗長化は難しい」ということだ。\u003c/p\u003e\n\u003cp\u003e理論上可能ではあるが、私は万年初級自宅インフラエンジニアでいつまで経ってもできる目処が立たない。\u003c/p\u003e\n\u003cp\u003eそれでいて、生成AIという過ぎた兵器を持ってしても、この問題は解決しなかったので、難しいというよりは不可能とした。\u003c/p\u003e\n\u003cp\u003e2台のサーバでDHCPを冗長化しようとすると、リースの同期問題が必ず発生する。同期ツールを使ったりしたが、どうしても同期しきれなかった。\u003c/p\u003e\n\u003cp\u003eそこで今回は思い切って諦めた上で役割を分離する方針にした。\u003c/p\u003e\n\u003cpre tabindex=\"0\"\u003e\u003ccode\u003eルータ（冗長化） → GWとTailscaleだけ担当\nAP             → hostapdのみ\nDNS/DHCP       → 専用機1台に集約（冗長化しない）\n\u003c/code\u003e\u003c/pre\u003e\u003cp\u003eDNSとDHCPは単一障害点になるが、自宅ラボであれば許容範囲だと判断した。\u003c/p\u003e\n\u003cp\u003e\u003cstrong\u003eてかどうしようもない。\u003c/strong\u003e\u003c/p\u003e\n\u003ch2 id=\"構成\"\u003e構成\u003c/h2\u003e\n\u003cp\u003e使用機材はすべてRaspberry Pi。封印していたタワーからも引っ張り出した。\u003c/p\u003e\n\u003ctable\u003e\n  \u003cthead\u003e\n      \u003ctr\u003e\n          \u003cth\u003eホスト\u003c/th\u003e\n          \u003cth\u003e役割\u003c/th\u003e\n      \u003c/tr\u003e\n  \u003c/thead\u003e\n  \u003ctbody\u003e\n      \u003ctr\u003e\n          \u003ctd\u003eルータA (MASTER)\u003c/td\u003e\n          \u003ctd\u003eKeepalived + Tailscale\u003c/td\u003e\n      \u003c/tr\u003e\n      \u003ctr\u003e\n          \u003ctd\u003eルータB (BACKUP)\u003c/td\u003e\n          \u003ctd\u003eKeepalived + Tailscale\u003c/td\u003e\n      \u003c/tr\u003e\n      \u003ctr\u003e\n          \u003ctd\u003eDNS/DHCP機\u003c/td\u003e\n          \u003ctd\u003ednsmasq専用\u003c/td\u003e\n      \u003c/tr\u003e\n      \u003ctr\u003e\n          \u003ctd\u003eAP x2\u003c/td\u003e\n          \u003ctd\u003eWIFI冗長化\u003c/td\u003e\n      \u003c/tr\u003e\n  \u003c/tbody\u003e\n\u003c/table\u003e\n\u003cp\u003eネットワークセグメントはひとつ。VIPをデフォルトゲートウェイ兼DNSとして各クライアントに配布する。\u003c/p\u003e\n\u003ch2 id=\"keepalivedの設定\"\u003eKeepalivedの設定\u003c/h2\u003e\n\u003cp\u003eまずルータ2台にKeepalivedを入れる。\u003c/p\u003e\n\u003cdiv class=\"highlight\"\u003e\u003cpre tabindex=\"0\" style=\"color:#f8f8f2;background-color:#272822;-moz-tab-size:4;-o-tab-size:4;tab-size:4;\"\u003e\u003ccode class=\"language-bash\" data-lang=\"bash\"\u003e\u003cspan style=\"display:flex;\"\u003e\u003cspan\u003esudo apt install -y keepalived\n\u003c/span\u003e\u003c/span\u003e\u003c/code\u003e\u003c/pre\u003e\u003c/div\u003e\u003cp\u003eMASTER側の設定:\u003c/p\u003e\n\u003cpre tabindex=\"0\"\u003e\u003ccode\u003evrrp_instance VI_1 {\n    state MASTER\n    interface lan-if\n    virtual_router_id 51\n    priority 100\n    advert_int 1\n    authentication {\n        auth_type PASS\n        auth_pass xxxxxxxx\n    }\n    virtual_ipaddress {\n        192.168.xx.1/24\n    }\n}\n\u003c/code\u003e\u003c/pre\u003e\u003cp\u003eBACKUP側は \u003ccode\u003estate BACKUP\u003c/code\u003e と \u003ccode\u003epriority 90\u003c/code\u003e に変えるだけ。シンプルだ。\u003c/p\u003e","title":"自宅NWをKeepalivedとdnsmasqで作り直した話"},{"content":"wasutech.dev を公開したはいいが、メール周りを何もしていなかった。放置するとなりすましに悪用される可能性があるので、Cloudflare Email Routing・SPF・DMARCを設定した。\nCloudflare Email Routing 自前のメールサーバーを建てるのはコストがかかる。Cloudflareには Email Routing という機能があり、@wasutech.dev 宛のメールを既存のGmailなどに転送できる。無料。\n仕組みとしては、Cloudflareが自動でMXレコードを追加し、受信したメールを指定のアドレスへ転送する。\ndig MX wasutech.dev # → isaac.mx.cloudflare.net 等が返ってくる MXレコードはドメインレベルの情報なので、転送先のメールアドレスは外部に公開されない。dig で見えるのはCloudflareのサーバーだけで、「どこに転送しているか」は誰にもわからない。\n設定はCloudflareのダッシュボードから数クリックで完了する。\n転送先のアクションは3種類から選べる。\nアクション 動作 Forward to email 指定のメールアドレスへ転送 Drop 受信して即破棄。転送しない Send to Worker Cloudflare Workersで独自処理 今回はメールを受け取る必要がないため Drop に設定した。スパム対策にもなるし、転送先アドレスを管理する必要もない。Workers連携は受信メールをSlackに流したり、自動返信を実装したりする場合に使う。\nSPF - 送信元を証明するレコード SPFとは SPF（Sender Policy Framework） は、「このドメインからメールを送信していいサーバーはどこか」を定義するDNSレコード。\nなぜ必要か。メールのプロトコル（SMTP）は設計上、送信元アドレスを自由に詐称できる。つまり誰でも admin@wasutech.dev を名乗ってメールを送れる。SPFはこれを受信側が検証できるようにする仕組み。\n受信側のメールサーバーは、届いたメールの送信元IPアドレスを確認し、そのドメインのSPFレコードに記載されたIPと照合する。一致しなければ怪しいメールとして処理できる。\n設定したレコード v=spf1 include:_spf.mx.cloudflare.net ~all 各要素の意味：\n要素 意味 v=spf1 SPFバージョン1 include:_spf.mx.cloudflare.net CloudflareのメールサーバーからのSMTPを許可 ~all 上記以外は「疑わしい」扱い（ソフトフェイル） ~all は疑わしいメールを迷惑メール扱いにする。-all にすると完全拒否になるが、DMARCと組み合わせて制御するのが一般的。\n確認：\ndig TXT wasutech.dev # v=spf1 include:_spf.mx.cloudflare.net ~all DMARC - SPFの結果を使って何をするか決めるレコード DMARCとは DMARC（Domain-based Message Authentication, Reporting, and Conformance） は、SPF（やDKIM）の検証結果に基づいて、受信側が「そのメールをどう扱うか」を指示するレコード。\nSPFが「このメールは正規か？」を判定するなら、DMARCは「正規じゃなかったらどうすればいい？」をドメインオーナーが宣言する仕組み。\nポリシー（p=）の違い ポリシー 動作 p=none 何もしない。モニタリング専用 p=quarantine 迷惑メールフォルダに振り分け p=reject 完全に拒否。受信しない 最初は p=none で様子を見てから上げていくのが定石だが、今回はメール送信自体しないブログ用ドメインなので最初から p=reject にした。\nrua - レポート送信先 DMARCには認証失敗のレポートを受け取るメールアドレスを指定できる（rua=）。Cloudflareが自動設定してくれるアドレスはハッシュ化されたもの。\nrua=mailto:cea958224db542c0bcf04f319d159b3c@dmarc-reports.cloudflare.net 個人のメールアドレスは露出しない。\n設定したレコード DMARCは _dmarc.wasutech.dev というサブドメインのTXTレコードとして登録する。\nv=DMARC1; p=reject; rua=mailto:cea958224db542c0bcf04f319d159b3c@dmarc-reports.cloudflare.net 確認：\ndig TXT _dmarc.wasutech.dev # v=DMARC1; p=reject; rua=mailto:... まとめ 設定 役割 Email Routing @wasutech.dev 宛メールをGmailへ転送 SPF 正規の送信元サーバーを定義 DMARC なりすましメールを拒否 ブログ用途で自分からメールを送信しないなら、この3つで十分。DKIMはメール送信時に必要になるが、今回は対象外。\nCloudflareのダッシュボードがほぼ自動でレコードを提案してくれるので、言われるがままに追加するだけで完了した。\n","permalink":"https://techblog.wasutech.dev/posts/cloudflare-dns-setup/","summary":"\u003cp\u003ewasutech.dev を公開したはいいが、メール周りを何もしていなかった。放置するとなりすましに悪用される可能性があるので、Cloudflare Email Routing・SPF・DMARCを設定した。\u003c/p\u003e\n\u003ch2 id=\"cloudflare-email-routing\"\u003eCloudflare Email Routing\u003c/h2\u003e\n\u003cp\u003e自前のメールサーバーを建てるのはコストがかかる。Cloudflareには \u003cstrong\u003eEmail Routing\u003c/strong\u003e という機能があり、\u003ccode\u003e@wasutech.dev\u003c/code\u003e 宛のメールを既存のGmailなどに転送できる。無料。\u003c/p\u003e\n\u003cp\u003e仕組みとしては、Cloudflareが自動でMXレコードを追加し、受信したメールを指定のアドレスへ転送する。\u003c/p\u003e\n\u003cdiv class=\"highlight\"\u003e\u003cpre tabindex=\"0\" style=\"color:#f8f8f2;background-color:#272822;-moz-tab-size:4;-o-tab-size:4;tab-size:4;\"\u003e\u003ccode class=\"language-bash\" data-lang=\"bash\"\u003e\u003cspan style=\"display:flex;\"\u003e\u003cspan\u003edig MX wasutech.dev\n\u003c/span\u003e\u003c/span\u003e\u003cspan style=\"display:flex;\"\u003e\u003cspan\u003e\u003cspan style=\"color:#75715e\"\u003e# → isaac.mx.cloudflare.net 等が返ってくる\u003c/span\u003e\n\u003c/span\u003e\u003c/span\u003e\u003c/code\u003e\u003c/pre\u003e\u003c/div\u003e\u003cp\u003eMXレコードはドメインレベルの情報なので、転送先のメールアドレスは外部に公開されない。\u003ccode\u003edig\u003c/code\u003e で見えるのはCloudflareのサーバーだけで、「どこに転送しているか」は誰にもわからない。\u003c/p\u003e\n\u003cp\u003e設定はCloudflareのダッシュボードから数クリックで完了する。\u003c/p\u003e\n\u003cp\u003e転送先のアクションは3種類から選べる。\u003c/p\u003e\n\u003ctable\u003e\n  \u003cthead\u003e\n      \u003ctr\u003e\n          \u003cth\u003eアクション\u003c/th\u003e\n          \u003cth\u003e動作\u003c/th\u003e\n      \u003c/tr\u003e\n  \u003c/thead\u003e\n  \u003ctbody\u003e\n      \u003ctr\u003e\n          \u003ctd\u003eForward to email\u003c/td\u003e\n          \u003ctd\u003e指定のメールアドレスへ転送\u003c/td\u003e\n      \u003c/tr\u003e\n      \u003ctr\u003e\n          \u003ctd\u003eDrop\u003c/td\u003e\n          \u003ctd\u003e受信して即破棄。転送しない\u003c/td\u003e\n      \u003c/tr\u003e\n      \u003ctr\u003e\n          \u003ctd\u003eSend to Worker\u003c/td\u003e\n          \u003ctd\u003eCloudflare Workersで独自処理\u003c/td\u003e\n      \u003c/tr\u003e\n  \u003c/tbody\u003e\n\u003c/table\u003e\n\u003cp\u003e今回はメールを受け取る必要がないため \u003cstrong\u003eDrop\u003c/strong\u003e に設定した。スパム対策にもなるし、転送先アドレスを管理する必要もない。Workers連携は受信メールをSlackに流したり、自動返信を実装したりする場合に使う。\u003c/p\u003e\n\u003ch2 id=\"spf---送信元を証明するレコード\"\u003eSPF - 送信元を証明するレコード\u003c/h2\u003e\n\u003ch3 id=\"spfとは\"\u003eSPFとは\u003c/h3\u003e\n\u003cp\u003e\u003cstrong\u003eSPF（Sender Policy Framework）\u003c/strong\u003e は、「このドメインからメールを送信していいサーバーはどこか」を定義するDNSレコード。\u003c/p\u003e\n\u003cp\u003eなぜ必要か。メールのプロトコル（SMTP）は設計上、送信元アドレスを自由に詐称できる。つまり誰でも \u003ccode\u003eadmin@wasutech.dev\u003c/code\u003e を名乗ってメールを送れる。SPFはこれを受信側が検証できるようにする仕組み。\u003c/p\u003e\n\u003cp\u003e受信側のメールサーバーは、届いたメールの送信元IPアドレスを確認し、そのドメインのSPFレコードに記載されたIPと照合する。一致しなければ怪しいメールとして処理できる。\u003c/p\u003e\n\u003ch3 id=\"設定したレコード\"\u003e設定したレコード\u003c/h3\u003e\n\u003cpre tabindex=\"0\"\u003e\u003ccode\u003ev=spf1 include:_spf.mx.cloudflare.net ~all\n\u003c/code\u003e\u003c/pre\u003e\u003cp\u003e各要素の意味：\u003c/p\u003e\n\u003ctable\u003e\n  \u003cthead\u003e\n      \u003ctr\u003e\n          \u003cth\u003e要素\u003c/th\u003e\n          \u003cth\u003e意味\u003c/th\u003e\n      \u003c/tr\u003e\n  \u003c/thead\u003e\n  \u003ctbody\u003e\n      \u003ctr\u003e\n          \u003ctd\u003e\u003ccode\u003ev=spf1\u003c/code\u003e\u003c/td\u003e\n          \u003ctd\u003eSPFバージョン1\u003c/td\u003e\n      \u003c/tr\u003e\n      \u003ctr\u003e\n          \u003ctd\u003e\u003ccode\u003einclude:_spf.mx.cloudflare.net\u003c/code\u003e\u003c/td\u003e\n          \u003ctd\u003eCloudflareのメールサーバーからのSMTPを許可\u003c/td\u003e\n      \u003c/tr\u003e\n      \u003ctr\u003e\n          \u003ctd\u003e\u003ccode\u003e~all\u003c/code\u003e\u003c/td\u003e\n          \u003ctd\u003e上記以外は「疑わしい」扱い（ソフトフェイル）\u003c/td\u003e\n      \u003c/tr\u003e\n  \u003c/tbody\u003e\n\u003c/table\u003e\n\u003cp\u003e\u003ccode\u003e~all\u003c/code\u003e は疑わしいメールを迷惑メール扱いにする。\u003ccode\u003e-all\u003c/code\u003e にすると完全拒否になるが、DMARCと組み合わせて制御するのが一般的。\u003c/p\u003e\n\u003cp\u003e確認：\u003c/p\u003e\n\u003cdiv class=\"highlight\"\u003e\u003cpre tabindex=\"0\" style=\"color:#f8f8f2;background-color:#272822;-moz-tab-size:4;-o-tab-size:4;tab-size:4;\"\u003e\u003ccode class=\"language-bash\" data-lang=\"bash\"\u003e\u003cspan style=\"display:flex;\"\u003e\u003cspan\u003edig TXT wasutech.dev\n\u003c/span\u003e\u003c/span\u003e\u003cspan style=\"display:flex;\"\u003e\u003cspan\u003e\u003cspan style=\"color:#75715e\"\u003e# v=spf1 include:_spf.mx.cloudflare.net ~all\u003c/span\u003e\n\u003c/span\u003e\u003c/span\u003e\u003c/code\u003e\u003c/pre\u003e\u003c/div\u003e\u003ch2 id=\"dmarc---spfの結果を使って何をするか決めるレコード\"\u003eDMARC - SPFの結果を使って何をするか決めるレコード\u003c/h2\u003e\n\u003ch3 id=\"dmarcとは\"\u003eDMARCとは\u003c/h3\u003e\n\u003cp\u003e\u003cstrong\u003eDMARC（Domain-based Message Authentication, Reporting, and Conformance）\u003c/strong\u003e は、SPF（やDKIM）の検証結果に基づいて、受信側が「そのメールをどう扱うか」を指示するレコード。\u003c/p\u003e","title":"ドメイン購入後のやり残し作業 - メールとDNS設定編"},{"content":"外形監視をどこに任せるか迷った。ラズパイでやるか、Cloudflareに任せるか。 外から見えるサービスの監視なら せっかくなのでCloudflare Workersにする。\n自宅NWそこそこ落ちたりするので・・・。\nラズパイと比較した Cloudflare Workers ラズパイ 向いてる監視 外形監視（外からの死活確認） 内部NW監視 コスト 無料 電気代のみ メンテ ほぼゼロ たまに落ちる ローカルNW確認 ❌ ✅ 最小間隔 1分 自由 今回は wasutech.dev と blog.wasutech.dev、techblog.wasutech.dev の3つを監視したい。\nCloudflare Workers 無料枠で十分な理由 公式ドキュメント - Limits によると、無料プランは以下のとおり。\nCron Triggers: 5個まで リクエスト: 1日10万回まで CPU時間: 10ms/invocation 今回のユースケースは「HTTPリクエスト投げてステータスコード確認して Discord Webhook 叩く」だけなので、CPU時間は 2〜3ms で収まる。5分間隔で3ドメイン監視しても 1日864リクエストなので余裕。\nコード全文 // src/index.ts const TARGETS = [ { name: \u0026#34;wasutech.dev\u0026#34;, url: \u0026#34;https://wasutech.dev\u0026#34; }, { name: \u0026#34;blog.wasutech.dev\u0026#34;, url: \u0026#34;https://blog.wasutech.dev\u0026#34; }, { name: \u0026#34;techblog.wasutech.dev\u0026#34;, url: \u0026#34;https://techblog.wasutech.dev\u0026#34; }, ]; export default { async scheduled(_event: ScheduledEvent, env: Env, _ctx: ExecutionContext) { const results = await Promise.allSettled( TARGETS.map((t) =\u0026gt; check(t.name, t.url)) ); const failures = results .map((r, i) =\u0026gt; ({ result: r, target: TARGETS[i] })) .filter(({ result }) =\u0026gt; result.status === \u0026#34;rejected\u0026#34; || (result.status === \u0026#34;fulfilled\u0026#34; \u0026amp;\u0026amp; !result.value.ok) ); if (failures.length \u0026gt; 0) { const lines = failures.map(({ result, target }) =\u0026gt; { const detail = result.status === \u0026#34;rejected\u0026#34; ? (result.reason as Error).message : `HTTP ${(result.value as Response).status}`; return `🔴 ${target.name} | ${detail}`; }); await notify(env.DISCORD_WEBHOOK, lines.join(\u0026#34;\\n\u0026#34;)); } }, }; async function check(name: string, url: string): Promise\u0026lt;Response\u0026gt; { const res = await fetch(url, { method: \u0026#34;HEAD\u0026#34;, signal: AbortSignal.timeout(10000), }); if (!res.ok) throw new Error(`HTTP ${res.status}`); return res; } async function notify(webhookUrl: string, msg: string) { await fetch(webhookUrl, { method: \u0026#34;POST\u0026#34;, headers: { \u0026#34;Content-Type\u0026#34;: \u0026#34;application/json\u0026#34; }, body: JSON.stringify({ content: msg }), }); } interface Env { DISCORD_WEBHOOK: string; } # wrangler.toml name = \u0026#34;wasutech-monitor\u0026#34; main = \u0026#34;src/index.ts\u0026#34; compatibility_date = \u0026#34;2025-01-01\u0026#34; [triggers] crons = [\u0026#34;*/5 * * * *\u0026#34;] Promise.allSettled を使った理由 Promise.all だと1つが失敗した時点で残りを待たずに throw する。 Promise.allSettled なら3つ並列でチェックして、全部の結果を集めてからまとめて通知できる。 1メッセージにまとめることでDiscordがスパムにならない。\n便利ー\nデプロイ手順 # 1. テンプレ作成（Hello World / TypeScript を選ぶ） npm create cloudflare@latest wasutech-monitor cd wasutech-monitor # 2. src/index.ts を上のコードで上書き # 3. wrangler.toml に crons を追記 # [triggers] # crons = [\u0026#34;*/5 * * * *\u0026#34;] # 4. Discord Webhook URLをシークレットとして登録 wrangler secret put DISCORD_WEBHOOK # 5. デプロイ wrangler deploy package.json も tsconfig.json もテンプレのまま触らなくていい。 シークレットは wrangler secret put でベタ書きせずに管理する。\nなお、私は\n通知タイミング 異常時のみ通知。全ドメイン正常なら Discord は無音。\nif (failures.length \u0026gt; 0) { await notify(...) } // 正常時は何もしない これだけで十分。監視が死んでるかどうかが不安なら、Cloudflare ダッシュボードの Workers \u0026gt; Cron Events から直近100件の実行履歴が確認できる。\nまとめ 外形監視なら Cloudflare Workers の無料枠で完結する。 サーバー管理不要・メンテ不要・コスト無料と三拍子揃っている。\n内部NW監視（Proxmoxノードや k3s クラスタなど）は別途ラズパイで担当させると役割が明確になってよい。\n","permalink":"https://techblog.wasutech.dev/posts/cloudflare-worker-site-watcher/","summary":"\u003cp\u003e外形監視をどこに任せるか迷った。ラズパイでやるか、Cloudflareに任せるか。\n外から見えるサービスの監視なら せっかくなので\u003cstrong\u003eCloudflare Workers\u003c/strong\u003eにする。\u003c/p\u003e\n\u003cp\u003e自宅NWそこそこ落ちたりするので・・・。\u003c/p\u003e\n\u003ch2 id=\"ラズパイと比較した\"\u003eラズパイと比較した\u003c/h2\u003e\n\u003ctable\u003e\n  \u003cthead\u003e\n      \u003ctr\u003e\n          \u003cth\u003e\u003c/th\u003e\n          \u003cth\u003eCloudflare Workers\u003c/th\u003e\n          \u003cth\u003eラズパイ\u003c/th\u003e\n      \u003c/tr\u003e\n  \u003c/thead\u003e\n  \u003ctbody\u003e\n      \u003ctr\u003e\n          \u003ctd\u003e向いてる監視\u003c/td\u003e\n          \u003ctd\u003e外形監視（外からの死活確認）\u003c/td\u003e\n          \u003ctd\u003e内部NW監視\u003c/td\u003e\n      \u003c/tr\u003e\n      \u003ctr\u003e\n          \u003ctd\u003eコスト\u003c/td\u003e\n          \u003ctd\u003e無料\u003c/td\u003e\n          \u003ctd\u003e電気代のみ\u003c/td\u003e\n      \u003c/tr\u003e\n      \u003ctr\u003e\n          \u003ctd\u003eメンテ\u003c/td\u003e\n          \u003ctd\u003eほぼゼロ\u003c/td\u003e\n          \u003ctd\u003eたまに落ちる\u003c/td\u003e\n      \u003c/tr\u003e\n      \u003ctr\u003e\n          \u003ctd\u003eローカルNW確認\u003c/td\u003e\n          \u003ctd\u003e❌\u003c/td\u003e\n          \u003ctd\u003e✅\u003c/td\u003e\n      \u003c/tr\u003e\n      \u003ctr\u003e\n          \u003ctd\u003e最小間隔\u003c/td\u003e\n          \u003ctd\u003e1分\u003c/td\u003e\n          \u003ctd\u003e自由\u003c/td\u003e\n      \u003c/tr\u003e\n  \u003c/tbody\u003e\n\u003c/table\u003e\n\u003cp\u003e今回は \u003ccode\u003ewasutech.dev\u003c/code\u003e と \u003ccode\u003eblog.wasutech.dev\u003c/code\u003e、\u003ccode\u003etechblog.wasutech.dev\u003c/code\u003e の3つを監視したい。\u003c/p\u003e\n\u003ch2 id=\"cloudflare-workers-無料枠で十分な理由\"\u003eCloudflare Workers 無料枠で十分な理由\u003c/h2\u003e\n\u003cp\u003e\u003ca href=\"https://developers.cloudflare.com/workers/platform/limits/\"\u003e公式ドキュメント - Limits\u003c/a\u003e によると、無料プランは以下のとおり。\u003c/p\u003e\n\u003cul\u003e\n\u003cli\u003eCron Triggers: 5個まで\u003c/li\u003e\n\u003cli\u003eリクエスト: 1日10万回まで\u003c/li\u003e\n\u003cli\u003eCPU時間: 10ms/invocation\u003c/li\u003e\n\u003c/ul\u003e\n\u003cp\u003e今回のユースケースは「HTTPリクエスト投げてステータスコード確認して Discord Webhook 叩く」だけなので、CPU時間は 2〜3ms で収まる。5分間隔で3ドメイン監視しても 1日864リクエストなので余裕。\u003c/p\u003e\n\u003ch2 id=\"コード全文\"\u003eコード全文\u003c/h2\u003e\n\u003cdiv class=\"highlight\"\u003e\u003cpre tabindex=\"0\" style=\"color:#f8f8f2;background-color:#272822;-moz-tab-size:4;-o-tab-size:4;tab-size:4;\"\u003e\u003ccode class=\"language-typescript\" data-lang=\"typescript\"\u003e\u003cspan style=\"display:flex;\"\u003e\u003cspan\u003e\u003cspan style=\"color:#75715e\"\u003e// src/index.ts\n\u003c/span\u003e\u003c/span\u003e\u003c/span\u003e\u003cspan style=\"display:flex;\"\u003e\u003cspan\u003e\u003cspan style=\"color:#75715e\"\u003e\u003c/span\u003e\u003cspan style=\"color:#66d9ef\"\u003econst\u003c/span\u003e \u003cspan style=\"color:#a6e22e\"\u003eTARGETS\u003c/span\u003e \u003cspan style=\"color:#f92672\"\u003e=\u003c/span\u003e [\n\u003c/span\u003e\u003c/span\u003e\u003cspan style=\"display:flex;\"\u003e\u003cspan\u003e  { \u003cspan style=\"color:#a6e22e\"\u003ename\u003c/span\u003e\u003cspan style=\"color:#f92672\"\u003e:\u003c/span\u003e \u003cspan style=\"color:#e6db74\"\u003e\u0026#34;wasutech.dev\u0026#34;\u003c/span\u003e,          \u003cspan style=\"color:#a6e22e\"\u003eurl\u003c/span\u003e\u003cspan style=\"color:#f92672\"\u003e:\u003c/span\u003e \u003cspan style=\"color:#e6db74\"\u003e\u0026#34;https://wasutech.dev\u0026#34;\u003c/span\u003e },\n\u003c/span\u003e\u003c/span\u003e\u003cspan style=\"display:flex;\"\u003e\u003cspan\u003e  { \u003cspan style=\"color:#a6e22e\"\u003ename\u003c/span\u003e\u003cspan style=\"color:#f92672\"\u003e:\u003c/span\u003e \u003cspan style=\"color:#e6db74\"\u003e\u0026#34;blog.wasutech.dev\u0026#34;\u003c/span\u003e,     \u003cspan style=\"color:#a6e22e\"\u003eurl\u003c/span\u003e\u003cspan style=\"color:#f92672\"\u003e:\u003c/span\u003e \u003cspan style=\"color:#e6db74\"\u003e\u0026#34;https://blog.wasutech.dev\u0026#34;\u003c/span\u003e },\n\u003c/span\u003e\u003c/span\u003e\u003cspan style=\"display:flex;\"\u003e\u003cspan\u003e  { \u003cspan style=\"color:#a6e22e\"\u003ename\u003c/span\u003e\u003cspan style=\"color:#f92672\"\u003e:\u003c/span\u003e \u003cspan style=\"color:#e6db74\"\u003e\u0026#34;techblog.wasutech.dev\u0026#34;\u003c/span\u003e, \u003cspan style=\"color:#a6e22e\"\u003eurl\u003c/span\u003e\u003cspan style=\"color:#f92672\"\u003e:\u003c/span\u003e \u003cspan style=\"color:#e6db74\"\u003e\u0026#34;https://techblog.wasutech.dev\u0026#34;\u003c/span\u003e },\n\u003c/span\u003e\u003c/span\u003e\u003cspan style=\"display:flex;\"\u003e\u003cspan\u003e];\n\u003c/span\u003e\u003c/span\u003e\u003cspan style=\"display:flex;\"\u003e\u003cspan\u003e\n\u003c/span\u003e\u003c/span\u003e\u003cspan style=\"display:flex;\"\u003e\u003cspan\u003e\u003cspan style=\"color:#66d9ef\"\u003eexport\u003c/span\u003e \u003cspan style=\"color:#66d9ef\"\u003edefault\u003c/span\u003e {\n\u003c/span\u003e\u003c/span\u003e\u003cspan style=\"display:flex;\"\u003e\u003cspan\u003e  \u003cspan style=\"color:#66d9ef\"\u003easync\u003c/span\u003e \u003cspan style=\"color:#a6e22e\"\u003escheduled\u003c/span\u003e(\u003cspan style=\"color:#a6e22e\"\u003e_event\u003c/span\u003e: \u003cspan style=\"color:#66d9ef\"\u003eScheduledEvent\u003c/span\u003e, \u003cspan style=\"color:#a6e22e\"\u003eenv\u003c/span\u003e: \u003cspan style=\"color:#66d9ef\"\u003eEnv\u003c/span\u003e, \u003cspan style=\"color:#a6e22e\"\u003e_ctx\u003c/span\u003e: \u003cspan style=\"color:#66d9ef\"\u003eExecutionContext\u003c/span\u003e) {\n\u003c/span\u003e\u003c/span\u003e\u003cspan style=\"display:flex;\"\u003e\u003cspan\u003e    \u003cspan style=\"color:#66d9ef\"\u003econst\u003c/span\u003e \u003cspan style=\"color:#a6e22e\"\u003eresults\u003c/span\u003e \u003cspan style=\"color:#f92672\"\u003e=\u003c/span\u003e \u003cspan style=\"color:#66d9ef\"\u003eawait\u003c/span\u003e \u003cspan style=\"color:#a6e22e\"\u003ePromise\u003c/span\u003e.\u003cspan style=\"color:#a6e22e\"\u003eallSettled\u003c/span\u003e(\n\u003c/span\u003e\u003c/span\u003e\u003cspan style=\"display:flex;\"\u003e\u003cspan\u003e      \u003cspan style=\"color:#a6e22e\"\u003eTARGETS\u003c/span\u003e.\u003cspan style=\"color:#a6e22e\"\u003emap\u003c/span\u003e((\u003cspan style=\"color:#a6e22e\"\u003et\u003c/span\u003e) \u003cspan style=\"color:#f92672\"\u003e=\u0026gt;\u003c/span\u003e \u003cspan style=\"color:#a6e22e\"\u003echeck\u003c/span\u003e(\u003cspan style=\"color:#a6e22e\"\u003et\u003c/span\u003e.\u003cspan style=\"color:#a6e22e\"\u003ename\u003c/span\u003e, \u003cspan style=\"color:#a6e22e\"\u003et\u003c/span\u003e.\u003cspan style=\"color:#a6e22e\"\u003eurl\u003c/span\u003e))\n\u003c/span\u003e\u003c/span\u003e\u003cspan style=\"display:flex;\"\u003e\u003cspan\u003e    );\n\u003c/span\u003e\u003c/span\u003e\u003cspan style=\"display:flex;\"\u003e\u003cspan\u003e\n\u003c/span\u003e\u003c/span\u003e\u003cspan style=\"display:flex;\"\u003e\u003cspan\u003e    \u003cspan style=\"color:#66d9ef\"\u003econst\u003c/span\u003e \u003cspan style=\"color:#a6e22e\"\u003efailures\u003c/span\u003e \u003cspan style=\"color:#f92672\"\u003e=\u003c/span\u003e \u003cspan style=\"color:#a6e22e\"\u003eresults\u003c/span\u003e\n\u003c/span\u003e\u003c/span\u003e\u003cspan style=\"display:flex;\"\u003e\u003cspan\u003e      .\u003cspan style=\"color:#a6e22e\"\u003emap\u003c/span\u003e((\u003cspan style=\"color:#a6e22e\"\u003er\u003c/span\u003e, \u003cspan style=\"color:#a6e22e\"\u003ei\u003c/span\u003e) \u003cspan style=\"color:#f92672\"\u003e=\u0026gt;\u003c/span\u003e ({ \u003cspan style=\"color:#a6e22e\"\u003eresult\u003c/span\u003e: \u003cspan style=\"color:#66d9ef\"\u003er\u003c/span\u003e, \u003cspan style=\"color:#a6e22e\"\u003etarget\u003c/span\u003e: \u003cspan style=\"color:#66d9ef\"\u003eTARGETS\u003c/span\u003e[\u003cspan style=\"color:#a6e22e\"\u003ei\u003c/span\u003e] }))\n\u003c/span\u003e\u003c/span\u003e\u003cspan style=\"display:flex;\"\u003e\u003cspan\u003e      .\u003cspan style=\"color:#a6e22e\"\u003efilter\u003c/span\u003e(({ \u003cspan style=\"color:#a6e22e\"\u003eresult\u003c/span\u003e }) \u003cspan style=\"color:#f92672\"\u003e=\u0026gt;\u003c/span\u003e\n\u003c/span\u003e\u003c/span\u003e\u003cspan style=\"display:flex;\"\u003e\u003cspan\u003e        \u003cspan style=\"color:#a6e22e\"\u003eresult\u003c/span\u003e.\u003cspan style=\"color:#a6e22e\"\u003estatus\u003c/span\u003e \u003cspan style=\"color:#f92672\"\u003e===\u003c/span\u003e \u003cspan style=\"color:#e6db74\"\u003e\u0026#34;rejected\u0026#34;\u003c/span\u003e \u003cspan style=\"color:#f92672\"\u003e||\u003c/span\u003e\n\u003c/span\u003e\u003c/span\u003e\u003cspan style=\"display:flex;\"\u003e\u003cspan\u003e        (\u003cspan style=\"color:#a6e22e\"\u003eresult\u003c/span\u003e.\u003cspan style=\"color:#a6e22e\"\u003estatus\u003c/span\u003e \u003cspan style=\"color:#f92672\"\u003e===\u003c/span\u003e \u003cspan style=\"color:#e6db74\"\u003e\u0026#34;fulfilled\u0026#34;\u003c/span\u003e \u003cspan style=\"color:#f92672\"\u003e\u0026amp;\u0026amp;\u003c/span\u003e \u003cspan style=\"color:#f92672\"\u003e!\u003c/span\u003e\u003cspan style=\"color:#a6e22e\"\u003eresult\u003c/span\u003e.\u003cspan style=\"color:#a6e22e\"\u003evalue\u003c/span\u003e.\u003cspan style=\"color:#a6e22e\"\u003eok\u003c/span\u003e)\n\u003c/span\u003e\u003c/span\u003e\u003cspan style=\"display:flex;\"\u003e\u003cspan\u003e      );\n\u003c/span\u003e\u003c/span\u003e\u003cspan style=\"display:flex;\"\u003e\u003cspan\u003e\n\u003c/span\u003e\u003c/span\u003e\u003cspan style=\"display:flex;\"\u003e\u003cspan\u003e    \u003cspan style=\"color:#66d9ef\"\u003eif\u003c/span\u003e (\u003cspan style=\"color:#a6e22e\"\u003efailures\u003c/span\u003e.\u003cspan style=\"color:#a6e22e\"\u003elength\u003c/span\u003e \u003cspan style=\"color:#f92672\"\u003e\u0026gt;\u003c/span\u003e \u003cspan style=\"color:#ae81ff\"\u003e0\u003c/span\u003e) {\n\u003c/span\u003e\u003c/span\u003e\u003cspan style=\"display:flex;\"\u003e\u003cspan\u003e      \u003cspan style=\"color:#66d9ef\"\u003econst\u003c/span\u003e \u003cspan style=\"color:#a6e22e\"\u003elines\u003c/span\u003e \u003cspan style=\"color:#f92672\"\u003e=\u003c/span\u003e \u003cspan style=\"color:#a6e22e\"\u003efailures\u003c/span\u003e.\u003cspan style=\"color:#a6e22e\"\u003emap\u003c/span\u003e(({ \u003cspan style=\"color:#a6e22e\"\u003eresult\u003c/span\u003e, \u003cspan style=\"color:#a6e22e\"\u003etarget\u003c/span\u003e }) \u003cspan style=\"color:#f92672\"\u003e=\u0026gt;\u003c/span\u003e {\n\u003c/span\u003e\u003c/span\u003e\u003cspan style=\"display:flex;\"\u003e\u003cspan\u003e        \u003cspan style=\"color:#66d9ef\"\u003econst\u003c/span\u003e \u003cspan style=\"color:#a6e22e\"\u003edetail\u003c/span\u003e \u003cspan style=\"color:#f92672\"\u003e=\u003c/span\u003e\n\u003c/span\u003e\u003c/span\u003e\u003cspan style=\"display:flex;\"\u003e\u003cspan\u003e          \u003cspan style=\"color:#a6e22e\"\u003eresult\u003c/span\u003e.\u003cspan style=\"color:#a6e22e\"\u003estatus\u003c/span\u003e \u003cspan style=\"color:#f92672\"\u003e===\u003c/span\u003e \u003cspan style=\"color:#e6db74\"\u003e\u0026#34;rejected\u0026#34;\u003c/span\u003e\n\u003c/span\u003e\u003c/span\u003e\u003cspan style=\"display:flex;\"\u003e\u003cspan\u003e            \u003cspan style=\"color:#f92672\"\u003e?\u003c/span\u003e (\u003cspan style=\"color:#a6e22e\"\u003eresult\u003c/span\u003e.\u003cspan style=\"color:#a6e22e\"\u003ereason\u003c/span\u003e \u003cspan style=\"color:#66d9ef\"\u003eas\u003c/span\u003e Error).\u003cspan style=\"color:#a6e22e\"\u003emessage\u003c/span\u003e\n\u003c/span\u003e\u003c/span\u003e\u003cspan style=\"display:flex;\"\u003e\u003cspan\u003e            \u003cspan style=\"color:#f92672\"\u003e:\u003c/span\u003e \u003cspan style=\"color:#e6db74\"\u003e`HTTP \u003c/span\u003e\u003cspan style=\"color:#e6db74\"\u003e${\u003c/span\u003e(\u003cspan style=\"color:#a6e22e\"\u003eresult\u003c/span\u003e.\u003cspan style=\"color:#a6e22e\"\u003evalue\u003c/span\u003e \u003cspan style=\"color:#66d9ef\"\u003eas\u003c/span\u003e \u003cspan style=\"color:#a6e22e\"\u003eResponse\u003c/span\u003e).\u003cspan style=\"color:#a6e22e\"\u003estatus\u003c/span\u003e\u003cspan style=\"color:#e6db74\"\u003e}\u003c/span\u003e\u003cspan style=\"color:#e6db74\"\u003e`\u003c/span\u003e;\n\u003c/span\u003e\u003c/span\u003e\u003cspan style=\"display:flex;\"\u003e\u003cspan\u003e        \u003cspan style=\"color:#66d9ef\"\u003ereturn\u003c/span\u003e \u003cspan style=\"color:#e6db74\"\u003e`🔴 \u003c/span\u003e\u003cspan style=\"color:#e6db74\"\u003e${\u003c/span\u003e\u003cspan style=\"color:#a6e22e\"\u003etarget\u003c/span\u003e.\u003cspan style=\"color:#a6e22e\"\u003ename\u003c/span\u003e\u003cspan style=\"color:#e6db74\"\u003e}\u003c/span\u003e\u003cspan style=\"color:#e6db74\"\u003e | \u003c/span\u003e\u003cspan style=\"color:#e6db74\"\u003e${\u003c/span\u003e\u003cspan style=\"color:#a6e22e\"\u003edetail\u003c/span\u003e\u003cspan style=\"color:#e6db74\"\u003e}\u003c/span\u003e\u003cspan style=\"color:#e6db74\"\u003e`\u003c/span\u003e;\n\u003c/span\u003e\u003c/span\u003e\u003cspan style=\"display:flex;\"\u003e\u003cspan\u003e      });\n\u003c/span\u003e\u003c/span\u003e\u003cspan style=\"display:flex;\"\u003e\u003cspan\u003e\n\u003c/span\u003e\u003c/span\u003e\u003cspan style=\"display:flex;\"\u003e\u003cspan\u003e      \u003cspan style=\"color:#66d9ef\"\u003eawait\u003c/span\u003e \u003cspan style=\"color:#a6e22e\"\u003enotify\u003c/span\u003e(\u003cspan style=\"color:#a6e22e\"\u003eenv\u003c/span\u003e.\u003cspan style=\"color:#a6e22e\"\u003eDISCORD_WEBHOOK\u003c/span\u003e, \u003cspan style=\"color:#a6e22e\"\u003elines\u003c/span\u003e.\u003cspan style=\"color:#a6e22e\"\u003ejoin\u003c/span\u003e(\u003cspan style=\"color:#e6db74\"\u003e\u0026#34;\\n\u0026#34;\u003c/span\u003e));\n\u003c/span\u003e\u003c/span\u003e\u003cspan style=\"display:flex;\"\u003e\u003cspan\u003e    }\n\u003c/span\u003e\u003c/span\u003e\u003cspan style=\"display:flex;\"\u003e\u003cspan\u003e  },\n\u003c/span\u003e\u003c/span\u003e\u003cspan style=\"display:flex;\"\u003e\u003cspan\u003e};\n\u003c/span\u003e\u003c/span\u003e\u003cspan style=\"display:flex;\"\u003e\u003cspan\u003e\n\u003c/span\u003e\u003c/span\u003e\u003cspan style=\"display:flex;\"\u003e\u003cspan\u003e\u003cspan style=\"color:#66d9ef\"\u003easync\u003c/span\u003e \u003cspan style=\"color:#66d9ef\"\u003efunction\u003c/span\u003e \u003cspan style=\"color:#a6e22e\"\u003echeck\u003c/span\u003e(\u003cspan style=\"color:#a6e22e\"\u003ename\u003c/span\u003e: \u003cspan style=\"color:#66d9ef\"\u003estring\u003c/span\u003e, \u003cspan style=\"color:#a6e22e\"\u003eurl\u003c/span\u003e: \u003cspan style=\"color:#66d9ef\"\u003estring\u003c/span\u003e)\u003cspan style=\"color:#f92672\"\u003e:\u003c/span\u003e \u003cspan style=\"color:#a6e22e\"\u003ePromise\u003c/span\u003e\u0026lt;\u003cspan style=\"color:#f92672\"\u003eResponse\u003c/span\u003e\u0026gt; {\n\u003c/span\u003e\u003c/span\u003e\u003cspan style=\"display:flex;\"\u003e\u003cspan\u003e  \u003cspan style=\"color:#66d9ef\"\u003econst\u003c/span\u003e \u003cspan style=\"color:#a6e22e\"\u003eres\u003c/span\u003e \u003cspan style=\"color:#f92672\"\u003e=\u003c/span\u003e \u003cspan style=\"color:#66d9ef\"\u003eawait\u003c/span\u003e \u003cspan style=\"color:#a6e22e\"\u003efetch\u003c/span\u003e(\u003cspan style=\"color:#a6e22e\"\u003eurl\u003c/span\u003e, {\n\u003c/span\u003e\u003c/span\u003e\u003cspan style=\"display:flex;\"\u003e\u003cspan\u003e    \u003cspan style=\"color:#a6e22e\"\u003emethod\u003c/span\u003e\u003cspan style=\"color:#f92672\"\u003e:\u003c/span\u003e \u003cspan style=\"color:#e6db74\"\u003e\u0026#34;HEAD\u0026#34;\u003c/span\u003e,\n\u003c/span\u003e\u003c/span\u003e\u003cspan style=\"display:flex;\"\u003e\u003cspan\u003e    \u003cspan style=\"color:#a6e22e\"\u003esignal\u003c/span\u003e: \u003cspan style=\"color:#66d9ef\"\u003eAbortSignal.timeout\u003c/span\u003e(\u003cspan style=\"color:#ae81ff\"\u003e10000\u003c/span\u003e),\n\u003c/span\u003e\u003c/span\u003e\u003cspan style=\"display:flex;\"\u003e\u003cspan\u003e  });\n\u003c/span\u003e\u003c/span\u003e\u003cspan style=\"display:flex;\"\u003e\u003cspan\u003e  \u003cspan style=\"color:#66d9ef\"\u003eif\u003c/span\u003e (\u003cspan style=\"color:#f92672\"\u003e!\u003c/span\u003e\u003cspan style=\"color:#a6e22e\"\u003eres\u003c/span\u003e.\u003cspan style=\"color:#a6e22e\"\u003eok\u003c/span\u003e) \u003cspan style=\"color:#66d9ef\"\u003ethrow\u003c/span\u003e \u003cspan style=\"color:#66d9ef\"\u003enew\u003c/span\u003e Error(\u003cspan style=\"color:#e6db74\"\u003e`HTTP \u003c/span\u003e\u003cspan style=\"color:#e6db74\"\u003e${\u003c/span\u003e\u003cspan style=\"color:#a6e22e\"\u003eres\u003c/span\u003e.\u003cspan style=\"color:#a6e22e\"\u003estatus\u003c/span\u003e\u003cspan style=\"color:#e6db74\"\u003e}\u003c/span\u003e\u003cspan style=\"color:#e6db74\"\u003e`\u003c/span\u003e);\n\u003c/span\u003e\u003c/span\u003e\u003cspan style=\"display:flex;\"\u003e\u003cspan\u003e  \u003cspan style=\"color:#66d9ef\"\u003ereturn\u003c/span\u003e \u003cspan style=\"color:#a6e22e\"\u003eres\u003c/span\u003e;\n\u003c/span\u003e\u003c/span\u003e\u003cspan style=\"display:flex;\"\u003e\u003cspan\u003e}\n\u003c/span\u003e\u003c/span\u003e\u003cspan style=\"display:flex;\"\u003e\u003cspan\u003e\n\u003c/span\u003e\u003c/span\u003e\u003cspan style=\"display:flex;\"\u003e\u003cspan\u003e\u003cspan style=\"color:#66d9ef\"\u003easync\u003c/span\u003e \u003cspan style=\"color:#66d9ef\"\u003efunction\u003c/span\u003e \u003cspan style=\"color:#a6e22e\"\u003enotify\u003c/span\u003e(\u003cspan style=\"color:#a6e22e\"\u003ewebhookUrl\u003c/span\u003e: \u003cspan style=\"color:#66d9ef\"\u003estring\u003c/span\u003e, \u003cspan style=\"color:#a6e22e\"\u003emsg\u003c/span\u003e: \u003cspan style=\"color:#66d9ef\"\u003estring\u003c/span\u003e) {\n\u003c/span\u003e\u003c/span\u003e\u003cspan style=\"display:flex;\"\u003e\u003cspan\u003e  \u003cspan style=\"color:#66d9ef\"\u003eawait\u003c/span\u003e \u003cspan style=\"color:#a6e22e\"\u003efetch\u003c/span\u003e(\u003cspan style=\"color:#a6e22e\"\u003ewebhookUrl\u003c/span\u003e, {\n\u003c/span\u003e\u003c/span\u003e\u003cspan style=\"display:flex;\"\u003e\u003cspan\u003e    \u003cspan style=\"color:#a6e22e\"\u003emethod\u003c/span\u003e\u003cspan style=\"color:#f92672\"\u003e:\u003c/span\u003e \u003cspan style=\"color:#e6db74\"\u003e\u0026#34;POST\u0026#34;\u003c/span\u003e,\n\u003c/span\u003e\u003c/span\u003e\u003cspan style=\"display:flex;\"\u003e\u003cspan\u003e    \u003cspan style=\"color:#a6e22e\"\u003eheaders\u003c/span\u003e\u003cspan style=\"color:#f92672\"\u003e:\u003c/span\u003e { \u003cspan style=\"color:#e6db74\"\u003e\u0026#34;Content-Type\u0026#34;\u003c/span\u003e\u003cspan style=\"color:#f92672\"\u003e:\u003c/span\u003e \u003cspan style=\"color:#e6db74\"\u003e\u0026#34;application/json\u0026#34;\u003c/span\u003e },\n\u003c/span\u003e\u003c/span\u003e\u003cspan style=\"display:flex;\"\u003e\u003cspan\u003e    \u003cspan style=\"color:#a6e22e\"\u003ebody\u003c/span\u003e: \u003cspan style=\"color:#66d9ef\"\u003eJSON.stringify\u003c/span\u003e({ \u003cspan style=\"color:#a6e22e\"\u003econtent\u003c/span\u003e: \u003cspan style=\"color:#66d9ef\"\u003emsg\u003c/span\u003e }),\n\u003c/span\u003e\u003c/span\u003e\u003cspan style=\"display:flex;\"\u003e\u003cspan\u003e  });\n\u003c/span\u003e\u003c/span\u003e\u003cspan style=\"display:flex;\"\u003e\u003cspan\u003e}\n\u003c/span\u003e\u003c/span\u003e\u003cspan style=\"display:flex;\"\u003e\u003cspan\u003e\n\u003c/span\u003e\u003c/span\u003e\u003cspan style=\"display:flex;\"\u003e\u003cspan\u003e\u003cspan style=\"color:#66d9ef\"\u003einterface\u003c/span\u003e \u003cspan style=\"color:#a6e22e\"\u003eEnv\u003c/span\u003e {\n\u003c/span\u003e\u003c/span\u003e\u003cspan style=\"display:flex;\"\u003e\u003cspan\u003e  \u003cspan style=\"color:#a6e22e\"\u003eDISCORD_WEBHOOK\u003c/span\u003e: \u003cspan style=\"color:#66d9ef\"\u003estring\u003c/span\u003e;\n\u003c/span\u003e\u003c/span\u003e\u003cspan style=\"display:flex;\"\u003e\u003cspan\u003e}\n\u003c/span\u003e\u003c/span\u003e\u003c/code\u003e\u003c/pre\u003e\u003c/div\u003e\u003cdiv class=\"highlight\"\u003e\u003cpre tabindex=\"0\" style=\"color:#f8f8f2;background-color:#272822;-moz-tab-size:4;-o-tab-size:4;tab-size:4;\"\u003e\u003ccode class=\"language-toml\" data-lang=\"toml\"\u003e\u003cspan style=\"display:flex;\"\u003e\u003cspan\u003e\u003cspan style=\"color:#75715e\"\u003e# wrangler.toml\u003c/span\u003e\n\u003c/span\u003e\u003c/span\u003e\u003cspan style=\"display:flex;\"\u003e\u003cspan\u003e\u003cspan style=\"color:#a6e22e\"\u003ename\u003c/span\u003e = \u003cspan style=\"color:#e6db74\"\u003e\u0026#34;wasutech-monitor\u0026#34;\u003c/span\u003e\n\u003c/span\u003e\u003c/span\u003e\u003cspan style=\"display:flex;\"\u003e\u003cspan\u003e\u003cspan style=\"color:#a6e22e\"\u003emain\u003c/span\u003e = \u003cspan style=\"color:#e6db74\"\u003e\u0026#34;src/index.ts\u0026#34;\u003c/span\u003e\n\u003c/span\u003e\u003c/span\u003e\u003cspan style=\"display:flex;\"\u003e\u003cspan\u003e\u003cspan style=\"color:#a6e22e\"\u003ecompatibility_date\u003c/span\u003e = \u003cspan style=\"color:#e6db74\"\u003e\u0026#34;2025-01-01\u0026#34;\u003c/span\u003e\n\u003c/span\u003e\u003c/span\u003e\u003cspan style=\"display:flex;\"\u003e\u003cspan\u003e\n\u003c/span\u003e\u003c/span\u003e\u003cspan style=\"display:flex;\"\u003e\u003cspan\u003e[\u003cspan style=\"color:#a6e22e\"\u003etriggers\u003c/span\u003e]\n\u003c/span\u003e\u003c/span\u003e\u003cspan style=\"display:flex;\"\u003e\u003cspan\u003e\u003cspan style=\"color:#a6e22e\"\u003ecrons\u003c/span\u003e = [\u003cspan style=\"color:#e6db74\"\u003e\u0026#34;*/5 * * * *\u0026#34;\u003c/span\u003e]\n\u003c/span\u003e\u003c/span\u003e\u003c/code\u003e\u003c/pre\u003e\u003c/div\u003e\u003ch3 id=\"promiseallsettled-を使った理由\"\u003e\u003ccode\u003ePromise.allSettled\u003c/code\u003e を使った理由\u003c/h3\u003e\n\u003cp\u003e\u003ccode\u003ePromise.all\u003c/code\u003e だと1つが失敗した時点で残りを待たずに throw する。\n\u003ccode\u003ePromise.allSettled\u003c/code\u003e なら3つ並列でチェックして、\u003cstrong\u003e全部の結果を集めてから\u003c/strong\u003eまとめて通知できる。\n1メッセージにまとめることでDiscordがスパムにならない。\u003c/p\u003e","title":"Cloudflare Workers でサイト監視 + Discord通知を作った"},{"content":"Cloudflareの通知をDiscord Webhookに流していたら、通知が大量に届くようになってしまった。重要なアラートが埋もれるので、Cloudflare Workers + Gemini APIで要約してから投稿するようにした。\n方針 Cloudflareの通知設定で追加できるアラートはとりあえず全部有効にしている。各アラートが実際に役立つかは様子を見ながら判断する予定。\nただしセキュリティアラートはだいたい30分に数回届いたため、現時点では通知をスキップするようにした。それ以外のアラートは引き続き通知して様子見中。\n構成 Cloudflare Alert → Worker受信 → フィルタリング（スキップ対象なら早期リターン） → Gemini API で日本語要約 → Discord Webhook に送信 前提 Cloudflare WorkersにDiscord Webhook通知のWorkerが既にある Google AI StudioのAPIキーを持っている モデル選定 Geminiのモデルは料金帯がいくつかある。\ngemini-2.5-pro \u0026gt; gemini-2.5-flash \u0026gt; gemini-2.5-flash-lite Cloudflareアラートの要約程度であれば gemini-2.5-flash-lite で十分。一番安い。\n最新のモデル名は公式ドキュメントで確認すること。 https://ai.google.dev/gemini-api/docs/models\n実装 フィルタリングの考え方 頻度の高いアラートはWorker側でスキップできるようにしている。SKIP_ALERT_TYPES に列挙したタイプが一致した場合、Gemini APIを呼ばずに早期リターンする。不要なAPI呼び出しも減るのでコスト面でも良い。\nどのアラートがどの alert_type を持つかはCloudflareの公式ドキュメントで確認できる。 https://developers.cloudflare.com/notifications/notification-available/\nWorker コード // スキップしたいアラートタイプ（頻度が高くて邪魔なものを列挙） const SKIP_ALERT_TYPES = [ \u0026#34;security_alerts\u0026#34;, // セキュリティアラート：30分に数回来るので無効化中 ]; async function summarizeWithGemini(apiKey, input) { const prompt = `以下のCloudflareアラートを日本語で3行以内に要約してください。重要度（🔴高/🟡中/🟢低）も判定してください。\\n\\n${input}`; const res = await fetch( `https://generativelanguage.googleapis.com/v1beta/models/gemini-2.5-flash-lite-preview-06-17:generateContent?key=${apiKey}`, { method: \u0026#34;POST\u0026#34;, headers: { \u0026#34;Content-Type\u0026#34;: \u0026#34;application/json\u0026#34; }, body: JSON.stringify({ contents: [{ parts: [{ text: prompt }] }] }) } ); const data = await res.json(); console.log(\u0026#34;Gemini response:\u0026#34;, JSON.stringify(data)); return data.candidates?.[0]?.content?.parts?.[0]?.text || \u0026#34;要約失敗\u0026#34;; } export default { async fetch(request, env) { if (request.method !== \u0026#34;POST\u0026#34;) { return new Response(\u0026#34;ok\u0026#34;); } let body; try { body = await request.json(); } catch { return new Response(\u0026#34;invalid json\u0026#34;, { status: 400 }); } // アラートタイプによるフィルタリング const alertType = body.text?.alert_type || body.alert_type || \u0026#34;\u0026#34;; if (SKIP_ALERT_TYPES.some(t =\u0026gt; alertType.includes(t))) { console.log(`Skipped alert type: ${alertType}`); return new Response(\u0026#34;skipped\u0026#34;); } // Geminiには常にbody全体を渡す const input = JSON.stringify(body).slice(0, 500); const summary = await summarizeWithGemini(env.GEMINI_API_KEY, input); const message = { username: \u0026#34;Cloudflare\u0026#34;, embeds: [{ title: body.text?.title || body.text?.description || \u0026#34;Cloudflare Notification\u0026#34;, description: summary, color: 0xF6821F, timestamp: new Date().toISOString() }] }; await fetch(env.DISCORD_WEBHOOK, { method: \u0026#34;POST\u0026#34;, headers: { \u0026#34;Content-Type\u0026#34;: \u0026#34;application/json\u0026#34; }, body: JSON.stringify(message) }); return new Response(\u0026#34;ok\u0026#34;); } } シークレット登録 wrangler secret put GEMINI_API_KEY wrangler secret put DISCORD_WEBHOOK 動作確認 curl -X POST https://\u0026lt;your-worker\u0026gt;.workers.dev \\ -H \u0026#34;Content-Type: application/json\u0026#34; \\ -d \u0026#39;{\u0026#34;text\u0026#34;: {\u0026#34;title\u0026#34;: \u0026#34;テスト通知\u0026#34;, \u0026#34;description\u0026#34;: \u0026#34;これはテストです\u0026#34;}}\u0026#39; スキップ確認：\ncurl -X POST https://\u0026lt;your-worker\u0026gt;.workers.dev \\ -H \u0026#34;Content-Type: application/json\u0026#34; \\ -d \u0026#39;{\u0026#34;alert_type\u0026#34;: \u0026#34;security_alerts\u0026#34;, \u0026#34;text\u0026#34;: {\u0026#34;title\u0026#34;: \u0026#34;セキュリティテスト\u0026#34;}}\u0026#39; # → \u0026#34;skipped\u0026#34; が返ってくればOK ログ確認 Workerのログは Cloudflare Dashboard → Workers \u0026amp; Pages → 該当Worker → Observability で確認できる。\nローカルでリアルタイム確認したい場合は：\nwrangler tail ハマったポイント spending cap に引っかかる Gemini APIで RESOURCE_EXHAUSTED エラーが返ってくる場合、APIキーの問題ではなくAI Studio側のspending capに達している可能性がある。\n{ \u0026#34;error\u0026#34;: { \u0026#34;code\u0026#34;: 429, \u0026#34;message\u0026#34;: \u0026#34;Your project has exceeded its monthly spending cap.\u0026#34;, \u0026#34;status\u0026#34;: \u0026#34;RESOURCE_EXHAUSTED\u0026#34; } } https://aistudio.google.com/billing でspending capを確認・変更する。\n別プロジェクトで上限に達したAPIキーを使い回していると気づきにくいので注意。\nまとめ Cloudflareの通知はとりあえず全部有効にして様子見する運用にしている 頻度が高くて邪魔なアラートは SKIP_ALERT_TYPES に追加してWorker側でフィルタリング セキュリティアラートは30分に数回来るので現時点ではスキップ中 フィルタリングで早期リターンするのでGemini APIの無駄な呼び出しも減る モデルはflash-liteで十分、コスト低い spending capはAI Studio側で管理されているので別プロジェクトの使用量も把握しておく ","permalink":"https://techblog.wasutech.dev/posts/cloudflare-worker-gemini-discord/","summary":"\u003cp\u003eCloudflareの通知をDiscord Webhookに流していたら、通知が大量に届くようになってしまった。重要なアラートが埋もれるので、Cloudflare Workers + Gemini APIで要約してから投稿するようにした。\u003c/p\u003e\n\u003ch2 id=\"方針\"\u003e方針\u003c/h2\u003e\n\u003cp\u003eCloudflareの通知設定で追加できるアラートはとりあえず全部有効にしている。各アラートが実際に役立つかは様子を見ながら判断する予定。\u003c/p\u003e\n\u003cp\u003eただし\u003cstrong\u003eセキュリティアラートはだいたい30分に数回届いた\u003c/strong\u003eため、現時点では通知をスキップするようにした。それ以外のアラートは引き続き通知して様子見中。\u003c/p\u003e\n\u003ch2 id=\"構成\"\u003e構成\u003c/h2\u003e\n\u003cpre tabindex=\"0\"\u003e\u003ccode\u003eCloudflare Alert\n  → Worker受信\n  → フィルタリング（スキップ対象なら早期リターン）\n  → Gemini API で日本語要約\n  → Discord Webhook に送信\n\u003c/code\u003e\u003c/pre\u003e\u003ch2 id=\"前提\"\u003e前提\u003c/h2\u003e\n\u003cul\u003e\n\u003cli\u003eCloudflare WorkersにDiscord Webhook通知のWorkerが既にある\u003c/li\u003e\n\u003cli\u003eGoogle AI StudioのAPIキーを持っている\u003c/li\u003e\n\u003c/ul\u003e\n\u003ch2 id=\"モデル選定\"\u003eモデル選定\u003c/h2\u003e\n\u003cp\u003eGeminiのモデルは料金帯がいくつかある。\u003c/p\u003e\n\u003cpre tabindex=\"0\"\u003e\u003ccode\u003egemini-2.5-pro \u0026gt; gemini-2.5-flash \u0026gt; gemini-2.5-flash-lite\n\u003c/code\u003e\u003c/pre\u003e\u003cp\u003eCloudflareアラートの要約程度であれば \u003cstrong\u003egemini-2.5-flash-lite\u003c/strong\u003e で十分。一番安い。\u003c/p\u003e\n\u003cp\u003e最新のモデル名は公式ドキュメントで確認すること。\n\u003ca href=\"https://ai.google.dev/gemini-api/docs/models\"\u003ehttps://ai.google.dev/gemini-api/docs/models\u003c/a\u003e\u003c/p\u003e\n\u003ch2 id=\"実装\"\u003e実装\u003c/h2\u003e\n\u003ch3 id=\"フィルタリングの考え方\"\u003eフィルタリングの考え方\u003c/h3\u003e\n\u003cp\u003e頻度の高いアラートはWorker側でスキップできるようにしている。\u003ccode\u003eSKIP_ALERT_TYPES\u003c/code\u003e に列挙したタイプが一致した場合、Gemini APIを呼ばずに早期リターンする。不要なAPI呼び出しも減るのでコスト面でも良い。\u003c/p\u003e\n\u003cp\u003eどのアラートがどの \u003ccode\u003ealert_type\u003c/code\u003e を持つかはCloudflareの公式ドキュメントで確認できる。\n\u003ca href=\"https://developers.cloudflare.com/notifications/notification-available/\"\u003ehttps://developers.cloudflare.com/notifications/notification-available/\u003c/a\u003e\u003c/p\u003e\n\u003ch3 id=\"worker-コード\"\u003eWorker コード\u003c/h3\u003e\n\u003cdiv class=\"highlight\"\u003e\u003cpre tabindex=\"0\" style=\"color:#f8f8f2;background-color:#272822;-moz-tab-size:4;-o-tab-size:4;tab-size:4;\"\u003e\u003ccode class=\"language-javascript\" data-lang=\"javascript\"\u003e\u003cspan style=\"display:flex;\"\u003e\u003cspan\u003e\u003cspan style=\"color:#75715e\"\u003e// スキップしたいアラートタイプ（頻度が高くて邪魔なものを列挙）\n\u003c/span\u003e\u003c/span\u003e\u003c/span\u003e\u003cspan style=\"display:flex;\"\u003e\u003cspan\u003e\u003cspan style=\"color:#75715e\"\u003e\u003c/span\u003e\u003cspan style=\"color:#66d9ef\"\u003econst\u003c/span\u003e \u003cspan style=\"color:#a6e22e\"\u003eSKIP_ALERT_TYPES\u003c/span\u003e \u003cspan style=\"color:#f92672\"\u003e=\u003c/span\u003e [\n\u003c/span\u003e\u003c/span\u003e\u003cspan style=\"display:flex;\"\u003e\u003cspan\u003e  \u003cspan style=\"color:#e6db74\"\u003e\u0026#34;security_alerts\u0026#34;\u003c/span\u003e, \u003cspan style=\"color:#75715e\"\u003e// セキュリティアラート：30分に数回来るので無効化中\n\u003c/span\u003e\u003c/span\u003e\u003c/span\u003e\u003cspan style=\"display:flex;\"\u003e\u003cspan\u003e\u003cspan style=\"color:#75715e\"\u003e\u003c/span\u003e];\n\u003c/span\u003e\u003c/span\u003e\u003cspan style=\"display:flex;\"\u003e\u003cspan\u003e\n\u003c/span\u003e\u003c/span\u003e\u003cspan style=\"display:flex;\"\u003e\u003cspan\u003e\u003cspan style=\"color:#66d9ef\"\u003easync\u003c/span\u003e \u003cspan style=\"color:#66d9ef\"\u003efunction\u003c/span\u003e \u003cspan style=\"color:#a6e22e\"\u003esummarizeWithGemini\u003c/span\u003e(\u003cspan style=\"color:#a6e22e\"\u003eapiKey\u003c/span\u003e, \u003cspan style=\"color:#a6e22e\"\u003einput\u003c/span\u003e) {\n\u003c/span\u003e\u003c/span\u003e\u003cspan style=\"display:flex;\"\u003e\u003cspan\u003e  \u003cspan style=\"color:#66d9ef\"\u003econst\u003c/span\u003e \u003cspan style=\"color:#a6e22e\"\u003eprompt\u003c/span\u003e \u003cspan style=\"color:#f92672\"\u003e=\u003c/span\u003e \u003cspan style=\"color:#e6db74\"\u003e`以下のCloudflareアラートを日本語で3行以内に要約してください。重要度（🔴高/🟡中/🟢低）も判定してください。\\n\\n\u003c/span\u003e\u003cspan style=\"color:#e6db74\"\u003e${\u003c/span\u003e\u003cspan style=\"color:#a6e22e\"\u003einput\u003c/span\u003e\u003cspan style=\"color:#e6db74\"\u003e}\u003c/span\u003e\u003cspan style=\"color:#e6db74\"\u003e`\u003c/span\u003e;\n\u003c/span\u003e\u003c/span\u003e\u003cspan style=\"display:flex;\"\u003e\u003cspan\u003e\n\u003c/span\u003e\u003c/span\u003e\u003cspan style=\"display:flex;\"\u003e\u003cspan\u003e  \u003cspan style=\"color:#66d9ef\"\u003econst\u003c/span\u003e \u003cspan style=\"color:#a6e22e\"\u003eres\u003c/span\u003e \u003cspan style=\"color:#f92672\"\u003e=\u003c/span\u003e \u003cspan style=\"color:#66d9ef\"\u003eawait\u003c/span\u003e \u003cspan style=\"color:#a6e22e\"\u003efetch\u003c/span\u003e(\n\u003c/span\u003e\u003c/span\u003e\u003cspan style=\"display:flex;\"\u003e\u003cspan\u003e    \u003cspan style=\"color:#e6db74\"\u003e`https://generativelanguage.googleapis.com/v1beta/models/gemini-2.5-flash-lite-preview-06-17:generateContent?key=\u003c/span\u003e\u003cspan style=\"color:#e6db74\"\u003e${\u003c/span\u003e\u003cspan style=\"color:#a6e22e\"\u003eapiKey\u003c/span\u003e\u003cspan style=\"color:#e6db74\"\u003e}\u003c/span\u003e\u003cspan style=\"color:#e6db74\"\u003e`\u003c/span\u003e,\n\u003c/span\u003e\u003c/span\u003e\u003cspan style=\"display:flex;\"\u003e\u003cspan\u003e    {\n\u003c/span\u003e\u003c/span\u003e\u003cspan style=\"display:flex;\"\u003e\u003cspan\u003e      \u003cspan style=\"color:#a6e22e\"\u003emethod\u003c/span\u003e\u003cspan style=\"color:#f92672\"\u003e:\u003c/span\u003e \u003cspan style=\"color:#e6db74\"\u003e\u0026#34;POST\u0026#34;\u003c/span\u003e,\n\u003c/span\u003e\u003c/span\u003e\u003cspan style=\"display:flex;\"\u003e\u003cspan\u003e      \u003cspan style=\"color:#a6e22e\"\u003eheaders\u003c/span\u003e\u003cspan style=\"color:#f92672\"\u003e:\u003c/span\u003e { \u003cspan style=\"color:#e6db74\"\u003e\u0026#34;Content-Type\u0026#34;\u003c/span\u003e\u003cspan style=\"color:#f92672\"\u003e:\u003c/span\u003e \u003cspan style=\"color:#e6db74\"\u003e\u0026#34;application/json\u0026#34;\u003c/span\u003e },\n\u003c/span\u003e\u003c/span\u003e\u003cspan style=\"display:flex;\"\u003e\u003cspan\u003e      \u003cspan style=\"color:#a6e22e\"\u003ebody\u003c/span\u003e\u003cspan style=\"color:#f92672\"\u003e:\u003c/span\u003e \u003cspan style=\"color:#a6e22e\"\u003eJSON\u003c/span\u003e.\u003cspan style=\"color:#a6e22e\"\u003estringify\u003c/span\u003e({\n\u003c/span\u003e\u003c/span\u003e\u003cspan style=\"display:flex;\"\u003e\u003cspan\u003e        \u003cspan style=\"color:#a6e22e\"\u003econtents\u003c/span\u003e\u003cspan style=\"color:#f92672\"\u003e:\u003c/span\u003e [{ \u003cspan style=\"color:#a6e22e\"\u003eparts\u003c/span\u003e\u003cspan style=\"color:#f92672\"\u003e:\u003c/span\u003e [{ \u003cspan style=\"color:#a6e22e\"\u003etext\u003c/span\u003e\u003cspan style=\"color:#f92672\"\u003e:\u003c/span\u003e \u003cspan style=\"color:#a6e22e\"\u003eprompt\u003c/span\u003e }] }]\n\u003c/span\u003e\u003c/span\u003e\u003cspan style=\"display:flex;\"\u003e\u003cspan\u003e      })\n\u003c/span\u003e\u003c/span\u003e\u003cspan style=\"display:flex;\"\u003e\u003cspan\u003e    }\n\u003c/span\u003e\u003c/span\u003e\u003cspan style=\"display:flex;\"\u003e\u003cspan\u003e  );\n\u003c/span\u003e\u003c/span\u003e\u003cspan style=\"display:flex;\"\u003e\u003cspan\u003e\n\u003c/span\u003e\u003c/span\u003e\u003cspan style=\"display:flex;\"\u003e\u003cspan\u003e  \u003cspan style=\"color:#66d9ef\"\u003econst\u003c/span\u003e \u003cspan style=\"color:#a6e22e\"\u003edata\u003c/span\u003e \u003cspan style=\"color:#f92672\"\u003e=\u003c/span\u003e \u003cspan style=\"color:#66d9ef\"\u003eawait\u003c/span\u003e \u003cspan style=\"color:#a6e22e\"\u003eres\u003c/span\u003e.\u003cspan style=\"color:#a6e22e\"\u003ejson\u003c/span\u003e();\n\u003c/span\u003e\u003c/span\u003e\u003cspan style=\"display:flex;\"\u003e\u003cspan\u003e  \u003cspan style=\"color:#a6e22e\"\u003econsole\u003c/span\u003e.\u003cspan style=\"color:#a6e22e\"\u003elog\u003c/span\u003e(\u003cspan style=\"color:#e6db74\"\u003e\u0026#34;Gemini response:\u0026#34;\u003c/span\u003e, \u003cspan style=\"color:#a6e22e\"\u003eJSON\u003c/span\u003e.\u003cspan style=\"color:#a6e22e\"\u003estringify\u003c/span\u003e(\u003cspan style=\"color:#a6e22e\"\u003edata\u003c/span\u003e));\n\u003c/span\u003e\u003c/span\u003e\u003cspan style=\"display:flex;\"\u003e\u003cspan\u003e  \u003cspan style=\"color:#66d9ef\"\u003ereturn\u003c/span\u003e \u003cspan style=\"color:#a6e22e\"\u003edata\u003c/span\u003e.\u003cspan style=\"color:#a6e22e\"\u003ecandidates\u003c/span\u003e\u003cspan style=\"color:#f92672\"\u003e?\u003c/span\u003e.[\u003cspan style=\"color:#ae81ff\"\u003e0\u003c/span\u003e]\u003cspan style=\"color:#f92672\"\u003e?\u003c/span\u003e.\u003cspan style=\"color:#a6e22e\"\u003econtent\u003c/span\u003e\u003cspan style=\"color:#f92672\"\u003e?\u003c/span\u003e.\u003cspan style=\"color:#a6e22e\"\u003eparts\u003c/span\u003e\u003cspan style=\"color:#f92672\"\u003e?\u003c/span\u003e.[\u003cspan style=\"color:#ae81ff\"\u003e0\u003c/span\u003e]\u003cspan style=\"color:#f92672\"\u003e?\u003c/span\u003e.\u003cspan style=\"color:#a6e22e\"\u003etext\u003c/span\u003e \u003cspan style=\"color:#f92672\"\u003e||\u003c/span\u003e \u003cspan style=\"color:#e6db74\"\u003e\u0026#34;要約失敗\u0026#34;\u003c/span\u003e;\n\u003c/span\u003e\u003c/span\u003e\u003cspan style=\"display:flex;\"\u003e\u003cspan\u003e}\n\u003c/span\u003e\u003c/span\u003e\u003cspan style=\"display:flex;\"\u003e\u003cspan\u003e\n\u003c/span\u003e\u003c/span\u003e\u003cspan style=\"display:flex;\"\u003e\u003cspan\u003e\u003cspan style=\"color:#66d9ef\"\u003eexport\u003c/span\u003e \u003cspan style=\"color:#66d9ef\"\u003edefault\u003c/span\u003e {\n\u003c/span\u003e\u003c/span\u003e\u003cspan style=\"display:flex;\"\u003e\u003cspan\u003e  \u003cspan style=\"color:#66d9ef\"\u003easync\u003c/span\u003e \u003cspan style=\"color:#a6e22e\"\u003efetch\u003c/span\u003e(\u003cspan style=\"color:#a6e22e\"\u003erequest\u003c/span\u003e, \u003cspan style=\"color:#a6e22e\"\u003eenv\u003c/span\u003e) {\n\u003c/span\u003e\u003c/span\u003e\u003cspan style=\"display:flex;\"\u003e\u003cspan\u003e    \u003cspan style=\"color:#66d9ef\"\u003eif\u003c/span\u003e (\u003cspan style=\"color:#a6e22e\"\u003erequest\u003c/span\u003e.\u003cspan style=\"color:#a6e22e\"\u003emethod\u003c/span\u003e \u003cspan style=\"color:#f92672\"\u003e!==\u003c/span\u003e \u003cspan style=\"color:#e6db74\"\u003e\u0026#34;POST\u0026#34;\u003c/span\u003e) {\n\u003c/span\u003e\u003c/span\u003e\u003cspan style=\"display:flex;\"\u003e\u003cspan\u003e      \u003cspan style=\"color:#66d9ef\"\u003ereturn\u003c/span\u003e \u003cspan style=\"color:#66d9ef\"\u003enew\u003c/span\u003e \u003cspan style=\"color:#a6e22e\"\u003eResponse\u003c/span\u003e(\u003cspan style=\"color:#e6db74\"\u003e\u0026#34;ok\u0026#34;\u003c/span\u003e);\n\u003c/span\u003e\u003c/span\u003e\u003cspan style=\"display:flex;\"\u003e\u003cspan\u003e    }\n\u003c/span\u003e\u003c/span\u003e\u003cspan style=\"display:flex;\"\u003e\u003cspan\u003e\n\u003c/span\u003e\u003c/span\u003e\u003cspan style=\"display:flex;\"\u003e\u003cspan\u003e    \u003cspan style=\"color:#66d9ef\"\u003elet\u003c/span\u003e \u003cspan style=\"color:#a6e22e\"\u003ebody\u003c/span\u003e;\n\u003c/span\u003e\u003c/span\u003e\u003cspan style=\"display:flex;\"\u003e\u003cspan\u003e    \u003cspan style=\"color:#66d9ef\"\u003etry\u003c/span\u003e {\n\u003c/span\u003e\u003c/span\u003e\u003cspan style=\"display:flex;\"\u003e\u003cspan\u003e      \u003cspan style=\"color:#a6e22e\"\u003ebody\u003c/span\u003e \u003cspan style=\"color:#f92672\"\u003e=\u003c/span\u003e \u003cspan style=\"color:#66d9ef\"\u003eawait\u003c/span\u003e \u003cspan style=\"color:#a6e22e\"\u003erequest\u003c/span\u003e.\u003cspan style=\"color:#a6e22e\"\u003ejson\u003c/span\u003e();\n\u003c/span\u003e\u003c/span\u003e\u003cspan style=\"display:flex;\"\u003e\u003cspan\u003e    } \u003cspan style=\"color:#66d9ef\"\u003ecatch\u003c/span\u003e {\n\u003c/span\u003e\u003c/span\u003e\u003cspan style=\"display:flex;\"\u003e\u003cspan\u003e      \u003cspan style=\"color:#66d9ef\"\u003ereturn\u003c/span\u003e \u003cspan style=\"color:#66d9ef\"\u003enew\u003c/span\u003e \u003cspan style=\"color:#a6e22e\"\u003eResponse\u003c/span\u003e(\u003cspan style=\"color:#e6db74\"\u003e\u0026#34;invalid json\u0026#34;\u003c/span\u003e, { \u003cspan style=\"color:#a6e22e\"\u003estatus\u003c/span\u003e\u003cspan style=\"color:#f92672\"\u003e:\u003c/span\u003e \u003cspan style=\"color:#ae81ff\"\u003e400\u003c/span\u003e });\n\u003c/span\u003e\u003c/span\u003e\u003cspan style=\"display:flex;\"\u003e\u003cspan\u003e    }\n\u003c/span\u003e\u003c/span\u003e\u003cspan style=\"display:flex;\"\u003e\u003cspan\u003e\n\u003c/span\u003e\u003c/span\u003e\u003cspan style=\"display:flex;\"\u003e\u003cspan\u003e    \u003cspan style=\"color:#75715e\"\u003e// アラートタイプによるフィルタリング\n\u003c/span\u003e\u003c/span\u003e\u003c/span\u003e\u003cspan style=\"display:flex;\"\u003e\u003cspan\u003e\u003cspan style=\"color:#75715e\"\u003e\u003c/span\u003e    \u003cspan style=\"color:#66d9ef\"\u003econst\u003c/span\u003e \u003cspan style=\"color:#a6e22e\"\u003ealertType\u003c/span\u003e \u003cspan style=\"color:#f92672\"\u003e=\u003c/span\u003e \u003cspan style=\"color:#a6e22e\"\u003ebody\u003c/span\u003e.\u003cspan style=\"color:#a6e22e\"\u003etext\u003c/span\u003e\u003cspan style=\"color:#f92672\"\u003e?\u003c/span\u003e.\u003cspan style=\"color:#a6e22e\"\u003ealert_type\u003c/span\u003e \u003cspan style=\"color:#f92672\"\u003e||\u003c/span\u003e \u003cspan style=\"color:#a6e22e\"\u003ebody\u003c/span\u003e.\u003cspan style=\"color:#a6e22e\"\u003ealert_type\u003c/span\u003e \u003cspan style=\"color:#f92672\"\u003e||\u003c/span\u003e \u003cspan style=\"color:#e6db74\"\u003e\u0026#34;\u0026#34;\u003c/span\u003e;\n\u003c/span\u003e\u003c/span\u003e\u003cspan style=\"display:flex;\"\u003e\u003cspan\u003e    \u003cspan style=\"color:#66d9ef\"\u003eif\u003c/span\u003e (\u003cspan style=\"color:#a6e22e\"\u003eSKIP_ALERT_TYPES\u003c/span\u003e.\u003cspan style=\"color:#a6e22e\"\u003esome\u003c/span\u003e(\u003cspan style=\"color:#a6e22e\"\u003et\u003c/span\u003e =\u0026gt; \u003cspan style=\"color:#a6e22e\"\u003ealertType\u003c/span\u003e.\u003cspan style=\"color:#a6e22e\"\u003eincludes\u003c/span\u003e(\u003cspan style=\"color:#a6e22e\"\u003et\u003c/span\u003e))) {\n\u003c/span\u003e\u003c/span\u003e\u003cspan style=\"display:flex;\"\u003e\u003cspan\u003e      \u003cspan style=\"color:#a6e22e\"\u003econsole\u003c/span\u003e.\u003cspan style=\"color:#a6e22e\"\u003elog\u003c/span\u003e(\u003cspan style=\"color:#e6db74\"\u003e`Skipped alert type: \u003c/span\u003e\u003cspan style=\"color:#e6db74\"\u003e${\u003c/span\u003e\u003cspan style=\"color:#a6e22e\"\u003ealertType\u003c/span\u003e\u003cspan style=\"color:#e6db74\"\u003e}\u003c/span\u003e\u003cspan style=\"color:#e6db74\"\u003e`\u003c/span\u003e);\n\u003c/span\u003e\u003c/span\u003e\u003cspan style=\"display:flex;\"\u003e\u003cspan\u003e      \u003cspan style=\"color:#66d9ef\"\u003ereturn\u003c/span\u003e \u003cspan style=\"color:#66d9ef\"\u003enew\u003c/span\u003e \u003cspan style=\"color:#a6e22e\"\u003eResponse\u003c/span\u003e(\u003cspan style=\"color:#e6db74\"\u003e\u0026#34;skipped\u0026#34;\u003c/span\u003e);\n\u003c/span\u003e\u003c/span\u003e\u003cspan style=\"display:flex;\"\u003e\u003cspan\u003e    }\n\u003c/span\u003e\u003c/span\u003e\u003cspan style=\"display:flex;\"\u003e\u003cspan\u003e\n\u003c/span\u003e\u003c/span\u003e\u003cspan style=\"display:flex;\"\u003e\u003cspan\u003e    \u003cspan style=\"color:#75715e\"\u003e// Geminiには常にbody全体を渡す\n\u003c/span\u003e\u003c/span\u003e\u003c/span\u003e\u003cspan style=\"display:flex;\"\u003e\u003cspan\u003e\u003cspan style=\"color:#75715e\"\u003e\u003c/span\u003e    \u003cspan style=\"color:#66d9ef\"\u003econst\u003c/span\u003e \u003cspan style=\"color:#a6e22e\"\u003einput\u003c/span\u003e \u003cspan style=\"color:#f92672\"\u003e=\u003c/span\u003e \u003cspan style=\"color:#a6e22e\"\u003eJSON\u003c/span\u003e.\u003cspan style=\"color:#a6e22e\"\u003estringify\u003c/span\u003e(\u003cspan style=\"color:#a6e22e\"\u003ebody\u003c/span\u003e).\u003cspan style=\"color:#a6e22e\"\u003eslice\u003c/span\u003e(\u003cspan style=\"color:#ae81ff\"\u003e0\u003c/span\u003e, \u003cspan style=\"color:#ae81ff\"\u003e500\u003c/span\u003e);\n\u003c/span\u003e\u003c/span\u003e\u003cspan style=\"display:flex;\"\u003e\u003cspan\u003e\n\u003c/span\u003e\u003c/span\u003e\u003cspan style=\"display:flex;\"\u003e\u003cspan\u003e    \u003cspan style=\"color:#66d9ef\"\u003econst\u003c/span\u003e \u003cspan style=\"color:#a6e22e\"\u003esummary\u003c/span\u003e \u003cspan style=\"color:#f92672\"\u003e=\u003c/span\u003e \u003cspan style=\"color:#66d9ef\"\u003eawait\u003c/span\u003e \u003cspan style=\"color:#a6e22e\"\u003esummarizeWithGemini\u003c/span\u003e(\u003cspan style=\"color:#a6e22e\"\u003eenv\u003c/span\u003e.\u003cspan style=\"color:#a6e22e\"\u003eGEMINI_API_KEY\u003c/span\u003e, \u003cspan style=\"color:#a6e22e\"\u003einput\u003c/span\u003e);\n\u003c/span\u003e\u003c/span\u003e\u003cspan style=\"display:flex;\"\u003e\u003cspan\u003e\n\u003c/span\u003e\u003c/span\u003e\u003cspan style=\"display:flex;\"\u003e\u003cspan\u003e    \u003cspan style=\"color:#66d9ef\"\u003econst\u003c/span\u003e \u003cspan style=\"color:#a6e22e\"\u003emessage\u003c/span\u003e \u003cspan style=\"color:#f92672\"\u003e=\u003c/span\u003e {\n\u003c/span\u003e\u003c/span\u003e\u003cspan style=\"display:flex;\"\u003e\u003cspan\u003e      \u003cspan style=\"color:#a6e22e\"\u003eusername\u003c/span\u003e\u003cspan style=\"color:#f92672\"\u003e:\u003c/span\u003e \u003cspan style=\"color:#e6db74\"\u003e\u0026#34;Cloudflare\u0026#34;\u003c/span\u003e,\n\u003c/span\u003e\u003c/span\u003e\u003cspan style=\"display:flex;\"\u003e\u003cspan\u003e      \u003cspan style=\"color:#a6e22e\"\u003eembeds\u003c/span\u003e\u003cspan style=\"color:#f92672\"\u003e:\u003c/span\u003e [{\n\u003c/span\u003e\u003c/span\u003e\u003cspan style=\"display:flex;\"\u003e\u003cspan\u003e        \u003cspan style=\"color:#a6e22e\"\u003etitle\u003c/span\u003e\u003cspan style=\"color:#f92672\"\u003e:\u003c/span\u003e \u003cspan style=\"color:#a6e22e\"\u003ebody\u003c/span\u003e.\u003cspan style=\"color:#a6e22e\"\u003etext\u003c/span\u003e\u003cspan style=\"color:#f92672\"\u003e?\u003c/span\u003e.\u003cspan style=\"color:#a6e22e\"\u003etitle\u003c/span\u003e \u003cspan style=\"color:#f92672\"\u003e||\u003c/span\u003e \u003cspan style=\"color:#a6e22e\"\u003ebody\u003c/span\u003e.\u003cspan style=\"color:#a6e22e\"\u003etext\u003c/span\u003e\u003cspan style=\"color:#f92672\"\u003e?\u003c/span\u003e.\u003cspan style=\"color:#a6e22e\"\u003edescription\u003c/span\u003e \u003cspan style=\"color:#f92672\"\u003e||\u003c/span\u003e \u003cspan style=\"color:#e6db74\"\u003e\u0026#34;Cloudflare Notification\u0026#34;\u003c/span\u003e,\n\u003c/span\u003e\u003c/span\u003e\u003cspan style=\"display:flex;\"\u003e\u003cspan\u003e        \u003cspan style=\"color:#a6e22e\"\u003edescription\u003c/span\u003e\u003cspan style=\"color:#f92672\"\u003e:\u003c/span\u003e \u003cspan style=\"color:#a6e22e\"\u003esummary\u003c/span\u003e,\n\u003c/span\u003e\u003c/span\u003e\u003cspan style=\"display:flex;\"\u003e\u003cspan\u003e        \u003cspan style=\"color:#a6e22e\"\u003ecolor\u003c/span\u003e\u003cspan style=\"color:#f92672\"\u003e:\u003c/span\u003e \u003cspan style=\"color:#ae81ff\"\u003e0xF6821F\u003c/span\u003e,\n\u003c/span\u003e\u003c/span\u003e\u003cspan style=\"display:flex;\"\u003e\u003cspan\u003e        \u003cspan style=\"color:#a6e22e\"\u003etimestamp\u003c/span\u003e\u003cspan style=\"color:#f92672\"\u003e:\u003c/span\u003e \u003cspan style=\"color:#66d9ef\"\u003enew\u003c/span\u003e Date().\u003cspan style=\"color:#a6e22e\"\u003etoISOString\u003c/span\u003e()\n\u003c/span\u003e\u003c/span\u003e\u003cspan style=\"display:flex;\"\u003e\u003cspan\u003e      }]\n\u003c/span\u003e\u003c/span\u003e\u003cspan style=\"display:flex;\"\u003e\u003cspan\u003e    };\n\u003c/span\u003e\u003c/span\u003e\u003cspan style=\"display:flex;\"\u003e\u003cspan\u003e\n\u003c/span\u003e\u003c/span\u003e\u003cspan style=\"display:flex;\"\u003e\u003cspan\u003e    \u003cspan style=\"color:#66d9ef\"\u003eawait\u003c/span\u003e \u003cspan style=\"color:#a6e22e\"\u003efetch\u003c/span\u003e(\u003cspan style=\"color:#a6e22e\"\u003eenv\u003c/span\u003e.\u003cspan style=\"color:#a6e22e\"\u003eDISCORD_WEBHOOK\u003c/span\u003e, {\n\u003c/span\u003e\u003c/span\u003e\u003cspan style=\"display:flex;\"\u003e\u003cspan\u003e      \u003cspan style=\"color:#a6e22e\"\u003emethod\u003c/span\u003e\u003cspan style=\"color:#f92672\"\u003e:\u003c/span\u003e \u003cspan style=\"color:#e6db74\"\u003e\u0026#34;POST\u0026#34;\u003c/span\u003e,\n\u003c/span\u003e\u003c/span\u003e\u003cspan style=\"display:flex;\"\u003e\u003cspan\u003e      \u003cspan style=\"color:#a6e22e\"\u003eheaders\u003c/span\u003e\u003cspan style=\"color:#f92672\"\u003e:\u003c/span\u003e { \u003cspan style=\"color:#e6db74\"\u003e\u0026#34;Content-Type\u0026#34;\u003c/span\u003e\u003cspan style=\"color:#f92672\"\u003e:\u003c/span\u003e \u003cspan style=\"color:#e6db74\"\u003e\u0026#34;application/json\u0026#34;\u003c/span\u003e },\n\u003c/span\u003e\u003c/span\u003e\u003cspan style=\"display:flex;\"\u003e\u003cspan\u003e      \u003cspan style=\"color:#a6e22e\"\u003ebody\u003c/span\u003e\u003cspan style=\"color:#f92672\"\u003e:\u003c/span\u003e \u003cspan style=\"color:#a6e22e\"\u003eJSON\u003c/span\u003e.\u003cspan style=\"color:#a6e22e\"\u003estringify\u003c/span\u003e(\u003cspan style=\"color:#a6e22e\"\u003emessage\u003c/span\u003e)\n\u003c/span\u003e\u003c/span\u003e\u003cspan style=\"display:flex;\"\u003e\u003cspan\u003e    });\n\u003c/span\u003e\u003c/span\u003e\u003cspan style=\"display:flex;\"\u003e\u003cspan\u003e\n\u003c/span\u003e\u003c/span\u003e\u003cspan style=\"display:flex;\"\u003e\u003cspan\u003e    \u003cspan style=\"color:#66d9ef\"\u003ereturn\u003c/span\u003e \u003cspan style=\"color:#66d9ef\"\u003enew\u003c/span\u003e \u003cspan style=\"color:#a6e22e\"\u003eResponse\u003c/span\u003e(\u003cspan style=\"color:#e6db74\"\u003e\u0026#34;ok\u0026#34;\u003c/span\u003e);\n\u003c/span\u003e\u003c/span\u003e\u003cspan style=\"display:flex;\"\u003e\u003cspan\u003e  }\n\u003c/span\u003e\u003c/span\u003e\u003cspan style=\"display:flex;\"\u003e\u003cspan\u003e}\n\u003c/span\u003e\u003c/span\u003e\u003c/code\u003e\u003c/pre\u003e\u003c/div\u003e\u003ch3 id=\"シークレット登録\"\u003eシークレット登録\u003c/h3\u003e\n\u003cdiv class=\"highlight\"\u003e\u003cpre tabindex=\"0\" style=\"color:#f8f8f2;background-color:#272822;-moz-tab-size:4;-o-tab-size:4;tab-size:4;\"\u003e\u003ccode class=\"language-bash\" data-lang=\"bash\"\u003e\u003cspan style=\"display:flex;\"\u003e\u003cspan\u003ewrangler secret put GEMINI_API_KEY\n\u003c/span\u003e\u003c/span\u003e\u003cspan style=\"display:flex;\"\u003e\u003cspan\u003ewrangler secret put DISCORD_WEBHOOK\n\u003c/span\u003e\u003c/span\u003e\u003c/code\u003e\u003c/pre\u003e\u003c/div\u003e\u003ch3 id=\"動作確認\"\u003e動作確認\u003c/h3\u003e\n\u003cdiv class=\"highlight\"\u003e\u003cpre tabindex=\"0\" style=\"color:#f8f8f2;background-color:#272822;-moz-tab-size:4;-o-tab-size:4;tab-size:4;\"\u003e\u003ccode class=\"language-bash\" data-lang=\"bash\"\u003e\u003cspan style=\"display:flex;\"\u003e\u003cspan\u003ecurl -X POST https://\u0026lt;your-worker\u0026gt;.workers.dev \u003cspan style=\"color:#ae81ff\"\u003e\\\n\u003c/span\u003e\u003c/span\u003e\u003c/span\u003e\u003cspan style=\"display:flex;\"\u003e\u003cspan\u003e\u003cspan style=\"color:#ae81ff\"\u003e\u003c/span\u003e  -H \u003cspan style=\"color:#e6db74\"\u003e\u0026#34;Content-Type: application/json\u0026#34;\u003c/span\u003e \u003cspan style=\"color:#ae81ff\"\u003e\\\n\u003c/span\u003e\u003c/span\u003e\u003c/span\u003e\u003cspan style=\"display:flex;\"\u003e\u003cspan\u003e\u003cspan style=\"color:#ae81ff\"\u003e\u003c/span\u003e  -d \u003cspan style=\"color:#e6db74\"\u003e\u0026#39;{\u0026#34;text\u0026#34;: {\u0026#34;title\u0026#34;: \u0026#34;テスト通知\u0026#34;, \u0026#34;description\u0026#34;: \u0026#34;これはテストです\u0026#34;}}\u0026#39;\u003c/span\u003e\n\u003c/span\u003e\u003c/span\u003e\u003c/code\u003e\u003c/pre\u003e\u003c/div\u003e\u003cp\u003eスキップ確認：\u003c/p\u003e","title":"CloudflareアラートをWorker + Gemini APIでDiscordに要約通知する"},{"content":"CloudflareのNotificationsはUIから1個ずつ設定するのがとにかくだるい。 種別も多いし、手動でDiscord Webhookを登録していくのは非現実的。\nCloudflare APIとWorkersを組み合わせて全通知を一括登録する。\n構成 Cloudflare Notifications ↓ Cloudflare Worker (受け口) ↓ Discord Webhook Cloudflare NotificationsはWebhook送信に対応しているので、Workerを受け口にしてDiscordに転送する。\n1. Discord Webhookを作成 通知を飛ばしたいDiscordチャンネルの設定から作成する。\nチャンネル設定 → Integrations → Webhooks → New Webhook → Copy Webhook URL\n2. Cloudflare Workerを作成 Cloudflareダッシュボードから Workers \u0026amp; Pages → Create → Hello World で作成する。\n名前は cf-notify-discord など適当につけてDeploy。\nエディタ画面で以下のコードに置き換える。\nexport default { async fetch(request, env) { if (request.method !== \u0026#34;POST\u0026#34;) { return new Response(\u0026#34;ok\u0026#34;); } let body; try { body = await request.json(); } catch { return new Response(\u0026#34;invalid json\u0026#34;, { status: 400 }); } const message = { username: \u0026#34;Cloudflare\u0026#34;, embeds: [{ title: body.text?.title || \u0026#34;Cloudflare Notification\u0026#34;, description: body.text?.description || JSON.stringify(body, null, 2), color: 0xF6821F, timestamp: new Date().toISOString() }] }; await fetch(env.DISCORD_WEBHOOK, { method: \u0026#34;POST\u0026#34;, headers: { \u0026#34;Content-Type\u0026#34;: \u0026#34;application/json\u0026#34; }, body: JSON.stringify(message) }); return new Response(\u0026#34;ok\u0026#34;); } } Discord WebhookのURLはWorkerのSettings → Variables and Secretsでシークレットとして登録する。\n変数名: DISCORD_WEBHOOK 値: DiscordのWebhook URL 3. API Tokenを作成 My Profile → API Tokens → Create Token → Create Custom Token\n必要な権限は以下のみ。\nスコープ リソース 権限 Account Notifications Edit 4. 全通知種別を一括登録するスクリプト #!/bin/bash ACCOUNT_ID=\u0026#34;${1}\u0026#34; API_TOKEN=\u0026#34;${2}\u0026#34; WEBHOOK_URL=\u0026#34;${3}\u0026#34; # WorkerのURL if [ -z \u0026#34;$ACCOUNT_ID\u0026#34; ] || [ -z \u0026#34;$API_TOKEN\u0026#34; ] || [ -z \u0026#34;$WEBHOOK_URL\u0026#34; ]; then echo \u0026#34;Usage: $0 \u0026lt;account_id\u0026gt; \u0026lt;api_token\u0026gt; \u0026lt;worker_url\u0026gt;\u0026#34; exit 1 fi BASE_URL=\u0026#34;https://api.cloudflare.com/client/v4/accounts/${ACCOUNT_ID}/alerting/v3\u0026#34; AUTH_HEADER=\u0026#34;Authorization: Bearer ${API_TOKEN}\u0026#34; echo \u0026#34;=== 利用可能なアラート種別を取得 ===\u0026#34; ALERTS=$(curl -s -X GET \u0026#34;${BASE_URL}/available_alerts\u0026#34; \\ -H \u0026#34;${AUTH_HEADER}\u0026#34; \\ -H \u0026#34;Content-Type: application/json\u0026#34;) ALERT_TYPES=$(echo \u0026#34;$ALERTS\u0026#34; | jq -r \u0026#39;.result | to_entries[].value[].type\u0026#39;) echo \u0026#34;=== Webhook登録 ===\u0026#34; WEBHOOK_RESP=$(curl -s -X POST \u0026#34;${BASE_URL}/destinations/webhooks\u0026#34; \\ -H \u0026#34;${AUTH_HEADER}\u0026#34; \\ -H \u0026#34;Content-Type: application/json\u0026#34; \\ --data \u0026#34;{ \\\u0026#34;name\\\u0026#34;: \\\u0026#34;Discord Worker\\\u0026#34;, \\\u0026#34;url\\\u0026#34;: \\\u0026#34;${WEBHOOK_URL}\\\u0026#34; }\u0026#34;) WEBHOOK_ID=$(echo \u0026#34;$WEBHOOK_RESP\u0026#34; | jq -r \u0026#39;.result.id\u0026#39;) echo \u0026#34;Webhook ID: ${WEBHOOK_ID}\u0026#34; if [ -z \u0026#34;$WEBHOOK_ID\u0026#34; ] || [ \u0026#34;$WEBHOOK_ID\u0026#34; = \u0026#34;null\u0026#34; ]; then echo \u0026#34;Webhook登録失敗\u0026#34; echo \u0026#34;$WEBHOOK_RESP\u0026#34; exit 1 fi echo \u0026#34;=== 全アラート種別にNotification登録 ===\u0026#34; for ALERT_TYPE in $ALERT_TYPES; do echo -n \u0026#34;登録中: ${ALERT_TYPE} ... \u0026#34; RESP=$(curl -s -X POST \u0026#34;${BASE_URL}/policies\u0026#34; \\ -H \u0026#34;${AUTH_HEADER}\u0026#34; \\ -H \u0026#34;Content-Type: application/json\u0026#34; \\ --data \u0026#34;{ \\\u0026#34;name\\\u0026#34;: \\\u0026#34;${ALERT_TYPE}\\\u0026#34;, \\\u0026#34;enabled\\\u0026#34;: true, \\\u0026#34;alert_type\\\u0026#34;: \\\u0026#34;${ALERT_TYPE}\\\u0026#34;, \\\u0026#34;mechanisms\\\u0026#34;: { \\\u0026#34;webhooks\\\u0026#34;: [{\\\u0026#34;id\\\u0026#34;: \\\u0026#34;${WEBHOOK_ID}\\\u0026#34;}] } }\u0026#34;) SUCCESS=$(echo \u0026#34;$RESP\u0026#34; | jq -r \u0026#39;.success\u0026#39;) if [ \u0026#34;$SUCCESS\u0026#34; = \u0026#34;true\u0026#34; ]; then echo \u0026#34;OK\u0026#34; else ERROR_CODE=$(echo \u0026#34;$RESP\u0026#34; | jq -r \u0026#39;.errors[0].code\u0026#39;) if [ \u0026#34;$ERROR_CODE\u0026#34; = \u0026#34;17103\u0026#34; ]; then echo \u0026#34;SKIP (フィルター必須)\u0026#34; else echo \u0026#34;FAIL\u0026#34; echo \u0026#34;$RESP\u0026#34; | jq \u0026#39;.errors\u0026#39; fi fi done echo \u0026#34;=== 完了 ===\u0026#34; 実行方法。\nchmod +x setup_cf_notifications.sh ./setup_cf_notifications.sh \u0026lt;account_id\u0026gt; \u0026lt;api_token\u0026gt; \u0026lt;worker_url\u0026gt; jq が必要なので未インストールの場合は入れておく。\n# Arch Linux sudo pacman -S jq # Ubuntu/Debian sudo apt install jq Account IDの確認 Cloudflareダッシュボードのトップページ右サイドバーに表示されている32文字の英数字。\ncfut_から始まる文字列はAPI Tokenなので間違えないこと。\n5. 動作確認 WorkerのURLに直接POSTしてDiscordに通知が飛ぶか確認する。\ncurl -X POST \u0026#34;https://cf-notify-discord.{サブドメイン}.workers.dev\u0026#34; \\ -H \u0026#34;Content-Type: application/json\u0026#34; \\ --data \u0026#39;{ \u0026#34;text\u0026#34;: { \u0026#34;title\u0026#34;: \u0026#34;テスト通知\u0026#34;, \u0026#34;description\u0026#34;: \u0026#34;Cloudflare通知のテストです\u0026#34; } }\u0026#39; Discordに飛んでくれば完了。\n注意点 エラーコード 17103 が出る種別はフィルターの指定が必須なためSKIPされる。これらは手動で個別設定が必要 API Tokenは作業後に削除しておく Cloudflare Workers無料枠は1日10万リクエストなので通知の受け口程度であれば実質無料で運用できる 参考 Cloudflare Notifications API Cloudflare Workers Discord Webhooks ","permalink":"https://techblog.wasutech.dev/posts/cloudflare-notifications-discord/","summary":"\u003cp\u003eCloudflareのNotificationsはUIから1個ずつ設定するのがとにかくだるい。\n種別も多いし、手動でDiscord Webhookを登録していくのは非現実的。\u003c/p\u003e\n\u003cp\u003eCloudflare APIとWorkersを組み合わせて全通知を一括登録する。\u003c/p\u003e\n\u003ch2 id=\"構成\"\u003e構成\u003c/h2\u003e\n\u003cpre tabindex=\"0\"\u003e\u003ccode\u003eCloudflare Notifications\n        ↓\n  Cloudflare Worker (受け口)\n        ↓\n  Discord Webhook\n\u003c/code\u003e\u003c/pre\u003e\u003cp\u003eCloudflare NotificationsはWebhook送信に対応しているので、Workerを受け口にしてDiscordに転送する。\u003c/p\u003e\n\u003ch2 id=\"1-discord-webhookを作成\"\u003e1. Discord Webhookを作成\u003c/h2\u003e\n\u003cp\u003e通知を飛ばしたいDiscordチャンネルの設定から作成する。\u003c/p\u003e\n\u003cp\u003e\u003cstrong\u003eチャンネル設定 → Integrations → Webhooks → New Webhook → Copy Webhook URL\u003c/strong\u003e\u003c/p\u003e\n\u003ch2 id=\"2-cloudflare-workerを作成\"\u003e2. Cloudflare Workerを作成\u003c/h2\u003e\n\u003cp\u003eCloudflareダッシュボードから \u003cstrong\u003eWorkers \u0026amp; Pages → Create → Hello World\u003c/strong\u003e で作成する。\u003c/p\u003e\n\u003cp\u003e名前は \u003ccode\u003ecf-notify-discord\u003c/code\u003e など適当につけてDeploy。\u003c/p\u003e\n\u003cp\u003eエディタ画面で以下のコードに置き換える。\u003c/p\u003e\n\u003cdiv class=\"highlight\"\u003e\u003cpre tabindex=\"0\" style=\"color:#f8f8f2;background-color:#272822;-moz-tab-size:4;-o-tab-size:4;tab-size:4;\"\u003e\u003ccode class=\"language-javascript\" data-lang=\"javascript\"\u003e\u003cspan style=\"display:flex;\"\u003e\u003cspan\u003e\u003cspan style=\"color:#66d9ef\"\u003eexport\u003c/span\u003e \u003cspan style=\"color:#66d9ef\"\u003edefault\u003c/span\u003e {\n\u003c/span\u003e\u003c/span\u003e\u003cspan style=\"display:flex;\"\u003e\u003cspan\u003e  \u003cspan style=\"color:#66d9ef\"\u003easync\u003c/span\u003e \u003cspan style=\"color:#a6e22e\"\u003efetch\u003c/span\u003e(\u003cspan style=\"color:#a6e22e\"\u003erequest\u003c/span\u003e, \u003cspan style=\"color:#a6e22e\"\u003eenv\u003c/span\u003e) {\n\u003c/span\u003e\u003c/span\u003e\u003cspan style=\"display:flex;\"\u003e\u003cspan\u003e    \u003cspan style=\"color:#66d9ef\"\u003eif\u003c/span\u003e (\u003cspan style=\"color:#a6e22e\"\u003erequest\u003c/span\u003e.\u003cspan style=\"color:#a6e22e\"\u003emethod\u003c/span\u003e \u003cspan style=\"color:#f92672\"\u003e!==\u003c/span\u003e \u003cspan style=\"color:#e6db74\"\u003e\u0026#34;POST\u0026#34;\u003c/span\u003e) {\n\u003c/span\u003e\u003c/span\u003e\u003cspan style=\"display:flex;\"\u003e\u003cspan\u003e      \u003cspan style=\"color:#66d9ef\"\u003ereturn\u003c/span\u003e \u003cspan style=\"color:#66d9ef\"\u003enew\u003c/span\u003e \u003cspan style=\"color:#a6e22e\"\u003eResponse\u003c/span\u003e(\u003cspan style=\"color:#e6db74\"\u003e\u0026#34;ok\u0026#34;\u003c/span\u003e);\n\u003c/span\u003e\u003c/span\u003e\u003cspan style=\"display:flex;\"\u003e\u003cspan\u003e    }\n\u003c/span\u003e\u003c/span\u003e\u003cspan style=\"display:flex;\"\u003e\u003cspan\u003e\n\u003c/span\u003e\u003c/span\u003e\u003cspan style=\"display:flex;\"\u003e\u003cspan\u003e    \u003cspan style=\"color:#66d9ef\"\u003elet\u003c/span\u003e \u003cspan style=\"color:#a6e22e\"\u003ebody\u003c/span\u003e;\n\u003c/span\u003e\u003c/span\u003e\u003cspan style=\"display:flex;\"\u003e\u003cspan\u003e    \u003cspan style=\"color:#66d9ef\"\u003etry\u003c/span\u003e {\n\u003c/span\u003e\u003c/span\u003e\u003cspan style=\"display:flex;\"\u003e\u003cspan\u003e      \u003cspan style=\"color:#a6e22e\"\u003ebody\u003c/span\u003e \u003cspan style=\"color:#f92672\"\u003e=\u003c/span\u003e \u003cspan style=\"color:#66d9ef\"\u003eawait\u003c/span\u003e \u003cspan style=\"color:#a6e22e\"\u003erequest\u003c/span\u003e.\u003cspan style=\"color:#a6e22e\"\u003ejson\u003c/span\u003e();\n\u003c/span\u003e\u003c/span\u003e\u003cspan style=\"display:flex;\"\u003e\u003cspan\u003e    } \u003cspan style=\"color:#66d9ef\"\u003ecatch\u003c/span\u003e {\n\u003c/span\u003e\u003c/span\u003e\u003cspan style=\"display:flex;\"\u003e\u003cspan\u003e      \u003cspan style=\"color:#66d9ef\"\u003ereturn\u003c/span\u003e \u003cspan style=\"color:#66d9ef\"\u003enew\u003c/span\u003e \u003cspan style=\"color:#a6e22e\"\u003eResponse\u003c/span\u003e(\u003cspan style=\"color:#e6db74\"\u003e\u0026#34;invalid json\u0026#34;\u003c/span\u003e, { \u003cspan style=\"color:#a6e22e\"\u003estatus\u003c/span\u003e\u003cspan style=\"color:#f92672\"\u003e:\u003c/span\u003e \u003cspan style=\"color:#ae81ff\"\u003e400\u003c/span\u003e });\n\u003c/span\u003e\u003c/span\u003e\u003cspan style=\"display:flex;\"\u003e\u003cspan\u003e    }\n\u003c/span\u003e\u003c/span\u003e\u003cspan style=\"display:flex;\"\u003e\u003cspan\u003e\n\u003c/span\u003e\u003c/span\u003e\u003cspan style=\"display:flex;\"\u003e\u003cspan\u003e    \u003cspan style=\"color:#66d9ef\"\u003econst\u003c/span\u003e \u003cspan style=\"color:#a6e22e\"\u003emessage\u003c/span\u003e \u003cspan style=\"color:#f92672\"\u003e=\u003c/span\u003e {\n\u003c/span\u003e\u003c/span\u003e\u003cspan style=\"display:flex;\"\u003e\u003cspan\u003e      \u003cspan style=\"color:#a6e22e\"\u003eusername\u003c/span\u003e\u003cspan style=\"color:#f92672\"\u003e:\u003c/span\u003e \u003cspan style=\"color:#e6db74\"\u003e\u0026#34;Cloudflare\u0026#34;\u003c/span\u003e,\n\u003c/span\u003e\u003c/span\u003e\u003cspan style=\"display:flex;\"\u003e\u003cspan\u003e      \u003cspan style=\"color:#a6e22e\"\u003eembeds\u003c/span\u003e\u003cspan style=\"color:#f92672\"\u003e:\u003c/span\u003e [{\n\u003c/span\u003e\u003c/span\u003e\u003cspan style=\"display:flex;\"\u003e\u003cspan\u003e        \u003cspan style=\"color:#a6e22e\"\u003etitle\u003c/span\u003e\u003cspan style=\"color:#f92672\"\u003e:\u003c/span\u003e \u003cspan style=\"color:#a6e22e\"\u003ebody\u003c/span\u003e.\u003cspan style=\"color:#a6e22e\"\u003etext\u003c/span\u003e\u003cspan style=\"color:#f92672\"\u003e?\u003c/span\u003e.\u003cspan style=\"color:#a6e22e\"\u003etitle\u003c/span\u003e \u003cspan style=\"color:#f92672\"\u003e||\u003c/span\u003e \u003cspan style=\"color:#e6db74\"\u003e\u0026#34;Cloudflare Notification\u0026#34;\u003c/span\u003e,\n\u003c/span\u003e\u003c/span\u003e\u003cspan style=\"display:flex;\"\u003e\u003cspan\u003e        \u003cspan style=\"color:#a6e22e\"\u003edescription\u003c/span\u003e\u003cspan style=\"color:#f92672\"\u003e:\u003c/span\u003e \u003cspan style=\"color:#a6e22e\"\u003ebody\u003c/span\u003e.\u003cspan style=\"color:#a6e22e\"\u003etext\u003c/span\u003e\u003cspan style=\"color:#f92672\"\u003e?\u003c/span\u003e.\u003cspan style=\"color:#a6e22e\"\u003edescription\u003c/span\u003e \u003cspan style=\"color:#f92672\"\u003e||\u003c/span\u003e \u003cspan style=\"color:#a6e22e\"\u003eJSON\u003c/span\u003e.\u003cspan style=\"color:#a6e22e\"\u003estringify\u003c/span\u003e(\u003cspan style=\"color:#a6e22e\"\u003ebody\u003c/span\u003e, \u003cspan style=\"color:#66d9ef\"\u003enull\u003c/span\u003e, \u003cspan style=\"color:#ae81ff\"\u003e2\u003c/span\u003e),\n\u003c/span\u003e\u003c/span\u003e\u003cspan style=\"display:flex;\"\u003e\u003cspan\u003e        \u003cspan style=\"color:#a6e22e\"\u003ecolor\u003c/span\u003e\u003cspan style=\"color:#f92672\"\u003e:\u003c/span\u003e \u003cspan style=\"color:#ae81ff\"\u003e0xF6821F\u003c/span\u003e,\n\u003c/span\u003e\u003c/span\u003e\u003cspan style=\"display:flex;\"\u003e\u003cspan\u003e        \u003cspan style=\"color:#a6e22e\"\u003etimestamp\u003c/span\u003e\u003cspan style=\"color:#f92672\"\u003e:\u003c/span\u003e \u003cspan style=\"color:#66d9ef\"\u003enew\u003c/span\u003e Date().\u003cspan style=\"color:#a6e22e\"\u003etoISOString\u003c/span\u003e()\n\u003c/span\u003e\u003c/span\u003e\u003cspan style=\"display:flex;\"\u003e\u003cspan\u003e      }]\n\u003c/span\u003e\u003c/span\u003e\u003cspan style=\"display:flex;\"\u003e\u003cspan\u003e    };\n\u003c/span\u003e\u003c/span\u003e\u003cspan style=\"display:flex;\"\u003e\u003cspan\u003e\n\u003c/span\u003e\u003c/span\u003e\u003cspan style=\"display:flex;\"\u003e\u003cspan\u003e    \u003cspan style=\"color:#66d9ef\"\u003eawait\u003c/span\u003e \u003cspan style=\"color:#a6e22e\"\u003efetch\u003c/span\u003e(\u003cspan style=\"color:#a6e22e\"\u003eenv\u003c/span\u003e.\u003cspan style=\"color:#a6e22e\"\u003eDISCORD_WEBHOOK\u003c/span\u003e, {\n\u003c/span\u003e\u003c/span\u003e\u003cspan style=\"display:flex;\"\u003e\u003cspan\u003e      \u003cspan style=\"color:#a6e22e\"\u003emethod\u003c/span\u003e\u003cspan style=\"color:#f92672\"\u003e:\u003c/span\u003e \u003cspan style=\"color:#e6db74\"\u003e\u0026#34;POST\u0026#34;\u003c/span\u003e,\n\u003c/span\u003e\u003c/span\u003e\u003cspan style=\"display:flex;\"\u003e\u003cspan\u003e      \u003cspan style=\"color:#a6e22e\"\u003eheaders\u003c/span\u003e\u003cspan style=\"color:#f92672\"\u003e:\u003c/span\u003e { \u003cspan style=\"color:#e6db74\"\u003e\u0026#34;Content-Type\u0026#34;\u003c/span\u003e\u003cspan style=\"color:#f92672\"\u003e:\u003c/span\u003e \u003cspan style=\"color:#e6db74\"\u003e\u0026#34;application/json\u0026#34;\u003c/span\u003e },\n\u003c/span\u003e\u003c/span\u003e\u003cspan style=\"display:flex;\"\u003e\u003cspan\u003e      \u003cspan style=\"color:#a6e22e\"\u003ebody\u003c/span\u003e\u003cspan style=\"color:#f92672\"\u003e:\u003c/span\u003e \u003cspan style=\"color:#a6e22e\"\u003eJSON\u003c/span\u003e.\u003cspan style=\"color:#a6e22e\"\u003estringify\u003c/span\u003e(\u003cspan style=\"color:#a6e22e\"\u003emessage\u003c/span\u003e)\n\u003c/span\u003e\u003c/span\u003e\u003cspan style=\"display:flex;\"\u003e\u003cspan\u003e    });\n\u003c/span\u003e\u003c/span\u003e\u003cspan style=\"display:flex;\"\u003e\u003cspan\u003e\n\u003c/span\u003e\u003c/span\u003e\u003cspan style=\"display:flex;\"\u003e\u003cspan\u003e    \u003cspan style=\"color:#66d9ef\"\u003ereturn\u003c/span\u003e \u003cspan style=\"color:#66d9ef\"\u003enew\u003c/span\u003e \u003cspan style=\"color:#a6e22e\"\u003eResponse\u003c/span\u003e(\u003cspan style=\"color:#e6db74\"\u003e\u0026#34;ok\u0026#34;\u003c/span\u003e);\n\u003c/span\u003e\u003c/span\u003e\u003cspan style=\"display:flex;\"\u003e\u003cspan\u003e  }\n\u003c/span\u003e\u003c/span\u003e\u003cspan style=\"display:flex;\"\u003e\u003cspan\u003e}\n\u003c/span\u003e\u003c/span\u003e\u003c/code\u003e\u003c/pre\u003e\u003c/div\u003e\u003cp\u003eDiscord WebhookのURLはWorkerの\u003cstrong\u003eSettings → Variables and Secrets\u003c/strong\u003eでシークレットとして登録する。\u003c/p\u003e","title":"Cloudflareの全通知をDiscord Webhookに飛ばす"},{"content":"問題 ふとDesktopの画面を見るとモニターがマイクとスピーカーとしてOSに認識されていた。 誤って爆音で音が再生されるリスクが気になったため無効化することにした。 ついでにマイクも有効にする意味がない環境だったので止めた。\n純粋な開発PCで動画とかも見ないそこそこ特殊？な環境なので最悪読み込まないなら何でもいい状態。\n環境 OS: ArchLinux サウンドサーバー: PipeWire + WirePlumber 0.5.14 GPU: AMD Ryzen（APU） 問題のデバイス: AMD/ATI Raven/Raven2/Fenghuang HDMI/DP Audio Controller 原因 HDMI/DisplayPortには映像だけでなく音声も伝送できる仕様（Audio over HDMI）がある。 LinuxはこれをALSAレベルで別サウンドカードとして認識するため、 PipeWireがそのまま拾ってオーディオデバイスとして公開してしまう。\n調査 認識されているカードを確認 pactl list cards short 49 alsa_card.pci-0000_04_00.1 alsa 50 alsa_card.pci-0000_04_00.6 alsa 2枚のサウンドカードが認識されている。詳細を確認する。\npactl list cards | grep -A 30 \u0026#34;alsa_card.pci-0000_04_00\u0026#34; 結果を整理すると：\nPCI アドレス ベンダー 説明 用途 0000:04:00.1 AMD/ATI Raven HDMI/DP Audio Controller モニター側（不要） 0000:04:00.6 AMD + Realtek ALC269VB Ryzen HD Audio Controller 本物のオンボードサウンド 0000:04:00.1 の alsa_mixer_name が ATI R6xx HDMI であることからも、 これがHDMI経由のオーディオデバイスだと確定できる。\n一時的に無効化して動作確認 pactl set-card-profile 49 off これでモニター側のデバイスがOSから消える。ただし再起動で元に戻る。\n解決：WirePlumberで永続化 WirePlumber 0.5系ではLuaではなく .conf 形式 で設定を記述する。\nmkdir -p ~/.config/wireplumber/wireplumber.conf.d/ # ~/.config/wireplumber/wireplumber.conf.d/51-disable-hdmi-audio.conf monitor.alsa.rules = [ { matches = [ { device.name = \u0026#34;alsa_card.pci-0000_04_00.1\u0026#34; } ] actions = { update-props = { device.disabled = true } } } ] systemctl --user restart wireplumber pactl list cards short alsa_card.pci-0000_04_00.1 が消えていれば成功。\nWirePlumber 0.4系との違い 0.4系ではLuaで記述するのが一般的だった。\n-- 0.4系の書き方（0.5系では動かない） rule = { matches = { { { \u0026#34;device.name\u0026#34;, \u0026#34;=\u0026#34;, \u0026#34;alsa_card.pci-0000_04_00.1\u0026#34; }, }, }, apply_properties = { [\u0026#34;device.disabled\u0026#34;] = true, }, } table.insert(alsa_monitor.rules, rule) 0.5系でLua設定を書いても無視されるため、必ずバージョンを確認してから設定すること。\nwireplumber --version 結果 Dummy output 表示になるが、これは正常。実際の出力先がないためのフォールバック表示 マイクデバイスも存在しないため盗聴リスクなし 突然爆音が鳴るリスクもなし alsa_card.pci-0000_04_00.6（Realtek）はそのまま残り、通常のサウンドが使える 参考 ArchWiki: PipeWire ","permalink":"https://techblog.wasutech.dev/posts/disabled-microphone-and-speaker-in-wayland/","summary":"\u003ch2 id=\"問題\"\u003e問題\u003c/h2\u003e\n\u003cp\u003eふとDesktopの画面を見るとモニターがマイクとスピーカーとしてOSに認識されていた。\n誤って爆音で音が再生されるリスクが気になったため無効化することにした。\nついでにマイクも有効にする意味がない環境だったので止めた。\u003c/p\u003e\n\u003cp\u003e純粋な開発PCで動画とかも見ないそこそこ特殊？な環境なので最悪読み込まないなら何でもいい状態。\u003c/p\u003e\n\u003ch2 id=\"環境\"\u003e環境\u003c/h2\u003e\n\u003cul\u003e\n\u003cli\u003eOS: ArchLinux\u003c/li\u003e\n\u003cli\u003eサウンドサーバー: PipeWire + WirePlumber 0.5.14\u003c/li\u003e\n\u003cli\u003eGPU: AMD Ryzen（APU）\u003c/li\u003e\n\u003cli\u003e問題のデバイス: \u003ccode\u003eAMD/ATI Raven/Raven2/Fenghuang HDMI/DP Audio Controller\u003c/code\u003e\u003c/li\u003e\n\u003c/ul\u003e\n\u003ch2 id=\"原因\"\u003e原因\u003c/h2\u003e\n\u003cp\u003eHDMI/DisplayPortには映像だけでなく音声も伝送できる仕様（Audio over HDMI）がある。\nLinuxはこれをALSAレベルで別サウンドカードとして認識するため、\nPipeWireがそのまま拾ってオーディオデバイスとして公開してしまう。\u003c/p\u003e\n\u003ch2 id=\"調査\"\u003e調査\u003c/h2\u003e\n\u003ch3 id=\"認識されているカードを確認\"\u003e認識されているカードを確認\u003c/h3\u003e\n\u003cdiv class=\"highlight\"\u003e\u003cpre tabindex=\"0\" style=\"color:#f8f8f2;background-color:#272822;-moz-tab-size:4;-o-tab-size:4;tab-size:4;\"\u003e\u003ccode class=\"language-bash\" data-lang=\"bash\"\u003e\u003cspan style=\"display:flex;\"\u003e\u003cspan\u003epactl list cards short\n\u003c/span\u003e\u003c/span\u003e\u003c/code\u003e\u003c/pre\u003e\u003c/div\u003e\u003cpre tabindex=\"0\"\u003e\u003ccode\u003e49      alsa_card.pci-0000_04_00.1      alsa\n50      alsa_card.pci-0000_04_00.6      alsa\n\u003c/code\u003e\u003c/pre\u003e\u003cp\u003e2枚のサウンドカードが認識されている。詳細を確認する。\u003c/p\u003e\n\u003cdiv class=\"highlight\"\u003e\u003cpre tabindex=\"0\" style=\"color:#f8f8f2;background-color:#272822;-moz-tab-size:4;-o-tab-size:4;tab-size:4;\"\u003e\u003ccode class=\"language-bash\" data-lang=\"bash\"\u003e\u003cspan style=\"display:flex;\"\u003e\u003cspan\u003epactl list cards | grep -A \u003cspan style=\"color:#ae81ff\"\u003e30\u003c/span\u003e \u003cspan style=\"color:#e6db74\"\u003e\u0026#34;alsa_card.pci-0000_04_00\u0026#34;\u003c/span\u003e\n\u003c/span\u003e\u003c/span\u003e\u003c/code\u003e\u003c/pre\u003e\u003c/div\u003e\u003cp\u003e結果を整理すると：\u003c/p\u003e\n\u003ctable\u003e\n  \u003cthead\u003e\n      \u003ctr\u003e\n          \u003cth\u003ePCI アドレス\u003c/th\u003e\n          \u003cth\u003eベンダー\u003c/th\u003e\n          \u003cth\u003e説明\u003c/th\u003e\n          \u003cth\u003e用途\u003c/th\u003e\n      \u003c/tr\u003e\n  \u003c/thead\u003e\n  \u003ctbody\u003e\n      \u003ctr\u003e\n          \u003ctd\u003e\u003ccode\u003e0000:04:00.1\u003c/code\u003e\u003c/td\u003e\n          \u003ctd\u003eAMD/ATI\u003c/td\u003e\n          \u003ctd\u003eRaven HDMI/DP Audio Controller\u003c/td\u003e\n          \u003ctd\u003e\u003cstrong\u003eモニター側（不要）\u003c/strong\u003e\u003c/td\u003e\n      \u003c/tr\u003e\n      \u003ctr\u003e\n          \u003ctd\u003e\u003ccode\u003e0000:04:00.6\u003c/code\u003e\u003c/td\u003e\n          \u003ctd\u003eAMD + Realtek ALC269VB\u003c/td\u003e\n          \u003ctd\u003eRyzen HD Audio Controller\u003c/td\u003e\n          \u003ctd\u003e\u003cstrong\u003e本物のオンボードサウンド\u003c/strong\u003e\u003c/td\u003e\n      \u003c/tr\u003e\n  \u003c/tbody\u003e\n\u003c/table\u003e\n\u003cp\u003e\u003ccode\u003e0000:04:00.1\u003c/code\u003e の \u003ccode\u003ealsa_mixer_name\u003c/code\u003e が \u003ccode\u003eATI R6xx HDMI\u003c/code\u003e であることからも、\nこれがHDMI経由のオーディオデバイスだと確定できる。\u003c/p\u003e","title":"WaylandでモニターがマイクとスピーカーとしてOSに認識される問題をWirePlumberで無効化する"},{"content":"概要 はてな匿名ダイアリーとウーバーイーツをインフラレベルで封鎖したかった。 AdGuard HomeをProxmox LXCに立てて、Tailscale経由でDNSブロックする構成を作った。\n環境 Proxmox VE Tailscale導入済み AdGuard Home v0.108.0 手順 1. AdGuard Home LXCをスクリプト一発で作成 Proxmoxのノードシェルで以下を実行する。\nbash -c \u0026#34;$(curl -fsSL https://raw.githubusercontent.com/community-scripts/ProxmoxVE/main/ct/adguard.sh)\u0026#34; community-scripts/ProxmoxVEが提供するスクリプト。 LXCのコンテナ作成からAdGuard Homeのインストールまで全自動でやってくれる。\nデフォルト構成はDebian 13、CPU 1コア、RAM 512MB、HDD 2GB。DNS用途なら十分。\n2. LXCにTUNデバイスを追加する TailscaleはWireGuardベースのVPNで、動作に/dev/net/tunが必要。\n/dev/net/tunはLinuxの仮想ネットワークデバイス（TUNデバイス）。通常のネットワークデバイス（eth0等）は物理NICに紐づいているが、TUNはソフトウェアで作った仮想NIC。\nTailscaleは以下の流れで通信を処理する。\n通信をTailscaleプロセスが横取り WireGuardで暗号化 暗号化したパケットを相手に送る この「横取り」の実装に/dev/net/tunを使う。TUNデバイスを通してカーネルのネットワークスタックとTailscaleプロセスがやり取りする仕組みになっている。\nunprivileged LXCはセキュリティ上の理由でホストのデバイスに触れないようになっているため、明示的に/dev/net/tunをコンテナに見せてあげる必要がある。\nProxmoxのノードシェルで以下を実行してTUNを有効化する。\npct stop 106 echo \u0026#34;lxc.cgroup2.devices.allow: c 10:200 rwm\u0026#34; \u0026gt;\u0026gt; /etc/pve/lxc/106.conf echo \u0026#34;lxc.mount.entry: /dev/net/tun dev/net/tun none bind,create=file\u0026#34; \u0026gt;\u0026gt; /etc/pve/lxc/106.conf pct start 106 106の部分は自分のLXCのIDに置き換える。IDはpct listで確認できる。\n3. LXCにTailscaleを入れる LXCのシェルに入って以下を実行する。\ncurl -fsSL https://tailscale.com/install.sh | sh tailscale up 認証URLが表示されるのでブラウザで開いてログインする。 認証後、TailscaleのMachines画面からAdGuard HomeのTailscale IPを確認しておく。\n4. TailscaleのDNS設定にAdGuard HomeのIPを追加する Tailscale管理画面のDNS設定を開く。\nNameservers → Add nameserver → Custom でAdGuard HomeのTailscale IPを追加 Override DNS servers をONにする（これをONにしないとAdGuardが優先されない） デフォルトで入っているGoogle Public DNS（8.8.8.8等）は削除する 5. AdGuard Homeの管理画面でブロックルールを追加する http://\u0026lt;AdGuardのTailscale IP\u0026gt;:3000 にアクセスして管理画面を開く。\nFilters → Custom filtering rules に以下の形式でルールを追加する。\n||anond.hatelabo.jp^ ||ubereats.com^ Applyを押せば即時反映される。\n6. フィルタの自動更新 Filters → DNS blocklists の更新頻度はデフォルト24時間。そのままでOK。 AdGuard Home本体のアップデートはUIから手動で行う。\nまとめ Tailscaleネットワーク内からDNSレベルで特定ドメインをブロックできるようになった。 ルーターの設定を触らずに済むのでホームルーター環境でも安心して導入できる。\n","permalink":"https://techblog.wasutech.dev/posts/tailscale-proxmox-adguard/","summary":"\u003ch2 id=\"概要\"\u003e概要\u003c/h2\u003e\n\u003cp\u003eはてな匿名ダイアリーとウーバーイーツをインフラレベルで封鎖したかった。\nAdGuard HomeをProxmox LXCに立てて、Tailscale経由でDNSブロックする構成を作った。\u003c/p\u003e\n\u003ch2 id=\"環境\"\u003e環境\u003c/h2\u003e\n\u003cul\u003e\n\u003cli\u003eProxmox VE\u003c/li\u003e\n\u003cli\u003eTailscale導入済み\u003c/li\u003e\n\u003cli\u003eAdGuard Home v0.108.0\u003c/li\u003e\n\u003c/ul\u003e\n\u003ch2 id=\"手順\"\u003e手順\u003c/h2\u003e\n\u003ch3 id=\"1-adguard-home-lxcをスクリプト一発で作成\"\u003e1. AdGuard Home LXCをスクリプト一発で作成\u003c/h3\u003e\n\u003cp\u003eProxmoxのノードシェルで以下を実行する。\u003c/p\u003e\n\u003cdiv class=\"highlight\"\u003e\u003cpre tabindex=\"0\" style=\"color:#f8f8f2;background-color:#272822;-moz-tab-size:4;-o-tab-size:4;tab-size:4;\"\u003e\u003ccode class=\"language-bash\" data-lang=\"bash\"\u003e\u003cspan style=\"display:flex;\"\u003e\u003cspan\u003ebash -c \u003cspan style=\"color:#e6db74\"\u003e\u0026#34;\u003c/span\u003e\u003cspan style=\"color:#66d9ef\"\u003e$(\u003c/span\u003ecurl -fsSL https://raw.githubusercontent.com/community-scripts/ProxmoxVE/main/ct/adguard.sh\u003cspan style=\"color:#66d9ef\"\u003e)\u003c/span\u003e\u003cspan style=\"color:#e6db74\"\u003e\u0026#34;\u003c/span\u003e\n\u003c/span\u003e\u003c/span\u003e\u003c/code\u003e\u003c/pre\u003e\u003c/div\u003e\u003cp\u003e\u003ca href=\"https://github.com/community-scripts/ProxmoxVE\"\u003ecommunity-scripts/ProxmoxVE\u003c/a\u003eが提供するスクリプト。\nLXCのコンテナ作成からAdGuard Homeのインストールまで全自動でやってくれる。\u003c/p\u003e\n\u003cp\u003eデフォルト構成はDebian 13、CPU 1コア、RAM 512MB、HDD 2GB。DNS用途なら十分。\u003c/p\u003e\n\u003ch3 id=\"2-lxcにtunデバイスを追加する\"\u003e2. LXCにTUNデバイスを追加する\u003c/h3\u003e\n\u003cp\u003eTailscaleはWireGuardベースのVPNで、動作に\u003ccode\u003e/dev/net/tun\u003c/code\u003eが必要。\u003c/p\u003e\n\u003cp\u003e\u003ccode\u003e/dev/net/tun\u003c/code\u003eはLinuxの仮想ネットワークデバイス（TUNデバイス）。通常のネットワークデバイス（eth0等）は物理NICに紐づいているが、TUNはソフトウェアで作った仮想NIC。\u003c/p\u003e\n\u003cp\u003eTailscaleは以下の流れで通信を処理する。\u003c/p\u003e\n\u003col\u003e\n\u003cli\u003e通信をTailscaleプロセスが横取り\u003c/li\u003e\n\u003cli\u003eWireGuardで暗号化\u003c/li\u003e\n\u003cli\u003e暗号化したパケットを相手に送る\u003c/li\u003e\n\u003c/ol\u003e\n\u003cp\u003eこの「横取り」の実装に\u003ccode\u003e/dev/net/tun\u003c/code\u003eを使う。TUNデバイスを通してカーネルのネットワークスタックとTailscaleプロセスがやり取りする仕組みになっている。\u003c/p\u003e\n\u003cp\u003eunprivileged LXCはセキュリティ上の理由でホストのデバイスに触れないようになっているため、明示的に\u003ccode\u003e/dev/net/tun\u003c/code\u003eをコンテナに見せてあげる必要がある。\u003c/p\u003e\n\u003cp\u003eProxmoxのノードシェルで以下を実行してTUNを有効化する。\u003c/p\u003e\n\u003cdiv class=\"highlight\"\u003e\u003cpre tabindex=\"0\" style=\"color:#f8f8f2;background-color:#272822;-moz-tab-size:4;-o-tab-size:4;tab-size:4;\"\u003e\u003ccode class=\"language-bash\" data-lang=\"bash\"\u003e\u003cspan style=\"display:flex;\"\u003e\u003cspan\u003epct stop \u003cspan style=\"color:#ae81ff\"\u003e106\u003c/span\u003e\n\u003c/span\u003e\u003c/span\u003e\u003cspan style=\"display:flex;\"\u003e\u003cspan\u003eecho \u003cspan style=\"color:#e6db74\"\u003e\u0026#34;lxc.cgroup2.devices.allow: c 10:200 rwm\u0026#34;\u003c/span\u003e \u0026gt;\u0026gt; /etc/pve/lxc/106.conf\n\u003c/span\u003e\u003c/span\u003e\u003cspan style=\"display:flex;\"\u003e\u003cspan\u003eecho \u003cspan style=\"color:#e6db74\"\u003e\u0026#34;lxc.mount.entry: /dev/net/tun dev/net/tun none bind,create=file\u0026#34;\u003c/span\u003e \u0026gt;\u0026gt; /etc/pve/lxc/106.conf\n\u003c/span\u003e\u003c/span\u003e\u003cspan style=\"display:flex;\"\u003e\u003cspan\u003epct start \u003cspan style=\"color:#ae81ff\"\u003e106\u003c/span\u003e\n\u003c/span\u003e\u003c/span\u003e\u003c/code\u003e\u003c/pre\u003e\u003c/div\u003e\u003cp\u003e\u003ccode\u003e106\u003c/code\u003eの部分は自分のLXCのIDに置き換える。IDは\u003ccode\u003epct list\u003c/code\u003eで確認できる。\u003c/p\u003e\n\u003ch3 id=\"3-lxcにtailscaleを入れる\"\u003e3. LXCにTailscaleを入れる\u003c/h3\u003e\n\u003cp\u003eLXCのシェルに入って以下を実行する。\u003c/p\u003e\n\u003cdiv class=\"highlight\"\u003e\u003cpre tabindex=\"0\" style=\"color:#f8f8f2;background-color:#272822;-moz-tab-size:4;-o-tab-size:4;tab-size:4;\"\u003e\u003ccode class=\"language-bash\" data-lang=\"bash\"\u003e\u003cspan style=\"display:flex;\"\u003e\u003cspan\u003ecurl -fsSL https://tailscale.com/install.sh | sh\n\u003c/span\u003e\u003c/span\u003e\u003cspan style=\"display:flex;\"\u003e\u003cspan\u003etailscale up\n\u003c/span\u003e\u003c/span\u003e\u003c/code\u003e\u003c/pre\u003e\u003c/div\u003e\u003cp\u003e認証URLが表示されるのでブラウザで開いてログインする。\n認証後、TailscaleのMachines画面からAdGuard HomeのTailscale IPを確認しておく。\u003c/p\u003e","title":"AdGuard HomeをProxmox LXCに立ててTailscale経由でDNSブロックする"},{"content":"背景 手持ちのWalkmanをLinux（Arch Linux）環境で活用したいと考えた。 単に音楽を聴くだけでなく、PCのファイルを転送したり、時にはPCの音を高音質で鳴らすオーディオインターフェースとして使いこなすのが目的だ。\nドキュメントを読む限り、最近のデバイスはMTP（Media Transfer Protocol）に対応しており、Linuxでも標準的なツールで扱えるはずだ。\n環境 OS: Arch Linux File Manager: Thunar Device: Walkman (MTP/USB DAC対応モデル) Tools: usbutils, gvfs-mtp, libmtp, jmtpfs ThunarでWalkmanのFSが見えない WalkmanをUSBケーブルでPCに接続し、Thunarを開いたがサイドバーには何も表示されない。\nまず物理的な接続を確認しようと lsusb を叩いたところ、コマンド自体が入っていなかった。\nsudo pacman -S usbutils 改めて確認する。\n$ lsusb Bus 001 Device 008: ID 054c:0c2f Sony Corp. Walkman デバイス自体はUSBレベルでは認識されている。fdisk -l にブロックデバイスとして出てこないのはMTPなので当然だ。\n原因：MTP用ライブラリが未インストール gvfs-mtp と libmtp が入っていないのが原因だった。\nsudo pacman -S gvfs-mtp libmtp # Thunarを再起動して反映 thunar -q これでThunarのサイドバーにWalkmanが表示され、GUIでファイルをコピーできるようになった。\n補足：USB DACモードとは 調査中に「DACモードでなければ動かないのか？」と気になって調べたのでここにまとめておく。\nUSB DACモードとは、デバイスを「ストレージ」としてではなく、**「USBオーディオデバイス」**としてPCに認識させるモードだ。ファイル転送には使えない。\nモード PCからの見え方 用途 MTP / MSC ストレージ ファイル転送 USB DAC オーディオデバイス PC音声出力 Walkman側の設定でどちらのモードになっているかは確認しておく必要がある。\nGUIで認識しない場合の手動マウント gvfs-mtp を入れてもThunarに出ない場合は jmtpfs で手動マウントできる。\n# jmtpfsをインストール（AUR） yay -S jmtpfs # マウントポイントを作成してマウント mkdir -p ~/mnt/walkman jmtpfs ~/mnt/walkman # アンマウント fusermount -u ~/mnt/walkman まとめ ThunarでMTPデバイスのFSを参照するには gvfs-mtp と libmtp が必要だ。lsusb でデバイスが見えていても、これらがなければファイルマネージャーには出てこない。\n接続が認識されているかどうかの切り分けは以下の順で行うとよい。\nlsusb でUSBレベルの認識を確認（usbutils が必要） Walkman側のUSBモードがファイル転送になっているか確認 gvfs-mtp / libmtp のインストール それでも駄目なら jmtpfs で手動マウント ","permalink":"https://techblog.wasutech.dev/posts/linux-walkman-thunar/","summary":"\u003ch2 id=\"背景\"\u003e背景\u003c/h2\u003e\n\u003cp\u003e手持ちのWalkmanをLinux（Arch Linux）環境で活用したいと考えた。\n単に音楽を聴くだけでなく、PCのファイルを転送したり、時にはPCの音を高音質で鳴らすオーディオインターフェースとして使いこなすのが目的だ。\u003c/p\u003e\n\u003cp\u003eドキュメントを読む限り、最近のデバイスはMTP（Media Transfer Protocol）に対応しており、Linuxでも標準的なツールで扱えるはずだ。\u003c/p\u003e\n\u003ch3 id=\"環境\"\u003e環境\u003c/h3\u003e\n\u003cul\u003e\n\u003cli\u003e\u003cstrong\u003eOS\u003c/strong\u003e: Arch Linux\u003c/li\u003e\n\u003cli\u003e\u003cstrong\u003eFile Manager\u003c/strong\u003e: Thunar\u003c/li\u003e\n\u003cli\u003e\u003cstrong\u003eDevice\u003c/strong\u003e: Walkman (MTP/USB DAC対応モデル)\u003c/li\u003e\n\u003cli\u003e\u003cstrong\u003eTools\u003c/strong\u003e: \u003ccode\u003eusbutils\u003c/code\u003e, \u003ccode\u003egvfs-mtp\u003c/code\u003e, \u003ccode\u003elibmtp\u003c/code\u003e, \u003ccode\u003ejmtpfs\u003c/code\u003e\u003c/li\u003e\n\u003c/ul\u003e\n\u003chr\u003e\n\u003ch2 id=\"thunarでwalkmanのfsが見えない\"\u003eThunarでWalkmanのFSが見えない\u003c/h2\u003e\n\u003cp\u003eWalkmanをUSBケーブルでPCに接続し、Thunarを開いたがサイドバーには何も表示されない。\u003c/p\u003e\n\u003cp\u003eまず物理的な接続を確認しようと \u003ccode\u003elsusb\u003c/code\u003e を叩いたところ、コマンド自体が入っていなかった。\u003c/p\u003e\n\u003cdiv class=\"highlight\"\u003e\u003cpre tabindex=\"0\" style=\"color:#f8f8f2;background-color:#272822;-moz-tab-size:4;-o-tab-size:4;tab-size:4;\"\u003e\u003ccode class=\"language-bash\" data-lang=\"bash\"\u003e\u003cspan style=\"display:flex;\"\u003e\u003cspan\u003esudo pacman -S usbutils\n\u003c/span\u003e\u003c/span\u003e\u003c/code\u003e\u003c/pre\u003e\u003c/div\u003e\u003cp\u003e改めて確認する。\u003c/p\u003e\n\u003cdiv class=\"highlight\"\u003e\u003cpre tabindex=\"0\" style=\"color:#f8f8f2;background-color:#272822;-moz-tab-size:4;-o-tab-size:4;tab-size:4;\"\u003e\u003ccode class=\"language-bash\" data-lang=\"bash\"\u003e\u003cspan style=\"display:flex;\"\u003e\u003cspan\u003e$ lsusb\n\u003c/span\u003e\u003c/span\u003e\u003cspan style=\"display:flex;\"\u003e\u003cspan\u003eBus \u003cspan style=\"color:#ae81ff\"\u003e001\u003c/span\u003e Device 008: ID 054c:0c2f Sony Corp. Walkman\n\u003c/span\u003e\u003c/span\u003e\u003c/code\u003e\u003c/pre\u003e\u003c/div\u003e\u003cp\u003eデバイス自体はUSBレベルでは認識されている。\u003ccode\u003efdisk -l\u003c/code\u003e にブロックデバイスとして出てこないのはMTPなので当然だ。\u003c/p\u003e\n\u003chr\u003e\n\u003ch2 id=\"原因mtp用ライブラリが未インストール\"\u003e原因：MTP用ライブラリが未インストール\u003c/h2\u003e\n\u003cp\u003e\u003ccode\u003egvfs-mtp\u003c/code\u003e と \u003ccode\u003elibmtp\u003c/code\u003e が入っていないのが原因だった。\u003c/p\u003e\n\u003cdiv class=\"highlight\"\u003e\u003cpre tabindex=\"0\" style=\"color:#f8f8f2;background-color:#272822;-moz-tab-size:4;-o-tab-size:4;tab-size:4;\"\u003e\u003ccode class=\"language-bash\" data-lang=\"bash\"\u003e\u003cspan style=\"display:flex;\"\u003e\u003cspan\u003esudo pacman -S gvfs-mtp libmtp\n\u003c/span\u003e\u003c/span\u003e\u003cspan style=\"display:flex;\"\u003e\u003cspan\u003e\n\u003c/span\u003e\u003c/span\u003e\u003cspan style=\"display:flex;\"\u003e\u003cspan\u003e\u003cspan style=\"color:#75715e\"\u003e# Thunarを再起動して反映\u003c/span\u003e\n\u003c/span\u003e\u003c/span\u003e\u003cspan style=\"display:flex;\"\u003e\u003cspan\u003ethunar -q\n\u003c/span\u003e\u003c/span\u003e\u003c/code\u003e\u003c/pre\u003e\u003c/div\u003e\u003cp\u003eこれでThunarのサイドバーにWalkmanが表示され、GUIでファイルをコピーできるようになった。\u003c/p\u003e\n\u003chr\u003e\n\u003ch2 id=\"補足usb-dacモードとは\"\u003e補足：USB DACモードとは\u003c/h2\u003e\n\u003cp\u003e調査中に「DACモードでなければ動かないのか？」と気になって調べたのでここにまとめておく。\u003c/p\u003e\n\u003cp\u003eUSB DACモードとは、デバイスを「ストレージ」としてではなく、**「USBオーディオデバイス」**としてPCに認識させるモードだ。ファイル転送には使えない。\u003c/p\u003e\n\u003ctable\u003e\n  \u003cthead\u003e\n      \u003ctr\u003e\n          \u003cth\u003eモード\u003c/th\u003e\n          \u003cth\u003ePCからの見え方\u003c/th\u003e\n          \u003cth\u003e用途\u003c/th\u003e\n      \u003c/tr\u003e\n  \u003c/thead\u003e\n  \u003ctbody\u003e\n      \u003ctr\u003e\n          \u003ctd\u003eMTP / MSC\u003c/td\u003e\n          \u003ctd\u003eストレージ\u003c/td\u003e\n          \u003ctd\u003eファイル転送\u003c/td\u003e\n      \u003c/tr\u003e\n      \u003ctr\u003e\n          \u003ctd\u003eUSB DAC\u003c/td\u003e\n          \u003ctd\u003eオーディオデバイス\u003c/td\u003e\n          \u003ctd\u003ePC音声出力\u003c/td\u003e\n      \u003c/tr\u003e\n  \u003c/tbody\u003e\n\u003c/table\u003e\n\u003cp\u003eWalkman側の設定でどちらのモードになっているかは確認しておく必要がある。\u003c/p\u003e","title":"ArchLinuxのThunarでWalkmanのFSを開く"},{"content":"AI コーディングエージェントを飼い慣らす：最強の「AGENTS.md」の書き方 近年、Gemini、Claude、Aider といった「自律型 AI コーディングエージェント」が開発現場に浸透しつつあります。彼らは指示一つでファイル構成を理解し、コードを書き、テストを実行して修正まで行います。\nしかし、エージェントを使い始めた多くのエンジニアが直面するのが、**「AI エージェントの暴走」**です。\n型定義を面倒くさがって any を連発する プロジェクト独自のディレクトリ構造を無視して勝手に utils/ を作る vitest --watch などの終了しないコマンドを叩いてフリーズする 指示していないリファクタリングを始めて関係ないファイルを壊す これらの問題を防ぎ、エージェントを「熟練のペアプロ相手」に変える魔法のファイル、それが AGENTS.md です。本記事では、AI エージェントに守らせるべきルールを定義する AGENTS.md の書き方と、その設計思想を徹底解説します。\nなぜ AGENTS.md が必要なのか AI エージェントは、人間が数年かけて培った「プロジェクトの阿吽の呼吸」を知りません。 エージェントに与えられるコンテキスト（ファイル内容や履歴）は有限であり、その中で彼らは「最も確率的に正しそうな推論」を行います。その結果、彼らはしばしば**「最も楽な道（＝技術的負債を生む道）」**を選んでしまいます。\nAGENTS.md をプロジェクトのルートに配置する理由は、**「エージェントの推論空間に強制的な制約（ガードレール）を設けるため」**です。\nなぜその設計にしたか：外部メモリとしての役割 エージェントは毎回ゼロベースで思考するわけではありませんが、多くのファイルを見すぎることで逆に重要なルールを見失う（ロスト・イン・ザ・ミドル現象）ことがあります。AGENTS.md という明確なルールブックを定義し、エージェントの起動時やタスク開始時に必ず読み込ませることで、思考のブレを最小限に抑えることができます。\nAGENTS.md に書くべき内容の 4 つの分類 効果的な AGENTS.md は、以下の 4 つのセクションで構成するのがベストプラティスです。\n禁止事項 (Prohibitions): 致命的なエラーや環境のハングを防ぐ 命名規則・コーディング基準 (Standards): コードの品質を一定に保つ アーキテクチャ原則 (Architecture): システムの整合性を維持する テスト・検証方針 (Testing): 修正の正しさを担保する それぞれのセクションについて、具体的な記述例を見ていきましょう。\n1. 禁止事項 (Prohibitions) エージェントが最もやりがちな「環境破壊」を防ぐための最重要セクションです。\nルール 理由（なぜその設計にするか） インタラクティブコマンドの禁止 npm init や git commit (メッセージ入力待ち) など、ユーザー入力を待つコマンドは CLI エージェントをフリーズさせます。 Watch Mode の禁止 vitest --watch 等はプロセスが終了しないため、エージェントが「完了」を検知できなくなります。 any 型の原則禁止 AI は型の整合性を取るのが面倒になると any で逃げようとします。これは長期的な保守性を著しく低下させます。 勝手な依存関係の追加禁止 package.json を書き換えて新しいライブラリを入れる行為は、セキュリティやビルドサイズに影響するため、人間の許可を必須にします。 実例コード ## 禁止事項 - **コマンド実行**: - `vi`, `nano`, `top` などのインタラクティブなコマンドは実行しない。 - `npm start`, `vitest --watch` などの終了しないプロセスは背景実行 (`\u0026amp;`) するか、単発実行モードを使用すること。 - **TypeScript**: - `any` 型の使用は厳禁。どうしても必要な場合はコメントで理由を明記すること。 - **Git**: - ユーザーの明示的な指示なしに `git commit` や `git push` を行わない。 2. 命名規則・コーディング基準 (Standards) プロジェクトの「見た目」と「一貫性」を守るためのルールです。ここを疎かにすると、エージェントは自分の得意なスタイル（多くの場合、学習データで最も多いスタイル）で書き始めてしまいます。\nなぜその設計にしたか：レビューコストの削減 人間が後でコードを読んだときに「あ、ここは AI が書いたな」と分かってしまうような不一致を減らすためです。一貫性のあるコードは、次回の AI による修正の精度も高めます。\n実例コード ## 命名規則とスタイル - **ファイル名**: `kebab-case` を使用する (例: `user-repository.ts`)。 - **React コンポーネント**: - 関数コンポーネントのみを使用し、`export const ComponentName: React.FC = ...` の形式で定義する。 - スタイルは CSS Modules ではなく、同じディレクトリの `styles.css` に記述する。 - **非同期処理**: `callback` は禁止。常に `async/await` を使用すること。 3. アーキテクチャ原則 (Architecture) エージェントはしばしば、既存のパターンを無視して最短距離で機能を実装しようとします。これを防ぐために、フォルダ構成や依存の方向を明示します。\n実例コード ## アーキテクチャ原則 - **ディレクトリ構造**: - `packages/core/src/domain`: ビジネスロジックとインターフェースのみ。 - `packages/core/src/port`: 外部通信のポート定義。 - `apps/client/src/adapters`: 具体的な実装 (LocalStorage, API Client)。 - **依存ルール**: `domain` は他のどのパッケージにも依存してはならない。 - **モジュール化**: 1 ファイルは原則 200 行以内とする。超える場合は機能単位で分割すること。 4. テスト・検証方針 (Testing) エージェントに「コードを書いて終わり」にさせないためのルールです。\nなぜその設計にしたか：デグレードの防止 自律型エージェントは、A を直して B を壊すことがよくあります。変更のライフサイクル（修正 → 再現テスト作成 → 修正 → 全テストパス）をルール化することで、品質を自動的に担保させます。\n実例コード ## テストと検証のワークフロー 1. **バグ修正の場合**: - まず、バグが再現する失敗テストコードを作成し、実行して失敗を確認すること。 - 修正後、そのテストがパスすることを確認すること。 2. **機能追加の場合**: - 新規機能に対応するユニットテストを `tests/` ディレクトリに作成すること。 3. **最終確認**: - すべての修正が終わったら、必ず `npm run test:all` を実行し、既存機能に影響がないか確認すること。 運用上の注意：AGENTS.md を「腐らせない」ために せっかく書いた AGENTS.md も、エージェントが読まなければ意味がありません。また、プロジェクトの成長に合わせて更新し続ける必要があります。\n1. エージェントのプロンプトに組み込む エージェントを起動する際のエイリアスや設定ファイルに、AGENTS.md を最初に読み、そのルールを絶対厳守せよ という命令を含めてください。\n2. 表形式と箇条書きを多用する LLM は構造化されたデータを好みます。単なる文章よりも、表形式（Markdown Table）やチェックリスト形式の方が、ルールの重みを正しく認識しやすい傾向にあります。\n3. 「なぜ」よりも「何を」を強調する 人間向けには理由（Why）が重要ですが、エージェントには具体的な行動（What/How）を指示する方が効果的です。\n❌ 「保守性が下がるので any は避けてください」 ✅ 「any の使用を禁止します。発見した場合は Unknown または適切な Interface に置き換えてください」 まとめ AI コーディングエージェントは、強力なツールであると同時に、制御を誤ればコードベースを混乱させる「諸刃の剣」でもあります。\nAGENTS.md は、エージェントに対する**「プロジェクト憲法」**です。\n禁止事項で事故を防ぎ 基準で品質を保ち 原則で構造を守り 検証ルールで正しさを証明する このファイルを用意するだけで、エージェントの出力クオリティは劇的に向上し、あなたは「AI が生成したゴミの掃除」から解放されるはずです。今日からあなたのプロジェクトにも、一冊のルールブックを添えてみませんか？\n","permalink":"https://techblog.wasutech.dev/posts/write-agents-rules/","summary":"\u003ch1 id=\"ai-コーディングエージェントを飼い慣らす最強のagentsmdの書き方\"\u003eAI コーディングエージェントを飼い慣らす：最強の「AGENTS.md」の書き方\u003c/h1\u003e\n\u003cp\u003e近年、Gemini、Claude、Aider といった「自律型 AI コーディングエージェント」が開発現場に浸透しつつあります。彼らは指示一つでファイル構成を理解し、コードを書き、テストを実行して修正まで行います。\u003c/p\u003e\n\u003cp\u003eしかし、エージェントを使い始めた多くのエンジニアが直面するのが、**「AI エージェントの暴走」**です。\u003c/p\u003e\n\u003cul\u003e\n\u003cli\u003e型定義を面倒くさがって \u003ccode\u003eany\u003c/code\u003e を連発する\u003c/li\u003e\n\u003cli\u003eプロジェクト独自のディレクトリ構造を無視して勝手に \u003ccode\u003eutils/\u003c/code\u003e を作る\u003c/li\u003e\n\u003cli\u003e\u003ccode\u003evitest --watch\u003c/code\u003e などの終了しないコマンドを叩いてフリーズする\u003c/li\u003e\n\u003cli\u003e指示していないリファクタリングを始めて関係ないファイルを壊す\u003c/li\u003e\n\u003c/ul\u003e\n\u003cp\u003eこれらの問題を防ぎ、エージェントを「熟練のペアプロ相手」に変える魔法のファイル、それが \u003cstrong\u003e\u003ccode\u003eAGENTS.md\u003c/code\u003e\u003c/strong\u003e です。本記事では、AI エージェントに守らせるべきルールを定義する \u003ccode\u003eAGENTS.md\u003c/code\u003e の書き方と、その設計思想を徹底解説します。\u003c/p\u003e\n\u003chr\u003e\n\u003ch2 id=\"なぜ-agentsmd-が必要なのか\"\u003eなぜ \u003ccode\u003eAGENTS.md\u003c/code\u003e が必要なのか\u003c/h2\u003e\n\u003cp\u003eAI エージェントは、人間が数年かけて培った「プロジェクトの阿吽の呼吸」を知りません。\nエージェントに与えられるコンテキスト（ファイル内容や履歴）は有限であり、その中で彼らは「最も確率的に正しそうな推論」を行います。その結果、彼らはしばしば**「最も楽な道（＝技術的負債を生む道）」**を選んでしまいます。\u003c/p\u003e\n\u003cp\u003e\u003ccode\u003eAGENTS.md\u003c/code\u003e をプロジェクトのルートに配置する理由は、**「エージェントの推論空間に強制的な制約（ガードレール）を設けるため」**です。\u003c/p\u003e\n\u003ch3 id=\"なぜその設計にしたか外部メモリとしての役割\"\u003eなぜその設計にしたか：外部メモリとしての役割\u003c/h3\u003e\n\u003cp\u003eエージェントは毎回ゼロベースで思考するわけではありませんが、多くのファイルを見すぎることで逆に重要なルールを見失う（ロスト・イン・ザ・ミドル現象）ことがあります。\u003ccode\u003eAGENTS.md\u003c/code\u003e という明確なルールブックを定義し、エージェントの起動時やタスク開始時に必ず読み込ませることで、思考のブレを最小限に抑えることができます。\u003c/p\u003e\n\u003chr\u003e\n\u003ch2 id=\"agentsmd-に書くべき内容の-4-つの分類\"\u003e\u003ccode\u003eAGENTS.md\u003c/code\u003e に書くべき内容の 4 つの分類\u003c/h2\u003e\n\u003cp\u003e効果的な \u003ccode\u003eAGENTS.md\u003c/code\u003e は、以下の 4 つのセクションで構成するのがベストプラティスです。\u003c/p\u003e\n\u003col\u003e\n\u003cli\u003e\u003cstrong\u003e禁止事項 (Prohibitions):\u003c/strong\u003e 致命的なエラーや環境のハングを防ぐ\u003c/li\u003e\n\u003cli\u003e\u003cstrong\u003e命名規則・コーディング基準 (Standards):\u003c/strong\u003e コードの品質を一定に保つ\u003c/li\u003e\n\u003cli\u003e\u003cstrong\u003eアーキテクチャ原則 (Architecture):\u003c/strong\u003e システムの整合性を維持する\u003c/li\u003e\n\u003cli\u003e\u003cstrong\u003eテスト・検証方針 (Testing):\u003c/strong\u003e 修正の正しさを担保する\u003c/li\u003e\n\u003c/ol\u003e\n\u003cp\u003eそれぞれのセクションについて、具体的な記述例を見ていきましょう。\u003c/p\u003e\n\u003chr\u003e\n\u003ch2 id=\"1-禁止事項-prohibitions\"\u003e1. 禁止事項 (Prohibitions)\u003c/h2\u003e\n\u003cp\u003eエージェントが最もやりがちな「環境破壊」を防ぐための最重要セクションです。\u003c/p\u003e\n\u003ctable\u003e\n  \u003cthead\u003e\n      \u003ctr\u003e\n          \u003cth style=\"text-align: left\"\u003eルール\u003c/th\u003e\n          \u003cth style=\"text-align: left\"\u003e理由（なぜその設計にするか）\u003c/th\u003e\n      \u003c/tr\u003e\n  \u003c/thead\u003e\n  \u003ctbody\u003e\n      \u003ctr\u003e\n          \u003ctd style=\"text-align: left\"\u003e\u003cstrong\u003eインタラクティブコマンドの禁止\u003c/strong\u003e\u003c/td\u003e\n          \u003ctd style=\"text-align: left\"\u003e\u003ccode\u003enpm init\u003c/code\u003e や \u003ccode\u003egit commit\u003c/code\u003e (メッセージ入力待ち) など、ユーザー入力を待つコマンドは CLI エージェントをフリーズさせます。\u003c/td\u003e\n      \u003c/tr\u003e\n      \u003ctr\u003e\n          \u003ctd style=\"text-align: left\"\u003e\u003cstrong\u003eWatch Mode の禁止\u003c/strong\u003e\u003c/td\u003e\n          \u003ctd style=\"text-align: left\"\u003e\u003ccode\u003evitest --watch\u003c/code\u003e 等はプロセスが終了しないため、エージェントが「完了」を検知できなくなります。\u003c/td\u003e\n      \u003c/tr\u003e\n      \u003ctr\u003e\n          \u003ctd style=\"text-align: left\"\u003e\u003cstrong\u003e\u003ccode\u003eany\u003c/code\u003e 型の原則禁止\u003c/strong\u003e\u003c/td\u003e\n          \u003ctd style=\"text-align: left\"\u003eAI は型の整合性を取るのが面倒になると \u003ccode\u003eany\u003c/code\u003e で逃げようとします。これは長期的な保守性を著しく低下させます。\u003c/td\u003e\n      \u003c/tr\u003e\n      \u003ctr\u003e\n          \u003ctd style=\"text-align: left\"\u003e\u003cstrong\u003e勝手な依存関係の追加禁止\u003c/strong\u003e\u003c/td\u003e\n          \u003ctd style=\"text-align: left\"\u003e\u003ccode\u003epackage.json\u003c/code\u003e を書き換えて新しいライブラリを入れる行為は、セキュリティやビルドサイズに影響するため、人間の許可を必須にします。\u003c/td\u003e\n      \u003c/tr\u003e\n  \u003c/tbody\u003e\n\u003c/table\u003e\n\u003ch3 id=\"実例コード\"\u003e実例コード\u003c/h3\u003e\n\u003cdiv class=\"highlight\"\u003e\u003cpre tabindex=\"0\" style=\"color:#f8f8f2;background-color:#272822;-moz-tab-size:4;-o-tab-size:4;tab-size:4;\"\u003e\u003ccode class=\"language-markdown\" data-lang=\"markdown\"\u003e\u003cspan style=\"display:flex;\"\u003e\u003cspan\u003e\u003cspan style=\"color:#75715e\"\u003e## 禁止事項\n\u003c/span\u003e\u003c/span\u003e\u003c/span\u003e\u003cspan style=\"display:flex;\"\u003e\u003cspan\u003e\u003cspan style=\"color:#75715e\"\u003e\u003c/span\u003e\u003cspan style=\"color:#66d9ef\"\u003e-\u003c/span\u003e **コマンド実行**:\n\u003c/span\u003e\u003c/span\u003e\u003cspan style=\"display:flex;\"\u003e\u003cspan\u003e  \u003cspan style=\"color:#66d9ef\"\u003e-\u003c/span\u003e \u003cspan style=\"color:#e6db74\"\u003e`vi`\u003c/span\u003e, \u003cspan style=\"color:#e6db74\"\u003e`nano`\u003c/span\u003e, \u003cspan style=\"color:#e6db74\"\u003e`top`\u003c/span\u003e などのインタラクティブなコマンドは実行しない。\n\u003c/span\u003e\u003c/span\u003e\u003cspan style=\"display:flex;\"\u003e\u003cspan\u003e  \u003cspan style=\"color:#66d9ef\"\u003e-\u003c/span\u003e \u003cspan style=\"color:#e6db74\"\u003e`npm start`\u003c/span\u003e, \u003cspan style=\"color:#e6db74\"\u003e`vitest --watch`\u003c/span\u003e などの終了しないプロセスは背景実行 (\u003cspan style=\"color:#e6db74\"\u003e`\u0026amp;`\u003c/span\u003e) するか、単発実行モードを使用すること。\n\u003c/span\u003e\u003c/span\u003e\u003cspan style=\"display:flex;\"\u003e\u003cspan\u003e\u003cspan style=\"color:#66d9ef\"\u003e-\u003c/span\u003e **TypeScript**:\n\u003c/span\u003e\u003c/span\u003e\u003cspan style=\"display:flex;\"\u003e\u003cspan\u003e  \u003cspan style=\"color:#66d9ef\"\u003e-\u003c/span\u003e \u003cspan style=\"color:#e6db74\"\u003e`any`\u003c/span\u003e 型の使用は厳禁。どうしても必要な場合はコメントで理由を明記すること。\n\u003c/span\u003e\u003c/span\u003e\u003cspan style=\"display:flex;\"\u003e\u003cspan\u003e\u003cspan style=\"color:#66d9ef\"\u003e-\u003c/span\u003e **Git**:\n\u003c/span\u003e\u003c/span\u003e\u003cspan style=\"display:flex;\"\u003e\u003cspan\u003e  \u003cspan style=\"color:#66d9ef\"\u003e-\u003c/span\u003e ユーザーの明示的な指示なしに \u003cspan style=\"color:#e6db74\"\u003e`git commit`\u003c/span\u003e や \u003cspan style=\"color:#e6db74\"\u003e`git push`\u003c/span\u003e を行わない。\n\u003c/span\u003e\u003c/span\u003e\u003c/code\u003e\u003c/pre\u003e\u003c/div\u003e\u003chr\u003e\n\u003ch2 id=\"2-命名規則コーディング基準-standards\"\u003e2. 命名規則・コーディング基準 (Standards)\u003c/h2\u003e\n\u003cp\u003eプロジェクトの「見た目」と「一貫性」を守るためのルールです。ここを疎かにすると、エージェントは自分の得意なスタイル（多くの場合、学習データで最も多いスタイル）で書き始めてしまいます。\u003c/p\u003e","title":"AI コーディングエージェントを飼い慣らす：最強の「AGENTS.md」の書き方"},{"content":"Canvas 2D API でピクセルアートタイルを「手書き」する実装テクニック 1. 概要 Web ゲーム開発、特にローグライクやタクティカル RPG を開発する際、避けて通れないのが「マップの描画」だ。通常、これらは「タイルセット」と呼ばれる画像ファイルを読み込んで描画するが、開発の初期段階や、あえて外部アセットに頼りたくない場合、Canvas 2D API を使ってプログラムで直接タイルを描画する「手書き（プログラマティック描画）」の手法が非常に強力な武器になる。\n本記事では、HTML5 Canvas の fillRect や beginPath などの基本命令のみを使い、擬似的なピクセルアート風のタイルを描画するテクニックを解説する。画像を用意する手間を省きつつ、動的に色や形状を変更できる柔軟な描画システムを構築しよう。\n2. タイル描画の基本構造 まずは、どのようなタイルでも共通して利用できる描画の入り口を作る。ピクセル座標ではなく、タイル座標（x, y）とタイルサイズ（tileSize）を受け取る設計にすることで、グリッドベースのシステムと統合しやすくなる。\n/** * タイルを描画するメイン関数 * @param {CanvasRenderingContext2D} ctx - Canvasコンテキスト * @param {string} tileType - タイルの種類 (\u0026#39;wall\u0026#39;, \u0026#39;floor\u0026#39;, \u0026#39;grass\u0026#39;, etc.) * @param {number} x - タイルのX座標（グリッド単位） * @param {number} y - タイルのY座標（グリッド単位） * @param {number} tileSize - 1タイルのピクセルサイズ * @param {string} fieldType - フィールドの種類 (\u0026#39;meadow\u0026#39;, \u0026#39;forest\u0026#39;, \u0026#39;mountain\u0026#39;) */ function drawTile(ctx, tileType, x, y, tileSize, fieldType = \u0026#39;meadow\u0026#39;) { const px = x * tileSize; const py = y * tileSize; ctx.save(); ctx.translate(px, py); switch (tileType) { case \u0026#39;floor\u0026#39;: drawFloor(ctx, tileSize, fieldType); break; case \u0026#39;wall\u0026#39;: drawWall(ctx, tileSize, fieldType); break; case \u0026#39;object_grass\u0026#39;: drawFloor(ctx, tileSize, fieldType); drawGrass(ctx, tileSize); break; case \u0026#39;object_tree\u0026#39;: drawFloor(ctx, tileSize, fieldType); drawTree(ctx, tileSize); break; case \u0026#39;stairs_down\u0026#39;: drawFloor(ctx, tileSize, fieldType); drawStairs(ctx, tileSize, false); break; default: ctx.fillStyle = \u0026#39;#333\u0026#39;; ctx.fillRect(0, 0, tileSize, tileSize); } ctx.restore(); } この設計のポイントは、ctx.translate を使ってタイルの左上を原点 (0, 0) に固定することだ。これにより、各タイルの描画ロジック内で座標計算を簡略化できる。\n3. タイルタイプ別描画の実装 3.1 床（Floor）とフィールドタイプによる変化 床は最も描画回数が多いタイルだ。単色で塗るのではなく、フィールドのタイプによってベースカラーを変え、わずかなノイズ（ドット）を加えることで「ピクセルアート感」を出す。\nfunction drawFloor(ctx, tileSize, fieldType) { let baseColor, noiseColor; switch (fieldType) { case \u0026#39;forest\u0026#39;: baseColor = \u0026#39;#2d4c1e\u0026#39;; noiseColor = \u0026#39;#243b18\u0026#39;; break; case \u0026#39;mountain\u0026#39;: baseColor = \u0026#39;#5a5a5a\u0026#39;; noiseColor = \u0026#39;#4a4a4a\u0026#39;; break; case \u0026#39;meadow\u0026#39;: default: baseColor = \u0026#39;#4a773c\u0026#39;; noiseColor = \u0026#39;#3d6231\u0026#39;; } ctx.fillStyle = baseColor; ctx.fillRect(0, 0, tileSize, tileSize); ctx.fillStyle = noiseColor; const dotSize = tileSize / 8; ctx.fillRect(dotSize * 2, dotSize * 1, dotSize, dotSize); ctx.fillRect(dotSize * 5, dotSize * 4, dotSize, dotSize); ctx.fillRect(dotSize * 1, dotSize * 6, dotSize, dotSize); } 3.2 壁（Wall） 壁は奥行きを感じさせることが重要だ。上面と前面で色を変え、ハイライトを入れることで立体感を演出する。\nfunction drawWall(ctx, tileSize, fieldType) { const topColor = fieldType === \u0026#39;mountain\u0026#39; ? \u0026#39;#888\u0026#39; : \u0026#39;#5d4037\u0026#39;; const sideColor = fieldType === \u0026#39;mountain\u0026#39; ? \u0026#39;#555\u0026#39; : \u0026#39;#3e2723\u0026#39;; const highlight = \u0026#39;#ffffff33\u0026#39;; ctx.fillStyle = sideColor; ctx.fillRect(0, 0, tileSize, tileSize); ctx.fillStyle = topColor; ctx.fillRect(0, 0, tileSize, tileSize * 0.8); ctx.fillStyle = highlight; ctx.fillRect(0, 0, tileSize, 2); ctx.fillRect(0, 0, 2, tileSize * 0.8); ctx.fillStyle = \u0026#39;#00000022\u0026#39;; ctx.fillRect(tileSize * 0.5, tileSize * 0.2, 2, tileSize * 0.4); ctx.fillRect(0, tileSize * 0.5, tileSize, 2); } 3.3 オブジェクト：木（Tree）と草（Grass） オブジェクトは beginPath を活用して形状を作る。\nfunction drawTree(ctx, tileSize) { const unit = tileSize / 10; ctx.fillStyle = \u0026#39;#4e342e\u0026#39;; ctx.fillRect(unit * 4, unit * 6, unit * 2, unit * 4); ctx.fillStyle = \u0026#39;#2e7d32\u0026#39;; ctx.beginPath(); ctx.moveTo(unit * 1, unit * 7); ctx.lineTo(unit * 9, unit * 7); ctx.lineTo(unit * 5, unit * 3); ctx.fill(); ctx.fillStyle = \u0026#39;#388e3c\u0026#39;; ctx.beginPath(); ctx.moveTo(unit * 2, unit * 4); ctx.lineTo(unit * 8, unit * 4); ctx.lineTo(unit * 5, unit * 1); ctx.fill(); } function drawGrass(ctx, tileSize) { const unit = tileSize / 8; ctx.strokeStyle = \u0026#39;#8bc34a\u0026#39;; ctx.lineWidth = 2; ctx.beginPath(); ctx.moveTo(unit * 2, unit * 7); ctx.lineTo(unit * 1, unit * 4); ctx.moveTo(unit * 4, unit * 7); ctx.lineTo(unit * 4, unit * 3); ctx.moveTo(unit * 6, unit * 7); ctx.lineTo(unit * 7, unit * 4); ctx.stroke(); } 3.4 階段（Stairs） 階段はローグライクにおける重要な要素だ。コントラストを強めにして、プレイヤーがすぐに見つけられるようにする。\nfunction drawStairs(ctx, tileSize, isUp = false) { const stepCount = 4; const stepHeight = tileSize / stepCount; for (let i = 0; i \u0026lt; stepCount; i++) { const shade = 150 - (i * 20); ctx.fillStyle = `rgb(${shade}, ${shade}, ${shade})`; if (isUp) { ctx.fillRect(0, tileSize - (i + 1) * stepHeight, tileSize, stepHeight); } else { ctx.fillRect(i * (tileSize / stepCount / 2), i * stepHeight, tileSize - i * (tileSize / stepCount), stepHeight); } } } 4. フォールバック設計としての活用 なぜ画像を使わずにこのような手間をかけるのか。その最大の理由は「開発効率」と「柔軟性」だ。\nプロトタイピングの高速化: デザイナーがアセットを完成させるのを待つ必要がない。 動的なバリエーション: fieldType を変えるだけで、草原、砂漠、雪原などのバリエーションを無限に作れる。 アセット未発見時の回避策: ロードエラーが発生した際や、特定のタイル画像がまだ存在しない場合のフォールバックとして使える。 function renderMap(mapData, assets) { mapData.forEach((tile) =\u0026gt; { if (assets[tile.type]) { ctx.drawImage(assets[tile.type], tile.x * TILE_SIZE, tile.y * TILE_SIZE); } else { drawTile(ctx, tile.type, tile.x, tile.y, TILE_SIZE, mapData.environment); } }); } 5. 画像（PNG）との使い分け 特徴 Canvas 手書き 画像（PNG/Sprite） 制作コスト 低（コードのみ） 高（ペイントソフトが必要） 表現力 限定的（幾何学的） 無限（ディテールが凝れる） カスタマイズ 容易（変数を変えるだけ） 困難（色違い画像を量産） パフォーマンス 計算量に依存 転送量に依存 結論として、「ベースの地面や壁は Canvas で動的に描き、キャラや重要なボス、凝ったエフェクトには画像を使う」 というハイブリッドな構成が、インディーゲーム開発においては非常にバランスが良い選択だ。\n6. save/restore と translate を理解する 記事を読んで気になった点をまとめておく。\ntranslate はなぜ便利なのか 最初、「なんで元々原点を(0,0)に固定しとらんねん」と思った。\n答えはグリッド座標からピクセル座標への変換を1回で済ませるためだ。translate しないと各 fillRect の中で毎回 x * tileSize + ... って計算しないといけない。translate で原点をずらしておけば、あとは (0, 0) 基準で描くだけでいい。\nsave/restore は中間地点とロールバック save と restore はセットで使う。\nsave → 今の状態をスタックに積む（中間地点を記録） restore → 積んだ状態に戻す（ロールバック） ctx.translate(0, 0); // 原点はここ ctx.save(); // この状態を保存 ctx.translate(100, 100); // 原点を移動して描画 // ... ctx.restore(); // saveした時点に戻る → 原点は(0,0)に戻る restore だけじゃ「どこに戻るか」がわからないので save が必要だ。タイル1個描くたびに座標をリセットできる仕組みで、CSS の transform と同じ発想。\n7. まとめ Canvas 2D API を使ったタイルの手書き描画は、一見すると地味な作業だが、マスターすればゲーム開発の自由度が飛躍的に向上する。\nfillRect で基本的な色面を作る translate と save/restore で座標系を整理する フィールドタイプごとに色定数を切り替える わずかなハイライトとシャドウで立体感を出す これらのテクニックを組み合わせることで、画像アセットが一切なくても、十分に「ゲームらしい」画面を作り上げることが可能だ。\n","permalink":"https://techblog.wasutech.dev/posts/canvas-pixel-tile/","summary":"\u003ch1 id=\"canvas-2d-api-でピクセルアートタイルを手書きする実装テクニック\"\u003eCanvas 2D API でピクセルアートタイルを「手書き」する実装テクニック\u003c/h1\u003e\n\u003ch2 id=\"1-概要\"\u003e1. 概要\u003c/h2\u003e\n\u003cp\u003eWeb ゲーム開発、特にローグライクやタクティカル RPG を開発する際、避けて通れないのが「マップの描画」だ。通常、これらは「タイルセット」と呼ばれる画像ファイルを読み込んで描画するが、開発の初期段階や、あえて外部アセットに頼りたくない場合、Canvas 2D API を使ってプログラムで直接タイルを描画する「手書き（プログラマティック描画）」の手法が非常に強力な武器になる。\u003c/p\u003e\n\u003cp\u003e本記事では、HTML5 Canvas の \u003ccode\u003efillRect\u003c/code\u003e や \u003ccode\u003ebeginPath\u003c/code\u003e などの基本命令のみを使い、擬似的なピクセルアート風のタイルを描画するテクニックを解説する。画像を用意する手間を省きつつ、動的に色や形状を変更できる柔軟な描画システムを構築しよう。\u003c/p\u003e\n\u003chr\u003e\n\u003ch2 id=\"2-タイル描画の基本構造\"\u003e2. タイル描画の基本構造\u003c/h2\u003e\n\u003cp\u003eまずは、どのようなタイルでも共通して利用できる描画の入り口を作る。ピクセル座標ではなく、タイル座標（x, y）とタイルサイズ（tileSize）を受け取る設計にすることで、グリッドベースのシステムと統合しやすくなる。\u003c/p\u003e\n\u003cdiv class=\"highlight\"\u003e\u003cpre tabindex=\"0\" style=\"color:#f8f8f2;background-color:#272822;-moz-tab-size:4;-o-tab-size:4;tab-size:4;\"\u003e\u003ccode class=\"language-javascript\" data-lang=\"javascript\"\u003e\u003cspan style=\"display:flex;\"\u003e\u003cspan\u003e\u003cspan style=\"color:#75715e\"\u003e/**\n\u003c/span\u003e\u003c/span\u003e\u003c/span\u003e\u003cspan style=\"display:flex;\"\u003e\u003cspan\u003e\u003cspan style=\"color:#75715e\"\u003e * タイルを描画するメイン関数\n\u003c/span\u003e\u003c/span\u003e\u003c/span\u003e\u003cspan style=\"display:flex;\"\u003e\u003cspan\u003e\u003cspan style=\"color:#75715e\"\u003e * @param {CanvasRenderingContext2D} ctx - Canvasコンテキスト\n\u003c/span\u003e\u003c/span\u003e\u003c/span\u003e\u003cspan style=\"display:flex;\"\u003e\u003cspan\u003e\u003cspan style=\"color:#75715e\"\u003e * @param {string} tileType - タイルの種類 (\u0026#39;wall\u0026#39;, \u0026#39;floor\u0026#39;, \u0026#39;grass\u0026#39;, etc.)\n\u003c/span\u003e\u003c/span\u003e\u003c/span\u003e\u003cspan style=\"display:flex;\"\u003e\u003cspan\u003e\u003cspan style=\"color:#75715e\"\u003e * @param {number} x - タイルのX座標（グリッド単位）\n\u003c/span\u003e\u003c/span\u003e\u003c/span\u003e\u003cspan style=\"display:flex;\"\u003e\u003cspan\u003e\u003cspan style=\"color:#75715e\"\u003e * @param {number} y - タイルのY座標（グリッド単位）\n\u003c/span\u003e\u003c/span\u003e\u003c/span\u003e\u003cspan style=\"display:flex;\"\u003e\u003cspan\u003e\u003cspan style=\"color:#75715e\"\u003e * @param {number} tileSize - 1タイルのピクセルサイズ\n\u003c/span\u003e\u003c/span\u003e\u003c/span\u003e\u003cspan style=\"display:flex;\"\u003e\u003cspan\u003e\u003cspan style=\"color:#75715e\"\u003e * @param {string} fieldType - フィールドの種類 (\u0026#39;meadow\u0026#39;, \u0026#39;forest\u0026#39;, \u0026#39;mountain\u0026#39;)\n\u003c/span\u003e\u003c/span\u003e\u003c/span\u003e\u003cspan style=\"display:flex;\"\u003e\u003cspan\u003e\u003cspan style=\"color:#75715e\"\u003e */\u003c/span\u003e\n\u003c/span\u003e\u003c/span\u003e\u003cspan style=\"display:flex;\"\u003e\u003cspan\u003e\u003cspan style=\"color:#66d9ef\"\u003efunction\u003c/span\u003e \u003cspan style=\"color:#a6e22e\"\u003edrawTile\u003c/span\u003e(\u003cspan style=\"color:#a6e22e\"\u003ectx\u003c/span\u003e, \u003cspan style=\"color:#a6e22e\"\u003etileType\u003c/span\u003e, \u003cspan style=\"color:#a6e22e\"\u003ex\u003c/span\u003e, \u003cspan style=\"color:#a6e22e\"\u003ey\u003c/span\u003e, \u003cspan style=\"color:#a6e22e\"\u003etileSize\u003c/span\u003e, \u003cspan style=\"color:#a6e22e\"\u003efieldType\u003c/span\u003e \u003cspan style=\"color:#f92672\"\u003e=\u003c/span\u003e \u003cspan style=\"color:#e6db74\"\u003e\u0026#39;meadow\u0026#39;\u003c/span\u003e) {\n\u003c/span\u003e\u003c/span\u003e\u003cspan style=\"display:flex;\"\u003e\u003cspan\u003e    \u003cspan style=\"color:#66d9ef\"\u003econst\u003c/span\u003e \u003cspan style=\"color:#a6e22e\"\u003epx\u003c/span\u003e \u003cspan style=\"color:#f92672\"\u003e=\u003c/span\u003e \u003cspan style=\"color:#a6e22e\"\u003ex\u003c/span\u003e \u003cspan style=\"color:#f92672\"\u003e*\u003c/span\u003e \u003cspan style=\"color:#a6e22e\"\u003etileSize\u003c/span\u003e;\n\u003c/span\u003e\u003c/span\u003e\u003cspan style=\"display:flex;\"\u003e\u003cspan\u003e    \u003cspan style=\"color:#66d9ef\"\u003econst\u003c/span\u003e \u003cspan style=\"color:#a6e22e\"\u003epy\u003c/span\u003e \u003cspan style=\"color:#f92672\"\u003e=\u003c/span\u003e \u003cspan style=\"color:#a6e22e\"\u003ey\u003c/span\u003e \u003cspan style=\"color:#f92672\"\u003e*\u003c/span\u003e \u003cspan style=\"color:#a6e22e\"\u003etileSize\u003c/span\u003e;\n\u003c/span\u003e\u003c/span\u003e\u003cspan style=\"display:flex;\"\u003e\u003cspan\u003e\n\u003c/span\u003e\u003c/span\u003e\u003cspan style=\"display:flex;\"\u003e\u003cspan\u003e    \u003cspan style=\"color:#a6e22e\"\u003ectx\u003c/span\u003e.\u003cspan style=\"color:#a6e22e\"\u003esave\u003c/span\u003e();\n\u003c/span\u003e\u003c/span\u003e\u003cspan style=\"display:flex;\"\u003e\u003cspan\u003e    \u003cspan style=\"color:#a6e22e\"\u003ectx\u003c/span\u003e.\u003cspan style=\"color:#a6e22e\"\u003etranslate\u003c/span\u003e(\u003cspan style=\"color:#a6e22e\"\u003epx\u003c/span\u003e, \u003cspan style=\"color:#a6e22e\"\u003epy\u003c/span\u003e);\n\u003c/span\u003e\u003c/span\u003e\u003cspan style=\"display:flex;\"\u003e\u003cspan\u003e\n\u003c/span\u003e\u003c/span\u003e\u003cspan style=\"display:flex;\"\u003e\u003cspan\u003e    \u003cspan style=\"color:#66d9ef\"\u003eswitch\u003c/span\u003e (\u003cspan style=\"color:#a6e22e\"\u003etileType\u003c/span\u003e) {\n\u003c/span\u003e\u003c/span\u003e\u003cspan style=\"display:flex;\"\u003e\u003cspan\u003e        \u003cspan style=\"color:#66d9ef\"\u003ecase\u003c/span\u003e \u003cspan style=\"color:#e6db74\"\u003e\u0026#39;floor\u0026#39;\u003c/span\u003e\u003cspan style=\"color:#f92672\"\u003e:\u003c/span\u003e\n\u003c/span\u003e\u003c/span\u003e\u003cspan style=\"display:flex;\"\u003e\u003cspan\u003e            \u003cspan style=\"color:#a6e22e\"\u003edrawFloor\u003c/span\u003e(\u003cspan style=\"color:#a6e22e\"\u003ectx\u003c/span\u003e, \u003cspan style=\"color:#a6e22e\"\u003etileSize\u003c/span\u003e, \u003cspan style=\"color:#a6e22e\"\u003efieldType\u003c/span\u003e);\n\u003c/span\u003e\u003c/span\u003e\u003cspan style=\"display:flex;\"\u003e\u003cspan\u003e            \u003cspan style=\"color:#66d9ef\"\u003ebreak\u003c/span\u003e;\n\u003c/span\u003e\u003c/span\u003e\u003cspan style=\"display:flex;\"\u003e\u003cspan\u003e        \u003cspan style=\"color:#66d9ef\"\u003ecase\u003c/span\u003e \u003cspan style=\"color:#e6db74\"\u003e\u0026#39;wall\u0026#39;\u003c/span\u003e\u003cspan style=\"color:#f92672\"\u003e:\u003c/span\u003e\n\u003c/span\u003e\u003c/span\u003e\u003cspan style=\"display:flex;\"\u003e\u003cspan\u003e            \u003cspan style=\"color:#a6e22e\"\u003edrawWall\u003c/span\u003e(\u003cspan style=\"color:#a6e22e\"\u003ectx\u003c/span\u003e, \u003cspan style=\"color:#a6e22e\"\u003etileSize\u003c/span\u003e, \u003cspan style=\"color:#a6e22e\"\u003efieldType\u003c/span\u003e);\n\u003c/span\u003e\u003c/span\u003e\u003cspan style=\"display:flex;\"\u003e\u003cspan\u003e            \u003cspan style=\"color:#66d9ef\"\u003ebreak\u003c/span\u003e;\n\u003c/span\u003e\u003c/span\u003e\u003cspan style=\"display:flex;\"\u003e\u003cspan\u003e        \u003cspan style=\"color:#66d9ef\"\u003ecase\u003c/span\u003e \u003cspan style=\"color:#e6db74\"\u003e\u0026#39;object_grass\u0026#39;\u003c/span\u003e\u003cspan style=\"color:#f92672\"\u003e:\u003c/span\u003e\n\u003c/span\u003e\u003c/span\u003e\u003cspan style=\"display:flex;\"\u003e\u003cspan\u003e            \u003cspan style=\"color:#a6e22e\"\u003edrawFloor\u003c/span\u003e(\u003cspan style=\"color:#a6e22e\"\u003ectx\u003c/span\u003e, \u003cspan style=\"color:#a6e22e\"\u003etileSize\u003c/span\u003e, \u003cspan style=\"color:#a6e22e\"\u003efieldType\u003c/span\u003e);\n\u003c/span\u003e\u003c/span\u003e\u003cspan style=\"display:flex;\"\u003e\u003cspan\u003e            \u003cspan style=\"color:#a6e22e\"\u003edrawGrass\u003c/span\u003e(\u003cspan style=\"color:#a6e22e\"\u003ectx\u003c/span\u003e, \u003cspan style=\"color:#a6e22e\"\u003etileSize\u003c/span\u003e);\n\u003c/span\u003e\u003c/span\u003e\u003cspan style=\"display:flex;\"\u003e\u003cspan\u003e            \u003cspan style=\"color:#66d9ef\"\u003ebreak\u003c/span\u003e;\n\u003c/span\u003e\u003c/span\u003e\u003cspan style=\"display:flex;\"\u003e\u003cspan\u003e        \u003cspan style=\"color:#66d9ef\"\u003ecase\u003c/span\u003e \u003cspan style=\"color:#e6db74\"\u003e\u0026#39;object_tree\u0026#39;\u003c/span\u003e\u003cspan style=\"color:#f92672\"\u003e:\u003c/span\u003e\n\u003c/span\u003e\u003c/span\u003e\u003cspan style=\"display:flex;\"\u003e\u003cspan\u003e            \u003cspan style=\"color:#a6e22e\"\u003edrawFloor\u003c/span\u003e(\u003cspan style=\"color:#a6e22e\"\u003ectx\u003c/span\u003e, \u003cspan style=\"color:#a6e22e\"\u003etileSize\u003c/span\u003e, \u003cspan style=\"color:#a6e22e\"\u003efieldType\u003c/span\u003e);\n\u003c/span\u003e\u003c/span\u003e\u003cspan style=\"display:flex;\"\u003e\u003cspan\u003e            \u003cspan style=\"color:#a6e22e\"\u003edrawTree\u003c/span\u003e(\u003cspan style=\"color:#a6e22e\"\u003ectx\u003c/span\u003e, \u003cspan style=\"color:#a6e22e\"\u003etileSize\u003c/span\u003e);\n\u003c/span\u003e\u003c/span\u003e\u003cspan style=\"display:flex;\"\u003e\u003cspan\u003e            \u003cspan style=\"color:#66d9ef\"\u003ebreak\u003c/span\u003e;\n\u003c/span\u003e\u003c/span\u003e\u003cspan style=\"display:flex;\"\u003e\u003cspan\u003e        \u003cspan style=\"color:#66d9ef\"\u003ecase\u003c/span\u003e \u003cspan style=\"color:#e6db74\"\u003e\u0026#39;stairs_down\u0026#39;\u003c/span\u003e\u003cspan style=\"color:#f92672\"\u003e:\u003c/span\u003e\n\u003c/span\u003e\u003c/span\u003e\u003cspan style=\"display:flex;\"\u003e\u003cspan\u003e            \u003cspan style=\"color:#a6e22e\"\u003edrawFloor\u003c/span\u003e(\u003cspan style=\"color:#a6e22e\"\u003ectx\u003c/span\u003e, \u003cspan style=\"color:#a6e22e\"\u003etileSize\u003c/span\u003e, \u003cspan style=\"color:#a6e22e\"\u003efieldType\u003c/span\u003e);\n\u003c/span\u003e\u003c/span\u003e\u003cspan style=\"display:flex;\"\u003e\u003cspan\u003e            \u003cspan style=\"color:#a6e22e\"\u003edrawStairs\u003c/span\u003e(\u003cspan style=\"color:#a6e22e\"\u003ectx\u003c/span\u003e, \u003cspan style=\"color:#a6e22e\"\u003etileSize\u003c/span\u003e, \u003cspan style=\"color:#66d9ef\"\u003efalse\u003c/span\u003e);\n\u003c/span\u003e\u003c/span\u003e\u003cspan style=\"display:flex;\"\u003e\u003cspan\u003e            \u003cspan style=\"color:#66d9ef\"\u003ebreak\u003c/span\u003e;\n\u003c/span\u003e\u003c/span\u003e\u003cspan style=\"display:flex;\"\u003e\u003cspan\u003e        \u003cspan style=\"color:#66d9ef\"\u003edefault\u003c/span\u003e\u003cspan style=\"color:#f92672\"\u003e:\u003c/span\u003e\n\u003c/span\u003e\u003c/span\u003e\u003cspan style=\"display:flex;\"\u003e\u003cspan\u003e            \u003cspan style=\"color:#a6e22e\"\u003ectx\u003c/span\u003e.\u003cspan style=\"color:#a6e22e\"\u003efillStyle\u003c/span\u003e \u003cspan style=\"color:#f92672\"\u003e=\u003c/span\u003e \u003cspan style=\"color:#e6db74\"\u003e\u0026#39;#333\u0026#39;\u003c/span\u003e;\n\u003c/span\u003e\u003c/span\u003e\u003cspan style=\"display:flex;\"\u003e\u003cspan\u003e            \u003cspan style=\"color:#a6e22e\"\u003ectx\u003c/span\u003e.\u003cspan style=\"color:#a6e22e\"\u003efillRect\u003c/span\u003e(\u003cspan style=\"color:#ae81ff\"\u003e0\u003c/span\u003e, \u003cspan style=\"color:#ae81ff\"\u003e0\u003c/span\u003e, \u003cspan style=\"color:#a6e22e\"\u003etileSize\u003c/span\u003e, \u003cspan style=\"color:#a6e22e\"\u003etileSize\u003c/span\u003e);\n\u003c/span\u003e\u003c/span\u003e\u003cspan style=\"display:flex;\"\u003e\u003cspan\u003e    }\n\u003c/span\u003e\u003c/span\u003e\u003cspan style=\"display:flex;\"\u003e\u003cspan\u003e\n\u003c/span\u003e\u003c/span\u003e\u003cspan style=\"display:flex;\"\u003e\u003cspan\u003e    \u003cspan style=\"color:#a6e22e\"\u003ectx\u003c/span\u003e.\u003cspan style=\"color:#a6e22e\"\u003erestore\u003c/span\u003e();\n\u003c/span\u003e\u003c/span\u003e\u003cspan style=\"display:flex;\"\u003e\u003cspan\u003e}\n\u003c/span\u003e\u003c/span\u003e\u003c/code\u003e\u003c/pre\u003e\u003c/div\u003e\u003cp\u003eこの設計のポイントは、\u003ccode\u003ectx.translate\u003c/code\u003e を使ってタイルの左上を原点 (0, 0) に固定することだ。これにより、各タイルの描画ロジック内で座標計算を簡略化できる。\u003c/p\u003e","title":"Canvas 2D API でピクセルアートタイルを「手書き」する実装テクニック"},{"content":"React + Canvas 2D API で作るターン制ローグライク：論理と描画を切り離す設計 React でゲームを作る際、多くの開発者が最初に直面するのが「DOM で描画するか、Canvas で描画するか」という選択です。特に数千枚のタイルや多数のユニットが登場するローグライクゲームでは、DOM 要素の管理はすぐにパフォーマンスの限界に達します。\n本稿では、React の強力な状態管理（useReducer）と、Canvas 2D API の命令的な描画を組み合わせ、滑らかなアニメーションを実現しつつ堅牢なゲームロジックを維持する設計手法について解説します。\n1. 概要：なぜ React と Canvas を組み合わせるのか React は「宣言的」な UI 構築に長けていますが、毎秒 60 回の頻度で数千の DOM 要素を更新するような動的な描画には向いていません。一方で、Canvas は「命令的」であり、ピクセル単位での高速な描画が可能ですが、状態と描画の同期を自分で行う必要があります。\nこの二つの「いいとこ取り」をするのが、「ロジックは React（useReducer）で、描画は Canvas で」 という役割分担です。\n本記事で構築するアーキテクチャ Core Logic (useReducer): ゲームの「真実の状態（State of Truth）」を管理。ターン単位で離散的に変化する座標などを扱う。 Render Loop (requestAnimationFrame): Canvas 上で毎フレーム実行される描画処理。 Interpolation (補完): 離散的な論理座標を、滑らかな描画座標へと変換するローカル状態管理。 2. 設計判断：論理座標と描画座標の分離 ローグライクゲームは基本的に「ターン制」です。プレイヤーが右に移動したとき、内部データ（Core State）では x: 10 から x: 11 へと一瞬で書き換わります。しかし、これをそのまま描画すると、キャラクターがワープしたように見えてしまいます。\n滑らかな移動（アニメーション）を実現するためには、以下の二種類の状態を明確に分ける必要があります。\nなぜ分離が必要か 種類 管理場所 特徴 役割 論理座標 (Logical Position) useReducer (Global) 整数（タイル単位）。 当たり判定、AI、クエスト進行など。 描画座標 (Visual Position) Canvas 内の Actor クラス (Local) 小数点を含むピクセル単位。 滑らかな移動、揺れ、エフェクト。 判断理由： Core State にアニメーションの「途中経過（x: 10.2 など）」を持たせてしまうと、ゲームロジックが描画の都合に汚染されます。例えば、「まだ移動アニメーション中だから攻撃はできない」といった判定をロジック層で書く必要が出てき、コードが複雑化します。ロジック層は常に「今は（論理的に）どこにいるか」だけを知っていれば良いのです。\n3. Canvas Render Loop の実装 React のライフサイクルの中で requestAnimationFrame (rAF) を安全に回すために、カスタムフック useCanvas を作成します。\nuseCanvas.ts の実装 import { useEffect, useRef } from \u0026#39;react\u0026#39;; /** * Canvas の Render Loop を管理するフック * @param draw 毎フレーム実行される描画関数 */ export const useCanvas = (draw: (ctx: CanvasRenderingContext2D, frameCount: number) =\u0026gt; void) =\u0026gt; { const canvasRef = useRef\u0026lt;HTMLCanvasElement\u0026gt;(null); useEffect(() =\u0026gt; { const canvas = canvasRef.current; if (!canvas) return; const context = canvas.getContext(\u0026#39;2d\u0026#39;); if (!context) return; let frameCount = 0; let animationFrameId: number; const render = () =\u0026gt; { frameCount++; draw(context, frameCount); animationFrameId = window.requestAnimationFrame(render); }; render(); // クリーンアップ：コンポーネント消滅時にループを止める return () =\u0026gt; { window.cancelAnimationFrame(animationFrameId); }; }, [draw]); return canvasRef; }; なぜ useEffect を使うのか： Canvas は命令的な API です。React の宣言的な再レンダリングに描画を任せると、フレームレートが安定せず、また React 自身のオーバーヘッドでカクつきが発生します。useEffect 内で独立したループを回すことで、React のレンダリングサイクルとは切り離された 60FPS の安定した描画が可能になります。\n4. useReducer との共存：State を Canvas へ渡す 次に、ゲームの状態を管理する useReducer を定義し、それを Canvas のループに渡す方法を考えます。\ngame-reducer.ts（架空のロジック） type Position = { x: number; y: number }; interface GameState { player: { pos: Position; hp: number }; enemies: Array\u0026lt;{ id: string; pos: Position; type: string }\u0026gt;; map: number[][]; // タイルID } type Action = | { type: \u0026#39;MOVE_PLAYER\u0026#39;; delta: Position } | { type: \u0026#39;TICK_TURN\u0026#39; }; export const gameReducer = (state: GameState, action: Action): GameState =\u0026gt; { switch (action.type) { case \u0026#39;MOVE_PLAYER\u0026#39;: const newPos = { x: state.player.pos.x + action.delta.x, y: state.player.pos.y + action.delta.y }; // ここで一気に座標が書き換わる（ワープ状態） return { ...state, player: { ...state.player, pos: newPos } }; default: return state; } }; useRef によるブリッジ ここで問題になるのが、useCanvas に渡す draw 関数の中で最新の state をどう参照するかです。draw 関数を state が変わるたびに更新すると、useEffect が再実行され、ループがリセットされてしまいます。\nこれを避けるために、useRef を使って最新の State を保持するテクニックを使います。\nconst FieldScreen = () =\u0026gt; { const [state, dispatch] = useReducer(gameReducer, initialState); // 最新のstateを常にrefに同期させる const stateRef = useRef(state); useEffect(() =\u0026gt; { stateRef.current = state; }, [state]); // draw 関数は useCallback で固定し、内部で stateRef を見る const draw = useCallback((ctx: CanvasRenderingContext2D, frameCount: number) =\u0026gt; { const currentState = stateRef.current; // 描画処理 ctx.clearRect(0, 0, ctx.canvas.width, ctx.canvas.height); renderGame(ctx, currentState); }, []); const canvasRef = useCanvas(draw); return \u0026lt;canvas ref={canvasRef} width={800} height={600} /\u0026gt;; }; なぜ useRef なのか： useRef の .current は書き換えても再レンダリングを発生させません。これにより、「React が管理する最新の状態」を「Canvas の独立した描画ループ」から安全に、かつ最新の状態で読み出すことができます。\n5. 敵 Actor のローカル State 管理：補完アニメーション 前述の通り、state.enemies[0].pos はターンごとに (10, 10) から (11, 10) へ飛びます。これを滑らかに見せるために、描画層専用の Actor クラスを導入します。\nActor クラスの実装 class Actor { id: string; visualX: number; // 描画用の浮動小数点座標 visualY: number; lerpSpeed = 0.15; // 追従速度 constructor(id: string, initialPos: Position) { this.id = id; this.visualX = initialPos.x; this.visualY = initialPos.y; } /** * 毎フレーム実行される更新処理 * @param targetPos ロジック層（Core State）の論理座標 */ update(targetPos: Position) { // 線形補完 (Lerp) を用いて、現在の描画座標を目標座標に近づける this.visualX += (targetPos.x - this.visualX) * this.lerpSpeed; this.visualY += (targetPos.y - this.visualY) * this.lerpSpeed; } draw(ctx: CanvasRenderingContext2D, offsetX: number, offsetY: number, tileSize: number) { const screenX = this.visualX * tileSize + offsetX; const screenY = this.visualY * tileSize + offsetY; // キャラクターの描画 ctx.fillStyle = \u0026#39;red\u0026#39;; ctx.fillRect(screenX, screenY, tileSize, tileSize); } } 描画ループ内での Actor 管理 // コンポーネント外、または useRef で Actor の Map を保持 const actorsRef = useRef\u0026lt;Map\u0026lt;string, Actor\u0026gt;\u0026gt;(new Map()); const renderGame = (ctx: CanvasRenderingContext2D, state: GameState) =\u0026gt; { const TILE_SIZE = 32; const actors = actorsRef.current; // 1. 存在しない Actor を追加、不要なものを削除 syncActors(actors, state.enemies); // 2. 更新と描画 state.enemies.forEach(enemy =\u0026gt; { const actor = actors.get(enemy.id); if (actor) { actor.update(enemy.pos); // ここで論理座標に向かって滑らかに動く actor.draw(ctx, 0, 0, TILE_SIZE); } }); }; なぜ lerp (線形補完) なのか： lerp はシンプルながら非常に強力です。ターゲットとの距離に比例して移動速度が変わるため、動き始めが速く、停止直前がゆっくりになる「イージング」の効果が自然に得られます。また、通信遅延や処理落ちで論理座標の更新が飛んでも、描画座標はそれを追いかける形になるため、見た目上のガタつきを最小限に抑えられます。\n6. タイルベース描画の最適化：カメラとクリッピング 最後に、広大なマップを効率よく描画する手法についてです。\nカメラオフセットの計算 プレイヤーが常に画面中央に来るように描画位置をずらします。\nconst getCameraOffset = (ctx: CanvasRenderingContext2D, playerVisualX: number, playerVisualY: number, tileSize: number) =\u0026gt; { return { x: ctx.canvas.width / 2 - (playerVisualX * tileSize + tileSize / 2), y: ctx.canvas.height / 2 - (playerVisualY * tileSize + tileSize / 2) }; }; ビューポートクリッピング（間引き描画） 画面外にあるタイルを描画するのは CPU/GPU の無駄遣いです。描画範囲を計算してループを回します。\nconst drawMap = (ctx: CanvasRenderingContext2D, state: GameState, offset: {x: number, y: number}, tileSize: number) =\u0026gt; { const viewWidth = ctx.canvas.width; const viewHeight = ctx.canvas.height; // 画面内に収まるタイルのインデックス範囲を計算 const startCol = Math.floor(-offset.x / tileSize); const endCol = startCol + Math.ceil(viewWidth / tileSize); const startRow = Math.floor(-offset.y / tileSize); const endRow = startRow + Math.ceil(viewHeight / tileSize); for (let r = startRow; r \u0026lt;= endRow; r++) { for (let c = startCol; c \u0026lt;= endCol; c++) { const tileId = state.map[r]?.[c]; if (tileId === undefined) continue; const x = c * tileSize + offset.x; const y = r * tileSize + offset.y; // ここでタイル画像を描画 // ctx.drawImage(tileAtlas, ...); } } }; 判断理由： Canvas の drawImage は高速ですが、数万回の呼び出しは流石に重くなります。クリッピングを実装することで、たとえ 1000x1000 の広大なマップであっても、描画負荷は常に「画面解像度分」に固定されます。これはローグライクにおけるパフォーマンス最適化の基本です。\n7. まとめ React と Canvas を用いたゲーム開発では、「論理の React」と「描画の Canvas」をどう繋ぐかが最大のポイントです。\nuseReducer で純粋なゲームの状態を管理する。 useRef でその状態を Canvas のループへ橋渡しする。 Actor クラス に描画専用のローカル状態（補完座標）を持たせ、滑らかなアニメーションを実現する。 カメラとクリッピング で描画負荷を最小限に抑える。 この設計にすることで、React の高い生産性と、Canvas の圧倒的な描画パフォーマンスを両立させることができます。また、アニメーションのロジックが描画層に閉じ込められているため、将来的に「キャラをジャンプさせたい」「ダメージ時に画面を揺らしたい」といった要望が出ても、ゲームのコアロジックを一切書き換えることなく対応が可能です。\nぜひ、あなたのローグライク開発にもこの「分離の美学」を取り入れてみてください。\n","permalink":"https://techblog.wasutech.dev/posts/react-canvas-roguelike/","summary":"\u003ch1 id=\"react--canvas-2d-api-で作るターン制ローグライク論理と描画を切り離す設計\"\u003eReact + Canvas 2D API で作るターン制ローグライク：論理と描画を切り離す設計\u003c/h1\u003e\n\u003cp\u003eReact でゲームを作る際、多くの開発者が最初に直面するのが「DOM で描画するか、Canvas で描画するか」という選択です。特に数千枚のタイルや多数のユニットが登場するローグライクゲームでは、DOM 要素の管理はすぐにパフォーマンスの限界に達します。\u003c/p\u003e\n\u003cp\u003e本稿では、React の強力な状態管理（\u003ccode\u003euseReducer\u003c/code\u003e）と、Canvas 2D API の命令的な描画を組み合わせ、滑らかなアニメーションを実現しつつ堅牢なゲームロジックを維持する設計手法について解説します。\u003c/p\u003e\n\u003chr\u003e\n\u003ch2 id=\"1-概要なぜ-react-と-canvas-を組み合わせるのか\"\u003e1. 概要：なぜ React と Canvas を組み合わせるのか\u003c/h2\u003e\n\u003cp\u003eReact は「宣言的」な UI 構築に長けていますが、毎秒 60 回の頻度で数千の DOM 要素を更新するような動的な描画には向いていません。一方で、Canvas は「命令的」であり、ピクセル単位での高速な描画が可能ですが、状態と描画の同期を自分で行う必要があります。\u003c/p\u003e\n\u003cp\u003eこの二つの「いいとこ取り」をするのが、\u003cstrong\u003e「ロジックは React（useReducer）で、描画は Canvas で」\u003c/strong\u003e という役割分担です。\u003c/p\u003e\n\u003ch3 id=\"本記事で構築するアーキテクチャ\"\u003e本記事で構築するアーキテクチャ\u003c/h3\u003e\n\u003cul\u003e\n\u003cli\u003e\u003cstrong\u003eCore Logic (\u003ccode\u003euseReducer\u003c/code\u003e)\u003c/strong\u003e: ゲームの「真実の状態（State of Truth）」を管理。ターン単位で離散的に変化する座標などを扱う。\u003c/li\u003e\n\u003cli\u003e\u003cstrong\u003eRender Loop (\u003ccode\u003erequestAnimationFrame\u003c/code\u003e)\u003c/strong\u003e: Canvas 上で毎フレーム実行される描画処理。\u003c/li\u003e\n\u003cli\u003e\u003cstrong\u003eInterpolation (補完)\u003c/strong\u003e: 離散的な論理座標を、滑らかな描画座標へと変換するローカル状態管理。\u003c/li\u003e\n\u003c/ul\u003e\n\u003chr\u003e\n\u003ch2 id=\"2-設計判断論理座標と描画座標の分離\"\u003e2. 設計判断：論理座標と描画座標の分離\u003c/h2\u003e\n\u003cp\u003eローグライクゲームは基本的に「ターン制」です。プレイヤーが右に移動したとき、内部データ（Core State）では \u003ccode\u003ex: 10\u003c/code\u003e から \u003ccode\u003ex: 11\u003c/code\u003e へと一瞬で書き換わります。しかし、これをそのまま描画すると、キャラクターがワープしたように見えてしまいます。\u003c/p\u003e\n\u003cp\u003e滑らかな移動（アニメーション）を実現するためには、以下の二種類の状態を明確に分ける必要があります。\u003c/p\u003e\n\u003ch3 id=\"なぜ分離が必要か\"\u003eなぜ分離が必要か\u003c/h3\u003e\n\u003ctable\u003e\n  \u003cthead\u003e\n      \u003ctr\u003e\n          \u003cth style=\"text-align: left\"\u003e種類\u003c/th\u003e\n          \u003cth style=\"text-align: left\"\u003e管理場所\u003c/th\u003e\n          \u003cth style=\"text-align: left\"\u003e特徴\u003c/th\u003e\n          \u003cth style=\"text-align: left\"\u003e役割\u003c/th\u003e\n      \u003c/tr\u003e\n  \u003c/thead\u003e\n  \u003ctbody\u003e\n      \u003ctr\u003e\n          \u003ctd style=\"text-align: left\"\u003e\u003cstrong\u003e論理座標 (Logical Position)\u003c/strong\u003e\u003c/td\u003e\n          \u003ctd style=\"text-align: left\"\u003e\u003ccode\u003euseReducer\u003c/code\u003e (Global)\u003c/td\u003e\n          \u003ctd style=\"text-align: left\"\u003e整数（タイル単位）。\u003c/td\u003e\n          \u003ctd style=\"text-align: left\"\u003e当たり判定、AI、クエスト進行など。\u003c/td\u003e\n      \u003c/tr\u003e\n      \u003ctr\u003e\n          \u003ctd style=\"text-align: left\"\u003e\u003cstrong\u003e描画座標 (Visual Position)\u003c/strong\u003e\u003c/td\u003e\n          \u003ctd style=\"text-align: left\"\u003eCanvas 内の Actor クラス (Local)\u003c/td\u003e\n          \u003ctd style=\"text-align: left\"\u003e小数点を含むピクセル単位。\u003c/td\u003e\n          \u003ctd style=\"text-align: left\"\u003e滑らかな移動、揺れ、エフェクト。\u003c/td\u003e\n      \u003c/tr\u003e\n  \u003c/tbody\u003e\n\u003c/table\u003e\n\u003cp\u003e\u003cstrong\u003e判断理由：\u003c/strong\u003e\nCore State にアニメーションの「途中経過（x: 10.2 など）」を持たせてしまうと、ゲームロジックが描画の都合に汚染されます。例えば、「まだ移動アニメーション中だから攻撃はできない」といった判定をロジック層で書く必要が出てき、コードが複雑化します。ロジック層は常に「今は（論理的に）どこにいるか」だけを知っていれば良いのです。\u003c/p\u003e","title":"React + Canvas 2D API で作るターン制ローグライク：論理と描画を切り離す設計"},{"content":"React で作る堅牢なゲームセーブシステム：localStorage と Reducer を疎結合に保つ設計 ゲーム開発において、プレイヤーの進行状況を保存する「セーブ / ロード」は最も重要な機能の一つです。Web ブラウザで動作する React アプリケーションの場合、最も手軽な保存先は localStorage です。\nしかし、単純に localStorage.setItem をコードのあちこちに散りばめてしまうと、テスタビリティが低下し、データ構造の変更（スキーマ変更）に弱いシステムになってしまいます。\n本記事では、架空の RPG 『React Odyssey』を例に、SavePort インターフェースと Reducer の初期化関数を組み合わせた、クリーンで堅牢なセーブ / ロードの実装方法を解説します。\n1. 概要：なぜ「直接 localStorage」を避けるのか React の useReducer を使ったゲーム開発では、ゲームの状態（State）は一つの大きなオブジェクトとして管理されます。これを JSON.stringify して localStorage に保存するのは簡単です。\nしかし、以下の理由から、ロジックの中に直接 localStorage を書くべきではありません。\n副作用の分離: Reducer は純粋関数であるべきです。セーブ処理（副作用）を Reducer の中に入れることはできません。 環境非依存: 将来的に保存先を IndexedDB やクラウド（Firebase 等）に変更したくなったとき、コードを大幅に書き換える必要が出てきます。 テストのしやすさ: localStorage が存在しない Node.js 環境（Vitest 等）でロジックのテストを行う際、モック化が容易である必要があります。 これらを解決するために、Dependency Inversion Principle（依存性逆転の原則） に基づいた設計を採用します。\n2. SavePort 設計：抽象化の定義 まずは、ゲームエンジン側が必要とする「セーブ機能」のインターフェースを定義します。これを SavePort と呼びます。\n// domain/save/save-port.ts /** * 保存されるデータの構造（スキーマ） * ゲームの現在の状態に加え、メタ情報を付与する */ export interface SaveData { version: number; // セーブデータのバージョン timestamp: string; // 保存日時 state: GameState; // 実際のゲーム状態 } /** * セーブ/ロードに関する抽象インターフェース */ export interface SavePort { /** データを保存する */ save(data: SaveData): Promise\u0026lt;void\u0026gt;; /** データを読み込む。存在しない場合は null を返す */ load(): Promise\u0026lt;SaveData | null\u0026gt;; /** セーブデータが存在するか確認する */ exists(): Promise\u0026lt;boolean\u0026gt;; /** セーブデータを削除する */ clear(): Promise\u0026lt;void\u0026gt;; } なぜインターフェースにするのか？ ゲームのメインロジック（UseCase）は、この SavePort を通じて読み書きを行います。この時点では「どうやって保存するか」は知らなくてよく、「保存できる」という事実だけに依存させます。\n3. LocalStorageSaveAdapter 実装：具体化の責務 次に、SavePort を localStorage を使って具体的に実装した「アダプター」を作成します。\n// adapters/localstorage-save-adapter.ts import { SavePort, SaveData } from \u0026#39;../domain/save/save-port\u0026#39;; export class LocalStorageSaveAdapter implements SavePort { private readonly STORAGE_KEY = \u0026#39;react_odyssey_save_v1\u0026#39;; async save(data: SaveData): Promise\u0026lt;void\u0026gt; { try { const serialized = JSON.stringify(data); localStorage.setItem(this.STORAGE_KEY, serialized); } catch (error) { console.error(\u0026#39;Failed to save data to localStorage:\u0026#39;, error); throw new Error(\u0026#39;セーブに失敗しました。ディスク容量を確認してください。\u0026#39;); } } async load(): Promise\u0026lt;SaveData | null\u0026gt; { const serialized = localStorage.getItem(this.STORAGE_KEY); if (!serialized) return null; try { return JSON.parse(serialized) as SaveData; } catch (error) { console.error(\u0026#39;Failed to parse save data:\u0026#39;, error); return null; // 破損している場合は null を返して初期状態にする } } async exists(): Promise\u0026lt;boolean\u0026gt; { return localStorage.getItem(this.STORAGE_KEY) !== null; } async clear(): Promise\u0026lt;void\u0026gt; { localStorage.removeItem(this.STORAGE_KEY); } } 設計のポイント：\nSTORAGE_KEY を一箇所に定義し、他から参照させないことで、キーの重複やミスを防ぎます。 try-catch をアダプター内で完結させ、呼び出し側（React コンポーネント）にはクリーンな結果（または意味のあるエラーメッセージ）を返します。 4. Reducer 初期値へのロード：非同期と初期化の壁 React の useReducer にロードしたデータを反映させるには、少し工夫が必要です。useReducer の第3引数である initializer 関数を利用します。\nまずは、ゲームの状態と Reducer の定義を見てみましょう。\n// domain/game/game-reducer.ts export interface GameState { player: { hp: number; mp: number; gold: number }; location: string; } export const INITIAL_STATE: GameState = { player: { hp: 100, mp: 50, gold: 0 }, location: \u0026#39;始まりの町\u0026#39; }; export type GameAction = | { type: \u0026#39;RECOVER_HP\u0026#39;; amount: number } | { type: \u0026#39;MOVE\u0026#39;; to: string } | { type: \u0026#39;LOAD_GAME\u0026#39;; state: GameState }; // ロード専用のアクション export function gameReducer(state: GameState, action: GameAction): GameState { switch (action.type) { case \u0026#39;RECOVER_HP\u0026#39;: return { ...state, player: { ...state.player, hp: state.player.hp + action.amount } }; case \u0026#39;MOVE\u0026#39;: return { ...state, location: action.to }; case \u0026#39;LOAD_GAME\u0026#39;: return action.state; // 保存された状態で上書き default: return state; } } コンポーネントでのロード処理 localStorage.getItem は同期処理ですが、将来の IndexedDB 移行を見据えて async に対応させる必要があります。React の useEffect でロードを実行し、成功したらアクションをディスパッチします。\n// components/GameProvider.tsx import React, { useReducer, useEffect, useMemo } from \u0026#39;react\u0026#39;; import { gameReducer, INITIAL_STATE, GameState } from \u0026#39;../domain/game/game-reducer\u0026#39;; import { LocalStorageSaveAdapter } from \u0026#39;../adapters/localstorage-save-adapter\u0026#39;; export const GameContext = React.createContext\u0026lt;{ state: GameState; dispatch: React.Dispatch\u0026lt;any\u0026gt;; saveGame: () =\u0026gt; void; } | null\u0026gt;(null); export const GameProvider: React.FC\u0026lt;{ children: React.ReactNode }\u0026gt; = ({ children }) =\u0026gt; { const [state, dispatch] = useReducer(gameReducer, INITIAL_STATE); // アダプターのインスタンスをメモ化 const saveAdapter = useMemo(() =\u0026gt; new LocalStorageSaveAdapter(), []); // アプリ起動時にロードを試みる useEffect(() =\u0026gt; { const initLoad = async () =\u0026gt; { const savedData = await saveAdapter.load(); if (savedData) { dispatch({ type: \u0026#39;LOAD_GAME\u0026#39;, state: savedData.state }); } }; initLoad(); }, [saveAdapter]); // 手動セーブ用の関数 const saveGame = async () =\u0026gt; { const data = { version: 1, timestamp: new Date().toISOString(), state: state }; await saveAdapter.save(data); alert(\u0026#39;セーブが完了しました！\u0026#39;); }; return ( \u0026lt;GameContext.Provider value={{ state, dispatch, saveGame }}\u0026gt; {children} \u0026lt;/GameContext.Provider\u0026gt; ); }; 5. セーブデータの破損・バージョン不一致への対策 ここまでの実装では、データ構造が変わったときにクラッシュする危険があります。例えば、player オブジェクトに level フィールドを追加した後に古いセーブデータを読み込むと、level が undefined になり、計算で NaN が発生するかもしれません。\n対策1：バージョンチェックと移行（Migration） SaveData に含めた version をチェックします。\nfunction migrate(data: any): GameData { let currentData = data; // v1 から v2 への移行 if (currentData.version === 1) { currentData = { ...currentData, version: 2, state: { ...currentData.state, player: { ...currentData.state.player, level: 1 } // 新しいフィールドを追加 } }; } return currentData; } 対策2：スキーマバリデーションとフォールバック ロードしたデータが正しい形式か、実行時にチェックします。簡易的な方法としては、スプレッド構文を用いた「デフォルト値の流し込み」が有効です。\n// アダプターの load 内で、構造の不一致を最小限に抑える async load(): Promise\u0026lt;SaveData | null\u0026gt; { const serialized = localStorage.getItem(this.STORAGE_KEY); if (!serialized) return null; try { const rawData = JSON.parse(serialized); // 最小限の構造チェック if (!rawData.state || !rawData.version) { throw new Error(\u0026#39;Invalid save data format\u0026#39;); } // デフォルト値をマージして、新しく追加されたフィールドの欠落を防ぐ const sanitizedState = { ...INITIAL_STATE, ...rawData.state, player: { ...INITIAL_STATE.player, ...rawData.state.player } }; return { ...rawData, state: sanitizedState }; } catch (error) { console.warn(\u0026#39;Save data is corrupted. Starting a new game.\u0026#39;, error); return null; } } 6. まとめ 本記事では、React でゲームのセーブ / ロードを実装する際の「疎結合」な設計について解説しました。\nSavePort (Interface) で「何をすべきか」を定義する。 LocalStorageSaveAdapter (Class) で「どう保存するか」を実装する。 Reducer は副作用を持たず、専用のアクション（LOAD_GAME）で状態を受け取る。 サニタイズ処理 を挟むことで、コードの進化によるデータの破損から守る。 この設計の最大の利点は、テストコードが書きやすくなることです。テスト時には LocalStorageSaveAdapter の代わりに MemorySaveAdapter を渡すだけで、ブラウザ環境なしでセーブ機能の検証が可能になります。\n// テスト用のモックアダプター export class MemorySaveAdapter implements SavePort { private data: SaveData | null = null; async save(d: SaveData) { this.data = d; } async load() { return this.data; } // ... } ゲームが複雑になるにつれ、保存すべきデータの量は増えていきます。初期の段階で「保存の責務」を明確に分けておくことで、将来の機能追加やプラットフォーム変更に強いコードベースを維持できるでしょう。\nあなたの React Odyssey に、安全な「冒険の記録」を実装してみてください。\n","permalink":"https://techblog.wasutech.dev/posts/save-load-system-reducer/","summary":"\u003ch1 id=\"react-で作る堅牢なゲームセーブシステムlocalstorage-と-reducer-を疎結合に保つ設計\"\u003eReact で作る堅牢なゲームセーブシステム：localStorage と Reducer を疎結合に保つ設計\u003c/h1\u003e\n\u003cp\u003eゲーム開発において、プレイヤーの進行状況を保存する「セーブ / ロード」は最も重要な機能の一つです。Web ブラウザで動作する React アプリケーションの場合、最も手軽な保存先は \u003ccode\u003elocalStorage\u003c/code\u003e です。\u003c/p\u003e\n\u003cp\u003eしかし、単純に \u003ccode\u003elocalStorage.setItem\u003c/code\u003e をコードのあちこちに散りばめてしまうと、テスタビリティが低下し、データ構造の変更（スキーマ変更）に弱いシステムになってしまいます。\u003c/p\u003e\n\u003cp\u003e本記事では、架空の RPG 『React Odyssey』を例に、\u003ccode\u003eSavePort\u003c/code\u003e インターフェースと \u003ccode\u003eReducer\u003c/code\u003e の初期化関数を組み合わせた、クリーンで堅牢なセーブ / ロードの実装方法を解説します。\u003c/p\u003e\n\u003chr\u003e\n\u003ch2 id=\"1-概要なぜ直接-localstorageを避けるのか\"\u003e1. 概要：なぜ「直接 localStorage」を避けるのか\u003c/h2\u003e\n\u003cp\u003eReact の \u003ccode\u003euseReducer\u003c/code\u003e を使ったゲーム開発では、ゲームの状態（State）は一つの大きなオブジェクトとして管理されます。これを \u003ccode\u003eJSON.stringify\u003c/code\u003e して \u003ccode\u003elocalStorage\u003c/code\u003e に保存するのは簡単です。\u003c/p\u003e\n\u003cp\u003eしかし、以下の理由から、ロジックの中に直接 \u003ccode\u003elocalStorage\u003c/code\u003e を書くべきではありません。\u003c/p\u003e\n\u003col\u003e\n\u003cli\u003e\u003cstrong\u003e副作用の分離\u003c/strong\u003e: Reducer は純粋関数であるべきです。セーブ処理（副作用）を Reducer の中に入れることはできません。\u003c/li\u003e\n\u003cli\u003e\u003cstrong\u003e環境非依存\u003c/strong\u003e: 将来的に保存先を \u003ccode\u003eIndexedDB\u003c/code\u003e やクラウド（Firebase 等）に変更したくなったとき、コードを大幅に書き換える必要が出てきます。\u003c/li\u003e\n\u003cli\u003e\u003cstrong\u003eテストのしやすさ\u003c/strong\u003e: \u003ccode\u003elocalStorage\u003c/code\u003e が存在しない Node.js 環境（Vitest 等）でロジックのテストを行う際、モック化が容易である必要があります。\u003c/li\u003e\n\u003c/ol\u003e\n\u003cp\u003eこれらを解決するために、\u003cstrong\u003eDependency Inversion Principle（依存性逆転の原則）\u003c/strong\u003e に基づいた設計を採用します。\u003c/p\u003e\n\u003chr\u003e\n\u003ch2 id=\"2-saveport-設計抽象化の定義\"\u003e2. SavePort 設計：抽象化の定義\u003c/h2\u003e\n\u003cp\u003eまずは、ゲームエンジン側が必要とする「セーブ機能」のインターフェースを定義します。これを \u003ccode\u003eSavePort\u003c/code\u003e と呼びます。\u003c/p\u003e\n\u003cdiv class=\"highlight\"\u003e\u003cpre tabindex=\"0\" style=\"color:#f8f8f2;background-color:#272822;-moz-tab-size:4;-o-tab-size:4;tab-size:4;\"\u003e\u003ccode class=\"language-typescript\" data-lang=\"typescript\"\u003e\u003cspan style=\"display:flex;\"\u003e\u003cspan\u003e\u003cspan style=\"color:#75715e\"\u003e// domain/save/save-port.ts\n\u003c/span\u003e\u003c/span\u003e\u003c/span\u003e\u003cspan style=\"display:flex;\"\u003e\u003cspan\u003e\u003cspan style=\"color:#75715e\"\u003e\u003c/span\u003e\n\u003c/span\u003e\u003c/span\u003e\u003cspan style=\"display:flex;\"\u003e\u003cspan\u003e\u003cspan style=\"color:#75715e\"\u003e/** \n\u003c/span\u003e\u003c/span\u003e\u003c/span\u003e\u003cspan style=\"display:flex;\"\u003e\u003cspan\u003e\u003cspan style=\"color:#75715e\"\u003e * 保存されるデータの構造（スキーマ）\n\u003c/span\u003e\u003c/span\u003e\u003c/span\u003e\u003cspan style=\"display:flex;\"\u003e\u003cspan\u003e\u003cspan style=\"color:#75715e\"\u003e * ゲームの現在の状態に加え、メタ情報を付与する\n\u003c/span\u003e\u003c/span\u003e\u003c/span\u003e\u003cspan style=\"display:flex;\"\u003e\u003cspan\u003e\u003cspan style=\"color:#75715e\"\u003e */\u003c/span\u003e\n\u003c/span\u003e\u003c/span\u003e\u003cspan style=\"display:flex;\"\u003e\u003cspan\u003e\u003cspan style=\"color:#66d9ef\"\u003eexport\u003c/span\u003e \u003cspan style=\"color:#66d9ef\"\u003einterface\u003c/span\u003e \u003cspan style=\"color:#a6e22e\"\u003eSaveData\u003c/span\u003e {\n\u003c/span\u003e\u003c/span\u003e\u003cspan style=\"display:flex;\"\u003e\u003cspan\u003e  \u003cspan style=\"color:#a6e22e\"\u003eversion\u003c/span\u003e: \u003cspan style=\"color:#66d9ef\"\u003enumber\u003c/span\u003e;        \u003cspan style=\"color:#75715e\"\u003e// セーブデータのバージョン\n\u003c/span\u003e\u003c/span\u003e\u003c/span\u003e\u003cspan style=\"display:flex;\"\u003e\u003cspan\u003e\u003cspan style=\"color:#75715e\"\u003e\u003c/span\u003e  \u003cspan style=\"color:#a6e22e\"\u003etimestamp\u003c/span\u003e: \u003cspan style=\"color:#66d9ef\"\u003estring\u003c/span\u003e;      \u003cspan style=\"color:#75715e\"\u003e// 保存日時\n\u003c/span\u003e\u003c/span\u003e\u003c/span\u003e\u003cspan style=\"display:flex;\"\u003e\u003cspan\u003e\u003cspan style=\"color:#75715e\"\u003e\u003c/span\u003e  \u003cspan style=\"color:#a6e22e\"\u003estate\u003c/span\u003e: \u003cspan style=\"color:#66d9ef\"\u003eGameState\u003c/span\u003e;       \u003cspan style=\"color:#75715e\"\u003e// 実際のゲーム状態\n\u003c/span\u003e\u003c/span\u003e\u003c/span\u003e\u003cspan style=\"display:flex;\"\u003e\u003cspan\u003e\u003cspan style=\"color:#75715e\"\u003e\u003c/span\u003e}\n\u003c/span\u003e\u003c/span\u003e\u003cspan style=\"display:flex;\"\u003e\u003cspan\u003e\n\u003c/span\u003e\u003c/span\u003e\u003cspan style=\"display:flex;\"\u003e\u003cspan\u003e\u003cspan style=\"color:#75715e\"\u003e/**\n\u003c/span\u003e\u003c/span\u003e\u003c/span\u003e\u003cspan style=\"display:flex;\"\u003e\u003cspan\u003e\u003cspan style=\"color:#75715e\"\u003e * セーブ/ロードに関する抽象インターフェース\n\u003c/span\u003e\u003c/span\u003e\u003c/span\u003e\u003cspan style=\"display:flex;\"\u003e\u003cspan\u003e\u003cspan style=\"color:#75715e\"\u003e */\u003c/span\u003e\n\u003c/span\u003e\u003c/span\u003e\u003cspan style=\"display:flex;\"\u003e\u003cspan\u003e\u003cspan style=\"color:#66d9ef\"\u003eexport\u003c/span\u003e \u003cspan style=\"color:#66d9ef\"\u003einterface\u003c/span\u003e \u003cspan style=\"color:#a6e22e\"\u003eSavePort\u003c/span\u003e {\n\u003c/span\u003e\u003c/span\u003e\u003cspan style=\"display:flex;\"\u003e\u003cspan\u003e  \u003cspan style=\"color:#75715e\"\u003e/** データを保存する */\u003c/span\u003e\n\u003c/span\u003e\u003c/span\u003e\u003cspan style=\"display:flex;\"\u003e\u003cspan\u003e  \u003cspan style=\"color:#a6e22e\"\u003esave\u003c/span\u003e(\u003cspan style=\"color:#a6e22e\"\u003edata\u003c/span\u003e: \u003cspan style=\"color:#66d9ef\"\u003eSaveData\u003c/span\u003e)\u003cspan style=\"color:#f92672\"\u003e:\u003c/span\u003e \u003cspan style=\"color:#a6e22e\"\u003ePromise\u003c/span\u003e\u0026lt;\u003cspan style=\"color:#f92672\"\u003evoid\u003c/span\u003e\u0026gt;;\n\u003c/span\u003e\u003c/span\u003e\u003cspan style=\"display:flex;\"\u003e\u003cspan\u003e\n\u003c/span\u003e\u003c/span\u003e\u003cspan style=\"display:flex;\"\u003e\u003cspan\u003e  \u003cspan style=\"color:#75715e\"\u003e/** データを読み込む。存在しない場合は null を返す */\u003c/span\u003e\n\u003c/span\u003e\u003c/span\u003e\u003cspan style=\"display:flex;\"\u003e\u003cspan\u003e  \u003cspan style=\"color:#a6e22e\"\u003eload\u003c/span\u003e()\u003cspan style=\"color:#f92672\"\u003e:\u003c/span\u003e \u003cspan style=\"color:#a6e22e\"\u003ePromise\u003c/span\u003e\u0026lt;\u003cspan style=\"color:#f92672\"\u003eSaveData\u003c/span\u003e \u003cspan style=\"color:#960050;background-color:#1e0010\"\u003e|\u003c/span\u003e \u003cspan style=\"color:#a6e22e\"\u003enull\u003c/span\u003e\u0026gt;;\n\u003c/span\u003e\u003c/span\u003e\u003cspan style=\"display:flex;\"\u003e\u003cspan\u003e\n\u003c/span\u003e\u003c/span\u003e\u003cspan style=\"display:flex;\"\u003e\u003cspan\u003e  \u003cspan style=\"color:#75715e\"\u003e/** セーブデータが存在するか確認する */\u003c/span\u003e\n\u003c/span\u003e\u003c/span\u003e\u003cspan style=\"display:flex;\"\u003e\u003cspan\u003e  \u003cspan style=\"color:#a6e22e\"\u003eexists\u003c/span\u003e()\u003cspan style=\"color:#f92672\"\u003e:\u003c/span\u003e \u003cspan style=\"color:#a6e22e\"\u003ePromise\u003c/span\u003e\u0026lt;\u003cspan style=\"color:#f92672\"\u003eboolean\u003c/span\u003e\u0026gt;;\n\u003c/span\u003e\u003c/span\u003e\u003cspan style=\"display:flex;\"\u003e\u003cspan\u003e\n\u003c/span\u003e\u003c/span\u003e\u003cspan style=\"display:flex;\"\u003e\u003cspan\u003e  \u003cspan style=\"color:#75715e\"\u003e/** セーブデータを削除する */\u003c/span\u003e\n\u003c/span\u003e\u003c/span\u003e\u003cspan style=\"display:flex;\"\u003e\u003cspan\u003e  \u003cspan style=\"color:#a6e22e\"\u003eclear\u003c/span\u003e()\u003cspan style=\"color:#f92672\"\u003e:\u003c/span\u003e \u003cspan style=\"color:#a6e22e\"\u003ePromise\u003c/span\u003e\u0026lt;\u003cspan style=\"color:#f92672\"\u003evoid\u003c/span\u003e\u0026gt;;\n\u003c/span\u003e\u003c/span\u003e\u003cspan style=\"display:flex;\"\u003e\u003cspan\u003e}\n\u003c/span\u003e\u003c/span\u003e\u003c/code\u003e\u003c/pre\u003e\u003c/div\u003e\u003cp\u003e\u003cstrong\u003eなぜインターフェースにするのか？\u003c/strong\u003e\nゲームのメインロジック（UseCase）は、この \u003ccode\u003eSavePort\u003c/code\u003e を通じて読み書きを行います。この時点では「どうやって保存するか」は知らなくてよく、「保存できる」という事実だけに依存させます。\u003c/p\u003e","title":"React で作る堅牢なゲームセーブシステム：localStorage と Reducer を疎結合に保つ設計"},{"content":"Reducer パターンだけでゲーム状態を管理する：副作用ゼロのアーキテクチャ 概要 現代のフロントエンド開発において、Redux や React の useReducer を通じて「Reducer パターン」は広く浸透しました。しかし、このパターンを本格的なゲーム開発、特に複雑なロジックが絡み合う RPG やシミュレーションゲームに適用しようとすると、多くの開発者が「副作用」の壁にぶつかります。\n「ダメージ計算に乱数を使いたい」「ゲーム内の経過時間を管理したい」「マスターデータを参照したい」……。\nこれらの要素を素直に実装すると、Reducer の外側にある状態や関数に依存してしまい、純粋関数としての美しさとテストのしやすさが失われてしまいます。\n本記事では、すべての副作用を引数として注入し、gameReducer(state, action, masters, random) という純粋関数のみでゲームの全ロジックを完結させる「副作用ゼロ」のアーキテクチャについて解説します。\n課題：なぜゲーム開発で Reducer は敬遠されるのか 一般的な Web アプリケーションの Reducer は単純です。「ボタンを押したらフラグを反転させる」「入力された文字列を状態に保存する」といった操作がメインだからです。\nしかし、ゲームでは以下のような「非決定的な要素」や「巨大な静的データ」が頻繁に登場します。\n1. 乱数 (Randomness) の扱い クリティカルヒットの判定、モンスターのドロップアイテム、マップの自動生成など、ゲームは乱数の塊です。Reducer の中で Math.random() を呼んだ瞬間、その関数は「同じ入力に対して同じ出力を返す」という純粋性を失います。\n2. 静的データ (Master Data) の参照 モンスターのステータス表、アイテムの定義、スキル効果など、ゲームには膨大な「変わらないデータ」があります。これを Reducer の外にあるグローバル変数から参照すると、テスト時にそのグローバル変数の状態も気にしなければならなくなります。\n3. 時刻 (Time) と経過の管理 「3時間経過したらスタミナが回復する」「夜になるとモンスターが強くなる」といった時間依存の処理です。new Date() を Reducer で使うのは、乱数と同様に純粋性を破壊します。\n4. 非同期処理 (Async/Fetch) サーバーから最新のイベント情報を取得したり、セーブデータをロードしたりする処理です。Reducer は同期的に実行される必要があるため、非同期処理をそのまま中に書くことはできません。\nOOP（オブジェクト指向）との比較 クラスベースの OOP では、player.attack(monster) のようにメソッドを呼び出します。これは直感的ですが、内部で this.hp -= damage のように状態を直接書き換えます（ミュータブル）。 小規模なら良いですが、プロジェクトが大きくなり「攻撃時にスキルが発動し、その効果で回復し、さらにログに記録し……」と連鎖が始まると、どこで何が起きたかを追跡するのが不可能になります。\n設計：副作用を「引数」に封じ込める 副作用を排除するための解決策はシンプルです。「必要なものはすべて外から渡す」、つまり依存性の注入 (DI) です。\n目指すべき Reducer のシグネチャは以下の通りです。\ntype GameReducer = ( state: GameState, // 現在のゲーム状態（イミュータブル） action: GameAction, // ユーザーの操作やイベント（シリアライズ可能） masters: Masters, // 静的なマスタデータ（読み取り専用） random: RandomPort // 乱数生成器のインターフェース ) =\u0026gt; GameState; // 新しいゲーム状態 なぜこの設計にするのか 完全な再現性: state, action, masters, random のセットが同じなら、結果は 100% 同じになります。バグレポートが届いた際、この4つを再現するだけで、手元で確実にバグを再現できます。 テストの容易性: 乱数生成器をモック化することで、「1% の確率で発生する超レアドロップ」のテストも 100% の確率で再現させて検証できます。 ロジックの集中: ゲームのルールがこの関数一つ（とその配下のサブ Reducer）に集約されます。「このルールはどこに書いてある？」と迷うことがなくなります。 プラットフォーム非依存: Core ロジックが DOM や Node.js の API に依存しないため、React (Web), React Native (Mobile), Electron (Desktop) で全く同じコードを使い回せます。 実装：型安全な Reducer の構築 それでは、具体的な実装例を見ていきましょう。TypeScript の Union 型を活用し、any を排除した設計にします。\n1. アクションと状態の定義 まず、ゲーム内で発生するアクションを「データ」として定義します。\n// アクションの定義：判別可能な Union 型 type GameAction = | { type: \u0026#39;WALK\u0026#39;; direction: \u0026#39;north\u0026#39; | \u0026#39;south\u0026#39; | \u0026#39;east\u0026#39; | \u0026#39;west\u0026#39; } | { type: \u0026#39;ATTACK_MONSTER\u0026#39;; monsterId: string } | { type: \u0026#39;USE_ITEM\u0026#39;; instanceId: string } | { type: \u0026#39;TICK\u0026#39;; hours: number } | { type: \u0026#39;LOAD_SAVE_DATA\u0026#39;; data: GameState }; // 外部からのロード結果 // 状態の定義 interface GameState { player: PlayerState; world: WorldState; quests: QuestState; log: LogEntry[]; time: GameTime; } interface GameTime { day: number; hour: number; } 2. 乱数ポートのインターフェース Math.random() を直接使うのではなく、ラップしたインターフェースを渡します。これにより、テスト時には特定の数値を返す「決定的な乱数生成器」を注入できます。\nexport interface RandomPort { next(): number; // 0.0 ~ 1.0 nextInt(min: number, max: number): number; } 3. Reducer の合成 (Composition) と責務分割 一つの Reducer ですべてを書くと巨大になりすぎるため、責務ごとに分割します。これは Redux の combineReducers に近い考え方ですが、引数に masters や random を渡せるように拡張するのがポイントです。\nexport function gameReducer( state: GameState, action: GameAction, masters: Masters, random: RandomPort ): GameState { // 1. 各サブ Reducer に委譲（各々が純粋関数） // プレイヤーの基本ステータス更新などは playerReducer で let player = playerReducer(state.player, action, masters); // マップの状態などは worldReducer で let world = worldReducer(state.world, action); let time = state.time; let quests = state.quests; // 2. 複数の要素が絡み合う複雑なロジックをトップレベルで記述 switch (action.type) { case \u0026#39;TICK\u0026#39;: // 時間を進める（advanceTime も純粋関数） time = advanceTime(state.time, action.hours); // 時間経過によるクエストの自動生成（乱数を使用） if (time.hour % 8 === 0) { const newQuest = generateRandomQuest(player.rank, time, random, masters); quests = { ...quests, available: [...quests.available, newQuest] }; } break; case \u0026#39;ATTACK_MONSTER\u0026#39;: { const monsterDef = masters.monsters.find(m =\u0026gt; m.id === action.monsterId); if (!monsterDef) break; // 乱数を使用したダメージ計算 const isCritical = random.next() \u0026lt; (player.stats.luk * 0.01); const baseDamage = player.stats.str - monsterDef.def; const damage = Math.max(1, isCritical ? baseDamage * 2 : baseDamage); // モンスターを倒したか判定（本来は monster の HP も state で持つべきですが簡略化） if (damage \u0026gt;= 10) { player = gainExp(player, monsterDef.exp, masters); } break; } case \u0026#39;LOAD_SAVE_DATA\u0026#39;: // 非同期で取得されたデータは、アクションのペイロードとして渡される return action.data; } // 3. 新しい状態を返却 return { ...state, player, world, time, quests, log: updateLog(state.log, action, state) }; } 4. 非同期処理 (Fetch) の扱い：Result-in-Action パターン Reducer の外側（React の useEffect や Action Creator）で非同期処理を行い、その結果をアクションとして Dispatch します。\n// React コンポーネント内での例 const handleLoad = async () =\u0026gt; { const data = await fetchSaveData(); // 外部の副作用 dispatch({ type: \u0026#39;LOAD_SAVE_DATA\u0026#39;, data }); // 結果だけを Reducer に送る }; これにより、Reducer 自体は常に同期的な純粋関数のままでいられます。\nテスト：副作用ゼロがもたらす最強のデバッグ環境 このアーキテクチャの真価はテストで発揮されます。vitest や jest を使ったテストコードを見てみましょう。\nimport { describe, it, expect } from \u0026#39;vitest\u0026#39;; import { gameReducer } from \u0026#39;./game-reducer\u0026#39;; import { MockRandom } from \u0026#39;./test-helpers\u0026#39;; describe(\u0026#39;Combat Logic\u0026#39;, () =\u0026gt; { it(\u0026#39;should land a critical hit when random value is low\u0026#39;, () =\u0026gt; { // 準備：常に 0.0 を返す乱数生成器（クリティカル確定） const mockRandom = new MockRandom(0.0); const masters = { /* ... モンスターデータ ... */ }; const initialState = { /* ... プレイヤーデータ ... */ }; const action = { type: \u0026#39;ATTACK_MONSTER\u0026#39;, monsterId: \u0026#39;goblin\u0026#39; }; // 実行 const newState = gameReducer(initialState, action, masters, mockRandom); // 検証：クリティカルダメージが適用されているか expect(newState.log[0].message).toContain(\u0026#39;クリティカルヒット！\u0026#39;); }); it(\u0026#39;should increase player exp when a monster is killed\u0026#39;, () =\u0026gt; { const mockRandom = new MockRandom(0.5); const initialState = { player: { exp: 0, ... }, ... }; const action = { type: \u0026#39;ATTACK_MONSTER\u0026#39;, monsterId: \u0026#39;goblin\u0026#39; }; const newState = gameReducer(initialState, action, masters, mockRandom); expect(newState.player.exp).toBeGreaterThan(0); }); }); 「100回に1回しか起きないバグ」も、このように MockRandom を通じて確実に再現できます。\n運用上の注意とパフォーマンス イミュータブル更新のオーバーヘッド 大規模なゲームで毎回オブジェクトをコピー ({ ...state }) するのは、メモリや CPU の負荷が懸念されます。 これには Immer.js の導入が非常に有効です。Reducer 内部で draft を直接書き換えるようなコードが書けますが、最終的な出力はイミュータブルになります。\nimport { produce } from \u0026#39;immer\u0026#39;; const gameReducer = (state, action, masters, random) =\u0026gt; produce(state, draft =\u0026gt; { switch (action.type) { case \u0026#39;WALK\u0026#39;: draft.player.x += 1; // 直感的な記述が可能 break; } }); 巨大なマスタデータの扱い マスタデータを毎回引数で渡すのは、一見非効率に見えますが、JavaScript ではオブジェクトは参照渡しされるため、実行時のオーバーヘッドは無視できるほど小さいです。それよりも、どこからでもマスタデータにアクセスできる見通しの良さの方が価値があります。\nまとめ Reducer パターンをゲーム開発に適用し、副作用を徹底的に排除することで、以下のような恩恵が得られます。\nバグの再現が 100% 可能になる: state と action の履歴、シード値があれば、宇宙のどこで起きたバグも手元で再現できます。 ロジックと表示の完全な分離: React などの UI フレームワークは、単に state を受け取って描画するだけの「皮」になります。 高速な自動テスト: ゲームを実際に起動してポチポチ操作しなくても、コアロジックの正しさをミリ秒単位のテストで保証できます。 最初は「すべての副作用を引数で渡すのは冗長だ」と感じるかもしれません。しかし、プロジェクトの規模が大きくなればなるほど、この「純粋さ」がもたらす保守性の高さが、あなたの開発を強力に支えてくれるはずです。\n副作用を恐れず、Reducer の中にゲームの宇宙を閉じ込めてみてください。\n次のステップへのヒント シード可能な乱数生成器: seedrandom ライブラリを使えば、一つの文字列から再現可能な乱数系列を作れます。 Undo/Redo の実装: 状態がイミュータブルなので、過去の state を配列に保存しておくだけで、簡単に「一手戻す」機能が実装できます。 リプレイ機能: プレイヤーの全 action ログを保存すれば、それを再度 Reducer に流し込むだけでゲームのリプレイが再生できます。 ","permalink":"https://techblog.wasutech.dev/posts/reducer-pure-state/","summary":"\u003ch1 id=\"reducer-パターンだけでゲーム状態を管理する副作用ゼロのアーキテクチャ\"\u003eReducer パターンだけでゲーム状態を管理する：副作用ゼロのアーキテクチャ\u003c/h1\u003e\n\u003ch2 id=\"概要\"\u003e概要\u003c/h2\u003e\n\u003cp\u003e現代のフロントエンド開発において、Redux や React の \u003ccode\u003euseReducer\u003c/code\u003e を通じて「Reducer パターン」は広く浸透しました。しかし、このパターンを本格的なゲーム開発、特に複雑なロジックが絡み合う RPG やシミュレーションゲームに適用しようとすると、多くの開発者が「副作用」の壁にぶつかります。\u003c/p\u003e\n\u003cp\u003e「ダメージ計算に乱数を使いたい」「ゲーム内の経過時間を管理したい」「マスターデータを参照したい」……。\u003c/p\u003e\n\u003cp\u003eこれらの要素を素直に実装すると、Reducer の外側にある状態や関数に依存してしまい、純粋関数としての美しさとテストのしやすさが失われてしまいます。\u003c/p\u003e\n\u003cp\u003e本記事では、すべての副作用を引数として注入し、\u003cstrong\u003e\u003ccode\u003egameReducer(state, action, masters, random)\u003c/code\u003e\u003c/strong\u003e という純粋関数のみでゲームの全ロジックを完結させる「副作用ゼロ」のアーキテクチャについて解説します。\u003c/p\u003e\n\u003chr\u003e\n\u003ch2 id=\"課題なぜゲーム開発で-reducer-は敬遠されるのか\"\u003e課題：なぜゲーム開発で Reducer は敬遠されるのか\u003c/h2\u003e\n\u003cp\u003e一般的な Web アプリケーションの Reducer は単純です。「ボタンを押したらフラグを反転させる」「入力された文字列を状態に保存する」といった操作がメインだからです。\u003c/p\u003e\n\u003cp\u003eしかし、ゲームでは以下のような「非決定的な要素」や「巨大な静的データ」が頻繁に登場します。\u003c/p\u003e\n\u003ch3 id=\"1-乱数-randomness-の扱い\"\u003e1. 乱数 (Randomness) の扱い\u003c/h3\u003e\n\u003cp\u003eクリティカルヒットの判定、モンスターのドロップアイテム、マップの自動生成など、ゲームは乱数の塊です。Reducer の中で \u003ccode\u003eMath.random()\u003c/code\u003e を呼んだ瞬間、その関数は「同じ入力に対して同じ出力を返す」という純粋性を失います。\u003c/p\u003e\n\u003ch3 id=\"2-静的データ-master-data-の参照\"\u003e2. 静的データ (Master Data) の参照\u003c/h3\u003e\n\u003cp\u003eモンスターのステータス表、アイテムの定義、スキル効果など、ゲームには膨大な「変わらないデータ」があります。これを Reducer の外にあるグローバル変数から参照すると、テスト時にそのグローバル変数の状態も気にしなければならなくなります。\u003c/p\u003e\n\u003ch3 id=\"3-時刻-time-と経過の管理\"\u003e3. 時刻 (Time) と経過の管理\u003c/h3\u003e\n\u003cp\u003e「3時間経過したらスタミナが回復する」「夜になるとモンスターが強くなる」といった時間依存の処理です。\u003ccode\u003enew Date()\u003c/code\u003e を Reducer で使うのは、乱数と同様に純粋性を破壊します。\u003c/p\u003e\n\u003ch3 id=\"4-非同期処理-asyncfetch\"\u003e4. 非同期処理 (Async/Fetch)\u003c/h3\u003e\n\u003cp\u003eサーバーから最新のイベント情報を取得したり、セーブデータをロードしたりする処理です。Reducer は同期的に実行される必要があるため、非同期処理をそのまま中に書くことはできません。\u003c/p\u003e\n\u003ch3 id=\"oopオブジェクト指向との比較\"\u003eOOP（オブジェクト指向）との比較\u003c/h3\u003e\n\u003cp\u003eクラスベースの OOP では、\u003ccode\u003eplayer.attack(monster)\u003c/code\u003e のようにメソッドを呼び出します。これは直感的ですが、内部で \u003ccode\u003ethis.hp -= damage\u003c/code\u003e のように状態を直接書き換えます（ミュータブル）。\n小規模なら良いですが、プロジェクトが大きくなり「攻撃時にスキルが発動し、その効果で回復し、さらにログに記録し……」と連鎖が始まると、どこで何が起きたかを追跡するのが不可能になります。\u003c/p\u003e\n\u003chr\u003e\n\u003ch2 id=\"設計副作用を引数に封じ込める\"\u003e設計：副作用を「引数」に封じ込める\u003c/h2\u003e\n\u003cp\u003e副作用を排除するための解決策はシンプルです。\u003cstrong\u003e「必要なものはすべて外から渡す」\u003c/strong\u003e、つまり依存性の注入 (DI) です。\u003c/p\u003e","title":"Reducer パターンだけでゲーム状態を管理する：副作用ゼロのアーキテクチャ"},{"content":"TypeScript npm workspaces でゲームロジックを UI から完全分離する 概要 モダンなフロントエンド開発、特にゲーム開発において、「ロジック」と「表示（UI）」の分離は永遠の課題です。React や Vue などのフレームワークにロジックが密結合してしまうと、テストが困難になり、将来的に別のプラットフォーム（例えば Web から React Native や CLI ツールへ）に展開する際の大きな障害となります。\n本記事では、TypeScript npm workspaces を活用して、ゲームロジックを独立したパッケージ (packages/core) として切り出し、React UI (apps/client) から完全に分離する設計手法を解説します。また、外部 I/O や非決定的な処理（乱数など）を抽象化する Port / Adapter パターンについても触れます。\n課題：なぜロジックが UI に染み出すのか？ 多くのプロジェクトでは、気づかないうちにロジックが React コンポーネントや Hooks の中に漏れ出していきます。\n// 密結合な例 const PlayerStats = () =\u0026gt; { const [hp, setHp] = useState(100); const handleAttack = () =\u0026gt; { // UI の中で計算ロジックが動いている const damage = Math.floor(Math.random() * 10) + 5; setHp(prev =\u0026gt; Math.max(0, prev - damage)); }; return \u0026lt;button onClick={handleAttack}\u0026gt;攻撃を受ける\u0026lt;/button\u0026gt;; }; このような設計には以下の課題があります：\nテストの困難さ: Math.random() が直接使われているため、結果が不安定でユニットテストが書きにくい。 再利用性の欠如: この「ダメージ計算ロジック」を、サーバーサイドや別の UI フレームワークで使い回すことができない。 依存の混入: ロジックを動かすために React の実行環境（レンダリングサイクル）が必要になる。 設計：npm workspaces による物理的隔離 ロジックを「物理的に」隔離するために、以下の monorepo 構成を採用します。\nディレクトリ構成 . ├── package.json # 全体管理 ├── packages/ │ └── core/ # 純粋なゲームロジック（React 依存ゼロ） │ ├── package.json │ ├── src/ │ │ ├── domain/ # 状態定義・Reducer │ │ └── port/ # I/O 抽象化（Interface） └── apps/ └── client/ # React アプリケーション ├── package.json └── src/ ├── adapters/ # I/O 実装（Class） └── hooks/ # core を React で使うためのブリッジ なぜこの設計にするのか 依存の一方向化: apps/client は packages/core に依存しますが、その逆は決して許されません。 副作用の制御: 乱数生成や保存処理などを Port（インターフェース）として定義し、実装を Adapter として外部から注入することで、ロジックを純粋関数に保ちます。 実装：ロジックの分離と I/O の抽象化 それでは、具体的な実装を見ていきましょう。\n1. ワークスペースの設定 ルートの package.json で workspaces を宣言します。\n{ \u0026#34;name\u0026#34;: \u0026#34;my-game-project\u0026#34;, \u0026#34;private\u0026#34;: true, \u0026#34;workspaces\u0026#34;: [ \u0026#34;packages/*\u0026#34;, \u0026#34;apps/*\u0026#34; ] } 2. packages/core でのロジック実装 core パッケージでは、React に依存せず、TypeScript の型と純粋関数のみでゲームを表現します。\nPort（インターフェース）の定義 乱数生成など、環境に依存する処理を抽象化します。\n// packages/core/src/port/random-port.ts export interface RandomPort { nextInt(min: number, max: number): number; next(): number; } ドメインロジックの定義 ゲームの状態遷移を Reducer パターンで実装します。\n// packages/core/src/domain/game-reducer.ts import { RandomPort } from \u0026#39;../port/random-port\u0026#39;; export type GameState = { hp: number; status: \u0026#39;alive\u0026#39; | \u0026#39;dead\u0026#39;; }; export type GameAction = { type: \u0026#39;TAKE_DAMAGE\u0026#39;; amount: number }; export function gameReducer( state: GameState, action: GameAction, random: RandomPort // 外部から注入 ): GameState { switch (action.type) { case \u0026#39;TAKE_DAMAGE\u0026#39;: const actualDamage = action.amount + random.nextInt(0, 5); // 乱数を使用 const newHp = Math.max(0, state.hp - actualDamage); return { ...state, hp: newHp, status: newHp \u0026lt;= 0 ? \u0026#39;dead\u0026#39; : \u0026#39;alive\u0026#39;, }; default: return state; } } 3. apps/client での実装 UI 側では、core で定義された Port の具体的な実装（Adapter）を用意し、ロジックを呼び出します。\nAdapter（実装）の作成 // apps/client/src/adapters/math-random-adapter.ts import { RandomPort } from \u0026#39;@my-game/core\u0026#39;; export class MathRandomAdapter implements RandomPort { nextInt(min: number, max: number): number { return Math.floor(Math.random() * (max - min + 1)) + min; } next(): number { return Math.random(); } } React との接続 useReducer を使って、UI とロジックを繋ぎます。\n// apps/client/src/hooks/useGame.ts import { useReducer } from \u0026#39;react\u0026#39;; import { gameReducer, GameState } from \u0026#39;@my-game/core\u0026#39;; import { MathRandomAdapter } from \u0026#39;../adapters/math-random-adapter\u0026#39;; const random = new MathRandomAdapter(); export const useGame = (initialState: GameState) =\u0026gt; { // core の reducer を React の dispatch に変換する const [state, dispatch] = useReducer( (s, a) =\u0026gt; gameReducer(s, a, random), initialState ); return { state, dispatch }; }; この設計がもたらすメリット 1. 決定論的なテストが可能になる RandomPort をモックに差し替えることで、常に同じ結果が返るテストが書けます。\n// packages/core/tests/game.test.ts const mockRandom = { nextInt: () =\u0026gt; 0, next: () =\u0026gt; 0 }; // 常に 0 を返す const nextState = gameReducer({ hp: 100, status: \u0026#39;alive\u0026#39; }, { type: \u0026#39;TAKE_DAMAGE\u0026#39;, amount: 10 }, mockRandom); expect(nextState.hp).toBe(90); // 10 + 0 = 10 ダメージ 2. UI ライブラリのアップデートに強い もし将来的に React から別のフレームワークに乗り換えることになっても、packages/core は一切変更する必要がありません。apps/new-client を作るだけで済みます。\n3. 開発効率の向上 UI を作らなくても、core パッケージだけでゲームのルールが正しいかをテストコードで検証できます。これは、複雑な RPG やシミュレーションゲームにおいて非常に強力な武器になります。\nまとめ TypeScript npm workspaces を使ったパッケージの分離は、一見すると初期設定の手間がかかるように見えます。しかし、**「ロジックは純粋であるべき」「環境への依存は外部から注入する」**という原則を物理的に強制することで、中長期的なメンテナンスコストは劇的に下がります。\n大規模なゲーム開発はもちろん、小規模なプロジェクトでも「将来の自分への投資」として、ぜひこの Port / Adapter パターンを検討してみてください。\n","permalink":"https://techblog.wasutech.dev/posts/monorepo-logic-separation/","summary":"\u003ch1 id=\"typescript-npm-workspaces-でゲームロジックを-ui-から完全分離する\"\u003eTypeScript npm workspaces でゲームロジックを UI から完全分離する\u003c/h1\u003e\n\u003ch2 id=\"概要\"\u003e概要\u003c/h2\u003e\n\u003cp\u003eモダンなフロントエンド開発、特にゲーム開発において、「ロジック」と「表示（UI）」の分離は永遠の課題です。React や Vue などのフレームワークにロジックが密結合してしまうと、テストが困難になり、将来的に別のプラットフォーム（例えば Web から React Native や CLI ツールへ）に展開する際の大きな障害となります。\u003c/p\u003e\n\u003cp\u003e本記事では、\u003cstrong\u003eTypeScript npm workspaces\u003c/strong\u003e を活用して、ゲームロジックを独立したパッケージ (\u003ccode\u003epackages/core\u003c/code\u003e) として切り出し、React UI (\u003ccode\u003eapps/client\u003c/code\u003e) から完全に分離する設計手法を解説します。また、外部 I/O や非決定的な処理（乱数など）を抽象化する \u003cstrong\u003ePort / Adapter パターン\u003c/strong\u003eについても触れます。\u003c/p\u003e\n\u003chr\u003e\n\u003ch2 id=\"課題なぜロジックが-ui-に染み出すのか\"\u003e課題：なぜロジックが UI に染み出すのか？\u003c/h2\u003e\n\u003cp\u003e多くのプロジェクトでは、気づかないうちにロジックが React コンポーネントや Hooks の中に漏れ出していきます。\u003c/p\u003e\n\u003cdiv class=\"highlight\"\u003e\u003cpre tabindex=\"0\" style=\"color:#f8f8f2;background-color:#272822;-moz-tab-size:4;-o-tab-size:4;tab-size:4;\"\u003e\u003ccode class=\"language-tsx\" data-lang=\"tsx\"\u003e\u003cspan style=\"display:flex;\"\u003e\u003cspan\u003e\u003cspan style=\"color:#75715e\"\u003e// 密結合な例\n\u003c/span\u003e\u003c/span\u003e\u003c/span\u003e\u003cspan style=\"display:flex;\"\u003e\u003cspan\u003e\u003cspan style=\"color:#75715e\"\u003e\u003c/span\u003e\u003cspan style=\"color:#66d9ef\"\u003econst\u003c/span\u003e \u003cspan style=\"color:#a6e22e\"\u003ePlayerStats\u003c/span\u003e \u003cspan style=\"color:#f92672\"\u003e=\u003c/span\u003e () \u003cspan style=\"color:#f92672\"\u003e=\u0026gt;\u003c/span\u003e {\n\u003c/span\u003e\u003c/span\u003e\u003cspan style=\"display:flex;\"\u003e\u003cspan\u003e  \u003cspan style=\"color:#66d9ef\"\u003econst\u003c/span\u003e [\u003cspan style=\"color:#a6e22e\"\u003ehp\u003c/span\u003e, \u003cspan style=\"color:#a6e22e\"\u003esetHp\u003c/span\u003e] \u003cspan style=\"color:#f92672\"\u003e=\u003c/span\u003e \u003cspan style=\"color:#a6e22e\"\u003euseState\u003c/span\u003e(\u003cspan style=\"color:#ae81ff\"\u003e100\u003c/span\u003e);\n\u003c/span\u003e\u003c/span\u003e\u003cspan style=\"display:flex;\"\u003e\u003cspan\u003e\n\u003c/span\u003e\u003c/span\u003e\u003cspan style=\"display:flex;\"\u003e\u003cspan\u003e  \u003cspan style=\"color:#66d9ef\"\u003econst\u003c/span\u003e \u003cspan style=\"color:#a6e22e\"\u003ehandleAttack\u003c/span\u003e \u003cspan style=\"color:#f92672\"\u003e=\u003c/span\u003e () \u003cspan style=\"color:#f92672\"\u003e=\u0026gt;\u003c/span\u003e {\n\u003c/span\u003e\u003c/span\u003e\u003cspan style=\"display:flex;\"\u003e\u003cspan\u003e    \u003cspan style=\"color:#75715e\"\u003e// UI の中で計算ロジックが動いている\n\u003c/span\u003e\u003c/span\u003e\u003c/span\u003e\u003cspan style=\"display:flex;\"\u003e\u003cspan\u003e\u003cspan style=\"color:#75715e\"\u003e\u003c/span\u003e    \u003cspan style=\"color:#66d9ef\"\u003econst\u003c/span\u003e \u003cspan style=\"color:#a6e22e\"\u003edamage\u003c/span\u003e \u003cspan style=\"color:#f92672\"\u003e=\u003c/span\u003e Math.\u003cspan style=\"color:#a6e22e\"\u003efloor\u003c/span\u003e(Math.\u003cspan style=\"color:#a6e22e\"\u003erandom\u003c/span\u003e() \u003cspan style=\"color:#f92672\"\u003e*\u003c/span\u003e \u003cspan style=\"color:#ae81ff\"\u003e10\u003c/span\u003e) \u003cspan style=\"color:#f92672\"\u003e+\u003c/span\u003e \u003cspan style=\"color:#ae81ff\"\u003e5\u003c/span\u003e;\n\u003c/span\u003e\u003c/span\u003e\u003cspan style=\"display:flex;\"\u003e\u003cspan\u003e    \u003cspan style=\"color:#a6e22e\"\u003esetHp\u003c/span\u003e(\u003cspan style=\"color:#a6e22e\"\u003eprev\u003c/span\u003e \u003cspan style=\"color:#f92672\"\u003e=\u0026gt;\u003c/span\u003e Math.\u003cspan style=\"color:#a6e22e\"\u003emax\u003c/span\u003e(\u003cspan style=\"color:#ae81ff\"\u003e0\u003c/span\u003e, \u003cspan style=\"color:#a6e22e\"\u003eprev\u003c/span\u003e \u003cspan style=\"color:#f92672\"\u003e-\u003c/span\u003e \u003cspan style=\"color:#a6e22e\"\u003edamage\u003c/span\u003e));\n\u003c/span\u003e\u003c/span\u003e\u003cspan style=\"display:flex;\"\u003e\u003cspan\u003e  };\n\u003c/span\u003e\u003c/span\u003e\u003cspan style=\"display:flex;\"\u003e\u003cspan\u003e\n\u003c/span\u003e\u003c/span\u003e\u003cspan style=\"display:flex;\"\u003e\u003cspan\u003e  \u003cspan style=\"color:#66d9ef\"\u003ereturn\u003c/span\u003e \u0026lt;\u003cspan style=\"color:#f92672\"\u003ebutton\u003c/span\u003e \u003cspan style=\"color:#a6e22e\"\u003eonClick\u003c/span\u003e\u003cspan style=\"color:#f92672\"\u003e=\u003c/span\u003e{\u003cspan style=\"color:#a6e22e\"\u003ehandleAttack\u003c/span\u003e}\u0026gt;\u003cspan style=\"color:#960050;background-color:#1e0010\"\u003e攻撃を受ける\u003c/span\u003e\u0026lt;/\u003cspan style=\"color:#f92672\"\u003ebutton\u003c/span\u003e\u0026gt;;\n\u003c/span\u003e\u003c/span\u003e\u003cspan style=\"display:flex;\"\u003e\u003cspan\u003e};\n\u003c/span\u003e\u003c/span\u003e\u003c/code\u003e\u003c/pre\u003e\u003c/div\u003e\u003cp\u003eこのような設計には以下の課題があります：\u003c/p\u003e\n\u003col\u003e\n\u003cli\u003e\u003cstrong\u003eテストの困難さ\u003c/strong\u003e: \u003ccode\u003eMath.random()\u003c/code\u003e が直接使われているため、結果が不安定でユニットテストが書きにくい。\u003c/li\u003e\n\u003cli\u003e\u003cstrong\u003e再利用性の欠如\u003c/strong\u003e: この「ダメージ計算ロジック」を、サーバーサイドや別の UI フレームワークで使い回すことができない。\u003c/li\u003e\n\u003cli\u003e\u003cstrong\u003e依存の混入\u003c/strong\u003e: ロジックを動かすために React の実行環境（レンダリングサイクル）が必要になる。\u003c/li\u003e\n\u003c/ol\u003e\n\u003chr\u003e\n\u003ch2 id=\"設計npm-workspaces-による物理的隔離\"\u003e設計：npm workspaces による物理的隔離\u003c/h2\u003e\n\u003cp\u003eロジックを「物理的に」隔離するために、以下の monorepo 構成を採用します。\u003c/p\u003e","title":"TypeScript npm workspaces でゲームロジックを UI から完全分離する"},{"content":"TypeScript Union 型でスキルのレンジ種別とダメージ計算を型安全に実装する ゲーム開発、特に RPG やタクティカルゲームにおいて、スキルの「射程（レンジ）」や「効果範囲（AOE: Area of Effect）」の実装は非常に複雑になりがちです。単体攻撃、直線、周囲、円形、さらには自分自身を対象とするものまで、そのバリエーションは多岐にわたります。\nこれらを string 型の ID や、大量の if 文、あるいは複雑なクラス継承で管理しようとすると、いつか必ず「新しいレンジを追加したのに、判定処理を書き忘れた」あるいは「このスキルタイプには不要なはずのパラメータが混入している」といったバグに直面します。\nTypeScript の Union 型（特に Discriminated Union / 判別可能な共用体） を活用すれば、こうしたロジックをコンパイルレベルで安全に保護し、設計意図をコードに焼き付けることができます。本記事では、架空のゲームを題材に、any や as を一切使わずに、メンテナンス性の高いスキルシステムを構築する手法を徹底解説します。\n1. Union 型でスキル種別を表現する まず、スキルの「レンジ（範囲）」を型として定義します。ここでのポイントは、単に名前を列挙するのではなく、「そのレンジを計算するために最低限必要なデータ」をセットにすることです。\nなぜこの設計にするのか（データ構造の純粋性） オブジェクト指向的な発想では、BaseRange クラスを継承して AreaRange クラスを作る、といった方法が取られがちです。しかし、ゲームデータ（特に JSON などでシリアライズされるデータ）を扱う場合、純粋なオブジェクト（POJO）として扱える方がシリアリアライズの相性が良く、ロジックを分離（疎結合）しやすくなります。\n// 1. 各レンジの個別定義 type MeleeRange = { type: \u0026#39;melee\u0026#39; }; // 隣接マス（1マス） type RangedRange = { type: \u0026#39;ranged\u0026#39;; distance: number }; // 遠距離（単体指定）。最大射程が必要。 type LineRange = { type: \u0026#39;line\u0026#39;; length: number; width?: number; // オプションで太さを持たせることも可能 }; // 直線。貫通距離が必要。 type AreaRange = { type: \u0026#39;area\u0026#39;; radius: number; excludeCenter?: boolean }; // 円形または四角形範囲。半径が必要。 type SurroundRange = { type: \u0026#39;surround\u0026#39; }; // 自分の周囲8マス。パラメータ不要。 type SelfRange = { type: \u0026#39;self\u0026#39; }; // 自分自身。パラメータ不要。 // 2. これらを統合した Union 型（Discriminated Union） export type SkillRange = | MeleeRange | RangedRange | LineRange | AreaRange | SurroundRange | SelfRange; // 3. スキルの全体定義 export interface Skill { id: string; name: string; description: string; range: SkillRange; // 型安全なレンジ定義 baseDamage: number; scalingStat: \u0026#39;STR\u0026#39; | \u0026#39;INT\u0026#39; | \u0026#39;DEX\u0026#39;; // どのステータスに依存するか manaCost: number; } このように type という「タグ（判別子）」を持たせることで、TypeScript は「type が 'area' なら radius プロパティが確実に存在する」と認識します。逆に、'melee' の時には radius にアクセスしようとするとコンパイルエラーになります。これにより、不必要なデータへの依存を完全に排除できます。\n2. レンジ別ヒット判定の実装（網羅性チェックの活用） 次に、選択したスキルがフィールド上のどのマスに影響を与えるかを計算するロジックを実装します。ここで重要なのが、switch 文による Exhaustive Check（網羅性チェック） です。\n実装例：影響マスの算出ロジック type Point = { x: number; y: number }; /** * 指定したスキルと方向から、影響を受ける座標リストを計算する * @param origin 攻撃者の位置 * @param direction 攻撃方向（{x:1, y:0} など） * @param range スキルのレンジ定義 */ export function getAffectedCells( origin: Point, direction: Point, range: SkillRange ): Point[] { // ここで range.type によって型が絞り込まれる（Type Narrowing） switch (range.type) { case \u0026#39;melee\u0026#39;: return [{ x: origin.x + direction.x, y: origin.y + direction.y }]; case \u0026#39;ranged\u0026#39;: // 実際の実装ではカーソル位置などが必要だが、ここでは射程端を返す return [{ x: origin.x + direction.x * range.distance, y: origin.y + direction.y * range.distance }]; case \u0026#39;line\u0026#39;: const lineCells: Point[] = []; for (let i = 1; i \u0026lt;= range.length; i++) { lineCells.push({ x: origin.x + direction.x * i, y: origin.y + direction.y * i }); } return lineCells; case \u0026#39;area\u0026#39;: const areaCells: Point[] = []; for (let dx = -range.radius; dx \u0026lt;= range.radius; dx++) { for (let dy = -range.radius; dy \u0026lt;= range.radius; dy++) { // ユークリッド距離で円形判定 if (Math.sqrt(dx * dx + dy * dy) \u0026lt;= range.radius) { areaCells.push({ x: origin.x + dx, y: origin.y + dy }); } } } return areaCells; case \u0026#39;surround\u0026#39;: const surround: Point[] = []; for (let dx = -1; dx \u0026lt;= 1; dx++) { for (let dy = -1; dy \u0026lt;= 1; dy++) { if (dx === 0 \u0026amp;\u0026amp; dy === 0) continue; surround.push({ x: origin.x + dx, y: origin.y + dy }); } } return surround; case \u0026#39;self\u0026#39;: return [origin]; default: /** * 【重要】網羅性チェック（Exhaustive Check） * もし SkillRange に新しい type が追加されたのに、 * この switch 文で case を追加し忘れている場合、 * range は never 型にならず、ここでコンパイルエラーが発生する。 */ const _exhaustiveCheck: never = range; throw new Error(`Unhandled range type: ${(_exhaustiveCheck as any).type}`); } } 網羅性チェックが「開発の武器」になる理由 大規模な開発では、エンジニア A が新しいレンジ（例：扇形 cone）を追加し、エンジニア B がその描画処理を書く、といった分業が発生します。 このとき、getAffectedCells のような重要ロジックに default: never のガードを置いておけば、「新しいレンジを追加した瞬間に、プロジェクト中の判定ロジックがコンパイルエラーとして浮き彫りになる」 という状態を作れます。これは、ユニットテスト以上に強力な「実装の強制力」となります。\n3. ステータスに応じたダメージ計算と VIT 防御計算 スキルの命中範囲が決まったら、次は個別のターゲットに対するダメージ計算です。ここでは、スキルの特性（物理・魔法など）と、攻撃者・防御者のステータスを組み合わせます。\nステータスとユニットの型定義 export interface UnitStats { STR: number; // 筋力 INT: number; // 知力 DEX: number; // 器用さ VIT: number; // 生命力（物理防御） MEN: number; // 精神力（魔法防御） } export interface GameUnit { id: string; name: string; stats: UnitStats; currentHp: number; } ダメージ計算式の実装 /** * スキルによる最終ダメージを算出する */ export function calculateDamage( attacker: GameUnit, defender: GameUnit, skill: Skill ): number { const attackPower = attacker.stats[skill.scalingStat]; // 物理攻撃なら VIT, 魔法攻撃なら MEN を参照 const isMagic = skill.scalingStat === \u0026#39;INT\u0026#39;; const defensePower = isMagic ? defender.stats.MEN : defender.stats.VIT; const baseMultiplier = skill.baseDamage / 100; const rawDamage = (attackPower * baseMultiplier) - (defensePower * 0.4); return Math.floor(Math.max(1, rawDamage)); } 4. 発展：クラス継承 vs Union 型 「なぜクラス継承を使わないのか？」という疑問に答えるために、両者の設計思想を比較してみましょう。\nクラス継承による設計（OOP） abstract class BaseRange { abstract getAffectedCells(origin: Point, dir: Point): Point[]; } class AreaRange extends BaseRange { constructor(public radius: number) { super(); } getAffectedCells(origin: Point, dir: Point) { /* 実装 */ } } メリット: ロジックとデータが一体化しており、新しいレンジを追加する際に既存のコード（switch 文など）を触る必要がない（Open-Closed Principle）。 デメリット: データのシリアライズ（JSON 保存）が面倒。ロジックが各クラスに分散するため、全体の見通しが悪くなることがある。\nUnion 型による設計（関数型 / データ指向） メリット: データ構造がシンプルで JSON との親和性が高い。ロジックが getAffectedCells という一箇所に集約されるため、デバッグが容易。網羅性チェックにより、実装漏れを防げる。 デメリット: 新しいレンジを追加する際、既存の switch 文を修正する必要がある。\n現代的なゲームフロントエンド（React / Redux / Vue など）や、状態管理を不変（Immutable）に行うシステムでは、Union 型による設計の方が圧倒的に相性が良い です。\n5. 応用：ターゲット選択の型安全な拡張 スキルには「誰を対象にするか」という情報も必要です。これも Union 型でネストさせることができます。\ntype TargetFilter = \u0026#39;all\u0026#39; | \u0026#39;enemies\u0026#39; | \u0026#39;allies\u0026#39; | \u0026#39;except-self\u0026#39;; export interface ComplexSkill extends Skill { targetFilter: TargetFilter; } function filterTargets( attacker: GameUnit, candidates: GameUnit[], filter: TargetFilter ): GameUnit[] { switch (filter) { case \u0026#39;all\u0026#39;: return candidates; case \u0026#39;enemies\u0026#39;: return candidates.filter(u =\u0026gt; isEnemy(attacker, u)); case \u0026#39;allies\u0026#39;: return candidates.filter(u =\u0026gt; isAlly(attacker, u)); case \u0026#39;except-self\u0026#39;: return candidates.filter(u =\u0026gt; u.id !== attacker.id); // ここでも網羅性チェックが可能 } } 6. まとめ：型安全なゲーム設計がもたらす長期的なメリット 本記事では、TypeScript の Union 型を活用したスキルの設計手法を見てきました。\nデータの純粋性: Union 型により、各レンジに必要なデータだけを無駄なく持たせることができた。 ロジックの安全性: never 型を用いた網羅性チェックにより、実装漏れをコンパイルエラーとして検出できた。 キャストの排除: any や as を使わずに、ステータス参照やダメージ計算を型安全に行えた。 ゲーム開発は、機能追加やバランス調整による「破壊的な変更」が日常茶飯事です。その中で、「コードを変更したときに、どこが壊れたかを型システムが教えてくれる」 という安心感は、開発スピードを劇的に向上させます。\n最初は型定義が面倒に感じるかもしれませんが、一度この「型に守られた開発」を体験すると、もう string や any だらけのコードには戻れなくなるはずです。ぜひ、あなたのプロジェクトでも、Union 型による型主導の設計を取り入れてみてください。\n著者注: 本記事のサンプルコードは、概念理解のために簡略化しています。実際のタクティカルゲーム等では、高低差の判定、障害物による遮蔽（Line of Sight）、複数回ヒットする多段攻撃など、さらに複雑な要素が加わりますが、それらも同様に Union 型をネストさせることで美しく表現可能です。\n","permalink":"https://techblog.wasutech.dev/posts/typescript-union-skill-design/","summary":"\u003ch1 id=\"typescript-union-型でスキルのレンジ種別とダメージ計算を型安全に実装する\"\u003eTypeScript Union 型でスキルのレンジ種別とダメージ計算を型安全に実装する\u003c/h1\u003e\n\u003cp\u003eゲーム開発、特に RPG やタクティカルゲームにおいて、スキルの「射程（レンジ）」や「効果範囲（AOE: Area of Effect）」の実装は非常に複雑になりがちです。単体攻撃、直線、周囲、円形、さらには自分自身を対象とするものまで、そのバリエーションは多岐にわたります。\u003c/p\u003e\n\u003cp\u003eこれらを \u003ccode\u003estring\u003c/code\u003e 型の ID や、大量の \u003ccode\u003eif\u003c/code\u003e 文、あるいは複雑なクラス継承で管理しようとすると、いつか必ず「新しいレンジを追加したのに、判定処理を書き忘れた」あるいは「このスキルタイプには不要なはずのパラメータが混入している」といったバグに直面します。\u003c/p\u003e\n\u003cp\u003eTypeScript の \u003cstrong\u003eUnion 型（特に Discriminated Union / 判別可能な共用体）\u003c/strong\u003e を活用すれば、こうしたロジックをコンパイルレベルで安全に保護し、設計意図をコードに焼き付けることができます。本記事では、架空のゲームを題材に、\u003ccode\u003eany\u003c/code\u003e や \u003ccode\u003eas\u003c/code\u003e を一切使わずに、メンテナンス性の高いスキルシステムを構築する手法を徹底解説します。\u003c/p\u003e\n\u003chr\u003e\n\u003ch2 id=\"1-union-型でスキル種別を表現する\"\u003e1. Union 型でスキル種別を表現する\u003c/h2\u003e\n\u003cp\u003eまず、スキルの「レンジ（範囲）」を型として定義します。ここでのポイントは、単に名前を列挙するのではなく、\u003cstrong\u003e「そのレンジを計算するために最低限必要なデータ」をセットにする\u003c/strong\u003eことです。\u003c/p\u003e\n\u003ch3 id=\"なぜこの設計にするのかデータ構造の純粋性\"\u003eなぜこの設計にするのか（データ構造の純粋性）\u003c/h3\u003e\n\u003cp\u003eオブジェクト指向的な発想では、\u003ccode\u003eBaseRange\u003c/code\u003e クラスを継承して \u003ccode\u003eAreaRange\u003c/code\u003e クラスを作る、といった方法が取られがちです。しかし、ゲームデータ（特に JSON などでシリアライズされるデータ）を扱う場合、純粋なオブジェクト（POJO）として扱える方がシリアリアライズの相性が良く、ロジックを分離（疎結合）しやすくなります。\u003c/p\u003e\n\u003cdiv class=\"highlight\"\u003e\u003cpre tabindex=\"0\" style=\"color:#f8f8f2;background-color:#272822;-moz-tab-size:4;-o-tab-size:4;tab-size:4;\"\u003e\u003ccode class=\"language-typescript\" data-lang=\"typescript\"\u003e\u003cspan style=\"display:flex;\"\u003e\u003cspan\u003e\u003cspan style=\"color:#75715e\"\u003e// 1. 各レンジの個別定義\n\u003c/span\u003e\u003c/span\u003e\u003c/span\u003e\u003cspan style=\"display:flex;\"\u003e\u003cspan\u003e\u003cspan style=\"color:#75715e\"\u003e\u003c/span\u003e\u003cspan style=\"color:#66d9ef\"\u003etype\u003c/span\u003e \u003cspan style=\"color:#a6e22e\"\u003eMeleeRange\u003c/span\u003e \u003cspan style=\"color:#f92672\"\u003e=\u003c/span\u003e { \n\u003c/span\u003e\u003c/span\u003e\u003cspan style=\"display:flex;\"\u003e\u003cspan\u003e  \u003cspan style=\"color:#66d9ef\"\u003etype\u003c/span\u003e\u003cspan style=\"color:#f92672\"\u003e:\u003c/span\u003e \u003cspan style=\"color:#e6db74\"\u003e\u0026#39;melee\u0026#39;\u003c/span\u003e \n\u003c/span\u003e\u003c/span\u003e\u003cspan style=\"display:flex;\"\u003e\u003cspan\u003e}; \u003cspan style=\"color:#75715e\"\u003e// 隣接マス（1マス）\n\u003c/span\u003e\u003c/span\u003e\u003c/span\u003e\u003cspan style=\"display:flex;\"\u003e\u003cspan\u003e\u003cspan style=\"color:#75715e\"\u003e\u003c/span\u003e\n\u003c/span\u003e\u003c/span\u003e\u003cspan style=\"display:flex;\"\u003e\u003cspan\u003e\u003cspan style=\"color:#66d9ef\"\u003etype\u003c/span\u003e \u003cspan style=\"color:#a6e22e\"\u003eRangedRange\u003c/span\u003e \u003cspan style=\"color:#f92672\"\u003e=\u003c/span\u003e { \n\u003c/span\u003e\u003c/span\u003e\u003cspan style=\"display:flex;\"\u003e\u003cspan\u003e  \u003cspan style=\"color:#66d9ef\"\u003etype\u003c/span\u003e\u003cspan style=\"color:#f92672\"\u003e:\u003c/span\u003e \u003cspan style=\"color:#e6db74\"\u003e\u0026#39;ranged\u0026#39;\u003c/span\u003e; \n\u003c/span\u003e\u003c/span\u003e\u003cspan style=\"display:flex;\"\u003e\u003cspan\u003e  \u003cspan style=\"color:#a6e22e\"\u003edistance\u003c/span\u003e: \u003cspan style=\"color:#66d9ef\"\u003enumber\u003c/span\u003e \n\u003c/span\u003e\u003c/span\u003e\u003cspan style=\"display:flex;\"\u003e\u003cspan\u003e}; \u003cspan style=\"color:#75715e\"\u003e// 遠距離（単体指定）。最大射程が必要。\n\u003c/span\u003e\u003c/span\u003e\u003c/span\u003e\u003cspan style=\"display:flex;\"\u003e\u003cspan\u003e\u003cspan style=\"color:#75715e\"\u003e\u003c/span\u003e\n\u003c/span\u003e\u003c/span\u003e\u003cspan style=\"display:flex;\"\u003e\u003cspan\u003e\u003cspan style=\"color:#66d9ef\"\u003etype\u003c/span\u003e \u003cspan style=\"color:#a6e22e\"\u003eLineRange\u003c/span\u003e \u003cspan style=\"color:#f92672\"\u003e=\u003c/span\u003e { \n\u003c/span\u003e\u003c/span\u003e\u003cspan style=\"display:flex;\"\u003e\u003cspan\u003e  \u003cspan style=\"color:#66d9ef\"\u003etype\u003c/span\u003e\u003cspan style=\"color:#f92672\"\u003e:\u003c/span\u003e \u003cspan style=\"color:#e6db74\"\u003e\u0026#39;line\u0026#39;\u003c/span\u003e; \n\u003c/span\u003e\u003c/span\u003e\u003cspan style=\"display:flex;\"\u003e\u003cspan\u003e  \u003cspan style=\"color:#a6e22e\"\u003elength\u003c/span\u003e: \u003cspan style=\"color:#66d9ef\"\u003enumber\u003c/span\u003e;\n\u003c/span\u003e\u003c/span\u003e\u003cspan style=\"display:flex;\"\u003e\u003cspan\u003e  \u003cspan style=\"color:#a6e22e\"\u003ewidth?\u003c/span\u003e: \u003cspan style=\"color:#66d9ef\"\u003enumber\u003c/span\u003e; \u003cspan style=\"color:#75715e\"\u003e// オプションで太さを持たせることも可能\n\u003c/span\u003e\u003c/span\u003e\u003c/span\u003e\u003cspan style=\"display:flex;\"\u003e\u003cspan\u003e\u003cspan style=\"color:#75715e\"\u003e\u003c/span\u003e}; \u003cspan style=\"color:#75715e\"\u003e// 直線。貫通距離が必要。\n\u003c/span\u003e\u003c/span\u003e\u003c/span\u003e\u003cspan style=\"display:flex;\"\u003e\u003cspan\u003e\u003cspan style=\"color:#75715e\"\u003e\u003c/span\u003e\n\u003c/span\u003e\u003c/span\u003e\u003cspan style=\"display:flex;\"\u003e\u003cspan\u003e\u003cspan style=\"color:#66d9ef\"\u003etype\u003c/span\u003e \u003cspan style=\"color:#a6e22e\"\u003eAreaRange\u003c/span\u003e \u003cspan style=\"color:#f92672\"\u003e=\u003c/span\u003e { \n\u003c/span\u003e\u003c/span\u003e\u003cspan style=\"display:flex;\"\u003e\u003cspan\u003e  \u003cspan style=\"color:#66d9ef\"\u003etype\u003c/span\u003e\u003cspan style=\"color:#f92672\"\u003e:\u003c/span\u003e \u003cspan style=\"color:#e6db74\"\u003e\u0026#39;area\u0026#39;\u003c/span\u003e; \n\u003c/span\u003e\u003c/span\u003e\u003cspan style=\"display:flex;\"\u003e\u003cspan\u003e  \u003cspan style=\"color:#a6e22e\"\u003eradius\u003c/span\u003e: \u003cspan style=\"color:#66d9ef\"\u003enumber\u003c/span\u003e; \n\u003c/span\u003e\u003c/span\u003e\u003cspan style=\"display:flex;\"\u003e\u003cspan\u003e  \u003cspan style=\"color:#a6e22e\"\u003eexcludeCenter?\u003c/span\u003e: \u003cspan style=\"color:#66d9ef\"\u003eboolean\u003c/span\u003e \n\u003c/span\u003e\u003c/span\u003e\u003cspan style=\"display:flex;\"\u003e\u003cspan\u003e}; \u003cspan style=\"color:#75715e\"\u003e// 円形または四角形範囲。半径が必要。\n\u003c/span\u003e\u003c/span\u003e\u003c/span\u003e\u003cspan style=\"display:flex;\"\u003e\u003cspan\u003e\u003cspan style=\"color:#75715e\"\u003e\u003c/span\u003e\n\u003c/span\u003e\u003c/span\u003e\u003cspan style=\"display:flex;\"\u003e\u003cspan\u003e\u003cspan style=\"color:#66d9ef\"\u003etype\u003c/span\u003e \u003cspan style=\"color:#a6e22e\"\u003eSurroundRange\u003c/span\u003e \u003cspan style=\"color:#f92672\"\u003e=\u003c/span\u003e { \n\u003c/span\u003e\u003c/span\u003e\u003cspan style=\"display:flex;\"\u003e\u003cspan\u003e  \u003cspan style=\"color:#66d9ef\"\u003etype\u003c/span\u003e\u003cspan style=\"color:#f92672\"\u003e:\u003c/span\u003e \u003cspan style=\"color:#e6db74\"\u003e\u0026#39;surround\u0026#39;\u003c/span\u003e \n\u003c/span\u003e\u003c/span\u003e\u003cspan style=\"display:flex;\"\u003e\u003cspan\u003e}; \u003cspan style=\"color:#75715e\"\u003e// 自分の周囲8マス。パラメータ不要。\n\u003c/span\u003e\u003c/span\u003e\u003c/span\u003e\u003cspan style=\"display:flex;\"\u003e\u003cspan\u003e\u003cspan style=\"color:#75715e\"\u003e\u003c/span\u003e\n\u003c/span\u003e\u003c/span\u003e\u003cspan style=\"display:flex;\"\u003e\u003cspan\u003e\u003cspan style=\"color:#66d9ef\"\u003etype\u003c/span\u003e \u003cspan style=\"color:#a6e22e\"\u003eSelfRange\u003c/span\u003e \u003cspan style=\"color:#f92672\"\u003e=\u003c/span\u003e { \n\u003c/span\u003e\u003c/span\u003e\u003cspan style=\"display:flex;\"\u003e\u003cspan\u003e  \u003cspan style=\"color:#66d9ef\"\u003etype\u003c/span\u003e\u003cspan style=\"color:#f92672\"\u003e:\u003c/span\u003e \u003cspan style=\"color:#e6db74\"\u003e\u0026#39;self\u0026#39;\u003c/span\u003e \n\u003c/span\u003e\u003c/span\u003e\u003cspan style=\"display:flex;\"\u003e\u003cspan\u003e}; \u003cspan style=\"color:#75715e\"\u003e// 自分自身。パラメータ不要。\n\u003c/span\u003e\u003c/span\u003e\u003c/span\u003e\u003cspan style=\"display:flex;\"\u003e\u003cspan\u003e\u003cspan style=\"color:#75715e\"\u003e\u003c/span\u003e\n\u003c/span\u003e\u003c/span\u003e\u003cspan style=\"display:flex;\"\u003e\u003cspan\u003e\u003cspan style=\"color:#75715e\"\u003e// 2. これらを統合した Union 型（Discriminated Union）\n\u003c/span\u003e\u003c/span\u003e\u003c/span\u003e\u003cspan style=\"display:flex;\"\u003e\u003cspan\u003e\u003cspan style=\"color:#75715e\"\u003e\u003c/span\u003e\u003cspan style=\"color:#66d9ef\"\u003eexport\u003c/span\u003e \u003cspan style=\"color:#66d9ef\"\u003etype\u003c/span\u003e \u003cspan style=\"color:#a6e22e\"\u003eSkillRange\u003c/span\u003e \u003cspan style=\"color:#f92672\"\u003e=\u003c/span\u003e\n\u003c/span\u003e\u003c/span\u003e\u003cspan style=\"display:flex;\"\u003e\u003cspan\u003e  \u003cspan style=\"color:#f92672\"\u003e|\u003c/span\u003e \u003cspan style=\"color:#a6e22e\"\u003eMeleeRange\u003c/span\u003e\n\u003c/span\u003e\u003c/span\u003e\u003cspan style=\"display:flex;\"\u003e\u003cspan\u003e  \u003cspan style=\"color:#f92672\"\u003e|\u003c/span\u003e \u003cspan style=\"color:#a6e22e\"\u003eRangedRange\u003c/span\u003e\n\u003c/span\u003e\u003c/span\u003e\u003cspan style=\"display:flex;\"\u003e\u003cspan\u003e  \u003cspan style=\"color:#f92672\"\u003e|\u003c/span\u003e \u003cspan style=\"color:#a6e22e\"\u003eLineRange\u003c/span\u003e\n\u003c/span\u003e\u003c/span\u003e\u003cspan style=\"display:flex;\"\u003e\u003cspan\u003e  \u003cspan style=\"color:#f92672\"\u003e|\u003c/span\u003e \u003cspan style=\"color:#a6e22e\"\u003eAreaRange\u003c/span\u003e\n\u003c/span\u003e\u003c/span\u003e\u003cspan style=\"display:flex;\"\u003e\u003cspan\u003e  \u003cspan style=\"color:#f92672\"\u003e|\u003c/span\u003e \u003cspan style=\"color:#a6e22e\"\u003eSurroundRange\u003c/span\u003e\n\u003c/span\u003e\u003c/span\u003e\u003cspan style=\"display:flex;\"\u003e\u003cspan\u003e  \u003cspan style=\"color:#f92672\"\u003e|\u003c/span\u003e \u003cspan style=\"color:#a6e22e\"\u003eSelfRange\u003c/span\u003e;\n\u003c/span\u003e\u003c/span\u003e\u003cspan style=\"display:flex;\"\u003e\u003cspan\u003e\n\u003c/span\u003e\u003c/span\u003e\u003cspan style=\"display:flex;\"\u003e\u003cspan\u003e\u003cspan style=\"color:#75715e\"\u003e// 3. スキルの全体定義\n\u003c/span\u003e\u003c/span\u003e\u003c/span\u003e\u003cspan style=\"display:flex;\"\u003e\u003cspan\u003e\u003cspan style=\"color:#75715e\"\u003e\u003c/span\u003e\u003cspan style=\"color:#66d9ef\"\u003eexport\u003c/span\u003e \u003cspan style=\"color:#66d9ef\"\u003einterface\u003c/span\u003e \u003cspan style=\"color:#a6e22e\"\u003eSkill\u003c/span\u003e {\n\u003c/span\u003e\u003c/span\u003e\u003cspan style=\"display:flex;\"\u003e\u003cspan\u003e  \u003cspan style=\"color:#a6e22e\"\u003eid\u003c/span\u003e: \u003cspan style=\"color:#66d9ef\"\u003estring\u003c/span\u003e;\n\u003c/span\u003e\u003c/span\u003e\u003cspan style=\"display:flex;\"\u003e\u003cspan\u003e  \u003cspan style=\"color:#a6e22e\"\u003ename\u003c/span\u003e: \u003cspan style=\"color:#66d9ef\"\u003estring\u003c/span\u003e;\n\u003c/span\u003e\u003c/span\u003e\u003cspan style=\"display:flex;\"\u003e\u003cspan\u003e  \u003cspan style=\"color:#a6e22e\"\u003edescription\u003c/span\u003e: \u003cspan style=\"color:#66d9ef\"\u003estring\u003c/span\u003e;\n\u003c/span\u003e\u003c/span\u003e\u003cspan style=\"display:flex;\"\u003e\u003cspan\u003e  \u003cspan style=\"color:#a6e22e\"\u003erange\u003c/span\u003e: \u003cspan style=\"color:#66d9ef\"\u003eSkillRange\u003c/span\u003e; \u003cspan style=\"color:#75715e\"\u003e// 型安全なレンジ定義\n\u003c/span\u003e\u003c/span\u003e\u003c/span\u003e\u003cspan style=\"display:flex;\"\u003e\u003cspan\u003e\u003cspan style=\"color:#75715e\"\u003e\u003c/span\u003e  \u003cspan style=\"color:#a6e22e\"\u003ebaseDamage\u003c/span\u003e: \u003cspan style=\"color:#66d9ef\"\u003enumber\u003c/span\u003e;\n\u003c/span\u003e\u003c/span\u003e\u003cspan style=\"display:flex;\"\u003e\u003cspan\u003e  \u003cspan style=\"color:#a6e22e\"\u003escalingStat\u003c/span\u003e\u003cspan style=\"color:#f92672\"\u003e:\u003c/span\u003e \u003cspan style=\"color:#e6db74\"\u003e\u0026#39;STR\u0026#39;\u003c/span\u003e \u003cspan style=\"color:#f92672\"\u003e|\u003c/span\u003e \u003cspan style=\"color:#e6db74\"\u003e\u0026#39;INT\u0026#39;\u003c/span\u003e \u003cspan style=\"color:#f92672\"\u003e|\u003c/span\u003e \u003cspan style=\"color:#e6db74\"\u003e\u0026#39;DEX\u0026#39;\u003c/span\u003e; \u003cspan style=\"color:#75715e\"\u003e// どのステータスに依存するか\n\u003c/span\u003e\u003c/span\u003e\u003c/span\u003e\u003cspan style=\"display:flex;\"\u003e\u003cspan\u003e\u003cspan style=\"color:#75715e\"\u003e\u003c/span\u003e  \u003cspan style=\"color:#a6e22e\"\u003emanaCost\u003c/span\u003e: \u003cspan style=\"color:#66d9ef\"\u003enumber\u003c/span\u003e;\n\u003c/span\u003e\u003c/span\u003e\u003cspan style=\"display:flex;\"\u003e\u003cspan\u003e}\n\u003c/span\u003e\u003c/span\u003e\u003c/code\u003e\u003c/pre\u003e\u003c/div\u003e\u003cp\u003eこのように \u003ccode\u003etype\u003c/code\u003e という「タグ（判別子）」を持たせることで、TypeScript は「\u003ccode\u003etype\u003c/code\u003e が \u003ccode\u003e'area'\u003c/code\u003e なら \u003ccode\u003eradius\u003c/code\u003e プロパティが確実に存在する」と認識します。逆に、\u003ccode\u003e'melee'\u003c/code\u003e の時には \u003ccode\u003eradius\u003c/code\u003e にアクセスしようとするとコンパイルエラーになります。これにより、不必要なデータへの依存を完全に排除できます。\u003c/p\u003e","title":"TypeScript Union 型でスキルのレンジ種別とダメージ計算を型安全に実装する"},{"content":"ターン制戦闘をフィールドに統合する vs 専用画面に分ける：設計比較と判断基準 ターン制RPGを開発する際、エンジニアが最初に直面する大きな設計判断の一つが「戦闘をどこで行うか」です。具体的には、不思議のダンジョンのように**フィールド上でそのまま戦う（フィールド統合型）のか、ドラゴンクエストのように専用の戦闘画面に遷移する（専用画面型）**のか、という選択です。\nこの選択は単なるビジュアルの違いに留まらず、状態管理（State Management）、当たり判定、AIの実装、そしてスケーラビリティに決定的な影響を与えます。本稿では、TypeScriptを用いた架空のゲームの実装例を交えながら、両アプローチの設計思想と判断基準を深く掘り下げます。\n1. 2つのアプローチの比較 まずは、それぞれの特性をトレードオフの観点から整理します。\n項目 フィールド統合型 (Seamless) 専用画面型 (Isolated) UXの印象 テンポが良い、空間の繋がりを感じる 演出が豪華、戦略に集中できる 状態管理 非常に複雑（フィールド＋戦闘の混合） 比較的単純（画面ごとにStateを全入れ替え） 位置情報の意味 極めて重要（射程、視線、逃走経路） 抽象的（前衛・後衛、ターゲット選択） AIの実装コスト 高い（地形考慮、パス検索が必要） 低い（コマンド選択アルゴリズムに集中） 拡張性 難しい（新しいギミックが戦闘に影響する） 容易（戦闘専用の特殊ルールを作りやすい） 2. フィールド統合型の実装：空間と時間の同期 フィールド統合型（ローグライク方式）では、「移動」と「攻撃」が同じタイムライン上で扱われます。\nなぜその設計にするか プレイヤーが「一歩動く」ことと「剣を振る」ことが同等のコスト（1ターン）を持つため、戦略が空間的になります。壁を背にする、通路に誘い込むといった地形利用が自然にゲームプレイに組み込まれるのが最大のメリットです。\nアクション設計の例 TypeScriptでのアクション定義は以下のようになります。\ntype FieldAction = | { type: \u0026#39;FIELD_PLAYER_MOVE\u0026#39;; direction: Vector2 } | { type: \u0026#39;FIELD_PLAYER_ATTACK\u0026#39;; targetId: string } | { type: \u0026#39;FIELD_MONSTER_TURN_START\u0026#39; } | { type: \u0026#39;FIELD_DAMAGE_ENTITY\u0026#39;; entityId: string; amount: number } | { type: \u0026#39;FIELD_KILL_MONSTER\u0026#39;; monsterId: string }; interface FieldState { player: Player; monsters: Record\u0026lt;string, Monster\u0026gt;; tiles: TileMap; turnOwner: \u0026#39;PLAYER\u0026#39; | \u0026#39;MONSTER\u0026#39;; animations: AnimationQueue; } 実装のポイント この形式では、Reducer が非常に巨大になりがちです。なぜなら、「移動した結果、トラップを踏み、そのダメージでHPが0になり、死亡処理が走る」という一連の連鎖（Side Effects）を、同一のグリッド座標系で計算しなければならないからです。\nconst fieldReducer = (state: FieldState, action: FieldAction): FieldState =\u0026gt; { switch (action.type) { case \u0026#39;FIELD_PLAYER_ATTACK\u0026#39;: const monster = state.monsters[action.targetId]; if (!monster) return state; // 距離計算が必須 const dist = calculateDistance(state.player.pos, monster.pos); if (dist \u0026gt; state.player.range) return state; return { ...state, // 戦闘結果を直接フィールドの状態に反映 monsters: { ...state.monsters, [action.targetId]: { ...monster, hp: monster.hp - state.player.atk } }, animations: [...state.animations, { type: \u0026#39;SLASH\u0026#39;, pos: monster.pos }] }; // ... } }; 3. 専用画面型の実装：コンテキストの分離 専用画面型（エンカウント方式）では、戦闘が開始された瞬間にフィールドのコンテキストがシリアライズされ、独立した「戦闘エンジン」に制御が移ります。\nなぜその設計にするか 最大の理由は**「複雑度のカプセル化」**です。戦闘中、背後の木々や迷路のような地形を考慮する必要がなくなります。これにより、派手なエフェクト、複雑なバフ/デバフ、召喚魔法といった「戦闘専用のロジック」を、フィールドのシステムを壊すことなく自由に追加できます。\nステート遷移の設計 ゲーム全体のステートを以下のように分離します。\ntype GameMode = | { type: \u0026#39;EXPLORATION\u0026#39;; fieldData: FieldState } | { type: \u0026#39;BATTLE\u0026#39;; battleData: BattleState }; interface BattleState { allies: Combatant[]; enemies: Combatant[]; turnIndex: number; selectedCommand?: Command; phase: \u0026#39;INPUT\u0026#39; | \u0026#39;EXECUTION\u0026#39; | \u0026#39;RESULT\u0026#39;; } 実装のポイント 戦闘開始時に「どのモンスターと、どの地形で」遭遇したかという最小限の情報だけを渡します。\nfunction transitionToBattle(field: FieldState, monsterId: string): BattleState { const enemyGroup = spawnEnemyGroup(field.monsters[monsterId].type); return { allies: [transformToCombatant(field.player)], enemies: enemyGroup, turnIndex: 0, phase: \u0026#39;INPUT\u0026#39; }; } この設計の美しさは、BattleReducer がフィールドの座標（Vector2）を一切知らなくて良い点にあります。ターゲット選択はインデックス（enemies[0]）で行われ、AIは純粋な「期待値計算」に専念できます。\n4. ハイブリッド（フィールド上のUI重ね）の罠 「フィールドが見えたまま、メニューだけが戦闘用になる」というハイブリッド型を検討する人も多いですが、これは**「中途半端な実装負荷」**を招きやすい危険な道です。\n入力の競合: 「十字キーで移動したい」のか「メニューを選択したい」のかのフラグ管理が複雑化します。 視覚的同期の不一致: フィールド上のキャラがアニメーションしている間に、裏でStateが更新され、UIのHPバーと実際のデータがズレる等の問題が発生しやすくなります。 もしハイブリッドにするなら、**「操作モードを完全にロックする」か、あるいは「UIをフィールドの一部（World Space UI）として描画する」**覚悟が必要です。\n5. 判断基準：どちらを選ぶべきか 設計を選択する際のチェックリストです。\n「フィールド統合型」を選ぶべきケース リソース管理が主題: 「一歩歩くごとに腹が減る」ような、探索そのものが戦闘であるゲーム。 ポジショニングが核: 挟み撃ち、ノックバックによる壁衝突など、位置関係に戦術の8割がある場合。 シームレスな体験: ロードや画面転換による没入感の中断を極端に嫌う場合。 「専用画面型」を選ぶべきケース ビルドの多様性: 数百種類のスキル、複雑な属性相性、装備の組み合わせを重視する場合。 演出の重視: キャラクターのカットインや、ダイナミックなカメラワークを多用したい場合。 開発チームの分業: 「フィールド担当」と「戦闘ロジック担当」でコードベースを綺麗に分けたい場合。 6. まとめ フィールド統合型は**「空間の整合性」を保つためにエンジニアリングの努力を注ぎ、専用画面型は「ロジックの深さ」**を追求するためにコンテキストを分離します。\nTypeScriptで実装する場合、前者は Reducer 内での座標計算と Side Effect の管理が、後者は FieldState から BattleState へのシリアライズ/デシリアライズの堅牢性が、プロジェクトの成否を分けるポイントになります。\nあなたが作ろうとしているゲームの「面白さのコア」はどこにあるでしょうか？ 座標の上にありますか、それともコマンドの選択肢の中にありますか？ その答えが、自ずと取るべき設計を示してくれるはずです。\n","permalink":"https://techblog.wasutech.dev/posts/turn-based-combat-patterns/","summary":"\u003ch1 id=\"ターン制戦闘をフィールドに統合する-vs-専用画面に分ける設計比較と判断基準\"\u003eターン制戦闘をフィールドに統合する vs 専用画面に分ける：設計比較と判断基準\u003c/h1\u003e\n\u003cp\u003eターン制RPGを開発する際、エンジニアが最初に直面する大きな設計判断の一つが「戦闘をどこで行うか」です。具体的には、不思議のダンジョンのように**フィールド上でそのまま戦う（フィールド統合型）\u003cstrong\u003eのか、ドラゴンクエストのように\u003c/strong\u003e専用の戦闘画面に遷移する（専用画面型）**のか、という選択です。\u003c/p\u003e\n\u003cp\u003eこの選択は単なるビジュアルの違いに留まらず、状態管理（State Management）、当たり判定、AIの実装、そしてスケーラビリティに決定的な影響を与えます。本稿では、TypeScriptを用いた架空のゲームの実装例を交えながら、両アプローチの設計思想と判断基準を深く掘り下げます。\u003c/p\u003e\n\u003chr\u003e\n\u003ch2 id=\"1-2つのアプローチの比較\"\u003e1. 2つのアプローチの比較\u003c/h2\u003e\n\u003cp\u003eまずは、それぞれの特性をトレードオフの観点から整理します。\u003c/p\u003e\n\u003ctable\u003e\n  \u003cthead\u003e\n      \u003ctr\u003e\n          \u003cth style=\"text-align: left\"\u003e項目\u003c/th\u003e\n          \u003cth style=\"text-align: left\"\u003eフィールド統合型 (Seamless)\u003c/th\u003e\n          \u003cth style=\"text-align: left\"\u003e専用画面型 (Isolated)\u003c/th\u003e\n      \u003c/tr\u003e\n  \u003c/thead\u003e\n  \u003ctbody\u003e\n      \u003ctr\u003e\n          \u003ctd style=\"text-align: left\"\u003e\u003cstrong\u003eUXの印象\u003c/strong\u003e\u003c/td\u003e\n          \u003ctd style=\"text-align: left\"\u003eテンポが良い、空間の繋がりを感じる\u003c/td\u003e\n          \u003ctd style=\"text-align: left\"\u003e演出が豪華、戦略に集中できる\u003c/td\u003e\n      \u003c/tr\u003e\n      \u003ctr\u003e\n          \u003ctd style=\"text-align: left\"\u003e\u003cstrong\u003e状態管理\u003c/strong\u003e\u003c/td\u003e\n          \u003ctd style=\"text-align: left\"\u003e非常に複雑（フィールド＋戦闘の混合）\u003c/td\u003e\n          \u003ctd style=\"text-align: left\"\u003e比較的単純（画面ごとにStateを全入れ替え）\u003c/td\u003e\n      \u003c/tr\u003e\n      \u003ctr\u003e\n          \u003ctd style=\"text-align: left\"\u003e\u003cstrong\u003e位置情報の意味\u003c/strong\u003e\u003c/td\u003e\n          \u003ctd style=\"text-align: left\"\u003e極めて重要（射程、視線、逃走経路）\u003c/td\u003e\n          \u003ctd style=\"text-align: left\"\u003e抽象的（前衛・後衛、ターゲット選択）\u003c/td\u003e\n      \u003c/tr\u003e\n      \u003ctr\u003e\n          \u003ctd style=\"text-align: left\"\u003e\u003cstrong\u003eAIの実装コスト\u003c/strong\u003e\u003c/td\u003e\n          \u003ctd style=\"text-align: left\"\u003e高い（地形考慮、パス検索が必要）\u003c/td\u003e\n          \u003ctd style=\"text-align: left\"\u003e低い（コマンド選択アルゴリズムに集中）\u003c/td\u003e\n      \u003c/tr\u003e\n      \u003ctr\u003e\n          \u003ctd style=\"text-align: left\"\u003e\u003cstrong\u003e拡張性\u003c/strong\u003e\u003c/td\u003e\n          \u003ctd style=\"text-align: left\"\u003e難しい（新しいギミックが戦闘に影響する）\u003c/td\u003e\n          \u003ctd style=\"text-align: left\"\u003e容易（戦闘専用の特殊ルールを作りやすい）\u003c/td\u003e\n      \u003c/tr\u003e\n  \u003c/tbody\u003e\n\u003c/table\u003e\n\u003chr\u003e\n\u003ch2 id=\"2-フィールド統合型の実装空間と時間の同期\"\u003e2. フィールド統合型の実装：空間と時間の同期\u003c/h2\u003e\n\u003cp\u003eフィールド統合型（ローグライク方式）では、「移動」と「攻撃」が同じタイムライン上で扱われます。\u003c/p\u003e\n\u003ch3 id=\"なぜその設計にするか\"\u003eなぜその設計にするか\u003c/h3\u003e\n\u003cp\u003eプレイヤーが「一歩動く」ことと「剣を振る」ことが同等のコスト（1ターン）を持つため、戦略が\u003cstrong\u003e空間的\u003c/strong\u003eになります。壁を背にする、通路に誘い込むといった地形利用が自然にゲームプレイに組み込まれるのが最大のメリットです。\u003c/p\u003e\n\u003ch3 id=\"アクション設計の例\"\u003eアクション設計の例\u003c/h3\u003e\n\u003cp\u003eTypeScriptでのアクション定義は以下のようになります。\u003c/p\u003e\n\u003cdiv class=\"highlight\"\u003e\u003cpre tabindex=\"0\" style=\"color:#f8f8f2;background-color:#272822;-moz-tab-size:4;-o-tab-size:4;tab-size:4;\"\u003e\u003ccode class=\"language-typescript\" data-lang=\"typescript\"\u003e\u003cspan style=\"display:flex;\"\u003e\u003cspan\u003e\u003cspan style=\"color:#66d9ef\"\u003etype\u003c/span\u003e \u003cspan style=\"color:#a6e22e\"\u003eFieldAction\u003c/span\u003e \u003cspan style=\"color:#f92672\"\u003e=\u003c/span\u003e\n\u003c/span\u003e\u003c/span\u003e\u003cspan style=\"display:flex;\"\u003e\u003cspan\u003e  \u003cspan style=\"color:#f92672\"\u003e|\u003c/span\u003e { \u003cspan style=\"color:#66d9ef\"\u003etype\u003c/span\u003e\u003cspan style=\"color:#f92672\"\u003e:\u003c/span\u003e \u003cspan style=\"color:#e6db74\"\u003e\u0026#39;FIELD_PLAYER_MOVE\u0026#39;\u003c/span\u003e; \u003cspan style=\"color:#a6e22e\"\u003edirection\u003c/span\u003e: \u003cspan style=\"color:#66d9ef\"\u003eVector2\u003c/span\u003e }\n\u003c/span\u003e\u003c/span\u003e\u003cspan style=\"display:flex;\"\u003e\u003cspan\u003e  \u003cspan style=\"color:#f92672\"\u003e|\u003c/span\u003e { \u003cspan style=\"color:#66d9ef\"\u003etype\u003c/span\u003e\u003cspan style=\"color:#f92672\"\u003e:\u003c/span\u003e \u003cspan style=\"color:#e6db74\"\u003e\u0026#39;FIELD_PLAYER_ATTACK\u0026#39;\u003c/span\u003e; \u003cspan style=\"color:#a6e22e\"\u003etargetId\u003c/span\u003e: \u003cspan style=\"color:#66d9ef\"\u003estring\u003c/span\u003e }\n\u003c/span\u003e\u003c/span\u003e\u003cspan style=\"display:flex;\"\u003e\u003cspan\u003e  \u003cspan style=\"color:#f92672\"\u003e|\u003c/span\u003e { \u003cspan style=\"color:#66d9ef\"\u003etype\u003c/span\u003e\u003cspan style=\"color:#f92672\"\u003e:\u003c/span\u003e \u003cspan style=\"color:#e6db74\"\u003e\u0026#39;FIELD_MONSTER_TURN_START\u0026#39;\u003c/span\u003e }\n\u003c/span\u003e\u003c/span\u003e\u003cspan style=\"display:flex;\"\u003e\u003cspan\u003e  \u003cspan style=\"color:#f92672\"\u003e|\u003c/span\u003e { \u003cspan style=\"color:#66d9ef\"\u003etype\u003c/span\u003e\u003cspan style=\"color:#f92672\"\u003e:\u003c/span\u003e \u003cspan style=\"color:#e6db74\"\u003e\u0026#39;FIELD_DAMAGE_ENTITY\u0026#39;\u003c/span\u003e; \u003cspan style=\"color:#a6e22e\"\u003eentityId\u003c/span\u003e: \u003cspan style=\"color:#66d9ef\"\u003estring\u003c/span\u003e; \u003cspan style=\"color:#a6e22e\"\u003eamount\u003c/span\u003e: \u003cspan style=\"color:#66d9ef\"\u003enumber\u003c/span\u003e }\n\u003c/span\u003e\u003c/span\u003e\u003cspan style=\"display:flex;\"\u003e\u003cspan\u003e  \u003cspan style=\"color:#f92672\"\u003e|\u003c/span\u003e { \u003cspan style=\"color:#66d9ef\"\u003etype\u003c/span\u003e\u003cspan style=\"color:#f92672\"\u003e:\u003c/span\u003e \u003cspan style=\"color:#e6db74\"\u003e\u0026#39;FIELD_KILL_MONSTER\u0026#39;\u003c/span\u003e; \u003cspan style=\"color:#a6e22e\"\u003emonsterId\u003c/span\u003e: \u003cspan style=\"color:#66d9ef\"\u003estring\u003c/span\u003e };\n\u003c/span\u003e\u003c/span\u003e\u003cspan style=\"display:flex;\"\u003e\u003cspan\u003e\n\u003c/span\u003e\u003c/span\u003e\u003cspan style=\"display:flex;\"\u003e\u003cspan\u003e\u003cspan style=\"color:#66d9ef\"\u003einterface\u003c/span\u003e \u003cspan style=\"color:#a6e22e\"\u003eFieldState\u003c/span\u003e {\n\u003c/span\u003e\u003c/span\u003e\u003cspan style=\"display:flex;\"\u003e\u003cspan\u003e  \u003cspan style=\"color:#a6e22e\"\u003eplayer\u003c/span\u003e: \u003cspan style=\"color:#66d9ef\"\u003ePlayer\u003c/span\u003e;\n\u003c/span\u003e\u003c/span\u003e\u003cspan style=\"display:flex;\"\u003e\u003cspan\u003e  \u003cspan style=\"color:#a6e22e\"\u003emonsters\u003c/span\u003e: \u003cspan style=\"color:#66d9ef\"\u003eRecord\u003c/span\u003e\u0026lt;\u003cspan style=\"color:#f92672\"\u003estring\u003c/span\u003e, \u003cspan style=\"color:#a6e22e\"\u003eMonster\u003c/span\u003e\u0026gt;;\n\u003c/span\u003e\u003c/span\u003e\u003cspan style=\"display:flex;\"\u003e\u003cspan\u003e  \u003cspan style=\"color:#a6e22e\"\u003etiles\u003c/span\u003e: \u003cspan style=\"color:#66d9ef\"\u003eTileMap\u003c/span\u003e;\n\u003c/span\u003e\u003c/span\u003e\u003cspan style=\"display:flex;\"\u003e\u003cspan\u003e  \u003cspan style=\"color:#a6e22e\"\u003eturnOwner\u003c/span\u003e\u003cspan style=\"color:#f92672\"\u003e:\u003c/span\u003e \u003cspan style=\"color:#e6db74\"\u003e\u0026#39;PLAYER\u0026#39;\u003c/span\u003e \u003cspan style=\"color:#f92672\"\u003e|\u003c/span\u003e \u003cspan style=\"color:#e6db74\"\u003e\u0026#39;MONSTER\u0026#39;\u003c/span\u003e;\n\u003c/span\u003e\u003c/span\u003e\u003cspan style=\"display:flex;\"\u003e\u003cspan\u003e  \u003cspan style=\"color:#a6e22e\"\u003eanimations\u003c/span\u003e: \u003cspan style=\"color:#66d9ef\"\u003eAnimationQueue\u003c/span\u003e;\n\u003c/span\u003e\u003c/span\u003e\u003cspan style=\"display:flex;\"\u003e\u003cspan\u003e}\n\u003c/span\u003e\u003c/span\u003e\u003c/code\u003e\u003c/pre\u003e\u003c/div\u003e\u003ch3 id=\"実装のポイント\"\u003e実装のポイント\u003c/h3\u003e\n\u003cp\u003eこの形式では、\u003ccode\u003eReducer\u003c/code\u003e が非常に巨大になりがちです。なぜなら、「移動した結果、トラップを踏み、そのダメージでHPが0になり、死亡処理が走る」という一連の連鎖（Side Effects）を、同一のグリッド座標系で計算しなければならないからです。\u003c/p\u003e\n\u003cdiv class=\"highlight\"\u003e\u003cpre tabindex=\"0\" style=\"color:#f8f8f2;background-color:#272822;-moz-tab-size:4;-o-tab-size:4;tab-size:4;\"\u003e\u003ccode class=\"language-typescript\" data-lang=\"typescript\"\u003e\u003cspan style=\"display:flex;\"\u003e\u003cspan\u003e\u003cspan style=\"color:#66d9ef\"\u003econst\u003c/span\u003e \u003cspan style=\"color:#a6e22e\"\u003efieldReducer\u003c/span\u003e \u003cspan style=\"color:#f92672\"\u003e=\u003c/span\u003e (\u003cspan style=\"color:#a6e22e\"\u003estate\u003c/span\u003e: \u003cspan style=\"color:#66d9ef\"\u003eFieldState\u003c/span\u003e, \u003cspan style=\"color:#a6e22e\"\u003eaction\u003c/span\u003e: \u003cspan style=\"color:#66d9ef\"\u003eFieldAction\u003c/span\u003e)\u003cspan style=\"color:#f92672\"\u003e:\u003c/span\u003e \u003cspan style=\"color:#a6e22e\"\u003eFieldState\u003c/span\u003e \u003cspan style=\"color:#f92672\"\u003e=\u0026gt;\u003c/span\u003e {\n\u003c/span\u003e\u003c/span\u003e\u003cspan style=\"display:flex;\"\u003e\u003cspan\u003e  \u003cspan style=\"color:#66d9ef\"\u003eswitch\u003c/span\u003e (\u003cspan style=\"color:#a6e22e\"\u003eaction\u003c/span\u003e.\u003cspan style=\"color:#66d9ef\"\u003etype\u003c/span\u003e) {\n\u003c/span\u003e\u003c/span\u003e\u003cspan style=\"display:flex;\"\u003e\u003cspan\u003e    \u003cspan style=\"color:#66d9ef\"\u003ecase\u003c/span\u003e \u003cspan style=\"color:#e6db74\"\u003e\u0026#39;FIELD_PLAYER_ATTACK\u0026#39;\u003c/span\u003e\u003cspan style=\"color:#f92672\"\u003e:\u003c/span\u003e\n\u003c/span\u003e\u003c/span\u003e\u003cspan style=\"display:flex;\"\u003e\u003cspan\u003e      \u003cspan style=\"color:#66d9ef\"\u003econst\u003c/span\u003e \u003cspan style=\"color:#a6e22e\"\u003emonster\u003c/span\u003e \u003cspan style=\"color:#f92672\"\u003e=\u003c/span\u003e \u003cspan style=\"color:#a6e22e\"\u003estate\u003c/span\u003e.\u003cspan style=\"color:#a6e22e\"\u003emonsters\u003c/span\u003e[\u003cspan style=\"color:#a6e22e\"\u003eaction\u003c/span\u003e.\u003cspan style=\"color:#a6e22e\"\u003etargetId\u003c/span\u003e];\n\u003c/span\u003e\u003c/span\u003e\u003cspan style=\"display:flex;\"\u003e\u003cspan\u003e      \u003cspan style=\"color:#66d9ef\"\u003eif\u003c/span\u003e (\u003cspan style=\"color:#f92672\"\u003e!\u003c/span\u003e\u003cspan style=\"color:#a6e22e\"\u003emonster\u003c/span\u003e) \u003cspan style=\"color:#66d9ef\"\u003ereturn\u003c/span\u003e \u003cspan style=\"color:#a6e22e\"\u003estate\u003c/span\u003e;\n\u003c/span\u003e\u003c/span\u003e\u003cspan style=\"display:flex;\"\u003e\u003cspan\u003e\n\u003c/span\u003e\u003c/span\u003e\u003cspan style=\"display:flex;\"\u003e\u003cspan\u003e      \u003cspan style=\"color:#75715e\"\u003e// 距離計算が必須\n\u003c/span\u003e\u003c/span\u003e\u003c/span\u003e\u003cspan style=\"display:flex;\"\u003e\u003cspan\u003e\u003cspan style=\"color:#75715e\"\u003e\u003c/span\u003e      \u003cspan style=\"color:#66d9ef\"\u003econst\u003c/span\u003e \u003cspan style=\"color:#a6e22e\"\u003edist\u003c/span\u003e \u003cspan style=\"color:#f92672\"\u003e=\u003c/span\u003e \u003cspan style=\"color:#a6e22e\"\u003ecalculateDistance\u003c/span\u003e(\u003cspan style=\"color:#a6e22e\"\u003estate\u003c/span\u003e.\u003cspan style=\"color:#a6e22e\"\u003eplayer\u003c/span\u003e.\u003cspan style=\"color:#a6e22e\"\u003epos\u003c/span\u003e, \u003cspan style=\"color:#a6e22e\"\u003emonster\u003c/span\u003e.\u003cspan style=\"color:#a6e22e\"\u003epos\u003c/span\u003e);\n\u003c/span\u003e\u003c/span\u003e\u003cspan style=\"display:flex;\"\u003e\u003cspan\u003e      \u003cspan style=\"color:#66d9ef\"\u003eif\u003c/span\u003e (\u003cspan style=\"color:#a6e22e\"\u003edist\u003c/span\u003e \u003cspan style=\"color:#f92672\"\u003e\u0026gt;\u003c/span\u003e \u003cspan style=\"color:#a6e22e\"\u003estate\u003c/span\u003e.\u003cspan style=\"color:#a6e22e\"\u003eplayer\u003c/span\u003e.\u003cspan style=\"color:#a6e22e\"\u003erange\u003c/span\u003e) \u003cspan style=\"color:#66d9ef\"\u003ereturn\u003c/span\u003e \u003cspan style=\"color:#a6e22e\"\u003estate\u003c/span\u003e;\n\u003c/span\u003e\u003c/span\u003e\u003cspan style=\"display:flex;\"\u003e\u003cspan\u003e\n\u003c/span\u003e\u003c/span\u003e\u003cspan style=\"display:flex;\"\u003e\u003cspan\u003e      \u003cspan style=\"color:#66d9ef\"\u003ereturn\u003c/span\u003e {\n\u003c/span\u003e\u003c/span\u003e\u003cspan style=\"display:flex;\"\u003e\u003cspan\u003e        ...\u003cspan style=\"color:#a6e22e\"\u003estate\u003c/span\u003e,\n\u003c/span\u003e\u003c/span\u003e\u003cspan style=\"display:flex;\"\u003e\u003cspan\u003e        \u003cspan style=\"color:#75715e\"\u003e// 戦闘結果を直接フィールドの状態に反映\n\u003c/span\u003e\u003c/span\u003e\u003c/span\u003e\u003cspan style=\"display:flex;\"\u003e\u003cspan\u003e\u003cspan style=\"color:#75715e\"\u003e\u003c/span\u003e        \u003cspan style=\"color:#a6e22e\"\u003emonsters\u003c/span\u003e\u003cspan style=\"color:#f92672\"\u003e:\u003c/span\u003e {\n\u003c/span\u003e\u003c/span\u003e\u003cspan style=\"display:flex;\"\u003e\u003cspan\u003e          ...\u003cspan style=\"color:#a6e22e\"\u003estate\u003c/span\u003e.\u003cspan style=\"color:#a6e22e\"\u003emonsters\u003c/span\u003e,\n\u003c/span\u003e\u003c/span\u003e\u003cspan style=\"display:flex;\"\u003e\u003cspan\u003e          [\u003cspan style=\"color:#a6e22e\"\u003eaction\u003c/span\u003e.\u003cspan style=\"color:#a6e22e\"\u003etargetId\u003c/span\u003e]\u003cspan style=\"color:#f92672\"\u003e:\u003c/span\u003e { ...\u003cspan style=\"color:#a6e22e\"\u003emonster\u003c/span\u003e, \u003cspan style=\"color:#a6e22e\"\u003ehp\u003c/span\u003e: \u003cspan style=\"color:#66d9ef\"\u003emonster.hp\u003c/span\u003e \u003cspan style=\"color:#f92672\"\u003e-\u003c/span\u003e \u003cspan style=\"color:#a6e22e\"\u003estate\u003c/span\u003e.\u003cspan style=\"color:#a6e22e\"\u003eplayer\u003c/span\u003e.\u003cspan style=\"color:#a6e22e\"\u003eatk\u003c/span\u003e }\n\u003c/span\u003e\u003c/span\u003e\u003cspan style=\"display:flex;\"\u003e\u003cspan\u003e        },\n\u003c/span\u003e\u003c/span\u003e\u003cspan style=\"display:flex;\"\u003e\u003cspan\u003e        \u003cspan style=\"color:#a6e22e\"\u003eanimations\u003c/span\u003e\u003cspan style=\"color:#f92672\"\u003e:\u003c/span\u003e [...\u003cspan style=\"color:#a6e22e\"\u003estate\u003c/span\u003e.\u003cspan style=\"color:#a6e22e\"\u003eanimations\u003c/span\u003e, { \u003cspan style=\"color:#66d9ef\"\u003etype\u003c/span\u003e\u003cspan style=\"color:#f92672\"\u003e:\u003c/span\u003e \u003cspan style=\"color:#e6db74\"\u003e\u0026#39;SLASH\u0026#39;\u003c/span\u003e, \u003cspan style=\"color:#a6e22e\"\u003epos\u003c/span\u003e: \u003cspan style=\"color:#66d9ef\"\u003emonster.pos\u003c/span\u003e }]\n\u003c/span\u003e\u003c/span\u003e\u003cspan style=\"display:flex;\"\u003e\u003cspan\u003e      };\n\u003c/span\u003e\u003c/span\u003e\u003cspan style=\"display:flex;\"\u003e\u003cspan\u003e    \u003cspan style=\"color:#75715e\"\u003e// ...\n\u003c/span\u003e\u003c/span\u003e\u003c/span\u003e\u003cspan style=\"display:flex;\"\u003e\u003cspan\u003e\u003cspan style=\"color:#75715e\"\u003e\u003c/span\u003e  }\n\u003c/span\u003e\u003c/span\u003e\u003cspan style=\"display:flex;\"\u003e\u003cspan\u003e};\n\u003c/span\u003e\u003c/span\u003e\u003c/code\u003e\u003c/pre\u003e\u003c/div\u003e\u003chr\u003e\n\u003ch2 id=\"3-専用画面型の実装コンテキストの分離\"\u003e3. 専用画面型の実装：コンテキストの分離\u003c/h2\u003e\n\u003cp\u003e専用画面型（エンカウント方式）では、戦闘が開始された瞬間にフィールドのコンテキストがシリアライズされ、独立した「戦闘エンジン」に制御が移ります。\u003c/p\u003e","title":"ターン制戦闘をフィールドに統合する vs 専用画面に分ける：設計比較と判断基準"},{"content":"乱数を interface で抽象化してゲームロジックをテスタブルにする 概要 ゲーム開発において、「運」の要素は面白さを生む不可欠なスパイスです。クリティカルヒット、レアアイテムのドロップ、ダンジョンの自動生成など、多くの場面で乱数が使われます。\nしかし、プログラミングの文脈において、乱数は「不確実性」そのものであり、ユニットテストの天敵です。本記事では、TypeScript を用いて乱数をインターフェースで抽象化し、テスト時に決定論的な（結果が予測可能な）挙動をさせる設計パターンについて解説します。\n課題：Math.random() がもたらす「テスト不能」という病 もっとも素浦に実装すると、ゲームロジックの中で直接 Math.random() を呼び出すことになります。\n// 直接 Math.random() を使う例 export class CombatService { calculateDamage(baseDamage: number, critRate: number): number { // 運が悪ければテストが落ちる if (Math.random() \u0026lt; critRate) { return baseDamage * 2; } return baseDamage; } } このコードをテストしようとすると、以下の問題に直面します。\n非決定的 (Non-deterministic) なテスト: 同じ入力に対して、実行するたびに結果が変わる可能性があります。 境界値のテストが困難: 「クリティカル率 5% のとき、0.049 を引いたらクリティカル、0.051 を引いたら通常攻撃」という境界値の検証が運任せになります。 モックの乱立: vi.spyOn(Math, 'random') などでグローバルなオブジェクトを書き換える手法もありますが、並列テストで干渉したり、クリーンアップを忘れると他のテストに影響を与えたりと、脆いテストになりがちです。 設計：Port / Adapter パターンによる抽象化 この問題を解決するために、Dependency Inversion Principle (依存性逆転の原則) を適用します。\nロジックが「具体的な乱数生成器」に依存するのではなく、抽象的な「乱数提供インターフェース」に依存するように設計を変更します。\n1. Port (Interface) の定義 ロジックが必要とする「乱数を得るための窓口」を定義します。\n2. Adapter (Implementation) の実装 Production Adapter: 本番環境では Math.random() を使う。 Test Adapter: テスト環境では、事前に定義した値を返したり、シード値に基づいた再現性のある乱数を返す。 この設計にすることで、ロジック側は「誰がどうやって乱数を作っているか」を気にせず、「乱数がもらえること」だけを約束された状態になります。\n実装：RandomPort と各種 Adapter 実際に TypeScript で実装してみましょう。\nRandomPort インターフェース まずは、共通の型を定義します。単に 0〜1 の値を返すだけでなく、整数の範囲指定など便利なメソッドも持たせると使い勝手が良くなります。\n/** * 乱数生成の抽象ポート */ export interface RandomPort { /** 0以上1未満の浮動小数を返す */ next(): number; /** min以上max以下の整数を返す */ nextInt(min: number, max: number): number; } 本番用：MathRandomAdapter 標準の Math.random() をラップするだけの実装です。\nexport class MathRandomAdapter implements RandomPort { next(): number { return Math.random(); } nextInt(min: number, max: number): number { return Math.floor(this.next() * (max - min + 1)) + min; } } テスト用：SeededRandomAdapter テストのために、シード値（種）を指定すると必ず同じ順序で数値を返すアダプターを作成します。ここでは簡易的な線形合同法（LCG）を用いた例を示します。\nexport class SeededRandomAdapter implements RandomPort { private seed: number; constructor(seed: number = 42) { this.seed = seed; } next(): number { // 簡易的な LCG アルゴリズム this.seed = (this.seed * 1664525 + 1013904223) % 4294967296; return this.seed / 4294967296; } nextInt(min: number, max: number): number { return Math.floor(this.next() * (max - min + 1)) + min; } } 実践：戦闘ロジックとドロップ判定 この RandomPort を使って、架空の RPG の戦闘・ドロップロジックを書いてみます。\ninterface Item { id: string; name: string; } export class LootSystem { // コンストラクタでインターフェースを注入 (DI) constructor(private random: RandomPort) {} /** * モンスターを倒した時のドロップ判定 * dropRate: 0.0 ~ 1.0 */ tryGetLoot(item: Item, dropRate: number): Item | null { if (this.random.next() \u0026lt; dropRate) { return item; } return null; } /** * 複数のアイテムから1つを選択（重み付けなし） */ pickOne\u0026lt;T\u0026gt;(items: T[]): T { const index = this.random.nextInt(0, items.length - 1); return items[index]; } } なぜこの設計にしたか この設計の肝は、LootSystem が Math.random() というグローバルな副作用から切り離されている点です。\nLootSystem のインスタンス化の際に SeededRandomAdapter を渡せば、その LootSystem は「1回目の next() は 0.123、2回目は 0.456\u0026hellip;」というように、何度実行しても、どのマシンで実行しても同じ挙動をします。\nテスト例：Vitest による決定論的テスト それでは、Vitest を使ってテストを書いてみましょう。\nimport { describe, it, expect } from \u0026#39;vitest\u0026#39;; import { LootSystem } from \u0026#39;./LootSystem\u0026#39;; import { SeededRandomAdapter } from \u0026#39;./SeededRandomAdapter\u0026#39;; describe(\u0026#39;LootSystem\u0026#39;, () =\u0026gt; { const mockItem = { id: \u0026#39;potion\u0026#39;, name: \u0026#39;ポーション\u0026#39; }; it(\u0026#39;ドロップ率100%なら必ずアイテムが手に入ること\u0026#39;, () =\u0026gt; { // このテストでは乱数の質は関係ないので、何でも良い const lootSystem = new LootSystem(new SeededRandomAdapter()); const result = lootSystem.tryGetLoot(mockItem, 1.0); expect(result).toEqual(mockItem); }); it(\u0026#39;シード値を固定することで、特定のドロップ結果を再現できること\u0026#39;, () =\u0026gt; { // シード 12345 において、最初の next() が 0.5 以上になることを知っているとする // (あるいは、境界値を攻めるために固定値アダプターを作っても良い) const seed = 12345; const rng = new SeededRandomAdapter(seed); const lootSystem = new LootSystem(rng); // ドロップ率が非常に低い場合 const result = lootSystem.tryGetLoot(mockItem, 0.00001); // シード固定により、この結果は常に null になる（決定論的） expect(result).toBeNull(); }); it(\u0026#39;pickOne が配列の範囲内でランダムに選択すること\u0026#39;, () =\u0026gt; { const rng = new SeededRandomAdapter(999); const lootSystem = new LootSystem(rng); const items = [\u0026#39;A\u0026#39;, \u0026#39;B\u0026#39;, \u0026#39;C\u0026#39;, \u0026#39;D\u0026#39;]; // 100回試行しても、必ず範囲内のものが選ばれ、かつ特定のシードなら順序も固定 for (let i = 0; i \u0026lt; 100; i++) { const picked = lootSystem.pickOne(items); expect(items).toContain(picked); } }); }); さらに、もっと厳密に「特定の値を返してほしい」場合は、以下のようなシンプルな Stub を作ることも可能です。\nclass StubRandomAdapter implements RandomPort { constructor(public value: number) {} next() { return this.value; } nextInt() { return Math.floor(this.value); } } it(\u0026#39;境界値テスト：ドロップ率 0.05 のとき 0.049 ならドロップする\u0026#39;, () =\u0026gt; { const rng = new StubRandomAdapter(0.049); const lootSystem = new LootSystem(rng); expect(lootSystem.tryGetLoot(mockItem, 0.05)).not.toBeNull(); }); まとめ 乱数を interface で抽象化することには、単に「テストができるようになる」以上のメリットがあります。\nテストの信頼性: 運に左右される「不安定なテスト (Flaky Tests)」を排除できます。 デバッグの容易性: バグが発生した時のシード値をログに残しておけば、開発環境でそのシード値を使って全く同じ状況を再現できます。 リプレイ機能の実現: ゲームの対戦ログなどを保存する際、すべての乱数結果を保存する代わりに、初期シード値だけを保存すれば、後から全く同じ展開を再現できます。 コードの意図の明確化: コンストラクタで RandomPort を要求することで、「このクラスは内部でランダムな挙動を含む」ということを型レベルで明示できます。 「外部への依存（この場合は実行環境の乱数生成器）」をインターフェースの境界線の外側に押し出す。このシンプルな原則を守るだけで、あなたのゲームロジックは格段に堅牢で、メンテナンスしやすいものになるはずです。\n","permalink":"https://techblog.wasutech.dev/posts/random-interface-testability/","summary":"\u003ch1 id=\"乱数を-interface-で抽象化してゲームロジックをテスタブルにする\"\u003e乱数を interface で抽象化してゲームロジックをテスタブルにする\u003c/h1\u003e\n\u003ch2 id=\"概要\"\u003e概要\u003c/h2\u003e\n\u003cp\u003eゲーム開発において、「運」の要素は面白さを生む不可欠なスパイスです。クリティカルヒット、レアアイテムのドロップ、ダンジョンの自動生成など、多くの場面で乱数が使われます。\u003c/p\u003e\n\u003cp\u003eしかし、プログラミングの文脈において、乱数は「不確実性」そのものであり、ユニットテストの天敵です。本記事では、TypeScript を用いて乱数をインターフェースで抽象化し、テスト時に決定論的な（結果が予測可能な）挙動をさせる設計パターンについて解説します。\u003c/p\u003e\n\u003chr\u003e\n\u003ch2 id=\"課題mathrandom-がもたらすテスト不能という病\"\u003e課題：\u003ccode\u003eMath.random()\u003c/code\u003e がもたらす「テスト不能」という病\u003c/h2\u003e\n\u003cp\u003eもっとも素浦に実装すると、ゲームロジックの中で直接 \u003ccode\u003eMath.random()\u003c/code\u003e を呼び出すことになります。\u003c/p\u003e\n\u003cdiv class=\"highlight\"\u003e\u003cpre tabindex=\"0\" style=\"color:#f8f8f2;background-color:#272822;-moz-tab-size:4;-o-tab-size:4;tab-size:4;\"\u003e\u003ccode class=\"language-typescript\" data-lang=\"typescript\"\u003e\u003cspan style=\"display:flex;\"\u003e\u003cspan\u003e\u003cspan style=\"color:#75715e\"\u003e// 直接 Math.random() を使う例\n\u003c/span\u003e\u003c/span\u003e\u003c/span\u003e\u003cspan style=\"display:flex;\"\u003e\u003cspan\u003e\u003cspan style=\"color:#75715e\"\u003e\u003c/span\u003e\u003cspan style=\"color:#66d9ef\"\u003eexport\u003c/span\u003e \u003cspan style=\"color:#66d9ef\"\u003eclass\u003c/span\u003e \u003cspan style=\"color:#a6e22e\"\u003eCombatService\u003c/span\u003e {\n\u003c/span\u003e\u003c/span\u003e\u003cspan style=\"display:flex;\"\u003e\u003cspan\u003e  \u003cspan style=\"color:#a6e22e\"\u003ecalculateDamage\u003c/span\u003e(\u003cspan style=\"color:#a6e22e\"\u003ebaseDamage\u003c/span\u003e: \u003cspan style=\"color:#66d9ef\"\u003enumber\u003c/span\u003e, \u003cspan style=\"color:#a6e22e\"\u003ecritRate\u003c/span\u003e: \u003cspan style=\"color:#66d9ef\"\u003enumber\u003c/span\u003e)\u003cspan style=\"color:#f92672\"\u003e:\u003c/span\u003e \u003cspan style=\"color:#66d9ef\"\u003enumber\u003c/span\u003e {\n\u003c/span\u003e\u003c/span\u003e\u003cspan style=\"display:flex;\"\u003e\u003cspan\u003e    \u003cspan style=\"color:#75715e\"\u003e// 運が悪ければテストが落ちる\n\u003c/span\u003e\u003c/span\u003e\u003c/span\u003e\u003cspan style=\"display:flex;\"\u003e\u003cspan\u003e\u003cspan style=\"color:#75715e\"\u003e\u003c/span\u003e    \u003cspan style=\"color:#66d9ef\"\u003eif\u003c/span\u003e (Math.\u003cspan style=\"color:#a6e22e\"\u003erandom\u003c/span\u003e() \u003cspan style=\"color:#f92672\"\u003e\u0026lt;\u003c/span\u003e \u003cspan style=\"color:#a6e22e\"\u003ecritRate\u003c/span\u003e) {\n\u003c/span\u003e\u003c/span\u003e\u003cspan style=\"display:flex;\"\u003e\u003cspan\u003e      \u003cspan style=\"color:#66d9ef\"\u003ereturn\u003c/span\u003e \u003cspan style=\"color:#a6e22e\"\u003ebaseDamage\u003c/span\u003e \u003cspan style=\"color:#f92672\"\u003e*\u003c/span\u003e \u003cspan style=\"color:#ae81ff\"\u003e2\u003c/span\u003e;\n\u003c/span\u003e\u003c/span\u003e\u003cspan style=\"display:flex;\"\u003e\u003cspan\u003e    }\n\u003c/span\u003e\u003c/span\u003e\u003cspan style=\"display:flex;\"\u003e\u003cspan\u003e    \u003cspan style=\"color:#66d9ef\"\u003ereturn\u003c/span\u003e \u003cspan style=\"color:#a6e22e\"\u003ebaseDamage\u003c/span\u003e;\n\u003c/span\u003e\u003c/span\u003e\u003cspan style=\"display:flex;\"\u003e\u003cspan\u003e  }\n\u003c/span\u003e\u003c/span\u003e\u003cspan style=\"display:flex;\"\u003e\u003cspan\u003e}\n\u003c/span\u003e\u003c/span\u003e\u003c/code\u003e\u003c/pre\u003e\u003c/div\u003e\u003cp\u003eこのコードをテストしようとすると、以下の問題に直面します。\u003c/p\u003e\n\u003col\u003e\n\u003cli\u003e\u003cstrong\u003e非決定的 (Non-deterministic) なテスト\u003c/strong\u003e: 同じ入力に対して、実行するたびに結果が変わる可能性があります。\u003c/li\u003e\n\u003cli\u003e\u003cstrong\u003e境界値のテストが困難\u003c/strong\u003e: 「クリティカル率 5% のとき、0.049 を引いたらクリティカル、0.051 を引いたら通常攻撃」という境界値の検証が運任せになります。\u003c/li\u003e\n\u003cli\u003e\u003cstrong\u003eモックの乱立\u003c/strong\u003e: \u003ccode\u003evi.spyOn(Math, 'random')\u003c/code\u003e などでグローバルなオブジェクトを書き換える手法もありますが、並列テストで干渉したり、クリーンアップを忘れると他のテストに影響を与えたりと、脆いテストになりがちです。\u003c/li\u003e\n\u003c/ol\u003e\n\u003chr\u003e\n\u003ch2 id=\"設計port--adapter-パターンによる抽象化\"\u003e設計：Port / Adapter パターンによる抽象化\u003c/h2\u003e\n\u003cp\u003eこの問題を解決するために、\u003cstrong\u003eDependency Inversion Principle (依存性逆転の原則)\u003c/strong\u003e を適用します。\u003c/p\u003e\n\u003cp\u003eロジックが「具体的な乱数生成器」に依存するのではなく、抽象的な「乱数提供インターフェース」に依存するように設計を変更します。\u003c/p\u003e\n\u003ch3 id=\"1-port-interface-の定義\"\u003e1. Port (Interface) の定義\u003c/h3\u003e\n\u003cp\u003eロジックが必要とする「乱数を得るための窓口」を定義します。\u003c/p\u003e\n\u003ch3 id=\"2-adapter-implementation-の実装\"\u003e2. Adapter (Implementation) の実装\u003c/h3\u003e\n\u003cul\u003e\n\u003cli\u003e\u003cstrong\u003eProduction Adapter\u003c/strong\u003e: 本番環境では \u003ccode\u003eMath.random()\u003c/code\u003e を使う。\u003c/li\u003e\n\u003cli\u003e\u003cstrong\u003eTest Adapter\u003c/strong\u003e: テスト環境では、事前に定義した値を返したり、シード値に基づいた再現性のある乱数を返す。\u003c/li\u003e\n\u003c/ul\u003e\n\u003cp\u003eこの設計にすることで、ロジック側は「誰がどうやって乱数を作っているか」を気にせず、「乱数がもらえること」だけを約束された状態になります。\u003c/p\u003e","title":"乱数を interface で抽象化してゲームロジックをテスタブルにする"},{"content":"症状 Emacsで .go ファイルを開くと以下のログが無限ループする。\n[eglot] Asking EGLOT (mydns/(go-ts-mode go-mod-ts-mode)) politely to terminate [jsonrpc] Server exited with status 2 [eglot] Reconnected! [eglot] Connected! Server \u0026#39;gopls\u0026#39; now managing \u0026#39;(go-ts-mode go-mod-ts-mode)\u0026#39; buffers in project \u0026#39;mydns\u0026#39;. [jsonrpc] (warning) Sentinel for EGLOT (...) still hasn\u0026#39;t run, deleting it! [jsonrpc] Server exited with status 9 [eglot] Reconnected! [2 times] Error running timer: (error \u0026#34;Selecting deleted buffer\u0026#34;) status 9 は SIGKILL。gopls が起動→即死→reconnect を繰り返し、補完が一切効かない状態。\nfmt. と打っても候補が出ない、もしくは関係のないゴミ候補が出る。手動で M-x completion-at-point を叩いても No match。\n調査 eglot-events-bufferで何も見えない まず M-x eglot-events-buffer でgoplsとのやり取りを確認しようとしたが、何も表示されなかった。\n設定を確認すると原因がわかった。\n(setq eglot-events-buffer-config \u0026#39;(:size 0 :format short)) :size 0 でイベントバッファを無効化していた。デバッグのためにまず :size 100 以上に変更する必要がある。\ncompletion-at-pointでNo match M-x completion-at-point を手動で叩いても No match。goplsまでリクエストが届いていないことが確定した。\neglot-reconnectでループ開始 M-x eglot-reconnect を試したところ、上記のループが始まった。\ngoplsバイナリ自体は正常 goplsが壊れている可能性を疑った。\nwhich gopls # /home/wasu/go/bin/gopls gopls version # golang.org/x/tools/gopls v0.21.1 バイナリは正常にインストールされていた。\nデーモンモードの調査 goplsのログに以下が出ていた。\nserve.go:173: Gopls LSP daemon: listening on tcp network, address :12345... デーモンモードで動いているgoplsとeglotが競合している可能性を疑った。ss -atn | grep 12345 で確認したが該当なし。デーモンは関係なかった。\npkill -9 goplsは効かなかった プロセスが残骸として残っている可能性を疑い pkill -9 gopls を試したが、ループは継続した。\n設定ファイルの確認 lsp.elを確認したところ、eglot-managed-mode-hook に刺さっているパッケージが複数あった。\neglot-x eglot-tempel eldoc-box eglot-signature-eldoc-talkative flymake-collection cape まず eglot-x を疑った。eglotの内部フックを書き換えるパッケージで、壊れると症状がわかりにくい。:disabled t にして package-delete で削除したが、ループは継続した。\n素のeglotに削る lsp.elをeglotだけの最小構成に削った。\n(use-package eglot :config (setq eglot-events-buffer-config \u0026#39;(:size 100 :format full) eglot-send-changes-idle-time 1.0) (add-to-list \u0026#39;eglot-server-programs \u0026#39;((go-ts-mode go-mod-ts-mode) . (\u0026#34;gopls\u0026#34; \u0026#34;-remote=auto\u0026#34;)))) 補完が動いた。 eglot周辺のパッケージが原因と確定。\n一個ずつ戻す 以下の順で追加して都度 fmt. で補完を確認した。\nflymake → ok eglot-tempel → ok eldoc-box → ok eglot-signature-eldoc-talkative → ok consult-eglot → ok jsonrpc → ok flymake-collection → ok cape → ok eglot-x → ok 全部戻しても動いた。\n結果 犯人を特定できなかった。\n推定原因1 Lsp関連のパッケージをすべて消して、一つずつ追加していったことによって、パッケージが更新されたこと要因かもしれないと見ている。\n実際には更新されず、インストール済みのパッケージファイルを見ている場合はその限りではない？しかし現実問題動いたので混乱している。\n推定原因2 明示的に更新したのは eglot-x を一度 package-delete で削除し、:vc で再取得していた。\n(use-package eglot-x :straight nil :vc ( :fetcher github :repo \u0026#34;nemethf/eglot-x\u0026#34;) :after eglot :config (eglot-x-setup)) なお、eglot-xの再取得は全パッケージを削除する前に行っていた。\nつまり「一個ずつ戻す」作業の中でeglot-xが更新されたわけではない。\n推定原因2も確度は低い。結局原因不明のまま直った。\nまとめ やったこと 結果 eglot-events-buffer で確認 :size 0 で無効化されていて何も見えなかった completion-at-point No match、goplsまで届いていない goplsバイナリ確認 正常 デーモンモード調査 関係なかった pkill -9 gopls 効かなかった 素のeglotに削る 補完が動いた パッケージを一個ずつ戻す 全部okだった=いずれかの古いパッケージが更新された？(推定原因1) eglot-x を再取得済み 以降ループが止まった（推定原因2） eglotのフックに刺さるパッケージが壊れると症状がわかりにくい。補完が死んだらまず素のeglotに戻して二分探索するのが有効だった。\nこんなことで数時間溶かすことになるとは。\n","permalink":"https://techblog.wasutech.dev/posts/emacs-gopls-failed-loop/","summary":"\u003ch2 id=\"症状\"\u003e症状\u003c/h2\u003e\n\u003cp\u003eEmacsで \u003ccode\u003e.go\u003c/code\u003e ファイルを開くと以下のログが無限ループする。\u003c/p\u003e\n\u003cpre tabindex=\"0\"\u003e\u003ccode\u003e[eglot] Asking EGLOT (mydns/(go-ts-mode go-mod-ts-mode)) politely to terminate\n[jsonrpc] Server exited with status 2\n[eglot] Reconnected!\n[eglot] Connected! Server \u0026#39;gopls\u0026#39; now managing \u0026#39;(go-ts-mode go-mod-ts-mode)\u0026#39; buffers in project \u0026#39;mydns\u0026#39;.\n[jsonrpc] (warning) Sentinel for EGLOT (...) still hasn\u0026#39;t run, deleting it!\n[jsonrpc] Server exited with status 9\n[eglot] Reconnected! [2 times]\nError running timer: (error \u0026#34;Selecting deleted buffer\u0026#34;)\n\u003c/code\u003e\u003c/pre\u003e\u003cp\u003e\u003ccode\u003estatus 9\u003c/code\u003e は SIGKILL。gopls が起動→即死→reconnect を繰り返し、補完が一切効かない状態。\u003c/p\u003e\n\u003cp\u003e\u003ccode\u003efmt.\u003c/code\u003e と打っても候補が出ない、もしくは関係のないゴミ候補が出る。手動で \u003ccode\u003eM-x completion-at-point\u003c/code\u003e を叩いても \u003ccode\u003eNo match\u003c/code\u003e。\u003c/p\u003e","title":"EmacsでGoのLsp補完が死んだときの調査記録"},{"content":"はじめに Part5でUIまで実装が終わった。機能として動くものはできた。テストも15件、全パス。\nここで一度立ち止まって、なぜこの設計になったのかを振り返る。\n正直に言う。最初の要件はこうだった。\n「クリーンアーキテクチャっぽく、テスタブルにしたい」\nそれだけだった。クリーンアーキテクチャを理解して設計したわけじゃない。 結果的にクリーンアーキテクチャを遂行したのは生成AIだ。\nそしてPart3以降、自分はほとんど手を動かさなくなった。 設計ドキュメントをAIに渡したら、実装サイクルがほぼ自動で回るようになったからだ。\n途中でこう思った。\n「俺いる必要なくね？」\nこの記事はその問いに向き合いながら、設計を改めて言語化したものだ。\nこのシリーズ自体がTDDだった 書き終えてから気づいたことがある。\nこのシリーズのサイクルはこうだった。\nAIに実装させる ↓ 動かす・読む・会話する（認識のズレを検出） ↓ ズレを言語化してAIにフィードバック ↓ 納得したら記事にする ソフトウェアのTDDは「Red → Green → Refactor」だけど、 自分がやっていたのはこれだ。\nRed → 認識がズレていると感じる Green → 会話して納得する Refactor → 記事として言語化する 人間がテストケースになっていた。\nTDDが「仕様をテストで表現する」なら、自分がやっていたのは「理解をフィードバックループで検証する」だ。構造は同じだと思う。\n誤っていた認識たち 振り返ると、理解がズレていた箇所がいくつかあった。\n「PolicyはVOと1-1になる」と思っていた 最初、ReadingStatusPolicyがReadingStatusに対応しているのを見て、PolicyはVOと対になるものだと思っていた。\n違う。今回たまたま1-1になっているだけだ。\nPolicyの本質は複数のEntityやVOをまたいだ条件判定だ。たとえば「同じ本に同じユーザーが2回レビューできない」というルールは、Book・User・Review[]をまたぐ。これをPolicyに出す。\nVOに閉じるルールならVOに書けばいい。Entityをまたぐ判定が必要になったときにPolicyの出番だ。\n「Domainは外部を知らない」という捉え方が逆だった 「DomainはDBを知らない」という言い方をよくするが、正確には逆だ。\n外部がDomainを知っている。\n向きの問題だ。Domainが何かを避けているのではなく、依存の矢印がすべてDomainに向かって刺さっている。\nRoute Handler ↓ Service ↓ Repository interface ↓ Domain（Entity / ValueObject / Policy） この向きがわかって初めて、Serviceの役割も見えた。\nServiceが何をするのかわかっていなかった 依存の向きが腑に落ちるまで、Serviceが何者なのかずっと曖昧だった。\n答えはシンプルだった。フローだけ持つ接着剤。\nasync startReading(id: string): Promise\u0026lt;Book\u0026gt; { const book = await this.repo.findById(id); // 取得 if (!book) throw new NotFoundError(...); book.changeStatus(ReadingStatus.Reading); // Entityのルールに従う await this.repo.save(book); // 保存 return book; } ServiceはDomainのルールを自分で判定しない。 canTransitionを自分で呼ばない。book.changeStatus()に委ねるだけだ。 判定はEntity・VO・Policyが持っている。Serviceはその結果を使ってフローを組む。\n依存の向きがわかって、はじめてServiceの「フローだけ持つ」という役割が見えた。この順番で理解する必要があった。\n設計の全体像 改めて整理する。\nValueObject（VO） ルール付きの値。型の拡張。\nexport class ISBN { constructor(public readonly value: string) { if (!value.match(/^\\d{13}$/)) { throw new Error(\u0026#34;ISBNは13桁の数字\u0026#34;); } } } ISBN型のインスタンスが存在する時点で、13桁の数字であることが保証される。\nPolicy 条件判定だけ。副作用なし、booleanを返すだけ。\nstatic canTransition(from: ReadingStatus, to: ReadingStatus): boolean { const rules = { [ReadingStatus.Unread]: [ReadingStatus.Reading], [ReadingStatus.Reading]: [ReadingStatus.Completed], [ReadingStatus.Completed]: [ReadingStatus.Reading], }; return rules[from].includes(to); } Entity 複数のVOが絡むルールを持つ構造体。Policyを呼んで状態遷移を制御する。\nchangeStatus(to: ReadingStatus): void { if (!ReadingStatusPolicy.canTransition(this.status, to)) { throw new Error(`${this.status}から${to}への変更は不可`); } this.status = to; } Service ユースケースのフロー。ルール判定はDomainに委ねる。\nRepository DBとの橋渡し。Domainはこの存在を知らない。\nなぜテストが書きやすかったか DomainはDBもHTTPも知らないので、インスタンスを作るだけでテストできる。\ntest(\u0026#34;積読から直接読了はエラー\u0026#34;, async () =\u0026gt; { const service = new BookShelfService(createMockRepo()); await service.addBook(\u0026#34;1\u0026#34;, \u0026#34;Clean Code\u0026#34;, \u0026#34;9784048860000\u0026#34;); await expect(service.completeReading(\u0026#34;1\u0026#34;)).rejects.toThrow( \u0026#34;UnreadからCompletedへの変更は不可\u0026#34;, ); }); MockはMapベースのインメモリ実装をテストファイル内に書いただけ。 Prismaもサーバーも起動していない。\n依存の向きを守った結果としてテストが書きやすくなった。 テストのために設計したのではなく、設計が正しいからテストが書けた。\nクリーンアーキテクチャの簡略版として この設計はクリーンアーキテクチャの考え方をベースにしている。\n参考: The Clean Architecture – Clean Coder Blog\nフルだと4層あって、PresenterやViewModelも分離する。 今回省略しているのはPresenterとUseCase interfaceの分離。 規模に対して過剰になる部分は省いた。\n依存の向きだけは守った。それだけで十分にテスタブルな設計になった。\nただしこれを最初から理解して設計したわけじゃない。 「クリーンアーキテクチャっぽく、テスタブルにしたい」と言っただけで、 構造を作ったのはAIだ。自分は後から理解した。\n「俺いる必要なくね？」に対する答え Part3以降で手を放したせいで、設計の理解が抜けていた。 PolicyとVOの関係も、依存の向きも、Serviceの役割も、改めて問われると曖昧だった。\n実装が自動化されることと、設計を理解していることは別の話だ。\nAIが得意なのは仕様が決まった実装だ。 「何を仕様にするか」と「その設計が正しいかどうか」は人間が判断する必要がある。 今回それをサボっていた。\nそしてもう一つ。 「NDL連携を入れる」「Reviewを今入れない」という判断はAIがしていない。 何を作るかを決めたのは自分だ。AIは決まったことを実装した。\n「俺いる必要なくね？」の答えは、理解をサボったら本当にいらなくなる、だと思う。\nまとめ 最初の要件は「クリーンアーキテクチャっぽく、テスタブルにしたい」だけだった 設計を遂行したのはAIで、自分は後から理解した PolicyはVOと1-1ではない。複数EntityをまたぐときにPolicyが出てくる 依存の向きは「Domainが外部を知らない」ではなく「外部がDomainを知っている」 Serviceの役割はフローだけ。依存の向きがわかって初めて見えた このシリーズ自体がTDDだった。人間がテストケースになっていた 実装を自動化しても、設計の理解は自分でやる必要がある 次のPartではUser・ReviewのDB拡張とReviewServiceを実装する予定。 今度は手を動かす部分を意識的に残す。\n","permalink":"https://techblog.wasutech.dev/posts/test-driven-design-readmeter-6/","summary":"\u003ch2 id=\"はじめに\"\u003eはじめに\u003c/h2\u003e\n\u003cp\u003ePart5でUIまで実装が終わった。機能として動くものはできた。テストも15件、全パス。\u003c/p\u003e\n\u003cp\u003eここで一度立ち止まって、\u003cstrong\u003eなぜこの設計になったのか\u003c/strong\u003eを振り返る。\u003c/p\u003e\n\u003cp\u003e正直に言う。最初の要件はこうだった。\u003c/p\u003e\n\u003cblockquote\u003e\n\u003cp\u003e「クリーンアーキテクチャっぽく、テスタブルにしたい」\u003c/p\u003e\u003c/blockquote\u003e\n\u003cp\u003eそれだけだった。クリーンアーキテクチャを理解して設計したわけじゃない。\n\u003cstrong\u003e結果的にクリーンアーキテクチャを遂行したのは生成AIだ。\u003c/strong\u003e\u003c/p\u003e\n\u003cp\u003eそしてPart3以降、自分はほとんど手を動かさなくなった。\n設計ドキュメントをAIに渡したら、実装サイクルがほぼ自動で回るようになったからだ。\u003c/p\u003e\n\u003cp\u003e途中でこう思った。\u003c/p\u003e\n\u003cp\u003e\u003cstrong\u003e「俺いる必要なくね？」\u003c/strong\u003e\u003c/p\u003e\n\u003cp\u003eこの記事はその問いに向き合いながら、設計を改めて言語化したものだ。\u003c/p\u003e\n\u003chr\u003e\n\u003ch2 id=\"このシリーズ自体がtddだった\"\u003eこのシリーズ自体がTDDだった\u003c/h2\u003e\n\u003cp\u003e書き終えてから気づいたことがある。\u003c/p\u003e\n\u003cp\u003eこのシリーズのサイクルはこうだった。\u003c/p\u003e\n\u003cpre tabindex=\"0\"\u003e\u003ccode\u003eAIに実装させる\n  ↓\n動かす・読む・会話する（認識のズレを検出）\n  ↓\nズレを言語化してAIにフィードバック\n  ↓\n納得したら記事にする\n\u003c/code\u003e\u003c/pre\u003e\u003cp\u003eソフトウェアのTDDは「Red → Green → Refactor」だけど、\n自分がやっていたのはこれだ。\u003c/p\u003e\n\u003cul\u003e\n\u003cli\u003e\u003cstrong\u003eRed\u003c/strong\u003e → 認識がズレていると感じる\u003c/li\u003e\n\u003cli\u003e\u003cstrong\u003eGreen\u003c/strong\u003e → 会話して納得する\u003c/li\u003e\n\u003cli\u003e\u003cstrong\u003eRefactor\u003c/strong\u003e → 記事として言語化する\u003c/li\u003e\n\u003c/ul\u003e\n\u003cp\u003e\u003cstrong\u003e人間がテストケースになっていた。\u003c/strong\u003e\u003c/p\u003e\n\u003cp\u003eTDDが「仕様をテストで表現する」なら、自分がやっていたのは「理解をフィードバックループで検証する」だ。構造は同じだと思う。\u003c/p\u003e\n\u003chr\u003e\n\u003ch2 id=\"誤っていた認識たち\"\u003e誤っていた認識たち\u003c/h2\u003e\n\u003cp\u003e振り返ると、理解がズレていた箇所がいくつかあった。\u003c/p\u003e\n\u003ch3 id=\"policyはvoと1-1になると思っていた\"\u003e「PolicyはVOと1-1になる」と思っていた\u003c/h3\u003e\n\u003cp\u003e最初、\u003ccode\u003eReadingStatusPolicy\u003c/code\u003eが\u003ccode\u003eReadingStatus\u003c/code\u003eに対応しているのを見て、PolicyはVOと対になるものだと思っていた。\u003c/p\u003e\n\u003cp\u003e違う。今回たまたま1-1になっているだけだ。\u003c/p\u003e\n\u003cp\u003ePolicyの本質は\u003cstrong\u003e複数のEntityやVOをまたいだ条件判定\u003c/strong\u003eだ。たとえば「同じ本に同じユーザーが2回レビューできない」というルールは、Book・User・Review[]をまたぐ。これをPolicyに出す。\u003c/p\u003e\n\u003cp\u003eVOに閉じるルールならVOに書けばいい。Entityをまたぐ判定が必要になったときにPolicyの出番だ。\u003c/p\u003e\n\u003ch3 id=\"domainは外部を知らないという捉え方が逆だった\"\u003e「Domainは外部を知らない」という捉え方が逆だった\u003c/h3\u003e\n\u003cp\u003e「DomainはDBを知らない」という言い方をよくするが、正確には逆だ。\u003c/p\u003e\n\u003cp\u003e\u003cstrong\u003e外部がDomainを知っている。\u003c/strong\u003e\u003c/p\u003e\n\u003cp\u003e向きの問題だ。Domainが何かを避けているのではなく、依存の矢印がすべてDomainに向かって刺さっている。\u003c/p\u003e\n\u003cpre tabindex=\"0\"\u003e\u003ccode\u003eRoute Handler\n  ↓\nService\n  ↓\nRepository interface\n  ↓\nDomain（Entity / ValueObject / Policy）\n\u003c/code\u003e\u003c/pre\u003e\u003cp\u003eこの向きがわかって初めて、Serviceの役割も見えた。\u003c/p\u003e\n\u003ch3 id=\"serviceが何をするのかわかっていなかった\"\u003eServiceが何をするのかわかっていなかった\u003c/h3\u003e\n\u003cp\u003e依存の向きが腑に落ちるまで、Serviceが何者なのかずっと曖昧だった。\u003c/p\u003e\n\u003cp\u003e答えはシンプルだった。\u003cstrong\u003eフローだけ持つ接着剤。\u003c/strong\u003e\u003c/p\u003e\n\u003cdiv class=\"highlight\"\u003e\u003cpre tabindex=\"0\" style=\"color:#f8f8f2;background-color:#272822;-moz-tab-size:4;-o-tab-size:4;tab-size:4;\"\u003e\u003ccode class=\"language-typescript\" data-lang=\"typescript\"\u003e\u003cspan style=\"display:flex;\"\u003e\u003cspan\u003e\u003cspan style=\"color:#66d9ef\"\u003easync\u003c/span\u003e \u003cspan style=\"color:#a6e22e\"\u003estartReading\u003c/span\u003e(\u003cspan style=\"color:#a6e22e\"\u003eid\u003c/span\u003e: \u003cspan style=\"color:#66d9ef\"\u003estring\u003c/span\u003e)\u003cspan style=\"color:#f92672\"\u003e:\u003c/span\u003e \u003cspan style=\"color:#a6e22e\"\u003ePromise\u003c/span\u003e\u0026lt;\u003cspan style=\"color:#f92672\"\u003eBook\u003c/span\u003e\u0026gt; {\n\u003c/span\u003e\u003c/span\u003e\u003cspan style=\"display:flex;\"\u003e\u003cspan\u003e  \u003cspan style=\"color:#66d9ef\"\u003econst\u003c/span\u003e \u003cspan style=\"color:#a6e22e\"\u003ebook\u003c/span\u003e \u003cspan style=\"color:#f92672\"\u003e=\u003c/span\u003e \u003cspan style=\"color:#66d9ef\"\u003eawait\u003c/span\u003e \u003cspan style=\"color:#66d9ef\"\u003ethis\u003c/span\u003e.\u003cspan style=\"color:#a6e22e\"\u003erepo\u003c/span\u003e.\u003cspan style=\"color:#a6e22e\"\u003efindById\u003c/span\u003e(\u003cspan style=\"color:#a6e22e\"\u003eid\u003c/span\u003e);   \u003cspan style=\"color:#75715e\"\u003e// 取得\n\u003c/span\u003e\u003c/span\u003e\u003c/span\u003e\u003cspan style=\"display:flex;\"\u003e\u003cspan\u003e\u003cspan style=\"color:#75715e\"\u003e\u003c/span\u003e  \u003cspan style=\"color:#66d9ef\"\u003eif\u003c/span\u003e (\u003cspan style=\"color:#f92672\"\u003e!\u003c/span\u003e\u003cspan style=\"color:#a6e22e\"\u003ebook\u003c/span\u003e) \u003cspan style=\"color:#66d9ef\"\u003ethrow\u003c/span\u003e \u003cspan style=\"color:#66d9ef\"\u003enew\u003c/span\u003e \u003cspan style=\"color:#a6e22e\"\u003eNotFoundError\u003c/span\u003e(...);\n\u003c/span\u003e\u003c/span\u003e\u003cspan style=\"display:flex;\"\u003e\u003cspan\u003e  \u003cspan style=\"color:#a6e22e\"\u003ebook\u003c/span\u003e.\u003cspan style=\"color:#a6e22e\"\u003echangeStatus\u003c/span\u003e(\u003cspan style=\"color:#a6e22e\"\u003eReadingStatus\u003c/span\u003e.\u003cspan style=\"color:#a6e22e\"\u003eReading\u003c/span\u003e);     \u003cspan style=\"color:#75715e\"\u003e// Entityのルールに従う\n\u003c/span\u003e\u003c/span\u003e\u003c/span\u003e\u003cspan style=\"display:flex;\"\u003e\u003cspan\u003e\u003cspan style=\"color:#75715e\"\u003e\u003c/span\u003e  \u003cspan style=\"color:#66d9ef\"\u003eawait\u003c/span\u003e \u003cspan style=\"color:#66d9ef\"\u003ethis\u003c/span\u003e.\u003cspan style=\"color:#a6e22e\"\u003erepo\u003c/span\u003e.\u003cspan style=\"color:#a6e22e\"\u003esave\u003c/span\u003e(\u003cspan style=\"color:#a6e22e\"\u003ebook\u003c/span\u003e);                  \u003cspan style=\"color:#75715e\"\u003e// 保存\n\u003c/span\u003e\u003c/span\u003e\u003c/span\u003e\u003cspan style=\"display:flex;\"\u003e\u003cspan\u003e\u003cspan style=\"color:#75715e\"\u003e\u003c/span\u003e  \u003cspan style=\"color:#66d9ef\"\u003ereturn\u003c/span\u003e \u003cspan style=\"color:#a6e22e\"\u003ebook\u003c/span\u003e;\n\u003c/span\u003e\u003c/span\u003e\u003cspan style=\"display:flex;\"\u003e\u003cspan\u003e}\n\u003c/span\u003e\u003c/span\u003e\u003c/code\u003e\u003c/pre\u003e\u003c/div\u003e\u003cp\u003eServiceはDomainのルールを\u003cstrong\u003e自分で判定しない\u003c/strong\u003e。\n\u003ccode\u003ecanTransition\u003c/code\u003eを自分で呼ばない。\u003ccode\u003ebook.changeStatus()\u003c/code\u003eに委ねるだけだ。\n判定はEntity・VO・Policyが持っている。Serviceはその結果を使ってフローを組む。\u003c/p\u003e","title":"テスト前提で設計したWebアプリのハンズオン - 読書管理アプリ その6"},{"content":"なぜこの記事を書いたか 動くものをまず作って、そこにテストを後付けしようとした。しかし、できなかった。\n具体的にはこういう壁にぶつかった。\nModelなどのDB接続の切り替えがうまくいかない Controllerをテストしようにもいろいろおかしくなる 悩んだ結果、気づいたことがある。そもそもそんなものは単体テストじゃない。 ロジックをControllerやModelに全部書いていたのが悪かった。\n「じゃあ最初からそう書けばいいじゃないか」という話だが、それが難しい。雑魚プログラマにいきなりクリーンな設計はできない。\nなので発想を逆にした。テスト前提でコードを書く。 テストが書けない場所にはロジックを書かない。それを体で覚えるために、今回のハンズオンを始めた。\n方針 生成AIに実装ヒントと次のステップを教えてもらいながら、コードは自分で書く。\n正直、コード自体はAIに書かせてもいいと思っている。ただ、設計の考え方は頭に入れる必要があるので、写経しながら理解を深めている。\nテストする場所の原則はシンプルだ。\nフレームワークで用意された便利クラスはテストしない DBやAPIなど外界と接する場所はテストしない 自分で書いた純粋なTS部分だけをテストする 作るもの 読書レビューサイト。Todoアプリはビジネスロジックがほぼ存在しないのでテストの練習に向いていない。読書レビューサイトなら本の状態遷移（積読→読中→読了）などのルールが自然に生まれるので、テストの旨味がある。\n技術スタック\nNext.js SQLite + Prisma vitest ディレクトリ構成 src/ domain/ entity/ valueobject/ policy/ service/ repository/ この構成の考え方は以下の通り。\n層 役割 entity 概念そのもの（Book, User） valueobject 値の制約を持つクラス（ISBN, Rating） policy ビジネスルールを切り出したクラス service 複数クラスをまたぐフロー repository DBとのアダプタ（副作用をここに閉じ込める） repositoryはCI4でいうModelに近い立ち位置だ。 ただし決定的な違いがある。CI4のModelはActiveRecordパターンでクラス自身がDBを知っているため切り離せないが、repositoryはInterfaceと実装を分けることで差し替え可能にする。これがDI（依存性の注入）の肝で、テスト時にMockに差し替えられる。\nvitestのセットアップ npm install -D vitest @vitejs/plugin-react vite-tsconfig-paths npm install -D @testing-library/react @testing-library/jest-dom vitest.config.ts\nimport { defineConfig } from \u0026#39;vitest/config\u0026#39; import react from \u0026#39;@vitejs/plugin-react\u0026#39; import tsconfigPaths from \u0026#39;vite-tsconfig-paths\u0026#39; export default defineConfig({ plugins: [react(), tsconfigPaths()], test: { environment: \u0026#39;jsdom\u0026#39;, globals: true, setupFiles: [\u0026#39;./src/test/setup.ts\u0026#39;], }, }) package.jsonのscriptsに追加。\n\u0026#34;scripts\u0026#34;: { \u0026#34;test\u0026#34;: \u0026#34;vitest\u0026#34;, \u0026#34;typecheck\u0026#34;: \u0026#34;tsc --noEmit\u0026#34;, \u0026#34;ci\u0026#34;: \u0026#34;tsc --noEmit \u0026amp;\u0026amp; vitest run\u0026#34; } ciスクリプトがポイントで、vitestは型チェックをしない。tsc --noEmitと組み合わせることで、型エラーも含めて検証できる。\n実装したもの ReadingStatus export const ReadingStatus = { Unread: \u0026#39;Unread\u0026#39;, // 積読 Reading: \u0026#39;Reading\u0026#39;, // 読中 Completed: \u0026#39;Completed\u0026#39;, // 読了 } as const export type ReadingStatus = typeof ReadingStatus[keyof typeof ReadingStatus] ReadingStatusPolicy 状態遷移のルールをここに閉じ込める。\nimport { ReadingStatus } from \u0026#39;../valueobject/ReadingStatus\u0026#39; export class ReadingStatusPolicy { static canTransition(from: ReadingStatus, to: ReadingStatus): boolean { const rules: Record\u0026lt;ReadingStatus, ReadingStatus[]\u0026gt; = { [ReadingStatus.Unread]: [ReadingStatus.Reading], [ReadingStatus.Reading]: [ReadingStatus.Completed], [ReadingStatus.Completed]: [ReadingStatus.Reading], } return rules[from].includes(to) } } テストはこうなる。\ntest(\u0026#34;ReadingStatusPolicy.canTransitionの組み合わせチェック\u0026#34;, () =\u0026gt; { expect(ReadingStatusPolicy.canTransition(ReadingStatus.Unread, ReadingStatus.Reading)).toBe(true) expect(ReadingStatusPolicy.canTransition(ReadingStatus.Unread, ReadingStatus.Completed)).toBe(false) expect(ReadingStatusPolicy.canTransition(ReadingStatus.Reading, ReadingStatus.Completed)).toBe(true) expect(ReadingStatusPolicy.canTransition(ReadingStatus.Reading, ReadingStatus.Unread)).toBe(false) expect(ReadingStatusPolicy.canTransition(ReadingStatus.Completed, ReadingStatus.Reading)).toBe(true) expect(ReadingStatusPolicy.canTransition(ReadingStatus.Completed, ReadingStatus.Unread)).toBe(false) }) DB一切なし、Next.js一切なし、FW一切なし。 ただのクラスとロジックだけなのでテストが普通に書ける。\nRating export class Rating { constructor(public readonly value: number) { if (!Number.isInteger(value) || value \u0026lt; 1 || value \u0026gt; 5) { throw new Error(\u0026#39;Ratingは1〜5の整数\u0026#39;) } } } 例外テストはexpect(() =\u0026gt; ...).toThrow()で一行で書ける。\ntest(\u0026#39;Ratingは1〜5の整数のみ有効\u0026#39;, () =\u0026gt; { expect(() =\u0026gt; new Rating(1)).not.toThrow() expect(() =\u0026gt; new Rating(5)).not.toThrow() expect(() =\u0026gt; new Rating(0)).toThrow(\u0026#39;Ratingは1〜5の整数\u0026#39;) expect(() =\u0026gt; new Rating(6)).toThrow(\u0026#39;Ratingは1〜5の整数\u0026#39;) expect(() =\u0026gt; new Rating(3.5)).toThrow(\u0026#39;Ratingは1〜5の整数\u0026#39;) }) ISBN 今回は13桁の数字文字列という簡易バリデーションのみ。本来はチェックディジットの検証が必要だが、テストの練習が目的なので一旦これで。\nBook（Entity） export class Book { constructor( public readonly id: string, public readonly title: string, public readonly isbn: ISBN, public status: ReadingStatus, public rating: Rating | null = null, ) {} changeStatus(to: ReadingStatus): void { if (!ReadingStatusPolicy.canTransition(this.status, to)) { throw new Error(`${this.status}から${to}への変更は不可`) } this.status = to } addRating(rating: Rating): void { if (this.status !== ReadingStatus.Completed) { throw new Error(\u0026#39;読了していない本には評価できない\u0026#39;) } this.rating = rating } } 所感 序盤なので簡単なところだけだが、テストが普通に書けるという体験ができた。\n今まで詰まっていたのはやり方が悪かったわけでも実装が悪かったわけでもなく、フレームワークの設計と戦っていたからだと気づいた。ロジックをFWから分離してしまえば、テストはただの関数呼び出しになる。\n次回はService層の実装でDIが登場する。ここがこの設計の核心部分になるはず。\n","permalink":"https://techblog.wasutech.dev/posts/test-driven-design-readmeter-1/","summary":"\u003ch2 id=\"なぜこの記事を書いたか\"\u003eなぜこの記事を書いたか\u003c/h2\u003e\n\u003cp\u003e動くものをまず作って、そこにテストを後付けしようとした。しかし、できなかった。\u003c/p\u003e\n\u003cp\u003e具体的にはこういう壁にぶつかった。\u003c/p\u003e\n\u003cul\u003e\n\u003cli\u003eModelなどのDB接続の切り替えがうまくいかない\u003c/li\u003e\n\u003cli\u003eControllerをテストしようにもいろいろおかしくなる\u003c/li\u003e\n\u003c/ul\u003e\n\u003cp\u003e悩んだ結果、気づいたことがある。\u003cstrong\u003eそもそもそんなものは単体テストじゃない。\u003c/strong\u003e ロジックをControllerやModelに全部書いていたのが悪かった。\u003c/p\u003e\n\u003cp\u003e「じゃあ最初からそう書けばいいじゃないか」という話だが、それが難しい。雑魚プログラマにいきなりクリーンな設計はできない。\u003c/p\u003e\n\u003cp\u003eなので発想を逆にした。\u003cstrong\u003eテスト前提でコードを書く。\u003c/strong\u003e テストが書けない場所にはロジックを書かない。それを体で覚えるために、今回のハンズオンを始めた。\u003c/p\u003e\n\u003chr\u003e\n\u003ch2 id=\"方針\"\u003e方針\u003c/h2\u003e\n\u003cp\u003e生成AIに実装ヒントと次のステップを教えてもらいながら、コードは自分で書く。\u003c/p\u003e\n\u003cp\u003e正直、コード自体はAIに書かせてもいいと思っている。ただ、設計の考え方は頭に入れる必要があるので、写経しながら理解を深めている。\u003c/p\u003e\n\u003cp\u003e\u003cstrong\u003eテストする場所の原則はシンプルだ。\u003c/strong\u003e\u003c/p\u003e\n\u003cul\u003e\n\u003cli\u003eフレームワークで用意された便利クラスはテストしない\u003c/li\u003e\n\u003cli\u003eDBやAPIなど外界と接する場所はテストしない\u003c/li\u003e\n\u003cli\u003e自分で書いた純粋なTS部分だけをテストする\u003c/li\u003e\n\u003c/ul\u003e\n\u003chr\u003e\n\u003ch2 id=\"作るもの\"\u003e作るもの\u003c/h2\u003e\n\u003cp\u003e読書レビューサイト。Todoアプリはビジネスロジックがほぼ存在しないのでテストの練習に向いていない。読書レビューサイトなら本の状態遷移（積読→読中→読了）などのルールが自然に生まれるので、テストの旨味がある。\u003c/p\u003e\n\u003cp\u003e\u003cstrong\u003e技術スタック\u003c/strong\u003e\u003c/p\u003e\n\u003cul\u003e\n\u003cli\u003eNext.js\u003c/li\u003e\n\u003cli\u003eSQLite + Prisma\u003c/li\u003e\n\u003cli\u003evitest\u003c/li\u003e\n\u003c/ul\u003e\n\u003chr\u003e\n\u003ch2 id=\"ディレクトリ構成\"\u003eディレクトリ構成\u003c/h2\u003e\n\u003cpre tabindex=\"0\"\u003e\u003ccode\u003esrc/\n  domain/\n    entity/\n    valueobject/\n    policy/\n  service/\n  repository/\n\u003c/code\u003e\u003c/pre\u003e\u003cp\u003eこの構成の考え方は以下の通り。\u003c/p\u003e\n\u003ctable\u003e\n  \u003cthead\u003e\n      \u003ctr\u003e\n          \u003cth\u003e層\u003c/th\u003e\n          \u003cth\u003e役割\u003c/th\u003e\n      \u003c/tr\u003e\n  \u003c/thead\u003e\n  \u003ctbody\u003e\n      \u003ctr\u003e\n          \u003ctd\u003eentity\u003c/td\u003e\n          \u003ctd\u003e概念そのもの（Book, User）\u003c/td\u003e\n      \u003c/tr\u003e\n      \u003ctr\u003e\n          \u003ctd\u003evalueobject\u003c/td\u003e\n          \u003ctd\u003e値の制約を持つクラス（ISBN, Rating）\u003c/td\u003e\n      \u003c/tr\u003e\n      \u003ctr\u003e\n          \u003ctd\u003epolicy\u003c/td\u003e\n          \u003ctd\u003eビジネスルールを切り出したクラス\u003c/td\u003e\n      \u003c/tr\u003e\n      \u003ctr\u003e\n          \u003ctd\u003eservice\u003c/td\u003e\n          \u003ctd\u003e複数クラスをまたぐフロー\u003c/td\u003e\n      \u003c/tr\u003e\n      \u003ctr\u003e\n          \u003ctd\u003erepository\u003c/td\u003e\n          \u003ctd\u003eDBとのアダプタ（副作用をここに閉じ込める）\u003c/td\u003e\n      \u003c/tr\u003e\n  \u003c/tbody\u003e\n\u003c/table\u003e\n\u003cp\u003e\u003cstrong\u003erepositoryはCI4でいうModelに近い立ち位置だ。\u003c/strong\u003e ただし決定的な違いがある。CI4のModelはActiveRecordパターンでクラス自身がDBを知っているため切り離せないが、repositoryはInterfaceと実装を分けることで差し替え可能にする。これがDI（依存性の注入）の肝で、テスト時にMockに差し替えられる。\u003c/p\u003e\n\u003chr\u003e\n\u003ch2 id=\"vitestのセットアップ\"\u003evitestのセットアップ\u003c/h2\u003e\n\u003cdiv class=\"highlight\"\u003e\u003cpre tabindex=\"0\" style=\"color:#f8f8f2;background-color:#272822;-moz-tab-size:4;-o-tab-size:4;tab-size:4;\"\u003e\u003ccode class=\"language-bash\" data-lang=\"bash\"\u003e\u003cspan style=\"display:flex;\"\u003e\u003cspan\u003enpm install -D vitest @vitejs/plugin-react vite-tsconfig-paths\n\u003c/span\u003e\u003c/span\u003e\u003cspan style=\"display:flex;\"\u003e\u003cspan\u003enpm install -D @testing-library/react @testing-library/jest-dom\n\u003c/span\u003e\u003c/span\u003e\u003c/code\u003e\u003c/pre\u003e\u003c/div\u003e\u003cp\u003e\u003ccode\u003evitest.config.ts\u003c/code\u003e\u003c/p\u003e\n\u003cdiv class=\"highlight\"\u003e\u003cpre tabindex=\"0\" style=\"color:#f8f8f2;background-color:#272822;-moz-tab-size:4;-o-tab-size:4;tab-size:4;\"\u003e\u003ccode class=\"language-typescript\" data-lang=\"typescript\"\u003e\u003cspan style=\"display:flex;\"\u003e\u003cspan\u003e\u003cspan style=\"color:#66d9ef\"\u003eimport\u003c/span\u003e { \u003cspan style=\"color:#a6e22e\"\u003edefineConfig\u003c/span\u003e } \u003cspan style=\"color:#66d9ef\"\u003efrom\u003c/span\u003e \u003cspan style=\"color:#e6db74\"\u003e\u0026#39;vitest/config\u0026#39;\u003c/span\u003e\n\u003c/span\u003e\u003c/span\u003e\u003cspan style=\"display:flex;\"\u003e\u003cspan\u003e\u003cspan style=\"color:#66d9ef\"\u003eimport\u003c/span\u003e \u003cspan style=\"color:#a6e22e\"\u003ereact\u003c/span\u003e \u003cspan style=\"color:#66d9ef\"\u003efrom\u003c/span\u003e \u003cspan style=\"color:#e6db74\"\u003e\u0026#39;@vitejs/plugin-react\u0026#39;\u003c/span\u003e\n\u003c/span\u003e\u003c/span\u003e\u003cspan style=\"display:flex;\"\u003e\u003cspan\u003e\u003cspan style=\"color:#66d9ef\"\u003eimport\u003c/span\u003e \u003cspan style=\"color:#a6e22e\"\u003etsconfigPaths\u003c/span\u003e \u003cspan style=\"color:#66d9ef\"\u003efrom\u003c/span\u003e \u003cspan style=\"color:#e6db74\"\u003e\u0026#39;vite-tsconfig-paths\u0026#39;\u003c/span\u003e\n\u003c/span\u003e\u003c/span\u003e\u003cspan style=\"display:flex;\"\u003e\u003cspan\u003e\n\u003c/span\u003e\u003c/span\u003e\u003cspan style=\"display:flex;\"\u003e\u003cspan\u003e\u003cspan style=\"color:#66d9ef\"\u003eexport\u003c/span\u003e \u003cspan style=\"color:#66d9ef\"\u003edefault\u003c/span\u003e \u003cspan style=\"color:#a6e22e\"\u003edefineConfig\u003c/span\u003e({\n\u003c/span\u003e\u003c/span\u003e\u003cspan style=\"display:flex;\"\u003e\u003cspan\u003e    \u003cspan style=\"color:#a6e22e\"\u003eplugins\u003c/span\u003e\u003cspan style=\"color:#f92672\"\u003e:\u003c/span\u003e [\u003cspan style=\"color:#a6e22e\"\u003ereact\u003c/span\u003e(), \u003cspan style=\"color:#a6e22e\"\u003etsconfigPaths\u003c/span\u003e()],\n\u003c/span\u003e\u003c/span\u003e\u003cspan style=\"display:flex;\"\u003e\u003cspan\u003e    \u003cspan style=\"color:#a6e22e\"\u003etest\u003c/span\u003e\u003cspan style=\"color:#f92672\"\u003e:\u003c/span\u003e {\n\u003c/span\u003e\u003c/span\u003e\u003cspan style=\"display:flex;\"\u003e\u003cspan\u003e        \u003cspan style=\"color:#a6e22e\"\u003eenvironment\u003c/span\u003e\u003cspan style=\"color:#f92672\"\u003e:\u003c/span\u003e \u003cspan style=\"color:#e6db74\"\u003e\u0026#39;jsdom\u0026#39;\u003c/span\u003e,\n\u003c/span\u003e\u003c/span\u003e\u003cspan style=\"display:flex;\"\u003e\u003cspan\u003e        \u003cspan style=\"color:#a6e22e\"\u003eglobals\u003c/span\u003e: \u003cspan style=\"color:#66d9ef\"\u003etrue\u003c/span\u003e,\n\u003c/span\u003e\u003c/span\u003e\u003cspan style=\"display:flex;\"\u003e\u003cspan\u003e        \u003cspan style=\"color:#a6e22e\"\u003esetupFiles\u003c/span\u003e\u003cspan style=\"color:#f92672\"\u003e:\u003c/span\u003e [\u003cspan style=\"color:#e6db74\"\u003e\u0026#39;./src/test/setup.ts\u0026#39;\u003c/span\u003e],\n\u003c/span\u003e\u003c/span\u003e\u003cspan style=\"display:flex;\"\u003e\u003cspan\u003e    },\n\u003c/span\u003e\u003c/span\u003e\u003cspan style=\"display:flex;\"\u003e\u003cspan\u003e})\n\u003c/span\u003e\u003c/span\u003e\u003c/code\u003e\u003c/pre\u003e\u003c/div\u003e\u003cp\u003e\u003ccode\u003epackage.json\u003c/code\u003eのscriptsに追加。\u003c/p\u003e","title":"テスト前提で設計したWebアプリのハンズオン - 読書管理アプリ その1"},{"content":"前回のおさらい その1では ValueObject・Policy・Entity を作った。ポイントは「フレームワークを一切使わないので、テストがただの関数呼び出しになる」という体験だった。\n今回はいよいよ Service 層に入る。ここが設計の核心だ。 複数のクラスをまたぐフローを、DI（依存性の注入）を使って DB から切り離す。\n今回追加したもの src/ domain/ entity/ User.ts # 追加 Review.ts # 追加 valueobject/ UserName.ts # 追加 ReviewComment.ts # 追加 repository/ BookRepository.ts # 追加（Interface） service/ BookShelfService.ts # 追加 User と Review を追加する まず Entity の準備。今回は単純なので ValueObject から作る。\nUserName export class UserName { constructor(public readonly value: string) { if (!value || value.trim().length === 0) { throw new Error(\u0026#34;UserNameは空にできない\u0026#34;); } if (value.length \u0026gt; 50) { throw new Error(\u0026#34;UserNameは50文字以内\u0026#34;); } } } trim() してから空チェックしているのがポイント。スペースだけのユーザー名を弾く。\nReviewComment export class ReviewComment { constructor(public readonly value: string) { if (!value || value.trim().length === 0) { throw new Error(\u0026#34;ReviewCommentは空にできない\u0026#34;); } if (value.length \u0026gt; 1000) { throw new Error(\u0026#34;ReviewCommentは1000文字以内\u0026#34;); } } } User Entity import { UserName } from \u0026#34;../valueobject/UserName\u0026#34;; export class User { constructor( public readonly id: string, public readonly name: UserName, ) {} } User 自体はシンプルだ。ロジックがない。ビジネスルールが増えれば changeXxx() メソッドが生える設計だが、今は id と name を持つだけでいい。\nReview Entity import { Rating } from \u0026#34;../valueobject/Rating\u0026#34;; import { ReviewComment } from \u0026#34;../valueobject/ReviewComment\u0026#34;; export class Review { constructor( public readonly id: string, public readonly bookId: string, public readonly userId: string, public readonly rating: Rating, public readonly comment: ReviewComment, ) {} } Review は Book と User を ID だけで参照している。オブジェクト参照を持たない。こうすることで Review 単体をテストするときに Book や User の実態を用意しなくていい。\nBookRepository Interface を定義する import { Book } from \u0026#34;../domain/entity/Book\u0026#34;; export interface BookRepository { save(book: Book): Promise\u0026lt;void\u0026gt;; findById(id: string): Promise\u0026lt;Book | null\u0026gt;; findAll(): Promise\u0026lt;Book[]\u0026gt;; delete(id: string): Promise\u0026lt;void\u0026gt;; } これは実装ではなく契約だ。 DB が SQLite でも PostgreSQL でも、この Interface を満たせば差し替えられる。\n実際のDB実装（PrismaBookRepository など）は repository/ 層に置く。でも今はまだ作らない。Service とそのテストを書くのに、DB 実装は一切不要なのがこの設計のメリット。\nBookShelfService を実装する import { Book } from \u0026#34;../domain/entity/Book\u0026#34;; import { ISBN } from \u0026#34;../domain/valueobject/ISBN\u0026#34;; import { Rating } from \u0026#34;../domain/valueobject/Rating\u0026#34;; import { ReadingStatus } from \u0026#34;../domain/valueobject/ReadingStatus\u0026#34;; import { BookRepository } from \u0026#34;../repository/BookRepository\u0026#34;; export class BookShelfService { constructor(private readonly repo: BookRepository) {} async addBook(id: string, title: string, isbnValue: string): Promise\u0026lt;Book\u0026gt; { const isbn = new ISBN(isbnValue); const book = new Book(id, title, isbn, ReadingStatus.Unread, null); await this.repo.save(book); return book; } async startReading(id: string): Promise\u0026lt;Book\u0026gt; { const book = await this.repo.findById(id); if (!book) throw new Error(`Book not found: ${id}`); book.changeStatus(ReadingStatus.Reading); await this.repo.save(book); return book; } async completeReading(id: string, ratingValue?: number): Promise\u0026lt;Book\u0026gt; { const book = await this.repo.findById(id); if (!book) throw new Error(`Book not found: ${id}`); book.changeStatus(ReadingStatus.Completed); if (ratingValue !== undefined) { book.addRating(new Rating(ratingValue)); } await this.repo.save(book); return book; } async getAll(): Promise\u0026lt;Book[]\u0026gt; { return this.repo.findAll(); } async removeBook(id: string): Promise\u0026lt;void\u0026gt; { const book = await this.repo.findById(id); if (!book) throw new Error(`Book not found: ${id}`); await this.repo.delete(id); } } コンストラクタで BookRepository を受け取っている。Service は Interface しか知らない。 実装クラスの名前すら import していない。\n状態遷移のロジック自体は Book.changeStatus() に委譲している。Service は「どの順番で何を呼ぶか」のフロー制御だけを担う。\nMock を使って Service をテストする ここが今回の肝。本物の DB をまったく使わずに Service をテストする。\nfunction createMockRepo(): BookRepository { const store = new Map\u0026lt;string, Book\u0026gt;(); return { save: async (book) =\u0026gt; { store.set(book.id, book); }, findById: async (id) =\u0026gt; store.get(id) ?? null, findAll: async () =\u0026gt; Array.from(store.values()), delete: async (id) =\u0026gt; { store.delete(id); }, }; } Map を使ったインメモリ実装。これが Mock だ。DB のスキーマも Prisma の設定も不要。Interface の契約を満たしているだけ。\n意図的にクラスにしていない。テストファイルの中だけに存在するべきで、外に漏れる必要がないからだ。\nテストはこうなる。\ntest(\u0026#34;積読→読中に変更できる\u0026#34;, async () =\u0026gt; { const service = new BookShelfService(createMockRepo()); await service.addBook(\u0026#34;1\u0026#34;, \u0026#34;Clean Code\u0026#34;, \u0026#34;9784048860000\u0026#34;); const book = await service.startReading(\u0026#34;1\u0026#34;); expect(book.status).toBe(ReadingStatus.Reading); }); test(\u0026#34;積読から直接読了はエラー\u0026#34;, async () =\u0026gt; { const service = new BookShelfService(createMockRepo()); await service.addBook(\u0026#34;1\u0026#34;, \u0026#34;Clean Code\u0026#34;, \u0026#34;9784048860000\u0026#34;); await expect(service.completeReading(\u0026#34;1\u0026#34;)).rejects.toThrow( \u0026#34;UnreadからCompletedへの変更は不可\u0026#34;, ); }); DB なし、Next.js なし、FW なし。 相変わらずただのクラスと関数呼び出しだ。非同期なので await が入っているが、それだけ。\n全9テストケースを書いた。\nテストケース 内容 本を追加できる 初期状態は Unread 積読→読中 startReading で状態変更 読中→読了（評価あり） completeReading で rating がセット 読中→読了（評価なし） rating は null のまま 積読→読了はエラー Policy による遷移制限 存在しない本の startReading not found エラー 全件取得 2冊追加して length 確認 本を削除できる 削除後に length 0 存在しない本の削除 not found エラー 実行結果 ✓ src/service/BookShelfService.test.ts (9 tests) ✓ src/domain/valueobject/ReviewComment.test.ts (1 test) ✓ src/domain/entity/Review.test.ts (1 test) ✓ src/domain/valueobject/UserName.test.ts (1 test) ✓ src/domain/entity/User.test.ts (1 test) Test Files 5 passed (5) Tests 13 passed (13) Part1 からの累計で 13 テスト全パス。\nDI を整理する 今回やったことを図にすると以下だ。\nテスト時: BookShelfService ← MockBookRepository（Map） 本番時（将来）: BookShelfService ← PrismaBookRepository（SQLite） Service のコードは一行も変わらない。BookRepository という Interface だけを見ているから、差し込むものを変えればいい。\nCI4 の Model との違いを言語化するとこうだ。\nCI4 Model Repository Interface DB を知っているのは Model 自身 Repository 実装クラスのみ テスト時の切り離し 難しい（ActiveRecord） Interface ごと差し替える Service から見た DB 見えてしまう Interface の向こう側 テストが書きにくい、というのは設計の問題だ。 今回の構造にしておけば、DB を一切用意しなくてもビジネスロジックの全パスをテストできる。\n所感 正直、ここまでは「なんとなく理解していた」が「体で動かしたことはなかった」レベルだった。\n実際に createMockRepo() を書いて Service に差し込んだとき、「あ、これで DB 要らないじゃん」という実感が来た。理屈では知っていたが、手を動かして初めて腹落ちした。\nMock を別ファイルに切り出さなかった理由も、実装しながら考えた結果だ。テスト専用の実装を本番コードのディレクトリに置いてしまうと、境界が曖昧になる。テストファイルの中だけで完結させておく方が「これはテスト用だ」という意図が明確になる。\n次回は Prisma 導入と実際の DB 実装に入る。Repository Interface の実装クラスをついに作る。テストは書かないが、この層を作ることで「本物のアプリ」になる。\n次やること（Part3） Prisma セットアップ（SQLite） PrismaBookRepository 実装 Next.js の Route Handler で API エンドポイント作成 動作確認 ","permalink":"https://techblog.wasutech.dev/posts/test-driven-design-readmeter-2/","summary":"\u003ch2 id=\"前回のおさらい\"\u003e前回のおさらい\u003c/h2\u003e\n\u003cp\u003e\u003ca href=\"/posts/readmeter-part1\"\u003eその1\u003c/a\u003eでは ValueObject・Policy・Entity を作った。ポイントは「フレームワークを一切使わないので、テストがただの関数呼び出しになる」という体験だった。\u003c/p\u003e\n\u003cp\u003e今回はいよいよ Service 層に入る。\u003cstrong\u003eここが設計の核心だ。\u003c/strong\u003e 複数のクラスをまたぐフローを、DI（依存性の注入）を使って DB から切り離す。\u003c/p\u003e\n\u003chr\u003e\n\u003ch2 id=\"今回追加したもの\"\u003e今回追加したもの\u003c/h2\u003e\n\u003cpre tabindex=\"0\"\u003e\u003ccode\u003esrc/\n  domain/\n    entity/\n      User.ts              # 追加\n      Review.ts            # 追加\n    valueobject/\n      UserName.ts          # 追加\n      ReviewComment.ts     # 追加\n  repository/\n    BookRepository.ts      # 追加（Interface）\n  service/\n    BookShelfService.ts    # 追加\n\u003c/code\u003e\u003c/pre\u003e\u003chr\u003e\n\u003ch2 id=\"user-と-review-を追加する\"\u003eUser と Review を追加する\u003c/h2\u003e\n\u003cp\u003eまず Entity の準備。今回は単純なので ValueObject から作る。\u003c/p\u003e\n\u003ch3 id=\"username\"\u003eUserName\u003c/h3\u003e\n\u003cdiv class=\"highlight\"\u003e\u003cpre tabindex=\"0\" style=\"color:#f8f8f2;background-color:#272822;-moz-tab-size:4;-o-tab-size:4;tab-size:4;\"\u003e\u003ccode class=\"language-typescript\" data-lang=\"typescript\"\u003e\u003cspan style=\"display:flex;\"\u003e\u003cspan\u003e\u003cspan style=\"color:#66d9ef\"\u003eexport\u003c/span\u003e \u003cspan style=\"color:#66d9ef\"\u003eclass\u003c/span\u003e \u003cspan style=\"color:#a6e22e\"\u003eUserName\u003c/span\u003e {\n\u003c/span\u003e\u003c/span\u003e\u003cspan style=\"display:flex;\"\u003e\u003cspan\u003e  \u003cspan style=\"color:#66d9ef\"\u003econstructor\u003c/span\u003e(\u003cspan style=\"color:#66d9ef\"\u003epublic\u003c/span\u003e \u003cspan style=\"color:#66d9ef\"\u003ereadonly\u003c/span\u003e \u003cspan style=\"color:#a6e22e\"\u003evalue\u003c/span\u003e: \u003cspan style=\"color:#66d9ef\"\u003estring\u003c/span\u003e) {\n\u003c/span\u003e\u003c/span\u003e\u003cspan style=\"display:flex;\"\u003e\u003cspan\u003e    \u003cspan style=\"color:#66d9ef\"\u003eif\u003c/span\u003e (\u003cspan style=\"color:#f92672\"\u003e!\u003c/span\u003e\u003cspan style=\"color:#a6e22e\"\u003evalue\u003c/span\u003e \u003cspan style=\"color:#f92672\"\u003e||\u003c/span\u003e \u003cspan style=\"color:#a6e22e\"\u003evalue\u003c/span\u003e.\u003cspan style=\"color:#a6e22e\"\u003etrim\u003c/span\u003e().\u003cspan style=\"color:#a6e22e\"\u003elength\u003c/span\u003e \u003cspan style=\"color:#f92672\"\u003e===\u003c/span\u003e \u003cspan style=\"color:#ae81ff\"\u003e0\u003c/span\u003e) {\n\u003c/span\u003e\u003c/span\u003e\u003cspan style=\"display:flex;\"\u003e\u003cspan\u003e      \u003cspan style=\"color:#66d9ef\"\u003ethrow\u003c/span\u003e \u003cspan style=\"color:#66d9ef\"\u003enew\u003c/span\u003e Error(\u003cspan style=\"color:#e6db74\"\u003e\u0026#34;UserNameは空にできない\u0026#34;\u003c/span\u003e);\n\u003c/span\u003e\u003c/span\u003e\u003cspan style=\"display:flex;\"\u003e\u003cspan\u003e    }\n\u003c/span\u003e\u003c/span\u003e\u003cspan style=\"display:flex;\"\u003e\u003cspan\u003e    \u003cspan style=\"color:#66d9ef\"\u003eif\u003c/span\u003e (\u003cspan style=\"color:#a6e22e\"\u003evalue\u003c/span\u003e.\u003cspan style=\"color:#a6e22e\"\u003elength\u003c/span\u003e \u003cspan style=\"color:#f92672\"\u003e\u0026gt;\u003c/span\u003e \u003cspan style=\"color:#ae81ff\"\u003e50\u003c/span\u003e) {\n\u003c/span\u003e\u003c/span\u003e\u003cspan style=\"display:flex;\"\u003e\u003cspan\u003e      \u003cspan style=\"color:#66d9ef\"\u003ethrow\u003c/span\u003e \u003cspan style=\"color:#66d9ef\"\u003enew\u003c/span\u003e Error(\u003cspan style=\"color:#e6db74\"\u003e\u0026#34;UserNameは50文字以内\u0026#34;\u003c/span\u003e);\n\u003c/span\u003e\u003c/span\u003e\u003cspan style=\"display:flex;\"\u003e\u003cspan\u003e    }\n\u003c/span\u003e\u003c/span\u003e\u003cspan style=\"display:flex;\"\u003e\u003cspan\u003e  }\n\u003c/span\u003e\u003c/span\u003e\u003cspan style=\"display:flex;\"\u003e\u003cspan\u003e}\n\u003c/span\u003e\u003c/span\u003e\u003c/code\u003e\u003c/pre\u003e\u003c/div\u003e\u003cp\u003e\u003ccode\u003etrim()\u003c/code\u003e してから空チェックしているのがポイント。スペースだけのユーザー名を弾く。\u003c/p\u003e\n\u003ch3 id=\"reviewcomment\"\u003eReviewComment\u003c/h3\u003e\n\u003cdiv class=\"highlight\"\u003e\u003cpre tabindex=\"0\" style=\"color:#f8f8f2;background-color:#272822;-moz-tab-size:4;-o-tab-size:4;tab-size:4;\"\u003e\u003ccode class=\"language-typescript\" data-lang=\"typescript\"\u003e\u003cspan style=\"display:flex;\"\u003e\u003cspan\u003e\u003cspan style=\"color:#66d9ef\"\u003eexport\u003c/span\u003e \u003cspan style=\"color:#66d9ef\"\u003eclass\u003c/span\u003e \u003cspan style=\"color:#a6e22e\"\u003eReviewComment\u003c/span\u003e {\n\u003c/span\u003e\u003c/span\u003e\u003cspan style=\"display:flex;\"\u003e\u003cspan\u003e  \u003cspan style=\"color:#66d9ef\"\u003econstructor\u003c/span\u003e(\u003cspan style=\"color:#66d9ef\"\u003epublic\u003c/span\u003e \u003cspan style=\"color:#66d9ef\"\u003ereadonly\u003c/span\u003e \u003cspan style=\"color:#a6e22e\"\u003evalue\u003c/span\u003e: \u003cspan style=\"color:#66d9ef\"\u003estring\u003c/span\u003e) {\n\u003c/span\u003e\u003c/span\u003e\u003cspan style=\"display:flex;\"\u003e\u003cspan\u003e    \u003cspan style=\"color:#66d9ef\"\u003eif\u003c/span\u003e (\u003cspan style=\"color:#f92672\"\u003e!\u003c/span\u003e\u003cspan style=\"color:#a6e22e\"\u003evalue\u003c/span\u003e \u003cspan style=\"color:#f92672\"\u003e||\u003c/span\u003e \u003cspan style=\"color:#a6e22e\"\u003evalue\u003c/span\u003e.\u003cspan style=\"color:#a6e22e\"\u003etrim\u003c/span\u003e().\u003cspan style=\"color:#a6e22e\"\u003elength\u003c/span\u003e \u003cspan style=\"color:#f92672\"\u003e===\u003c/span\u003e \u003cspan style=\"color:#ae81ff\"\u003e0\u003c/span\u003e) {\n\u003c/span\u003e\u003c/span\u003e\u003cspan style=\"display:flex;\"\u003e\u003cspan\u003e      \u003cspan style=\"color:#66d9ef\"\u003ethrow\u003c/span\u003e \u003cspan style=\"color:#66d9ef\"\u003enew\u003c/span\u003e Error(\u003cspan style=\"color:#e6db74\"\u003e\u0026#34;ReviewCommentは空にできない\u0026#34;\u003c/span\u003e);\n\u003c/span\u003e\u003c/span\u003e\u003cspan style=\"display:flex;\"\u003e\u003cspan\u003e    }\n\u003c/span\u003e\u003c/span\u003e\u003cspan style=\"display:flex;\"\u003e\u003cspan\u003e    \u003cspan style=\"color:#66d9ef\"\u003eif\u003c/span\u003e (\u003cspan style=\"color:#a6e22e\"\u003evalue\u003c/span\u003e.\u003cspan style=\"color:#a6e22e\"\u003elength\u003c/span\u003e \u003cspan style=\"color:#f92672\"\u003e\u0026gt;\u003c/span\u003e \u003cspan style=\"color:#ae81ff\"\u003e1000\u003c/span\u003e) {\n\u003c/span\u003e\u003c/span\u003e\u003cspan style=\"display:flex;\"\u003e\u003cspan\u003e      \u003cspan style=\"color:#66d9ef\"\u003ethrow\u003c/span\u003e \u003cspan style=\"color:#66d9ef\"\u003enew\u003c/span\u003e Error(\u003cspan style=\"color:#e6db74\"\u003e\u0026#34;ReviewCommentは1000文字以内\u0026#34;\u003c/span\u003e);\n\u003c/span\u003e\u003c/span\u003e\u003cspan style=\"display:flex;\"\u003e\u003cspan\u003e    }\n\u003c/span\u003e\u003c/span\u003e\u003cspan style=\"display:flex;\"\u003e\u003cspan\u003e  }\n\u003c/span\u003e\u003c/span\u003e\u003cspan style=\"display:flex;\"\u003e\u003cspan\u003e}\n\u003c/span\u003e\u003c/span\u003e\u003c/code\u003e\u003c/pre\u003e\u003c/div\u003e\u003ch3 id=\"user-entity\"\u003eUser Entity\u003c/h3\u003e\n\u003cdiv class=\"highlight\"\u003e\u003cpre tabindex=\"0\" style=\"color:#f8f8f2;background-color:#272822;-moz-tab-size:4;-o-tab-size:4;tab-size:4;\"\u003e\u003ccode class=\"language-typescript\" data-lang=\"typescript\"\u003e\u003cspan style=\"display:flex;\"\u003e\u003cspan\u003e\u003cspan style=\"color:#66d9ef\"\u003eimport\u003c/span\u003e { \u003cspan style=\"color:#a6e22e\"\u003eUserName\u003c/span\u003e } \u003cspan style=\"color:#66d9ef\"\u003efrom\u003c/span\u003e \u003cspan style=\"color:#e6db74\"\u003e\u0026#34;../valueobject/UserName\u0026#34;\u003c/span\u003e;\n\u003c/span\u003e\u003c/span\u003e\u003cspan style=\"display:flex;\"\u003e\u003cspan\u003e\n\u003c/span\u003e\u003c/span\u003e\u003cspan style=\"display:flex;\"\u003e\u003cspan\u003e\u003cspan style=\"color:#66d9ef\"\u003eexport\u003c/span\u003e \u003cspan style=\"color:#66d9ef\"\u003eclass\u003c/span\u003e \u003cspan style=\"color:#a6e22e\"\u003eUser\u003c/span\u003e {\n\u003c/span\u003e\u003c/span\u003e\u003cspan style=\"display:flex;\"\u003e\u003cspan\u003e  \u003cspan style=\"color:#66d9ef\"\u003econstructor\u003c/span\u003e(\n\u003c/span\u003e\u003c/span\u003e\u003cspan style=\"display:flex;\"\u003e\u003cspan\u003e    \u003cspan style=\"color:#66d9ef\"\u003epublic\u003c/span\u003e \u003cspan style=\"color:#66d9ef\"\u003ereadonly\u003c/span\u003e \u003cspan style=\"color:#a6e22e\"\u003eid\u003c/span\u003e: \u003cspan style=\"color:#66d9ef\"\u003estring\u003c/span\u003e,\n\u003c/span\u003e\u003c/span\u003e\u003cspan style=\"display:flex;\"\u003e\u003cspan\u003e    \u003cspan style=\"color:#66d9ef\"\u003epublic\u003c/span\u003e \u003cspan style=\"color:#66d9ef\"\u003ereadonly\u003c/span\u003e \u003cspan style=\"color:#a6e22e\"\u003ename\u003c/span\u003e: \u003cspan style=\"color:#66d9ef\"\u003eUserName\u003c/span\u003e,\n\u003c/span\u003e\u003c/span\u003e\u003cspan style=\"display:flex;\"\u003e\u003cspan\u003e  ) {}\n\u003c/span\u003e\u003c/span\u003e\u003cspan style=\"display:flex;\"\u003e\u003cspan\u003e}\n\u003c/span\u003e\u003c/span\u003e\u003c/code\u003e\u003c/pre\u003e\u003c/div\u003e\u003cp\u003e\u003ccode\u003eUser\u003c/code\u003e 自体はシンプルだ。ロジックがない。ビジネスルールが増えれば \u003ccode\u003echangeXxx()\u003c/code\u003e メソッドが生える設計だが、今は id と name を持つだけでいい。\u003c/p\u003e","title":"テスト前提で設計したWebアプリのハンズオン - 読書管理アプリ その2"},{"content":"はじめに Part1 でValueObject・Policy・Entityを作り、Part2 でServiceをDI＋Mockテストで固めた。 累計13テスト、全パスの状態だ。\nPart3ではいよいよDBを繋ぐ。やることは3つ。\nPrismaセットアップ＋スキーマ定義 PrismaBookRepository 実装（ドメインオブジェクトへの変換） Route HandlerでDIを組み立てる そして最後に「テストを書かない層を意図的に決める」という話をする。\n1. Prismaセットアップ npm install prisma @prisma/client npx prisma init --datasource-provider sqlite prisma/schema.prisma に Bookモデルを定義する。\ngenerator client { provider = \u0026#34;prisma-client-js\u0026#34; } datasource db { provider = \u0026#34;sqlite\u0026#34; url = env(\u0026#34;DATABASE_URL\u0026#34;) } model Book { id String @id title String isbn String status String rating Int? } User と Review はまだDBに持たない。Book の CRUD が動けば Part3 のゴール。\nなぜ status を String で持つのか Prismaは SQLiteで enum をネイティブサポートしていない。 そのため status String で持ち、取り出し時に as ReadingStatus でキャストする。\n// DBレコード → ドメインオブジェクト変換時 record.status as ReadingStatus 不正値が入るリスクはある。ただしその責任はRepository層の内側に閉じている。 外のService層・ドメイン層は ReadingStatus 型として受け取るので影響を受けない。 この割り切りはアーキテクチャ上の判断であり、ここに ReadingStatusPolicy で検証を追加することもできる。今回はシンプルさを優先した。\nマイグレーションを実行する。\nnpx prisma migrate dev --name init .env に DATABASE_URL=\u0026quot;file:./dev.db\u0026quot; が自動生成されていることを確認する。\n2. PrismaBookRepository実装 src/repository/PrismaBookRepository.ts を新規作成する。 BookRepository interface を満たせばよく、Serviceはこの実装クラスを直接知らない。\nimport { PrismaClient, Book as PrismaBook } from \u0026#34;@prisma/client\u0026#34;; import { BookRepository } from \u0026#34;./BookRepository\u0026#34;; import { Book } from \u0026#34;../domain/entity/Book\u0026#34;; import { ISBN } from \u0026#34;../domain/valueobject/ISBN\u0026#34;; import { Rating } from \u0026#34;../domain/valueobject/Rating\u0026#34;; import { ReadingStatus } from \u0026#34;../domain/valueobject/ReadingStatus\u0026#34;; export class PrismaBookRepository implements BookRepository { constructor(private readonly prisma: PrismaClient) {} async save(book: Book): Promise\u0026lt;void\u0026gt; { await this.prisma.book.upsert({ where: { id: book.id }, update: this.toRecord(book), create: { id: book.id, ...this.toRecord(book) }, }); } async findById(id: string): Promise\u0026lt;Book | null\u0026gt; { const record = await this.prisma.book.findUnique({ where: { id } }); if (!record) return null; return this.toDomain(record); } async findAll(): Promise\u0026lt;Book[]\u0026gt; { const records = await this.prisma.book.findMany(); return records.map((r) =\u0026gt; this.toDomain(r)); } async delete(id: string): Promise\u0026lt;void\u0026gt; { await this.prisma.book.delete({ where: { id } }); } // ドメインオブジェクト → DBレコード用プレーンオブジェクト private toRecord(book: Book) { return { title: book.title, isbn: book.isbn.value, status: book.status, rating: book.rating?.value ?? null, }; } // DBレコード → ドメインオブジェクト private toDomain(record: PrismaBook): Book { return new Book( record.id, record.title, new ISBN(record.isbn), record.status as ReadingStatus, record.rating !== null ? new Rating(record.rating) : null, ); } } ポイント：変換ロジックはRepositoryの内側に閉じる toDomain と toRecord はこのクラスの private メソッドにしている。 外部から変換ロジックを操作できない設計だ。\nServiceは Book ドメインオブジェクトしか知らない Prismaの Book（DBスキーマ由来の型）はRepository内にしか登場しない DBスキーマが変わっても、変更箇所はここだけで済む save に upsert を使う理由 addBook（INSERT相当）と startReading/completeReading（UPDATE相当）が同じ save メソッドに集約されている。 Service層はDBの「新規か更新か」を気にしない。 Repositoryが upsert で吸収する。\nService: save(book) を呼ぶだけ Repository: id存在チェックして INSERT or UPDATE を判断する この責任分離がDIの恩恵の一つだ。\n3. PrismaClient のシングルトン管理 Next.jsの開発環境ではHMR（Hot Module Replacement）のたびにモジュールが再評価される。 new PrismaClient() が毎回走ると接続が枯渇する。\n公式が推奨するパターンで回避する。\n参考: https://www.prisma.io/docs/orm/more/troubleshooting/nextjs\n// src/lib/prisma.ts import { PrismaClient } from \u0026#34;@prisma/client\u0026#34;; const globalForPrisma = globalThis as unknown as { prisma: PrismaClient }; export const prisma = globalForPrisma.prisma ?? new PrismaClient(); if (process.env.NODE_ENV !== \u0026#34;production\u0026#34;) { globalForPrisma.prisma = prisma; } globalThis に一度生成したインスタンスを保持し、HMR後も使い回す。 production では毎回 new PrismaClient() で問題ない（HMRが走らないため）。\n4. Route HandlerでDIを組み立てる src/app/api/books/route.ts を作る。 ここが唯一の「組み立て場所」だ。\nimport { NextRequest, NextResponse } from \u0026#34;next/server\u0026#34;; import { prisma } from \u0026#34;@/lib/prisma\u0026#34;; import { PrismaBookRepository } from \u0026#34;@/repository/PrismaBookRepository\u0026#34;; import { BookShelfService } from \u0026#34;@/service/BookShelfService\u0026#34;; function buildService(): BookShelfService { const repo = new PrismaBookRepository(prisma); return new BookShelfService(repo); } export async function GET() { try { const service = buildService(); const books = await service.getAll(); const body = books.map((b) =\u0026gt; ({ id: b.id, title: b.title, isbn: b.isbn.value, status: b.status, rating: b.rating?.value ?? null, })); return NextResponse.json(body); } catch (e) { console.error(e); return NextResponse.json({ error: \u0026#34;Internal Server Error\u0026#34; }, { status: 500 }); } } export async function POST(req: NextRequest) { try { const { id, title, isbn } = await req.json(); if (!id || !title || !isbn) { return NextResponse.json( { error: \u0026#34;id, title, isbn は必須です\u0026#34; }, { status: 400 }, ); } const service = buildService(); const book = await service.addBook(id, title, isbn); return NextResponse.json( { id: book.id, title: book.title, isbn: book.isbn.value, status: book.status, rating: book.rating?.value ?? null, }, { status: 201 }, ); } catch (e) { if (e instanceof Error) { return NextResponse.json({ error: e.message }, { status: 400 }); } return NextResponse.json({ error: \u0026#34;Internal Server Error\u0026#34; }, { status: 500 }); } } 依存の流れ Route Handler └─ buildService() ├─ PrismaClient（インフラ） ├─ PrismaBookRepository（BookRepository interfaceを実装） └─ BookShelfService（BookRepository interfaceだけを知っている） Service は interface しか知らない。 Prismaに関する知識はRoute HandlerとRepositoryの2箇所だけに集中している。\n5. 動作確認 npx prisma migrate dev --name init npm run dev # 一覧取得（初期は空配列） curl http://localhost:3000/api/books # 本を追加 curl -X POST http://localhost:3000/api/books \\ -H \u0026#34;Content-Type: application/json\u0026#34; \\ -d \u0026#39;{\u0026#34;id\u0026#34;:\u0026#34;1\u0026#34;,\u0026#34;title\u0026#34;:\u0026#34;Clean Code\u0026#34;,\u0026#34;isbn\u0026#34;:\u0026#34;9784048860000\u0026#34;}\u0026#39; # 追加後に一覧取得 curl http://localhost:3000/api/books 期待するレスポンス例：\n[ { \u0026#34;id\u0026#34;: \u0026#34;1\u0026#34;, \u0026#34;title\u0026#34;: \u0026#34;Clean Code\u0026#34;, \u0026#34;isbn\u0026#34;: \u0026#34;9784048860000\u0026#34;, \u0026#34;status\u0026#34;: \u0026#34;Unread\u0026#34;, \u0026#34;rating\u0026#34;: null } ] 既存テストが引き続き通ることも確認する。\nnpm run ci # tsc --noEmit \u0026amp;\u0026amp; vitest run \u0026amp;\u0026amp; eslint Prismaを追加してもService層のテストは一切変更不要だ。 Mock経由でDBから切り離されているため、依存が増えても13件のテストはそのまま通る。\n所感：「テストしない層」を意図的に決める 今回 PrismaBookRepository のテストを書かなかった。 これは手を抜いたわけではなく、意図的な設計判断だ。\nPart1〜2でテスト可能な層を分離してきた流れを振り返る。\n層 テスト 理由 entity / valueobject / policy ✅ 書く 副作用なし。純粋関数的に検証できる service ✅ 書く MockでDB排除。ビジネスロジックを検証 repository ❌ 書かない DBが必要。ロジックを持たせない設計 route handler ❌ 書かない フレームワーク統合部分。E2Eで担保 Repositoryにテストを書かない理由は「面倒だから」ではない。 ここにビジネスロジックを書かない設計にしたからだ。\nロジックがなければテストする意味も薄い。 toDomain と toRecord はデータ変換だけ。 バリデーションはValueObjectが担う。 状態遷移ルールはPolicyが担う。\n「どこにロジックを置くか」を先に決めたからこそ、「どこをテストしないか」が自然に決まった。\nまとめ Part3でやったこと：\nPrismaをセットアップし、SQLiteにBookテーブルを作った PrismaBookRepository でDBレコードとドメインオブジェクトの変換を実装した Route HandlerでDIを組み立て、GET /api/books と POST /api/books を動かした 既存13テストは変更なしで通り続けることを確認した Part1から一貫して「テストできる設計を先に作る」という方針で進めてきた。 そのおかげでPart3のインフラ層追加が既存コードへの影響ゼロで済んだ。\n次は PATCH /api/books/:id でステータス変更と評価を繋ぐ予定。\n","permalink":"https://techblog.wasutech.dev/posts/test-driven-design-readmeter-3/","summary":"\u003ch2 id=\"はじめに\"\u003eはじめに\u003c/h2\u003e\n\u003cp\u003e\u003ca href=\"./part1\"\u003ePart1\u003c/a\u003e でValueObject・Policy・Entityを作り、\u003ca href=\"./part2\"\u003ePart2\u003c/a\u003e でServiceをDI＋Mockテストで固めた。\n累計13テスト、全パスの状態だ。\u003c/p\u003e\n\u003cp\u003ePart3ではいよいよDBを繋ぐ。やることは3つ。\u003c/p\u003e\n\u003col\u003e\n\u003cli\u003ePrismaセットアップ＋スキーマ定義\u003c/li\u003e\n\u003cli\u003e\u003ccode\u003ePrismaBookRepository\u003c/code\u003e 実装（ドメインオブジェクトへの変換）\u003c/li\u003e\n\u003cli\u003eRoute HandlerでDIを組み立てる\u003c/li\u003e\n\u003c/ol\u003e\n\u003cp\u003eそして最後に「\u003cstrong\u003eテストを書かない層を意図的に決める\u003c/strong\u003e」という話をする。\u003c/p\u003e\n\u003chr\u003e\n\u003ch2 id=\"1-prismaセットアップ\"\u003e1. Prismaセットアップ\u003c/h2\u003e\n\u003cdiv class=\"highlight\"\u003e\u003cpre tabindex=\"0\" style=\"color:#f8f8f2;background-color:#272822;-moz-tab-size:4;-o-tab-size:4;tab-size:4;\"\u003e\u003ccode class=\"language-bash\" data-lang=\"bash\"\u003e\u003cspan style=\"display:flex;\"\u003e\u003cspan\u003enpm install prisma @prisma/client\n\u003c/span\u003e\u003c/span\u003e\u003cspan style=\"display:flex;\"\u003e\u003cspan\u003enpx prisma init --datasource-provider sqlite\n\u003c/span\u003e\u003c/span\u003e\u003c/code\u003e\u003c/pre\u003e\u003c/div\u003e\u003cp\u003e\u003ccode\u003eprisma/schema.prisma\u003c/code\u003e に Bookモデルを定義する。\u003c/p\u003e\n\u003cpre tabindex=\"0\"\u003e\u003ccode class=\"language-prisma\" data-lang=\"prisma\"\u003egenerator client {\n  provider = \u0026#34;prisma-client-js\u0026#34;\n}\n\ndatasource db {\n  provider = \u0026#34;sqlite\u0026#34;\n  url      = env(\u0026#34;DATABASE_URL\u0026#34;)\n}\n\nmodel Book {\n  id     String  @id\n  title  String\n  isbn   String\n  status String\n  rating Int?\n}\n\u003c/code\u003e\u003c/pre\u003e\u003cp\u003e\u003ccode\u003eUser\u003c/code\u003e と \u003ccode\u003eReview\u003c/code\u003e はまだDBに持たない。Book の CRUD が動けば Part3 のゴール。\u003c/p\u003e\n\u003ch3 id=\"なぜ-status-を-string-で持つのか\"\u003eなぜ \u003ccode\u003estatus\u003c/code\u003e を String で持つのか\u003c/h3\u003e\n\u003cp\u003ePrismaは SQLiteで enum をネイティブサポートしていない。\nそのため \u003ccode\u003estatus String\u003c/code\u003e で持ち、取り出し時に \u003ccode\u003eas ReadingStatus\u003c/code\u003e でキャストする。\u003c/p\u003e","title":"テスト前提で設計したWebアプリのハンズオン - 読書管理アプリ その3"},{"content":"はじめに Part1 でValueObject・Policy・Entityを作り、Part2 でServiceをDI＋Mockテストで固めた。 Part3 でPrismaを繋ぎ、GET /api/books と POST /api/books を動かした。\nPart4では残りのエンドポイントを実装する。やることは3つ。\nカスタム例外クラスの導入 PATCH /api/books/:id/start と PATCH /api/books/:id/complete の実装 DELETE /api/books/:id の実装 そして「エラー種別ごとにHTTPステータスを整理する」という設計判断を掘り下げる。\nまた、Part3でPrisma v7特有のセットアップをしたが、v6以前と何が変わったのかをここで整理しておく。\n0. Prisma v7で何が変わったか Part3でPrismaをセットアップしたとき、v6以前と比べていくつか「見慣れない書き方」が必要だった。 v7は破壊的変更が多く、ネット上のv6時代の記事を参考にすると詰まる箇所がある。 ここで整理しておく。\n参考: Upgrade to Prisma ORM 7 | Prisma Documentation\ngenerator の変更 // ❌ v6以前 generator client { provider = \u0026#34;prisma-client-js\u0026#34; } // ✅ v7 generator client { provider = \u0026#34;prisma-client\u0026#34; output = \u0026#34;../src/generated/prisma\u0026#34; } v7では prisma-client-js が廃止され prisma-client に変わった。 Rustベースのエンジンを廃止しTypeScriptネイティブになったことに伴う変更だ。 また output が必須になり、node_modules への自動生成はなくなった。\nimportパスもこれに伴って変わる。\n// ❌ v6以前 import { PrismaClient } from \u0026#39;@prisma/client\u0026#39; // ✅ v7 import { PrismaClient } from \u0026#39;@/generated/prisma/client\u0026#39; driver adapterが必須になった v7では PrismaClient の初期化に必ずdriver adapterを渡す必要がある。 SQLiteの場合は @prisma/adapter-better-sqlite3 を使う。\n// ❌ v6以前 const prisma = new PrismaClient() // ✅ v7 import { PrismaBetterSqlite3 } from \u0026#39;@prisma/adapter-better-sqlite3\u0026#39; const adapter = new PrismaBetterSqlite3({ url: process.env.DATABASE_URL || \u0026#39;file:./dev.db\u0026#39; }) const prisma = new PrismaClient({ adapter }) 参考: Prisma ORM Quickstart with SQLite\nprisma.config.ts の導入 v7ではCLI設定を prisma.config.ts に集約する方式になった。 prisma migrate dev などのコマンドはここからDB接続情報を読む。\n// prisma.config.ts import \u0026#39;dotenv/config\u0026#39; import { defineConfig } from \u0026#39;prisma/config\u0026#39; export default defineConfig({ schema: \u0026#39;prisma/schema.prisma\u0026#39;, migrations: { path: \u0026#39;prisma/migrations\u0026#39;, }, datasource: { url: process.env[\u0026#39;DATABASE_URL\u0026#39;], }, }) schema.prisma の datasource ブロックにあった url はここに移す。 v7では schema.prisma の url はdeprecatedになり、prisma.config.ts が優先される。\nHMR対策のglobalThisキャッシュ Next.jsの開発サーバーはHMR（Hot Module Replacement）でモジュールを再評価する。 毎回 new PrismaClient() するとコネクションが枯渇する。 globalThis にキャッシュして使い回す。\n// src/lib/prisma.ts import { PrismaClient } from \u0026#39;@/generated/prisma/client\u0026#39; import { PrismaBetterSqlite3 } from \u0026#39;@prisma/adapter-better-sqlite3\u0026#39; const adapter = new PrismaBetterSqlite3({ url: process.env.DATABASE_URL || \u0026#39;file:./dev.db\u0026#39;, }) const globalForPrisma = globalThis as unknown as { prisma?: PrismaClient } export const prisma = globalForPrisma.prisma ?? new PrismaClient({ adapter }) if (process.env.NODE_ENV !== \u0026#39;production\u0026#39;) { globalForPrisma.prisma = prisma } 参考: Prisma Client in Next.js | Prisma Documentation\n1. カスタム例外クラスの導入 これまで BookShelfService では throw new Error(...) を使っていた。 Route Handlerで「このエラーは404か、400か、422か」を判定するには instanceof チェックが必要だ。 エラーメッセージの文字列に依存した判定は壊れやすい。\nsrc/errors/AppError.ts を作る。\nexport class NotFoundError extends Error { constructor(message: string) { super(message); this.name = \u0026#34;NotFoundError\u0026#34;; } } export class DomainError extends Error { constructor(message: string) { super(message); this.name = \u0026#34;DomainError\u0026#34;; } } シンプルに Error を継承するだけだ。 今回 DomainError は使わないが、将来のドメインルール違反（積読→読了の直接遷移など）のために定義しておく。\nBookShelfService を更新する NotFoundError を import して、 throw new Error を置き換える。\nimport { NotFoundError } from \u0026#34;../errors/AppError\u0026#34;; // 変更前 if (!book) throw new Error(`Book not found: ${id}`); // 変更後 if (!book) throw new NotFoundError(`Book not found: ${id}`); 既存テストのメッセージ検証（\u0026quot;Book not found: not-exist\u0026quot;）はそのまま通る。 さらに toBeInstanceOf(NotFoundError) でクラスも検証するテストを追加した。\n2. エラーハンドリングヘルパー Route Handlerを3本書くと、エラーハンドリングが重複する。 src/lib/handleError.ts に共通ロジックを切り出す。\nimport { NextResponse } from \u0026#34;next/server\u0026#34;; import { NotFoundError, DomainError } from \u0026#34;../errors/AppError\u0026#34;; export function handleError(e: unknown): NextResponse { if (e instanceof NotFoundError) { return NextResponse.json({ error: e.message }, { status: 404 }); } if (e instanceof DomainError) { return NextResponse.json({ error: e.message }, { status: 422 }); } if (e instanceof Error) { return NextResponse.json({ error: e.message }, { status: 400 }); } return NextResponse.json({ error: \u0026#34;Internal Server Error\u0026#34; }, { status: 500 }); } エラーの優先順位は以下の通り。\n例外クラス HTTPステータス 使いどころ NotFoundError 404 リソースが存在しない DomainError 422 ビジネスルール違反 Error 400 バリデーションエラー（ISBNが不正など） その他 500 予期せぬエラー DomainError が 422（Unprocessable Entity）なのは、リクエスト自体は正しい形式だが業務上処理できない場合に使う慣習に倣った。 参考: RFC 9110 Section 15.5.21 422 Unprocessable Content\n3. PATCH /api/books/[id]/start の実装 src/app/api/books/[id]/start/route.ts を作る。\nimport { NextResponse } from \u0026#34;next/server\u0026#34;; import { prisma } from \u0026#34;@/lib/prisma\u0026#34;; import { PrismaBookRepository } from \u0026#34;@/repository/PrismaBookRepository\u0026#34;; import { BookShelfService } from \u0026#34;@/service/BookShelfService\u0026#34;; import { handleError } from \u0026#34;@/lib/handleError\u0026#34;; function buildService(): BookShelfService { const repo = new PrismaBookRepository(prisma); return new BookShelfService(repo); } export async function PATCH( _req: Request, { params }: { params: Promise\u0026lt;{ id: string }\u0026gt; }, ) { try { const { id } = await params; const service = buildService(); const book = await service.startReading(id); return NextResponse.json({ id: book.id, title: book.title, isbn: book.isbn.value, status: book.status, rating: book.rating?.value ?? null, }); } catch (e) { return handleError(e); } } Next.js 15以降の params は Promise 注意点：Next.js 15以降、Dynamic Route の params は Promise になった。\n参考: https://nextjs.org/docs/app/api-reference/file-conventions/route\n// ❌ Next.js 14以前のパターン（15では動かない） export async function PATCH( _req: Request, { params }: { params: { id: string } }, ) { const { id } = params; // エラー: params should be awaited } // ✅ Next.js 15以降のパターン export async function PATCH( _req: Request, { params }: { params: Promise\u0026lt;{ id: string }\u0026gt; }, ) { const { id } = await params; // awaitが必要 } 4. PATCH /api/books/[id]/complete の実装 src/app/api/books/[id]/complete/route.ts を作る。 こちらはリクエストボディに { \u0026quot;rating\u0026quot;: number } を受け取る（省略可）。\nimport { NextRequest, NextResponse } from \u0026#34;next/server\u0026#34;; import { prisma } from \u0026#34;@/lib/prisma\u0026#34;; import { PrismaBookRepository } from \u0026#34;@/repository/PrismaBookRepository\u0026#34;; import { BookShelfService } from \u0026#34;@/service/BookShelfService\u0026#34;; import { handleError } from \u0026#34;@/lib/handleError\u0026#34;; function buildService(): BookShelfService { const repo = new PrismaBookRepository(prisma); return new BookShelfService(repo); } export async function PATCH( req: NextRequest, { params }: { params: Promise\u0026lt;{ id: string }\u0026gt; }, ) { try { const { id } = await params; const body = await req.json().catch(() =\u0026gt; ({})); const rating: number | undefined = typeof body.rating === \u0026#34;number\u0026#34; ? body.rating : undefined; const service = buildService(); const book = await service.completeReading(id, rating); return NextResponse.json({ id: book.id, title: book.title, isbn: book.isbn.value, status: book.status, rating: book.rating?.value ?? null, }); } catch (e) { return handleError(e); } } ポイント：body のパースに .catch(() =\u0026gt; ({})) を使う理由 rating はオプションなのでボディが空のリクエストも受け付けたい。 req.json() はボディが空だと例外を投げる。 .catch(() =\u0026gt; ({})) で空ボディを安全に {} に変換している。\n5. DELETE /api/books/[id] の実装 src/app/api/books/[id]/route.ts を作る。\nimport { NextResponse } from \u0026#34;next/server\u0026#34;; import { prisma } from \u0026#34;@/lib/prisma\u0026#34;; import { PrismaBookRepository } from \u0026#34;@/repository/PrismaBookRepository\u0026#34;; import { BookShelfService } from \u0026#34;@/service/BookShelfService\u0026#34;; import { handleError } from \u0026#34;@/lib/handleError\u0026#34;; function buildService(): BookShelfService { const repo = new PrismaBookRepository(prisma); return new BookShelfService(repo); } export async function DELETE( _req: Request, { params }: { params: Promise\u0026lt;{ id: string }\u0026gt; }, ) { try { const { id } = await params; const service = buildService(); await service.removeBook(id); return new NextResponse(null, { status: 204 }); } catch (e) { return handleError(e); } } 削除成功時は 204 No Content を返す。 レスポンスボディは不要なので NextResponse.json({}) ではなく new NextResponse(null, { status: 204 }) を使う。\n6. ディレクトリ構成の確認 Part4完了時点の新規追加ファイルは以下の通り。\nsrc/ errors/ AppError.ts # NotFoundError / DomainError lib/ handleError.ts # Route Handler共通エラーハンドラ app/api/books/ [id]/ route.ts # DELETE /api/books/:id start/ route.ts # PATCH /api/books/:id/start complete/ route.ts # PATCH /api/books/:id/complete 7. 動作確認 npm run dev # 本を追加 curl -X POST http://localhost:3000/api/books \\ -H \u0026#34;Content-Type: application/json\u0026#34; \\ -d \u0026#39;{\u0026#34;id\u0026#34;:\u0026#34;1\u0026#34;,\u0026#34;title\u0026#34;:\u0026#34;Clean Code\u0026#34;,\u0026#34;isbn\u0026#34;:\u0026#34;9784048860000\u0026#34;}\u0026#39; # 積読→読中 curl -X PATCH http://localhost:3000/api/books/1/start # 読中→読了（評価あり） curl -X PATCH http://localhost:3000/api/books/1/complete \\ -H \u0026#34;Content-Type: application/json\u0026#34; \\ -d \u0026#39;{\u0026#34;rating\u0026#34;:5}\u0026#39; # 読了確認 curl http://localhost:3000/api/books # 本を削除 curl -X DELETE http://localhost:3000/api/books/1 # 削除後確認（空配列） curl http://localhost:3000/api/books 存在しないIDへのリクエストは404が返ることも確認しておく。\n# 存在しない本へのアクセス curl -X PATCH http://localhost:3000/api/books/not-exist/start # {\u0026#34;error\u0026#34;:\u0026#34;Book not found: not-exist\u0026#34;} 404 テストも確認する。\nnpm run ci # tsc --noEmit \u0026amp;\u0026amp; vitest run \u0026amp;\u0026amp; eslint 既存13テストに加え、NotFoundError のインスタンス検証テストが2件追加されて計15テスト、全パスの状態になる。\n所感：例外クラスの設計は「誰が責任を持つか」で決まる 今回 NotFoundError と DomainError の2クラスを導入した。\nBookShelfService → NotFoundError を throw Book.changeStatus → Error を throw（Policyが弾いた） BookShelfService → DomainError に変換する選択肢もあった Book.changeStatus が投げる Error をそのまま通過させた設計にした。 Route Handlerの handleError では instanceof Error で400として受け取る。 「ISBNが不正」「状態遷移が不正」はどちらも400（クライアントの入力起因）として統一した。\n将来的に「積読→読了の遷移エラーは422にしたい」という要件が出たら、その時点で Book.changeStatus が DomainError を投げるように変える。 今は必要ないのでやらない。YAGNIの原則だ。\nまとめ Part4でやったこと：\nPrisma v7の主要な破壊的変更（generator・driver adapter・prisma.config.ts）を整理した NotFoundError / DomainError カスタム例外クラスを導入した handleError ヘルパーでRoute Handler間のエラー処理を共通化した PATCH /api/books/:id/start で積読→読中のステータス変更を実装した PATCH /api/books/:id/complete で読中→読了（評価オプション）を実装した DELETE /api/books/:id で本の削除を実装した テストは13→15件に増え、引き続き全パス 次はフロントエンドの簡易UIを繋ぐか、ReviewServiceを実装するか予定。\n","permalink":"https://techblog.wasutech.dev/posts/test-driven-design-readmeter-4/","summary":"\u003ch2 id=\"はじめに\"\u003eはじめに\u003c/h2\u003e\n\u003cp\u003e\u003ca href=\"./part1\"\u003ePart1\u003c/a\u003e でValueObject・Policy・Entityを作り、\u003ca href=\"./part2\"\u003ePart2\u003c/a\u003e でServiceをDI＋Mockテストで固めた。\n\u003ca href=\"./part3\"\u003ePart3\u003c/a\u003e でPrismaを繋ぎ、\u003ccode\u003eGET /api/books\u003c/code\u003e と \u003ccode\u003ePOST /api/books\u003c/code\u003e を動かした。\u003c/p\u003e\n\u003cp\u003ePart4では残りのエンドポイントを実装する。やることは3つ。\u003c/p\u003e\n\u003col\u003e\n\u003cli\u003eカスタム例外クラスの導入\u003c/li\u003e\n\u003cli\u003e\u003ccode\u003ePATCH /api/books/:id/start\u003c/code\u003e と \u003ccode\u003ePATCH /api/books/:id/complete\u003c/code\u003e の実装\u003c/li\u003e\n\u003cli\u003e\u003ccode\u003eDELETE /api/books/:id\u003c/code\u003e の実装\u003c/li\u003e\n\u003c/ol\u003e\n\u003cp\u003eそして「\u003cstrong\u003eエラー種別ごとにHTTPステータスを整理する\u003c/strong\u003e」という設計判断を掘り下げる。\u003c/p\u003e\n\u003cp\u003eまた、Part3でPrisma v7特有のセットアップをしたが、v6以前と何が変わったのかをここで整理しておく。\u003c/p\u003e\n\u003chr\u003e\n\u003ch2 id=\"0-prisma-v7で何が変わったか\"\u003e0. Prisma v7で何が変わったか\u003c/h2\u003e\n\u003cp\u003ePart3でPrismaをセットアップしたとき、v6以前と比べていくつか「見慣れない書き方」が必要だった。\nv7は破壊的変更が多く、ネット上のv6時代の記事を参考にすると詰まる箇所がある。\nここで整理しておく。\u003c/p\u003e\n\u003cp\u003e参考: \u003ca href=\"https://www.prisma.io/docs/guides/upgrade-prisma-orm/v7\"\u003eUpgrade to Prisma ORM 7 | Prisma Documentation\u003c/a\u003e\u003c/p\u003e\n\u003ch3 id=\"generator-の変更\"\u003egenerator の変更\u003c/h3\u003e\n\u003cpre tabindex=\"0\"\u003e\u003ccode class=\"language-prisma\" data-lang=\"prisma\"\u003e// ❌ v6以前\ngenerator client {\n  provider = \u0026#34;prisma-client-js\u0026#34;\n}\n\n// ✅ v7\ngenerator client {\n  provider = \u0026#34;prisma-client\u0026#34;\n  output   = \u0026#34;../src/generated/prisma\u0026#34;\n}\n\u003c/code\u003e\u003c/pre\u003e\u003cp\u003ev7では \u003ccode\u003eprisma-client-js\u003c/code\u003e が廃止され \u003ccode\u003eprisma-client\u003c/code\u003e に変わった。\nRustベースのエンジンを廃止しTypeScriptネイティブになったことに伴う変更だ。\nまた \u003ccode\u003eoutput\u003c/code\u003e が必須になり、\u003ccode\u003enode_modules\u003c/code\u003e への自動生成はなくなった。\u003c/p\u003e","title":"テスト前提で設計したWebアプリのハンズオン - 読書管理アプリ その4"},{"content":"はじめに Part4 でカスタム例外クラスと残りのエンドポイントを実装し、APIが完成した。\nPart5では src/app/page.tsx に簡易UIを実装する。バックエンドのロジックやテストには一切触れない。 フロントエンドからAPIを叩いて動くものを作るだけだ。\n実装方針 page.tsx をClient Componentにする。\nServer Componentでfetchする方法もあるが、ボタン操作のたびにstateを更新して再描画する必要がある。 今回のUIは「ボタンを押す → APIを叩く → 一覧を再取得して画面を更新する」という流れが中心なので、 useState + useEffect で管理するClient Componentのほうがシンプルだ。\n\u0026#34;use client\u0026#34;; ファイル先頭にこの1行を追加する。\n実装するUI 本の追加フォーム（id / title / isbn） 本棚（Unread / Reading / Completed のグループ表示） 各本へのアクションボタン Unread → 「読み始める」ボタン Reading → 評価入力（1〜5）＋「読了にする」ボタン 全ステータス → 削除ボタン 型定義 APIレスポンスの型を定義する。\ntype BookStatus = \u0026#34;Unread\u0026#34; | \u0026#34;Reading\u0026#34; | \u0026#34;Completed\u0026#34;; type Book = { id: string; title: string; isbn: string; status: BookStatus; rating: number | null; }; バックエンドの ReadingStatus は as const で定義した文字列リテラルなので、そのまま使える。\nデータ取得 async function fetchBooks() { try { const res = await fetch(\u0026#34;/api/books\u0026#34;); if (!res.ok) throw new Error(\u0026#34;取得失敗\u0026#34;); const data: Book[] = await res.json(); setBooks(data); } catch (e) { setError(e instanceof Error ? e.message : \u0026#34;不明なエラー\u0026#34;); } finally { setLoading(false); } } useEffect(() =\u0026gt; { fetchBooks(); }, []); fetchBooks は追加・ステータス変更・削除の後にも呼ぶ。 サーバーの状態を正として再取得するシンプルな方針だ。 楽観的更新（Optimistic Update）は今回やらない。\n本の追加 async function handleAdd(e: React.FormEvent) { e.preventDefault(); setFormError(null); setSubmitting(true); try { const res = await fetch(\u0026#34;/api/books\u0026#34;, { method: \u0026#34;POST\u0026#34;, headers: { \u0026#34;Content-Type\u0026#34;: \u0026#34;application/json\u0026#34; }, body: JSON.stringify(form), }); const data = await res.json(); if (!res.ok) throw new Error(data.error ?? \u0026#34;追加失敗\u0026#34;); setForm({ id: \u0026#34;\u0026#34;, title: \u0026#34;\u0026#34;, isbn: \u0026#34;\u0026#34; }); await fetchBooks(); } catch (e) { setFormError(e instanceof Error ? e.message : \u0026#34;不明なエラー\u0026#34;); } finally { setSubmitting(false); } } ISBNが不正な場合など、APIが400を返したときはフォームの下にエラーメッセージを出す。\n読了時の評価入力 ratingInputs という Record\u0026lt;string, string\u0026gt; で各本のrating入力値を管理する。\nconst [ratingInputs, setRatingInputs] = useState\u0026lt;Record\u0026lt;string, string\u0026gt;\u0026gt;({}); bookIdをkeyにして、それぞれの入力値を独立して持つ。 複数の本が同時に「Reading」状態でも干渉しない。\nasync function handleComplete(id: string) { const ratingRaw = ratingInputs[id]; const rating = ratingRaw ? parseInt(ratingRaw, 10) : undefined; const body = rating !== undefined ? { rating } : {}; const res = await fetch(`/api/books/${id}/complete`, { method: \u0026#34;PATCH\u0026#34;, headers: { \u0026#34;Content-Type\u0026#34;: \u0026#34;application/json\u0026#34; }, body: JSON.stringify(body), }); if (res.ok) { setRatingInputs((prev) =\u0026gt; { const next = { ...prev }; delete next[id]; return next; }); await fetchBooks(); } } ratingは省略可能なので parseInt した結果が undefined のときは空のbodyを送る。 これはPart4で req.json().catch(() =\u0026gt; ({})) として空ボディを許容している設計と対になっている。\nグループ表示 ステータスごとに本をグループ化して表示する。\nconst grouped: Record\u0026lt;BookStatus, Book[]\u0026gt; = { Unread: books.filter((b) =\u0026gt; b.status === \u0026#34;Unread\u0026#34;), Reading: books.filter((b) =\u0026gt; b.status === \u0026#34;Reading\u0026#34;), Completed: books.filter((b) =\u0026gt; b.status === \u0026#34;Completed\u0026#34;), }; 空のグループは表示しない。\n{([\u0026#34;Unread\u0026#34;, \u0026#34;Reading\u0026#34;, \u0026#34;Completed\u0026#34;] as BookStatus[]).map((status) =\u0026gt; { const group = grouped[status]; if (group.length === 0) return null; return (/* ... */); })} ディレクトリ構成の確認 Part5での変更は1ファイルだけ。\nsrc/ app/ page.tsx # ← Client Componentに書き換え 動作確認 npm run dev ブラウザで http://localhost:3000 を開く。\nフォームに id / title / isbn を入力して「追加する」 追加した本が「積読」グループに表示される 「読み始める」ボタンで「読中」に移動する 評価を入力して「読了にする」ボタンで「読了」に移動する ✕ボタンで削除できる ISBNを12桁にして追加しようとするとAPIが400を返し、フォーム下にエラーが表示されることも確認しておく。\nテストは変更なし。\nnpm run ci # 15テスト、全パス 所感：フロントエンドにロジックを書かない 今回のUIはAPIを叩くだけで、ビジネスロジックを一切持っていない。\n「積読→読了の直接遷移は不可」というルールはドメイン層の ReadingStatusPolicy が持っている。 フロントエンドでボタンの出し分けはしているが、それはUX上の都合であってバリデーションではない。 ボタンがなくても直接curlで叩けばAPIが422を返す。\nロジックの重複をなくすことで、将来モバイルアプリを作っても同じルールが適用される。\nまとめ Part5でやったこと：\npage.tsx をClient Componentとして実装した 本の追加フォーム・ステータス変更・削除をUIから操作できるようにした 読了時のrating入力を各本ごとに独立したstateで管理した フロントエンドにビジネスロジックを持たせない方針を維持した テストは15件のまま全パス（フロントはE2Eで担保する方針） 次はUser/ReviewのDB拡張とReviewServiceの実装を予定。\nここまで実装(生成)してわかったこと アプリの安定感が違う。\nこれまでの趣味のアプリ開発はある程度要望だけ書いてみてくれというだけだったが、\nコア部分を単体テスト書いた・・・というより、テストするために最適化したために\n責任がより詳細に、それでいて具体的に可視化したような・・・気がする。\nしかし、Claudeがすごかっただけかもしれないので気のせいかもしれない。\n","permalink":"https://techblog.wasutech.dev/posts/test-driven-design-readmeter-5/","summary":"\u003ch2 id=\"はじめに\"\u003eはじめに\u003c/h2\u003e\n\u003cp\u003e\u003ca href=\"./part4\"\u003ePart4\u003c/a\u003e でカスタム例外クラスと残りのエンドポイントを実装し、APIが完成した。\u003c/p\u003e\n\u003cp\u003ePart5では \u003ccode\u003esrc/app/page.tsx\u003c/code\u003e に簡易UIを実装する。バックエンドのロジックやテストには一切触れない。\nフロントエンドからAPIを叩いて動くものを作るだけだ。\u003c/p\u003e\n\u003chr\u003e\n\u003ch2 id=\"実装方針\"\u003e実装方針\u003c/h2\u003e\n\u003cp\u003e\u003ccode\u003epage.tsx\u003c/code\u003e をClient Componentにする。\u003c/p\u003e\n\u003cp\u003eServer Componentでfetchする方法もあるが、ボタン操作のたびにstateを更新して再描画する必要がある。\n今回のUIは「ボタンを押す → APIを叩く → 一覧を再取得して画面を更新する」という流れが中心なので、\n\u003ccode\u003euseState\u003c/code\u003e + \u003ccode\u003euseEffect\u003c/code\u003e で管理するClient Componentのほうがシンプルだ。\u003c/p\u003e\n\u003cdiv class=\"highlight\"\u003e\u003cpre tabindex=\"0\" style=\"color:#f8f8f2;background-color:#272822;-moz-tab-size:4;-o-tab-size:4;tab-size:4;\"\u003e\u003ccode class=\"language-typescript\" data-lang=\"typescript\"\u003e\u003cspan style=\"display:flex;\"\u003e\u003cspan\u003e\u003cspan style=\"color:#e6db74\"\u003e\u0026#34;use client\u0026#34;\u003c/span\u003e;\n\u003c/span\u003e\u003c/span\u003e\u003c/code\u003e\u003c/pre\u003e\u003c/div\u003e\u003cp\u003eファイル先頭にこの1行を追加する。\u003c/p\u003e\n\u003chr\u003e\n\u003ch2 id=\"実装するui\"\u003e実装するUI\u003c/h2\u003e\n\u003cul\u003e\n\u003cli\u003e本の追加フォーム（id / title / isbn）\u003c/li\u003e\n\u003cli\u003e本棚（Unread / Reading / Completed のグループ表示）\u003c/li\u003e\n\u003cli\u003e各本へのアクションボタン\n\u003cul\u003e\n\u003cli\u003eUnread → 「読み始める」ボタン\u003c/li\u003e\n\u003cli\u003eReading → 評価入力（1〜5）＋「読了にする」ボタン\u003c/li\u003e\n\u003cli\u003e全ステータス → 削除ボタン\u003c/li\u003e\n\u003c/ul\u003e\n\u003c/li\u003e\n\u003c/ul\u003e\n\u003chr\u003e\n\u003ch2 id=\"型定義\"\u003e型定義\u003c/h2\u003e\n\u003cp\u003eAPIレスポンスの型を定義する。\u003c/p\u003e\n\u003cdiv class=\"highlight\"\u003e\u003cpre tabindex=\"0\" style=\"color:#f8f8f2;background-color:#272822;-moz-tab-size:4;-o-tab-size:4;tab-size:4;\"\u003e\u003ccode class=\"language-typescript\" data-lang=\"typescript\"\u003e\u003cspan style=\"display:flex;\"\u003e\u003cspan\u003e\u003cspan style=\"color:#66d9ef\"\u003etype\u003c/span\u003e \u003cspan style=\"color:#a6e22e\"\u003eBookStatus\u003c/span\u003e \u003cspan style=\"color:#f92672\"\u003e=\u003c/span\u003e \u003cspan style=\"color:#e6db74\"\u003e\u0026#34;Unread\u0026#34;\u003c/span\u003e \u003cspan style=\"color:#f92672\"\u003e|\u003c/span\u003e \u003cspan style=\"color:#e6db74\"\u003e\u0026#34;Reading\u0026#34;\u003c/span\u003e \u003cspan style=\"color:#f92672\"\u003e|\u003c/span\u003e \u003cspan style=\"color:#e6db74\"\u003e\u0026#34;Completed\u0026#34;\u003c/span\u003e;\n\u003c/span\u003e\u003c/span\u003e\u003cspan style=\"display:flex;\"\u003e\u003cspan\u003e\n\u003c/span\u003e\u003c/span\u003e\u003cspan style=\"display:flex;\"\u003e\u003cspan\u003e\u003cspan style=\"color:#66d9ef\"\u003etype\u003c/span\u003e \u003cspan style=\"color:#a6e22e\"\u003eBook\u003c/span\u003e \u003cspan style=\"color:#f92672\"\u003e=\u003c/span\u003e {\n\u003c/span\u003e\u003c/span\u003e\u003cspan style=\"display:flex;\"\u003e\u003cspan\u003e  \u003cspan style=\"color:#a6e22e\"\u003eid\u003c/span\u003e: \u003cspan style=\"color:#66d9ef\"\u003estring\u003c/span\u003e;\n\u003c/span\u003e\u003c/span\u003e\u003cspan style=\"display:flex;\"\u003e\u003cspan\u003e  \u003cspan style=\"color:#a6e22e\"\u003etitle\u003c/span\u003e: \u003cspan style=\"color:#66d9ef\"\u003estring\u003c/span\u003e;\n\u003c/span\u003e\u003c/span\u003e\u003cspan style=\"display:flex;\"\u003e\u003cspan\u003e  \u003cspan style=\"color:#a6e22e\"\u003eisbn\u003c/span\u003e: \u003cspan style=\"color:#66d9ef\"\u003estring\u003c/span\u003e;\n\u003c/span\u003e\u003c/span\u003e\u003cspan style=\"display:flex;\"\u003e\u003cspan\u003e  \u003cspan style=\"color:#a6e22e\"\u003estatus\u003c/span\u003e: \u003cspan style=\"color:#66d9ef\"\u003eBookStatus\u003c/span\u003e;\n\u003c/span\u003e\u003c/span\u003e\u003cspan style=\"display:flex;\"\u003e\u003cspan\u003e  \u003cspan style=\"color:#a6e22e\"\u003erating\u003c/span\u003e: \u003cspan style=\"color:#66d9ef\"\u003enumber\u003c/span\u003e \u003cspan style=\"color:#f92672\"\u003e|\u003c/span\u003e \u003cspan style=\"color:#66d9ef\"\u003enull\u003c/span\u003e;\n\u003c/span\u003e\u003c/span\u003e\u003cspan style=\"display:flex;\"\u003e\u003cspan\u003e};\n\u003c/span\u003e\u003c/span\u003e\u003c/code\u003e\u003c/pre\u003e\u003c/div\u003e\u003cp\u003eバックエンドの \u003ccode\u003eReadingStatus\u003c/code\u003e は \u003ccode\u003eas const\u003c/code\u003e で定義した文字列リテラルなので、そのまま使える。\u003c/p\u003e\n\u003chr\u003e\n\u003ch2 id=\"データ取得\"\u003eデータ取得\u003c/h2\u003e\n\u003cdiv class=\"highlight\"\u003e\u003cpre tabindex=\"0\" style=\"color:#f8f8f2;background-color:#272822;-moz-tab-size:4;-o-tab-size:4;tab-size:4;\"\u003e\u003ccode class=\"language-typescript\" data-lang=\"typescript\"\u003e\u003cspan style=\"display:flex;\"\u003e\u003cspan\u003e\u003cspan style=\"color:#66d9ef\"\u003easync\u003c/span\u003e \u003cspan style=\"color:#66d9ef\"\u003efunction\u003c/span\u003e \u003cspan style=\"color:#a6e22e\"\u003efetchBooks() {\u003c/span\u003e\n\u003c/span\u003e\u003c/span\u003e\u003cspan style=\"display:flex;\"\u003e\u003cspan\u003e  \u003cspan style=\"color:#66d9ef\"\u003etry\u003c/span\u003e {\n\u003c/span\u003e\u003c/span\u003e\u003cspan style=\"display:flex;\"\u003e\u003cspan\u003e    \u003cspan style=\"color:#66d9ef\"\u003econst\u003c/span\u003e \u003cspan style=\"color:#a6e22e\"\u003eres\u003c/span\u003e \u003cspan style=\"color:#f92672\"\u003e=\u003c/span\u003e \u003cspan style=\"color:#66d9ef\"\u003eawait\u003c/span\u003e \u003cspan style=\"color:#a6e22e\"\u003efetch\u003c/span\u003e(\u003cspan style=\"color:#e6db74\"\u003e\u0026#34;/api/books\u0026#34;\u003c/span\u003e);\n\u003c/span\u003e\u003c/span\u003e\u003cspan style=\"display:flex;\"\u003e\u003cspan\u003e    \u003cspan style=\"color:#66d9ef\"\u003eif\u003c/span\u003e (\u003cspan style=\"color:#f92672\"\u003e!\u003c/span\u003e\u003cspan style=\"color:#a6e22e\"\u003eres\u003c/span\u003e.\u003cspan style=\"color:#a6e22e\"\u003eok\u003c/span\u003e) \u003cspan style=\"color:#66d9ef\"\u003ethrow\u003c/span\u003e \u003cspan style=\"color:#66d9ef\"\u003enew\u003c/span\u003e Error(\u003cspan style=\"color:#e6db74\"\u003e\u0026#34;取得失敗\u0026#34;\u003c/span\u003e);\n\u003c/span\u003e\u003c/span\u003e\u003cspan style=\"display:flex;\"\u003e\u003cspan\u003e    \u003cspan style=\"color:#66d9ef\"\u003econst\u003c/span\u003e \u003cspan style=\"color:#a6e22e\"\u003edata\u003c/span\u003e: \u003cspan style=\"color:#66d9ef\"\u003eBook\u003c/span\u003e[] \u003cspan style=\"color:#f92672\"\u003e=\u003c/span\u003e \u003cspan style=\"color:#66d9ef\"\u003eawait\u003c/span\u003e \u003cspan style=\"color:#a6e22e\"\u003eres\u003c/span\u003e.\u003cspan style=\"color:#a6e22e\"\u003ejson\u003c/span\u003e();\n\u003c/span\u003e\u003c/span\u003e\u003cspan style=\"display:flex;\"\u003e\u003cspan\u003e    \u003cspan style=\"color:#a6e22e\"\u003esetBooks\u003c/span\u003e(\u003cspan style=\"color:#a6e22e\"\u003edata\u003c/span\u003e);\n\u003c/span\u003e\u003c/span\u003e\u003cspan style=\"display:flex;\"\u003e\u003cspan\u003e  } \u003cspan style=\"color:#66d9ef\"\u003ecatch\u003c/span\u003e (\u003cspan style=\"color:#a6e22e\"\u003ee\u003c/span\u003e) {\n\u003c/span\u003e\u003c/span\u003e\u003cspan style=\"display:flex;\"\u003e\u003cspan\u003e    \u003cspan style=\"color:#a6e22e\"\u003esetError\u003c/span\u003e(\u003cspan style=\"color:#a6e22e\"\u003ee\u003c/span\u003e \u003cspan style=\"color:#66d9ef\"\u003einstanceof\u003c/span\u003e Error \u003cspan style=\"color:#f92672\"\u003e?\u003c/span\u003e \u003cspan style=\"color:#a6e22e\"\u003ee\u003c/span\u003e.\u003cspan style=\"color:#a6e22e\"\u003emessage\u003c/span\u003e \u003cspan style=\"color:#f92672\"\u003e:\u003c/span\u003e \u003cspan style=\"color:#e6db74\"\u003e\u0026#34;不明なエラー\u0026#34;\u003c/span\u003e);\n\u003c/span\u003e\u003c/span\u003e\u003cspan style=\"display:flex;\"\u003e\u003cspan\u003e  } \u003cspan style=\"color:#66d9ef\"\u003efinally\u003c/span\u003e {\n\u003c/span\u003e\u003c/span\u003e\u003cspan style=\"display:flex;\"\u003e\u003cspan\u003e    \u003cspan style=\"color:#a6e22e\"\u003esetLoading\u003c/span\u003e(\u003cspan style=\"color:#66d9ef\"\u003efalse\u003c/span\u003e);\n\u003c/span\u003e\u003c/span\u003e\u003cspan style=\"display:flex;\"\u003e\u003cspan\u003e  }\n\u003c/span\u003e\u003c/span\u003e\u003cspan style=\"display:flex;\"\u003e\u003cspan\u003e}\n\u003c/span\u003e\u003c/span\u003e\u003cspan style=\"display:flex;\"\u003e\u003cspan\u003e\n\u003c/span\u003e\u003c/span\u003e\u003cspan style=\"display:flex;\"\u003e\u003cspan\u003e\u003cspan style=\"color:#a6e22e\"\u003euseEffect\u003c/span\u003e(() \u003cspan style=\"color:#f92672\"\u003e=\u0026gt;\u003c/span\u003e {\n\u003c/span\u003e\u003c/span\u003e\u003cspan style=\"display:flex;\"\u003e\u003cspan\u003e  \u003cspan style=\"color:#a6e22e\"\u003efetchBooks\u003c/span\u003e();\n\u003c/span\u003e\u003c/span\u003e\u003cspan style=\"display:flex;\"\u003e\u003cspan\u003e}, []);\n\u003c/span\u003e\u003c/span\u003e\u003c/code\u003e\u003c/pre\u003e\u003c/div\u003e\u003cp\u003e\u003ccode\u003efetchBooks\u003c/code\u003e は追加・ステータス変更・削除の後にも呼ぶ。\nサーバーの状態を正として再取得するシンプルな方針だ。\n楽観的更新（Optimistic Update）は今回やらない。\u003c/p\u003e","title":"テスト前提で設計したWebアプリのハンズオン - 読書管理アプリ その5"},{"content":"はじめに 動かしながらゼロから学ぶLinuxカーネルの教科書 第2版\n上記の技術書を読んでいてLinuxスケジューラの話が出てきた。CFSもEEVDFも赤黒木を使っている。赤黒木とは何かを調べていくうちにAVL木との比較が面白かったので、両方Pythonで実装してベンチマークを取った。\n実装コードはClaude (Anthropic) により生成。リポジトリは以下。\nhttps://github.com/wasuken/avl-rb-tree\n自己平衡二分探索木とは まず前提として、ただの二分探索木には問題がある。\n挿入順: 1, 2, 3, 4, 5 1 \\ 2 \\ 3 \\ 4 ← 一直線になる 挿入順によっては木が一直線になり、検索がO(n)に劣化する。これを解決するのが自己平衡二分探索木で、挿入・削除のたびに自動でバランスを取り直す。AVL木と赤黒木はどちらもこのカテゴリに属する。\nAVL木 1962年にソ連の数学者Adelson-VelskyとLandis（AVLの名前の由来）が考案した世界初の自己平衡二分探索木。\nバランスの管理方法 各ノードに高さ（height）を持たせ、左右の高さの差（バランス係数）が常に1以内になるよう管理する。\ndef _balance_factor(self, node): return self._height(node.left) - self._height(node.right) 差が2以上になったら回転で修正する。\n回転 回転は「親子関係を1段入れ替えるだけ」の操作。二分探索木の順序を壊さずに形だけ変える。\nrotate_right(y): y x / \\ / \\ x C → A y / \\ / \\ A t2 t2 C t2 は回転で行き場を失う孫ノード。二分探索木の順序的に x \u0026lt; t2 \u0026lt; y が保証されているので、yの左に付け替えるだけでよい。\nバランス崩れのパターンは4つ（LL, RR, LR, RL）で、それぞれ1〜2回の回転で解消できる。\n挿入・削除 挿入・削除のたびに _rebalance が呼ばれ、高さの更新とバランスチェックが走る。\ndef _rebalance(self, node): self._update_height(node) bf = self._balance_factor(node) if bf \u0026gt; 1: # Left Heavy ... return self._rotate_right(node) if bf \u0026lt; -1: # Right Heavy ... return self._rotate_left(node) return node 削除は消すノードの子の数によって3パターン。両方の子がある場合は右部分木の最小値（successor）で置き換える。\ndef _min_node(self, node): while node.left: node = node.left return node _min_node がシンプルで面白い。「二分探索木では左に行くほど小さい」というルールを使って、ひたすら左を辿るだけ。\n赤黒木 AVL木との設計上の違い AVL木は高さという数値でバランスを管理するが、赤黒木は色（赤/黒）という1bitの情報でバランスを管理する。\nAVL: height の数値を見てバランス判定 RB: 色のルールを維持することでバランスを保証 4つのルール 1. ノードは赤か黒 2. ルートは必ず黒 3. 赤ノードの子は必ず黒（赤の連続禁止） 4. どのノードからNULLまでの経路の黒ノード数は全経路で同じ 4が一番重要で、これが実質的に高さを保証している。黒ノードだけで見れば完全にバランスが取れており、赤は黒の間にしか入れないのでどんなに多くても黒ノードの数を超えられない。結果として高さは 2*log2(n+1) を超えない。\n番兵（NIL） 赤黒木はNULLの代わりに番兵ノードを使う。\nself.NIL = RBNode(key=None, color=BLACK) self.root = self.NIL 葉ノードの子はすべてこのNILを指す。「黒ノード数が全経路で同じ」というルールをコードで扱いやすくするための実装上の工夫。\n挿入とfixup 挿入の場所探索はAVL木と同じ。新しいノードを赤で置いた後、色ルール違反が起きていれば _insert_fixup で修正する。\nfixupのケースは3パターン（叔父ノードの色と挿入位置の組み合わせ）で、色替えだけで済むケースと回転が必要なケースがある。コードが長く見えるのは左右対称で2セットあるから。\nベンチマーク比較 n=1000 n=10000 n=100000 insert AVL 3.00ms 41.70ms 591.12ms insert RB 1.27ms 16.70ms 289.75ms ← 約2倍速い search AVL 0.31ms 0.48ms 1.30ms search RB 0.49ms 1.11ms 1.87ms ← ほぼ同じ delete AVL 1.35ms 1.96ms 2.92ms delete RB 0.52ms 1.00ms 1.39ms ← 約2倍速い height AVL 11 / 16 / 20 height RB 11 / 16 / 20 ← 同じ 高さが同じなのに挿入・削除は2倍の差がある。\n挿入のたびにAVL木は全ノード辿りながら高さを更新してバランスチェックをするが、赤黒木は色替えだけで済むケースが多く回転が少ない。n=100000で挿入が10万回あれば、1回あたりのわずかな差が積み重なって2倍になる。\nまとめると：\nAVL: 高さという数値を正確に管理するコスト RB: 色という1bitで高さを間接的に担保するコスト → 結果的に高さはほぼ同じ、でも挿入・削除コストに2倍の差 Linuxのスケジューラが赤黒木を選ぶ理由がデータで見える結果になった。プロセスのIOのたびに挿入・削除が走るので、挿入・削除の速さが直接レイテンシに影響する。\n参考 wasuken/avl-rb-tree - GitHub Linux Kernel Documentation - CFS Scheduler ","permalink":"https://techblog.wasutech.dev/posts/avl-red-black-tree-impl/","summary":"\u003ch2 id=\"はじめに\"\u003eはじめに\u003c/h2\u003e\n\u003cp\u003e\u003ca href=\"https://info.nikkeibp.co.jp/media/LIN/atcl/books/070900046/\"\u003e動かしながらゼロから学ぶLinuxカーネルの教科書 第2版\u003c/a\u003e\u003c/p\u003e\n\u003cp\u003e上記の技術書を読んでいてLinuxスケジューラの話が出てきた。CFSもEEVDFも赤黒木を使っている。赤黒木とは何かを調べていくうちにAVL木との比較が面白かったので、両方Pythonで実装してベンチマークを取った。\u003c/p\u003e\n\u003cp\u003e実装コードはClaude (Anthropic) により生成。リポジトリは以下。\u003c/p\u003e\n\u003cp\u003e\u003ca href=\"https://github.com/wasuken/avl-rb-tree\"\u003ehttps://github.com/wasuken/avl-rb-tree\u003c/a\u003e\u003c/p\u003e\n\u003chr\u003e\n\u003ch2 id=\"自己平衡二分探索木とは\"\u003e自己平衡二分探索木とは\u003c/h2\u003e\n\u003cp\u003eまず前提として、ただの二分探索木には問題がある。\u003c/p\u003e\n\u003cpre tabindex=\"0\"\u003e\u003ccode\u003e挿入順: 1, 2, 3, 4, 5\n\n1\n \\\n  2\n   \\\n    3\n     \\\n      4  ← 一直線になる\n\u003c/code\u003e\u003c/pre\u003e\u003cp\u003e挿入順によっては木が一直線になり、検索がO(n)に劣化する。これを解決するのが自己平衡二分探索木で、挿入・削除のたびに自動でバランスを取り直す。AVL木と赤黒木はどちらもこのカテゴリに属する。\u003c/p\u003e\n\u003chr\u003e\n\u003ch2 id=\"avl木\"\u003eAVL木\u003c/h2\u003e\n\u003cp\u003e1962年にソ連の数学者Adelson-VelskyとLandis（AVLの名前の由来）が考案した世界初の自己平衡二分探索木。\u003c/p\u003e\n\u003ch3 id=\"バランスの管理方法\"\u003eバランスの管理方法\u003c/h3\u003e\n\u003cp\u003e各ノードに高さ（height）を持たせ、左右の高さの差（バランス係数）が常に1以内になるよう管理する。\u003c/p\u003e\n\u003cdiv class=\"highlight\"\u003e\u003cpre tabindex=\"0\" style=\"color:#f8f8f2;background-color:#272822;-moz-tab-size:4;-o-tab-size:4;tab-size:4;\"\u003e\u003ccode class=\"language-python\" data-lang=\"python\"\u003e\u003cspan style=\"display:flex;\"\u003e\u003cspan\u003e\u003cspan style=\"color:#66d9ef\"\u003edef\u003c/span\u003e \u003cspan style=\"color:#a6e22e\"\u003e_balance_factor\u003c/span\u003e(self, node):\n\u003c/span\u003e\u003c/span\u003e\u003cspan style=\"display:flex;\"\u003e\u003cspan\u003e    \u003cspan style=\"color:#66d9ef\"\u003ereturn\u003c/span\u003e self\u003cspan style=\"color:#f92672\"\u003e.\u003c/span\u003e_height(node\u003cspan style=\"color:#f92672\"\u003e.\u003c/span\u003eleft) \u003cspan style=\"color:#f92672\"\u003e-\u003c/span\u003e self\u003cspan style=\"color:#f92672\"\u003e.\u003c/span\u003e_height(node\u003cspan style=\"color:#f92672\"\u003e.\u003c/span\u003eright)\n\u003c/span\u003e\u003c/span\u003e\u003c/code\u003e\u003c/pre\u003e\u003c/div\u003e\u003cp\u003e差が2以上になったら回転で修正する。\u003c/p\u003e\n\u003ch3 id=\"回転\"\u003e回転\u003c/h3\u003e\n\u003cp\u003e回転は「親子関係を1段入れ替えるだけ」の操作。二分探索木の順序を壊さずに形だけ変える。\u003c/p\u003e\n\u003cpre tabindex=\"0\"\u003e\u003ccode\u003erotate_right(y):\n\n    y              x\n   / \\            / \\\n  x   C    →    A   y\n / \\               / \\\nA   t2           t2   C\n\u003c/code\u003e\u003c/pre\u003e\u003cp\u003e\u003ccode\u003et2\u003c/code\u003e は回転で行き場を失う孫ノード。二分探索木の順序的に \u003ccode\u003ex \u0026lt; t2 \u0026lt; y\u003c/code\u003e が保証されているので、yの左に付け替えるだけでよい。\u003c/p\u003e\n\u003cp\u003eバランス崩れのパターンは4つ（LL, RR, LR, RL）で、それぞれ1〜2回の回転で解消できる。\u003c/p\u003e\n\u003ch3 id=\"挿入削除\"\u003e挿入・削除\u003c/h3\u003e\n\u003cp\u003e挿入・削除のたびに \u003ccode\u003e_rebalance\u003c/code\u003e が呼ばれ、高さの更新とバランスチェックが走る。\u003c/p\u003e","title":"AVL木と赤黒木をPythonで実装して比較する"},{"content":"はじめに 動かしながらゼロから学ぶLinuxカーネルの教科書 第2版\n上記の技術書を読んでいてスケジューラ周りの理解が曖昧だったので、生成AIや公式ドキュメントを使って整理した。\nCFS (Completely Fair Scheduler) とは Linux 2.6.23から導入されたプロセススケジューラ。「全プロセスに公平にCPU時間を与える」という思想で設計されている。\nvruntime（仮想実行時間） vruntimeは「実際の実行時間をNICE値で補正した値」で、CFSの核心となる指標。\nvruntime += 実際のCPU時間 × (1024 / プロセスの重み) NICE値が低い（優先度高）→ 重みが大きい → vruntimeの増加が遅い → より長くCPUを使える NICE値が高い（優先度低）→ 重みが小さい → vruntimeの増加が速い → すぐ交代させられる CFSは「vruntimeが最も小さいプロセスを次に実行する」というルールで動く。後ろ向きの指標（過去の使用量の累積）であることがEEVDFとの本質的な差になる。\nNICE値と重み NICE値は -20（最高優先度）〜 +19（最低優先度）の範囲で、内部的に重みに変換される。\nNICE 0 → weight 1024 NICE -1 → weight 1277（約1.25倍） NICE +1 → weight 820（約0.8倍） NICE -20 → weight 88761 NICE +19 → weight 15 1段階変わるごとに約10%のCPU時間が変化する設計になっている。\nタイムスライスとスケジューリングレイテンシ スケジューリングレイテンシは「全プロセスが最低1回実行されるべき目標周期」。デフォルト約6〜24ms（プロセス数による）。\nタイムスライスはその比例配分：\nタイムスライス = スケジューリングレイテンシ × (タスクの重み / キュー内の全タスクの重みの合計) 具体例：\nレイテンシ = 12ms プロセスA: NICE -5 → weight 3121 プロセスB: NICE +5 → weight 335 合計 = 3456 A: 12ms × (3121 / 3456) ≈ 10.8ms B: 12ms × (335 / 3456) ≈ 1.2ms 「優先度が高いほど多くもらえるが、全員必ず実行される」がCFSの設計思想。優先度で独占させないのはスタベーション（低優先度プロセスが永久に実行されない）を防ぐため。\n赤黒木（Red-Black Tree） CFSとEEVDF両方で使われるデータ構造。「自己平衡二分探索木」のひとつ。\nなぜただの二分探索木ではダメか 挿入順によっては木が一直線になりO(n)に劣化する。\n挿入: 1, 2, 3, 4, 5 1 \\ 2 \\ 3 ← 検索がO(n)になる 赤黒木のバランス保証 ノードに赤/黒の色をつけ、以下のルールを維持することで自動的にバランスを保つ：\nノードは赤か黒 ルートは黒 赤ノードの子は必ず黒（赤の連続禁止） どのノードからNULLまでの黒ノード数は全経路で同じ 挿入・削除のたびに回転と色変更が自動で走り、常にO(log n)を保証する。\n最悪ケース ただの二分探索木 O(n) 赤黒木 O(log n) 保証 スケジューラが赤黒木を選ぶ理由 プロセスはIOを待ち始めると木から取り出され、IO完了で木に戻る。この挿入・削除がミリ秒単位で頻繁に発生するため、検索の精度より挿入・削除の速度が重要になる。\nAVL木は検索が速い代わりに挿入・削除のコストが高く、スケジューラのユースケースには合わない。赤黒木はその逆のトレードオフを持つ。\nEEVDF (Earliest Eligible Virtual Deadline First) Linux 6.6（2023年）で導入。元は1995年の論文のアルゴリズム。\nCFSの限界 vruntimeは後ろ向きの指標なので「次にいつ実行されるか」という前向きの予測ができない。レイテンシの保証が困難だった。\nEEVDFの2つの核心概念 eligible time（実行資格時刻）：タスクがCPUをもらう権利を得る時刻。これより前は実行されない。\nvirtual deadline：タスクが「このvruntime時点までには実行されるべき」という期限。\nvirtual deadline = eligible time + タイムスライス 選択ルールの違い CFS: vruntimeが最小のタスクを選ぶ EEVDF: eligibleなタスクの中でvirtual deadlineが最も早いタスクを選ぶ タイムスライスの要求 EEVDFではタスクが希望するタイムスライス長を伝えられる：\nレイテンシ重視タスク → 短いタイムスライスを要求 → 頻繁に実行される スループット重視タスク → 長いタイムスライスを要求 → まとめて実行される CFSはこの区別ができなかった。\n実装はCFSとほぼ同じ 赤黒木はそのまま使い、ソートキーだけ変更：\nCFS: key = vruntime EEVDF: key = virtual_deadline kernel/sched/fair.c の中にCFSとEEVDFの実装が共存しており、Linux 6.6でCFSのコードベースを拡張する形で導入された。\nCFSとEEVDFの対比 CFS: 過去の使用量(vruntime)で順番を決める → 公平だが「いつ実行されるか」の保証なし EEVDF: 未来の期限(virtual deadline)で順番を決める → 公平さを保ちつつ待ち時間の上限を保証できる 参考 Linux Kernel Documentation - Completely Fair Scheduler Linux Kernel Documentation - EEVDF Scheduler LWN.net - An EEVDF CPU scheduler for Linux Semantic Scholar - Earliest Eligible Virtual Deadline First (原論文 1995) ","permalink":"https://techblog.wasutech.dev/posts/linux-scheduler/","summary":"\u003ch2 id=\"はじめに\"\u003eはじめに\u003c/h2\u003e\n\u003cp\u003e\u003ca href=\"https://info.nikkeibp.co.jp/media/LIN/atcl/books/070900046/\"\u003e動かしながらゼロから学ぶLinuxカーネルの教科書 第2版\u003c/a\u003e\u003c/p\u003e\n\u003cp\u003e上記の技術書を読んでいてスケジューラ周りの理解が曖昧だったので、生成AIや公式ドキュメントを使って整理した。\u003c/p\u003e\n\u003chr\u003e\n\u003ch2 id=\"cfs-completely-fair-scheduler-とは\"\u003eCFS (Completely Fair Scheduler) とは\u003c/h2\u003e\n\u003cp\u003eLinux 2.6.23から導入されたプロセススケジューラ。「全プロセスに公平にCPU時間を与える」という思想で設計されている。\u003c/p\u003e\n\u003chr\u003e\n\u003ch2 id=\"vruntime仮想実行時間\"\u003evruntime（仮想実行時間）\u003c/h2\u003e\n\u003cp\u003evruntimeは「実際の実行時間をNICE値で補正した値」で、CFSの核心となる指標。\u003c/p\u003e\n\u003cpre tabindex=\"0\"\u003e\u003ccode\u003evruntime += 実際のCPU時間 × (1024 / プロセスの重み)\n\u003c/code\u003e\u003c/pre\u003e\u003cul\u003e\n\u003cli\u003eNICE値が低い（優先度高）→ 重みが大きい → vruntimeの増加が遅い → より長くCPUを使える\u003c/li\u003e\n\u003cli\u003eNICE値が高い（優先度低）→ 重みが小さい → vruntimeの増加が速い → すぐ交代させられる\u003c/li\u003e\n\u003c/ul\u003e\n\u003cp\u003eCFSは「vruntimeが最も小さいプロセスを次に実行する」というルールで動く。\u003cstrong\u003e後ろ向きの指標\u003c/strong\u003e（過去の使用量の累積）であることがEEVDFとの本質的な差になる。\u003c/p\u003e\n\u003chr\u003e\n\u003ch2 id=\"nice値と重み\"\u003eNICE値と重み\u003c/h2\u003e\n\u003cp\u003eNICE値は \u003ccode\u003e-20\u003c/code\u003e（最高優先度）〜 \u003ccode\u003e+19\u003c/code\u003e（最低優先度）の範囲で、内部的に重みに変換される。\u003c/p\u003e\n\u003cpre tabindex=\"0\"\u003e\u003ccode\u003eNICE  0  → weight 1024\nNICE -1  → weight 1277（約1.25倍）\nNICE +1  → weight 820（約0.8倍）\nNICE -20 → weight 88761\nNICE +19 → weight 15\n\u003c/code\u003e\u003c/pre\u003e\u003cp\u003e1段階変わるごとに約10%のCPU時間が変化する設計になっている。\u003c/p\u003e\n\u003chr\u003e\n\u003ch2 id=\"タイムスライスとスケジューリングレイテンシ\"\u003eタイムスライスとスケジューリングレイテンシ\u003c/h2\u003e\n\u003cp\u003e\u003cstrong\u003eスケジューリングレイテンシ\u003c/strong\u003eは「全プロセスが最低1回実行されるべき目標周期」。デフォルト約6〜24ms（プロセス数による）。\u003c/p\u003e\n\u003cp\u003eタイムスライスはその比例配分：\u003c/p\u003e\n\u003cpre tabindex=\"0\"\u003e\u003ccode\u003eタイムスライス = スケジューリングレイテンシ × (タスクの重み / キュー内の全タスクの重みの合計)\n\u003c/code\u003e\u003c/pre\u003e\u003cp\u003e具体例：\u003c/p\u003e","title":"LinuxのCFSとEEVDFを整理する - スケジューラはなぜ赤黒木を使うのか"},{"content":"はじめに [https://info.nikkeibp.co.jp/media/LIN/atcl/books/070900046/:embed:cite]\n上記の技術書を読んでいて、ブートローダとLinuxの初期スタート時の役割とか順番がいまいち掴めなかったので生成AIや他の記事など別軸から調べ直してまとめた。\n起動フロー全体像 UEFI/BIOS ↓ POST（ハードウェア初期化）、ブートデバイス選択 ブートローダー（GRUB等） ↓ /boot/vmlinuz（カーネルイメージ）をメモリに展開 ↓ /boot/initramfs をメモリに展開 カーネル起動 ↓ initramfsを一時的な / としてマウント ↓ ドライバ読み込み、本物のrootデバイスを認識 ↓ 本物のroot FSをマウント（switch_root） /sbin/init（systemd）に移譲 各フェーズの詳細 1. UEFI/BIOS 起動の最初はUEFI（または旧来のBIOS）が担う。\nPOST（Power-On Self Test）: メモリ、CPU、周辺デバイスの初期化 ブートデバイスの選択（NVMe, SSD, PXEなど） UEFIの場合はEFIパーティション（ESP）から .efi ファイルを直接実行できる UEFIとBIOSの大きな違いとして、UEFIはGPTディスクのネイティブサポートや、セキュアブートの仕組みを持つ。\n2. ブートローダー（GRUB2等） UEFI/BIOSからブートローダーに制御が渡る。 代表的なものはGRUB2で、設定ファイルは /boot/grub/grub.cfg にある。\nブートローダーの役割はシンプルで、以下の2点だけ：\nカーネルイメージ（vmlinuz）をメモリに展開する initramfs（initramfs-*.img）をメモリに展開する # /boot 以下の典型的な構成 $ ls /boot/ grub/ initramfs-6.1.0-28-amd64.img vmlinuz-6.1.0-28-amd64 ブートローダー自身はルートFSのマウントをしない。あくまでカーネルとinitramfsをメモリに置いて制御を渡すだけ。\n3. カーネル起動とinitramfs ここが一番誤解されやすいフェーズ。\nカーネルが起動すると、まず**initramfs（Initial RAM Filesystem）**を一時的なルート（/）としてマウントする。\nなぜinitramfsが必要か？\nカーネル本体はコンパクトに保つ設計になっており、NVMeやLVMやLUKS（暗号化）といった本物のディスクにアクセスするためのドライバを、起動時に動的にロードする必要がある。 initramfsはそのためのミニマルな環境を提供する。\ninitramfs の中身（概略） /init → 起動スクリプト /lib/modules → カーネルモジュール（ドライバ） /bin, /sbin → busybox等の最低限のコマンド群 処理の流れ：\ninitramfs内の /init スクリプトが実行される 必要なカーネルモジュール（ドライバ）をロード 本物のrootデバイス（/dev/nvme0n1p2 等）を認識 本物のroot FSをマウント switch_root で本物の / に切り替え 4. /sbin/init（systemd）への移譲 switch_root が完了すると、カーネルは /sbin/init を PID 1 として起動して移譲完了。\n現代のLinuxディストリビューションでは、/sbin/init は systemd へのシンボリックリンクになっている。\n$ ls -la /sbin/init lrwxrwxrwx 1 root root 20 /sbin/init -\u0026gt; /lib/systemd/systemd systemdはここからユニットファイルに従ってサービスを順次起動していく。\nよくある混乱ポイントの整理 疑問 答え rootFSをマウントするのは誰？ カーネル（initramfs経由） ブートローダーは何をする？ カーネルとinitramfsをメモリに置くだけ initramfsが必要な理由は？ カーネルが本物のディスクドライバをロードするための踏み台 /sbin/init の正体は？ 現代ではほぼsystemdへのシンボリックリンク 知っておくべきこと カーネルパニック時の読み方\n起動フローを把握していると、カーネルパニックのメッセージがどのフェーズで発生したかを特定しやすくなる。 VFS: Unable to mount root fs のようなエラーはinitramfsフェーズの問題、Kernel panic - not syncing: No working init found はinit移譲の失敗を示す。\nGRUBレスキュー\nブートローダーの設定が壊れた場合、GRUBのレスキューモードから手動でカーネルとinitramfsを指定して起動できる。\n# GRUBレスキューモードでの手動起動例 grub\u0026gt; set root=(hd0,gpt2) grub\u0026gt; linux /boot/vmlinuz root=/dev/nvme0n1p2 grub\u0026gt; initrd /boot/initramfs.img grub\u0026gt; boot 参考 Linux Kernel Documentation - initrd Arch Wiki - Arch boot process freedesktop.org - systemd GNU GRUB Manual ","permalink":"https://techblog.wasutech.dev/posts/linux-startup-flow/","summary":"\u003ch2 id=\"はじめに\"\u003eはじめに\u003c/h2\u003e\n\u003cp\u003e[https://info.nikkeibp.co.jp/media/LIN/atcl/books/070900046/:embed:cite]\u003c/p\u003e\n\u003cp\u003e上記の技術書を読んでいて、ブートローダとLinuxの初期スタート時の役割とか順番がいまいち掴めなかったので生成AIや他の記事など別軸から調べ直してまとめた。\u003c/p\u003e\n\u003chr\u003e\n\u003ch2 id=\"起動フロー全体像\"\u003e起動フロー全体像\u003c/h2\u003e\n\u003cpre tabindex=\"0\"\u003e\u003ccode\u003eUEFI/BIOS\n  ↓  POST（ハードウェア初期化）、ブートデバイス選択\nブートローダー（GRUB等）\n  ↓  /boot/vmlinuz（カーネルイメージ）をメモリに展開\n  ↓  /boot/initramfs をメモリに展開\nカーネル起動\n  ↓  initramfsを一時的な / としてマウント\n  ↓  ドライバ読み込み、本物のrootデバイスを認識\n  ↓  本物のroot FSをマウント（switch_root）\n/sbin/init（systemd）に移譲\n\u003c/code\u003e\u003c/pre\u003e\u003chr\u003e\n\u003ch2 id=\"各フェーズの詳細\"\u003e各フェーズの詳細\u003c/h2\u003e\n\u003ch3 id=\"1-uefibios\"\u003e1. UEFI/BIOS\u003c/h3\u003e\n\u003cp\u003e起動の最初はUEFI（または旧来のBIOS）が担う。\u003c/p\u003e\n\u003cul\u003e\n\u003cli\u003e\u003cstrong\u003ePOST（Power-On Self Test）\u003c/strong\u003e: メモリ、CPU、周辺デバイスの初期化\u003c/li\u003e\n\u003cli\u003eブートデバイスの選択（NVMe, SSD, PXEなど）\u003c/li\u003e\n\u003cli\u003eUEFIの場合はEFIパーティション（ESP）から \u003ccode\u003e.efi\u003c/code\u003e ファイルを直接実行できる\u003c/li\u003e\n\u003c/ul\u003e\n\u003cp\u003eUEFIとBIOSの大きな違いとして、UEFIはGPTディスクのネイティブサポートや、セキュアブートの仕組みを持つ。\u003c/p\u003e\n\u003ch3 id=\"2-ブートローダーgrub2等\"\u003e2. ブートローダー（GRUB2等）\u003c/h3\u003e\n\u003cp\u003eUEFI/BIOSからブートローダーに制御が渡る。\n代表的なものはGRUB2で、設定ファイルは \u003ccode\u003e/boot/grub/grub.cfg\u003c/code\u003e にある。\u003c/p\u003e\n\u003cp\u003eブートローダーの役割は\u003cstrong\u003eシンプル\u003c/strong\u003eで、以下の2点だけ：\u003c/p\u003e\n\u003col\u003e\n\u003cli\u003eカーネルイメージ（\u003ccode\u003evmlinuz\u003c/code\u003e）をメモリに展開する\u003c/li\u003e\n\u003cli\u003einitramfs（\u003ccode\u003einitramfs-*.img\u003c/code\u003e）をメモリに展開する\u003c/li\u003e\n\u003c/ol\u003e\n\u003cdiv class=\"highlight\"\u003e\u003cpre tabindex=\"0\" style=\"color:#f8f8f2;background-color:#272822;-moz-tab-size:4;-o-tab-size:4;tab-size:4;\"\u003e\u003ccode class=\"language-bash\" data-lang=\"bash\"\u003e\u003cspan style=\"display:flex;\"\u003e\u003cspan\u003e\u003cspan style=\"color:#75715e\"\u003e# /boot 以下の典型的な構成\u003c/span\u003e\n\u003c/span\u003e\u003c/span\u003e\u003cspan style=\"display:flex;\"\u003e\u003cspan\u003e$ ls /boot/\n\u003c/span\u003e\u003c/span\u003e\u003cspan style=\"display:flex;\"\u003e\u003cspan\u003egrub/\n\u003c/span\u003e\u003c/span\u003e\u003cspan style=\"display:flex;\"\u003e\u003cspan\u003einitramfs-6.1.0-28-amd64.img\n\u003c/span\u003e\u003c/span\u003e\u003cspan style=\"display:flex;\"\u003e\u003cspan\u003evmlinuz-6.1.0-28-amd64\n\u003c/span\u003e\u003c/span\u003e\u003c/code\u003e\u003c/pre\u003e\u003c/div\u003e\u003cp\u003eブートローダー自身は\u003cstrong\u003eルートFSのマウントをしない\u003c/strong\u003e。あくまでカーネルとinitramfsをメモリに置いて制御を渡すだけ。\u003c/p\u003e\n\u003ch3 id=\"3-カーネル起動とinitramfs\"\u003e3. カーネル起動とinitramfs\u003c/h3\u003e\n\u003cp\u003eここが一番誤解されやすいフェーズ。\u003c/p\u003e\n\u003cp\u003eカーネルが起動すると、まず**initramfs（Initial RAM Filesystem）**を一時的なルート（\u003ccode\u003e/\u003c/code\u003e）としてマウントする。\u003c/p\u003e\n\u003cp\u003e\u003cstrong\u003eなぜinitramfsが必要か？\u003c/strong\u003e\u003c/p\u003e\n\u003cp\u003eカーネル本体はコンパクトに保つ設計になっており、NVMeやLVMやLUKS（暗号化）といった本物のディスクにアクセスするためのドライバを、起動時に動的にロードする必要がある。\ninitramfsはそのためのミニマルな環境を提供する。\u003c/p\u003e\n\u003cpre tabindex=\"0\"\u003e\u003ccode\u003einitramfs の中身（概略）\n  /init        → 起動スクリプト\n  /lib/modules → カーネルモジュール（ドライバ）\n  /bin, /sbin  → busybox等の最低限のコマンド群\n\u003c/code\u003e\u003c/pre\u003e\u003cp\u003e処理の流れ：\u003c/p\u003e","title":"Linuxの起動フローを整理する - UEFI/BIOSからinitまで"},{"content":"歴史地図アプリの構成 React + TypeScript + Vite + MapLibre GL のSPA。歴史的国境データ（GeoJSON）を表示するアプリ。\nデータはpublic/data/以下にGeoJSONを置く構成で、.gitignoreに含めているためリポジトリには入っていない。\nちなみに、生成するスクリプトはあるが、GEMINIを利用しないといけない。\nしかし、APIキーのレート制限が入ってしまったので、ローカルで生成済みのデータを持ち込むことにした。\nインフラ構成 自宅のProxmox上にLXCコンテナとしてk3sクラスタを構築している。マスター1台＋ノード1台の最小構成。\n外部公開はNginx Proxy Manager（NPM）でポートフォワーディングしており、DuckDNSのドメインにSSL終端している。\nインターネット ↓ Nginx Proxy Manager（SSL終端） ↓ k3s NodePort ↓ Pod 問題：データファイルをどう持ち込むか public/data/がgitignoreされているため、コンテナ内でgit cloneしてもデータが存在しない。\n選択肢はいくつかあったが、今回はk3sのhostPathボリュームでマウントする方針にした。\nだるいファイル転送 データファイルをk3sノードに転送するのが一番面倒だった。\nProxmoxのファイルアップロード → UIの制限でNG ngrok経由 → Tailscale環境のためlocalhostの名前解決失敗 結局TailscaleのIPでProxmoxホストに転送 → pct pushでLXCコンテナへ # ProxmoxホストからLXCへ pct push \u0026lt;CTID\u0026gt; /path/to/data.tar.gz /tmp/data.tar.gz # k3sマスターで解凍 mkdir -p /opt/history-map-data tar -xzf /tmp/data.tar.gz -C /opt/history-map-data 融通の効かないViteとふわふわClaude君の罠 npm run previewはデフォルトで許可ホストを制限する。Nginx Proxy Manager経由でアクセスするとBlocked requestが出る。\n環境変数で全許可とかできたらよかったけど、結論だけ言うとできなかった。少なくともClaude君の指示では何をどうしても駄目だったので、最終的にvite.config.tsをデプロイ時に動的に書き換えることで回避した。\ncat \u0026gt; /app/vite.config.ts \u0026lt;\u0026lt; \u0026#39;EOF\u0026#39; import { defineConfig } from \u0026#39;vite\u0026#39; import react from \u0026#39;@vitejs/plugin-react\u0026#39; export default defineConfig({ plugins: [react()], preview: { allowedHosts: [\u0026#39;your-domain.example.com\u0026#39;], }, }) EOF 最終的なYAML apiVersion: apps/v1 kind: Deployment metadata: name: history-map spec: replicas: 1 selector: matchLabels: app: history-map template: metadata: labels: app: history-map spec: nodeName: k3s-master containers: - name: history-map image: node:20-alpine workingDir: /app command: [\u0026#34;sh\u0026#34;, \u0026#34;-c\u0026#34;] args: - | apk add --no-cache git git clone https://github.com/wasuken/history-map-app.git /app --depth=1 cat \u0026gt; /app/vite.config.ts \u0026lt;\u0026lt; \u0026#39;EOF\u0026#39; import { defineConfig } from \u0026#39;vite\u0026#39; import react from \u0026#39;@vitejs/plugin-react\u0026#39; export default defineConfig({ plugins: [react()], preview: { allowedHosts: [\u0026#39;your-domain.example.com\u0026#39;], }, }) EOF mkdir -p /app/public/data cp -r /data/historical /app/public/data/historical cp -r /data/modern /app/public/data/modern cp /data/translation-cache.json /app/public/data/translation-cache.json npm install npx vite build npm run preview -- --host 0.0.0.0 --port 3000 ports: - containerPort: 3000 volumeMounts: - name: map-data mountPath: /data volumes: - name: map-data hostPath: path: /opt/history-map-data type: Directory --- apiVersion: v1 kind: Service metadata: name: history-map-service spec: selector: app: history-map ports: - port: 80 targetPort: 3000 nodePort: 30080 type: NodePort nodeName: k3s-masterを指定しているのはhostPathがPodの動くノード上に存在する必要があるため。ProxmoxのNPM(Nginx Proxy Manager)から このNodePortに向けてプロキシを設定している。\nまとめ 本番運用するなら素直にDockerfileでビルドしてイメージに焼いた方がいいとかあるだろうが、今回は雑に動かすことを優先した。\n","permalink":"https://techblog.wasutech.dev/posts/k3s-history-map-deploy/","summary":"\u003ch2 id=\"歴史地図アプリの構成\"\u003e歴史地図アプリの構成\u003c/h2\u003e\n\u003cp\u003eReact + TypeScript + Vite + MapLibre GL のSPA。歴史的国境データ（GeoJSON）を表示するアプリ。\u003c/p\u003e\n\u003cp\u003eデータは\u003ccode\u003epublic/data/\u003c/code\u003e以下にGeoJSONを置く構成で、\u003ccode\u003e.gitignore\u003c/code\u003eに含めているためリポジトリには入っていない。\u003c/p\u003e\n\u003cp\u003eちなみに、生成するスクリプトはあるが、GEMINIを利用しないといけない。\u003c/p\u003e\n\u003cp\u003eしかし、APIキーのレート制限が入ってしまったので、ローカルで生成済みのデータを持ち込むことにした。\u003c/p\u003e\n\u003ch2 id=\"インフラ構成\"\u003eインフラ構成\u003c/h2\u003e\n\u003cp\u003e自宅のProxmox上にLXCコンテナとしてk3sクラスタを構築している。マスター1台＋ノード1台の最小構成。\u003c/p\u003e\n\u003cp\u003e外部公開はNginx Proxy Manager（NPM）でポートフォワーディングしており、DuckDNSのドメインにSSL終端している。\u003c/p\u003e\n\u003cpre tabindex=\"0\"\u003e\u003ccode\u003eインターネット\n    ↓\nNginx Proxy Manager（SSL終端）\n    ↓\nk3s NodePort\n    ↓\nPod\n\u003c/code\u003e\u003c/pre\u003e\u003ch2 id=\"問題データファイルをどう持ち込むか\"\u003e問題：データファイルをどう持ち込むか\u003c/h2\u003e\n\u003cp\u003e\u003ccode\u003epublic/data/\u003c/code\u003eがgitignoreされているため、コンテナ内でgit cloneしてもデータが存在しない。\u003c/p\u003e\n\u003cp\u003e選択肢はいくつかあったが、今回はk3sのhostPathボリュームでマウントする方針にした。\u003c/p\u003e\n\u003ch2 id=\"だるいファイル転送\"\u003eだるいファイル転送\u003c/h2\u003e\n\u003cp\u003eデータファイルをk3sノードに転送するのが一番面倒だった。\u003c/p\u003e\n\u003cul\u003e\n\u003cli\u003eProxmoxのファイルアップロード → UIの制限でNG\u003c/li\u003e\n\u003cli\u003engrok経由 → Tailscale環境のためlocalhostの名前解決失敗\u003c/li\u003e\n\u003cli\u003e結局TailscaleのIPでProxmoxホストに転送 → \u003ccode\u003epct push\u003c/code\u003eでLXCコンテナへ\u003c/li\u003e\n\u003c/ul\u003e\n\u003cdiv class=\"highlight\"\u003e\u003cpre tabindex=\"0\" style=\"color:#f8f8f2;background-color:#272822;-moz-tab-size:4;-o-tab-size:4;tab-size:4;\"\u003e\u003ccode class=\"language-bash\" data-lang=\"bash\"\u003e\u003cspan style=\"display:flex;\"\u003e\u003cspan\u003e\u003cspan style=\"color:#75715e\"\u003e# ProxmoxホストからLXCへ\u003c/span\u003e\n\u003c/span\u003e\u003c/span\u003e\u003cspan style=\"display:flex;\"\u003e\u003cspan\u003epct push \u0026lt;CTID\u0026gt; /path/to/data.tar.gz /tmp/data.tar.gz\n\u003c/span\u003e\u003c/span\u003e\u003cspan style=\"display:flex;\"\u003e\u003cspan\u003e\n\u003c/span\u003e\u003c/span\u003e\u003cspan style=\"display:flex;\"\u003e\u003cspan\u003e\u003cspan style=\"color:#75715e\"\u003e# k3sマスターで解凍\u003c/span\u003e\n\u003c/span\u003e\u003c/span\u003e\u003cspan style=\"display:flex;\"\u003e\u003cspan\u003emkdir -p /opt/history-map-data\n\u003c/span\u003e\u003c/span\u003e\u003cspan style=\"display:flex;\"\u003e\u003cspan\u003etar -xzf /tmp/data.tar.gz -C /opt/history-map-data\n\u003c/span\u003e\u003c/span\u003e\u003c/code\u003e\u003c/pre\u003e\u003c/div\u003e\u003ch2 id=\"融通の効かないviteとふわふわclaude君の罠\"\u003e融通の効かないViteとふわふわClaude君の罠\u003c/h2\u003e\n\u003cp\u003e\u003ccode\u003enpm run preview\u003c/code\u003eはデフォルトで許可ホストを制限する。Nginx Proxy Manager経由でアクセスすると\u003ccode\u003eBlocked request\u003c/code\u003eが出る。\u003c/p\u003e\n\u003cp\u003e環境変数で全許可とかできたらよかったけど、結論だけ言うとできなかった。少なくともClaude君の指示では何をどうしても駄目だったので、最終的に\u003ccode\u003evite.config.ts\u003c/code\u003eをデプロイ時に動的に書き換えることで回避した。\u003c/p\u003e\n\u003cdiv class=\"highlight\"\u003e\u003cpre tabindex=\"0\" style=\"color:#f8f8f2;background-color:#272822;-moz-tab-size:4;-o-tab-size:4;tab-size:4;\"\u003e\u003ccode class=\"language-yaml\" data-lang=\"yaml\"\u003e\u003cspan style=\"display:flex;\"\u003e\u003cspan\u003e\u003cspan style=\"color:#ae81ff\"\u003ecat \u0026gt; /app/vite.config.ts \u0026lt;\u0026lt; \u0026#39;EOF\u0026#39;\u003c/span\u003e\n\u003c/span\u003e\u003c/span\u003e\u003cspan style=\"display:flex;\"\u003e\u003cspan\u003e\u003cspan style=\"color:#ae81ff\"\u003eimport { defineConfig } from \u0026#39;vite\u0026#39;\u003c/span\u003e\n\u003c/span\u003e\u003c/span\u003e\u003cspan style=\"display:flex;\"\u003e\u003cspan\u003e\u003cspan style=\"color:#ae81ff\"\u003eimport react from \u0026#39;@vitejs/plugin-react\u0026#39;\u003c/span\u003e\n\u003c/span\u003e\u003c/span\u003e\u003cspan style=\"display:flex;\"\u003e\u003cspan\u003e\u003cspan style=\"color:#ae81ff\"\u003eexport default defineConfig({\u003c/span\u003e\n\u003c/span\u003e\u003c/span\u003e\u003cspan style=\"display:flex;\"\u003e\u003cspan\u003e  \u003cspan style=\"color:#f92672\"\u003eplugins\u003c/span\u003e: [\u003cspan style=\"color:#ae81ff\"\u003ereact()],\u003c/span\u003e\n\u003c/span\u003e\u003c/span\u003e\u003cspan style=\"display:flex;\"\u003e\u003cspan\u003e  \u003cspan style=\"color:#f92672\"\u003epreview\u003c/span\u003e: {\n\u003c/span\u003e\u003c/span\u003e\u003cspan style=\"display:flex;\"\u003e\u003cspan\u003e    \u003cspan style=\"color:#f92672\"\u003eallowedHosts\u003c/span\u003e: [\u003cspan style=\"color:#e6db74\"\u003e\u0026#39;your-domain.example.com\u0026#39;\u003c/span\u003e],\n\u003c/span\u003e\u003c/span\u003e\u003cspan style=\"display:flex;\"\u003e\u003cspan\u003e  },\n\u003c/span\u003e\u003c/span\u003e\u003cspan style=\"display:flex;\"\u003e\u003cspan\u003e}\u003cspan style=\"color:#ae81ff\"\u003e)\u003c/span\u003e\n\u003c/span\u003e\u003c/span\u003e\u003cspan style=\"display:flex;\"\u003e\u003cspan\u003e\u003cspan style=\"color:#ae81ff\"\u003eEOF\u003c/span\u003e\n\u003c/span\u003e\u003c/span\u003e\u003c/code\u003e\u003c/pre\u003e\u003c/div\u003e\u003ch2 id=\"最終的なyaml\"\u003e最終的なYAML\u003c/h2\u003e\n\u003cdiv class=\"highlight\"\u003e\u003cpre tabindex=\"0\" style=\"color:#f8f8f2;background-color:#272822;-moz-tab-size:4;-o-tab-size:4;tab-size:4;\"\u003e\u003ccode class=\"language-yaml\" data-lang=\"yaml\"\u003e\u003cspan style=\"display:flex;\"\u003e\u003cspan\u003e\u003cspan style=\"color:#f92672\"\u003eapiVersion\u003c/span\u003e: \u003cspan style=\"color:#ae81ff\"\u003eapps/v1\u003c/span\u003e\n\u003c/span\u003e\u003c/span\u003e\u003cspan style=\"display:flex;\"\u003e\u003cspan\u003e\u003cspan style=\"color:#f92672\"\u003ekind\u003c/span\u003e: \u003cspan style=\"color:#ae81ff\"\u003eDeployment\u003c/span\u003e\n\u003c/span\u003e\u003c/span\u003e\u003cspan style=\"display:flex;\"\u003e\u003cspan\u003e\u003cspan style=\"color:#f92672\"\u003emetadata\u003c/span\u003e:\n\u003c/span\u003e\u003c/span\u003e\u003cspan style=\"display:flex;\"\u003e\u003cspan\u003e  \u003cspan style=\"color:#f92672\"\u003ename\u003c/span\u003e: \u003cspan style=\"color:#ae81ff\"\u003ehistory-map\u003c/span\u003e\n\u003c/span\u003e\u003c/span\u003e\u003cspan style=\"display:flex;\"\u003e\u003cspan\u003e\u003cspan style=\"color:#f92672\"\u003espec\u003c/span\u003e:\n\u003c/span\u003e\u003c/span\u003e\u003cspan style=\"display:flex;\"\u003e\u003cspan\u003e  \u003cspan style=\"color:#f92672\"\u003ereplicas\u003c/span\u003e: \u003cspan style=\"color:#ae81ff\"\u003e1\u003c/span\u003e\n\u003c/span\u003e\u003c/span\u003e\u003cspan style=\"display:flex;\"\u003e\u003cspan\u003e  \u003cspan style=\"color:#f92672\"\u003eselector\u003c/span\u003e:\n\u003c/span\u003e\u003c/span\u003e\u003cspan style=\"display:flex;\"\u003e\u003cspan\u003e    \u003cspan style=\"color:#f92672\"\u003ematchLabels\u003c/span\u003e:\n\u003c/span\u003e\u003c/span\u003e\u003cspan style=\"display:flex;\"\u003e\u003cspan\u003e      \u003cspan style=\"color:#f92672\"\u003eapp\u003c/span\u003e: \u003cspan style=\"color:#ae81ff\"\u003ehistory-map\u003c/span\u003e\n\u003c/span\u003e\u003c/span\u003e\u003cspan style=\"display:flex;\"\u003e\u003cspan\u003e  \u003cspan style=\"color:#f92672\"\u003etemplate\u003c/span\u003e:\n\u003c/span\u003e\u003c/span\u003e\u003cspan style=\"display:flex;\"\u003e\u003cspan\u003e    \u003cspan style=\"color:#f92672\"\u003emetadata\u003c/span\u003e:\n\u003c/span\u003e\u003c/span\u003e\u003cspan style=\"display:flex;\"\u003e\u003cspan\u003e      \u003cspan style=\"color:#f92672\"\u003elabels\u003c/span\u003e:\n\u003c/span\u003e\u003c/span\u003e\u003cspan style=\"display:flex;\"\u003e\u003cspan\u003e        \u003cspan style=\"color:#f92672\"\u003eapp\u003c/span\u003e: \u003cspan style=\"color:#ae81ff\"\u003ehistory-map\u003c/span\u003e\n\u003c/span\u003e\u003c/span\u003e\u003cspan style=\"display:flex;\"\u003e\u003cspan\u003e    \u003cspan style=\"color:#f92672\"\u003espec\u003c/span\u003e:\n\u003c/span\u003e\u003c/span\u003e\u003cspan style=\"display:flex;\"\u003e\u003cspan\u003e      \u003cspan style=\"color:#f92672\"\u003enodeName\u003c/span\u003e: \u003cspan style=\"color:#ae81ff\"\u003ek3s-master\u003c/span\u003e\n\u003c/span\u003e\u003c/span\u003e\u003cspan style=\"display:flex;\"\u003e\u003cspan\u003e      \u003cspan style=\"color:#f92672\"\u003econtainers\u003c/span\u003e:\n\u003c/span\u003e\u003c/span\u003e\u003cspan style=\"display:flex;\"\u003e\u003cspan\u003e      - \u003cspan style=\"color:#f92672\"\u003ename\u003c/span\u003e: \u003cspan style=\"color:#ae81ff\"\u003ehistory-map\u003c/span\u003e\n\u003c/span\u003e\u003c/span\u003e\u003cspan style=\"display:flex;\"\u003e\u003cspan\u003e        \u003cspan style=\"color:#f92672\"\u003eimage\u003c/span\u003e: \u003cspan style=\"color:#ae81ff\"\u003enode:20-alpine\u003c/span\u003e\n\u003c/span\u003e\u003c/span\u003e\u003cspan style=\"display:flex;\"\u003e\u003cspan\u003e        \u003cspan style=\"color:#f92672\"\u003eworkingDir\u003c/span\u003e: \u003cspan style=\"color:#ae81ff\"\u003e/app\u003c/span\u003e\n\u003c/span\u003e\u003c/span\u003e\u003cspan style=\"display:flex;\"\u003e\u003cspan\u003e        \u003cspan style=\"color:#f92672\"\u003ecommand\u003c/span\u003e: [\u003cspan style=\"color:#e6db74\"\u003e\u0026#34;sh\u0026#34;\u003c/span\u003e, \u003cspan style=\"color:#e6db74\"\u003e\u0026#34;-c\u0026#34;\u003c/span\u003e]\n\u003c/span\u003e\u003c/span\u003e\u003cspan style=\"display:flex;\"\u003e\u003cspan\u003e        \u003cspan style=\"color:#f92672\"\u003eargs\u003c/span\u003e:\n\u003c/span\u003e\u003c/span\u003e\u003cspan style=\"display:flex;\"\u003e\u003cspan\u003e        - |\u003cspan style=\"color:#e6db74\"\u003e\n\u003c/span\u003e\u003c/span\u003e\u003c/span\u003e\u003cspan style=\"display:flex;\"\u003e\u003cspan\u003e\u003cspan style=\"color:#e6db74\"\u003e          apk add --no-cache git\n\u003c/span\u003e\u003c/span\u003e\u003c/span\u003e\u003cspan style=\"display:flex;\"\u003e\u003cspan\u003e\u003cspan style=\"color:#e6db74\"\u003e          git clone https://github.com/wasuken/history-map-app.git /app --depth=1\n\u003c/span\u003e\u003c/span\u003e\u003c/span\u003e\u003cspan style=\"display:flex;\"\u003e\u003cspan\u003e\u003cspan style=\"color:#e6db74\"\u003e          cat \u0026gt; /app/vite.config.ts \u0026lt;\u0026lt; \u0026#39;EOF\u0026#39;\n\u003c/span\u003e\u003c/span\u003e\u003c/span\u003e\u003cspan style=\"display:flex;\"\u003e\u003cspan\u003e\u003cspan style=\"color:#e6db74\"\u003e          import { defineConfig } from \u0026#39;vite\u0026#39;\n\u003c/span\u003e\u003c/span\u003e\u003c/span\u003e\u003cspan style=\"display:flex;\"\u003e\u003cspan\u003e\u003cspan style=\"color:#e6db74\"\u003e          import react from \u0026#39;@vitejs/plugin-react\u0026#39;\n\u003c/span\u003e\u003c/span\u003e\u003c/span\u003e\u003cspan style=\"display:flex;\"\u003e\u003cspan\u003e\u003cspan style=\"color:#e6db74\"\u003e          export default defineConfig({\n\u003c/span\u003e\u003c/span\u003e\u003c/span\u003e\u003cspan style=\"display:flex;\"\u003e\u003cspan\u003e\u003cspan style=\"color:#e6db74\"\u003e            plugins: [react()],\n\u003c/span\u003e\u003c/span\u003e\u003c/span\u003e\u003cspan style=\"display:flex;\"\u003e\u003cspan\u003e\u003cspan style=\"color:#e6db74\"\u003e            preview: {\n\u003c/span\u003e\u003c/span\u003e\u003c/span\u003e\u003cspan style=\"display:flex;\"\u003e\u003cspan\u003e\u003cspan style=\"color:#e6db74\"\u003e              allowedHosts: [\u0026#39;your-domain.example.com\u0026#39;],\n\u003c/span\u003e\u003c/span\u003e\u003c/span\u003e\u003cspan style=\"display:flex;\"\u003e\u003cspan\u003e\u003cspan style=\"color:#e6db74\"\u003e            },\n\u003c/span\u003e\u003c/span\u003e\u003c/span\u003e\u003cspan style=\"display:flex;\"\u003e\u003cspan\u003e\u003cspan style=\"color:#e6db74\"\u003e          })\n\u003c/span\u003e\u003c/span\u003e\u003c/span\u003e\u003cspan style=\"display:flex;\"\u003e\u003cspan\u003e\u003cspan style=\"color:#e6db74\"\u003e          EOF\n\u003c/span\u003e\u003c/span\u003e\u003c/span\u003e\u003cspan style=\"display:flex;\"\u003e\u003cspan\u003e\u003cspan style=\"color:#e6db74\"\u003e          mkdir -p /app/public/data\n\u003c/span\u003e\u003c/span\u003e\u003c/span\u003e\u003cspan style=\"display:flex;\"\u003e\u003cspan\u003e\u003cspan style=\"color:#e6db74\"\u003e          cp -r /data/historical /app/public/data/historical\n\u003c/span\u003e\u003c/span\u003e\u003c/span\u003e\u003cspan style=\"display:flex;\"\u003e\u003cspan\u003e\u003cspan style=\"color:#e6db74\"\u003e          cp -r /data/modern /app/public/data/modern\n\u003c/span\u003e\u003c/span\u003e\u003c/span\u003e\u003cspan style=\"display:flex;\"\u003e\u003cspan\u003e\u003cspan style=\"color:#e6db74\"\u003e          cp /data/translation-cache.json /app/public/data/translation-cache.json\n\u003c/span\u003e\u003c/span\u003e\u003c/span\u003e\u003cspan style=\"display:flex;\"\u003e\u003cspan\u003e\u003cspan style=\"color:#e6db74\"\u003e          npm install\n\u003c/span\u003e\u003c/span\u003e\u003c/span\u003e\u003cspan style=\"display:flex;\"\u003e\u003cspan\u003e\u003cspan style=\"color:#e6db74\"\u003e          npx vite build\n\u003c/span\u003e\u003c/span\u003e\u003c/span\u003e\u003cspan style=\"display:flex;\"\u003e\u003cspan\u003e\u003cspan style=\"color:#e6db74\"\u003e          npm run preview -- --host 0.0.0.0 --port 3000\u003c/span\u003e\n\u003c/span\u003e\u003c/span\u003e\u003cspan style=\"display:flex;\"\u003e\u003cspan\u003e        \u003cspan style=\"color:#f92672\"\u003eports\u003c/span\u003e:\n\u003c/span\u003e\u003c/span\u003e\u003cspan style=\"display:flex;\"\u003e\u003cspan\u003e        - \u003cspan style=\"color:#f92672\"\u003econtainerPort\u003c/span\u003e: \u003cspan style=\"color:#ae81ff\"\u003e3000\u003c/span\u003e\n\u003c/span\u003e\u003c/span\u003e\u003cspan style=\"display:flex;\"\u003e\u003cspan\u003e        \u003cspan style=\"color:#f92672\"\u003evolumeMounts\u003c/span\u003e:\n\u003c/span\u003e\u003c/span\u003e\u003cspan style=\"display:flex;\"\u003e\u003cspan\u003e        - \u003cspan style=\"color:#f92672\"\u003ename\u003c/span\u003e: \u003cspan style=\"color:#ae81ff\"\u003emap-data\u003c/span\u003e\n\u003c/span\u003e\u003c/span\u003e\u003cspan style=\"display:flex;\"\u003e\u003cspan\u003e          \u003cspan style=\"color:#f92672\"\u003emountPath\u003c/span\u003e: \u003cspan style=\"color:#ae81ff\"\u003e/data\u003c/span\u003e\n\u003c/span\u003e\u003c/span\u003e\u003cspan style=\"display:flex;\"\u003e\u003cspan\u003e      \u003cspan style=\"color:#f92672\"\u003evolumes\u003c/span\u003e:\n\u003c/span\u003e\u003c/span\u003e\u003cspan style=\"display:flex;\"\u003e\u003cspan\u003e      - \u003cspan style=\"color:#f92672\"\u003ename\u003c/span\u003e: \u003cspan style=\"color:#ae81ff\"\u003emap-data\u003c/span\u003e\n\u003c/span\u003e\u003c/span\u003e\u003cspan style=\"display:flex;\"\u003e\u003cspan\u003e        \u003cspan style=\"color:#f92672\"\u003ehostPath\u003c/span\u003e:\n\u003c/span\u003e\u003c/span\u003e\u003cspan style=\"display:flex;\"\u003e\u003cspan\u003e          \u003cspan style=\"color:#f92672\"\u003epath\u003c/span\u003e: \u003cspan style=\"color:#ae81ff\"\u003e/opt/history-map-data\u003c/span\u003e\n\u003c/span\u003e\u003c/span\u003e\u003cspan style=\"display:flex;\"\u003e\u003cspan\u003e          \u003cspan style=\"color:#f92672\"\u003etype\u003c/span\u003e: \u003cspan style=\"color:#ae81ff\"\u003eDirectory\u003c/span\u003e\n\u003c/span\u003e\u003c/span\u003e\u003cspan style=\"display:flex;\"\u003e\u003cspan\u003e---\n\u003c/span\u003e\u003c/span\u003e\u003cspan style=\"display:flex;\"\u003e\u003cspan\u003e\u003cspan style=\"color:#f92672\"\u003eapiVersion\u003c/span\u003e: \u003cspan style=\"color:#ae81ff\"\u003ev1\u003c/span\u003e\n\u003c/span\u003e\u003c/span\u003e\u003cspan style=\"display:flex;\"\u003e\u003cspan\u003e\u003cspan style=\"color:#f92672\"\u003ekind\u003c/span\u003e: \u003cspan style=\"color:#ae81ff\"\u003eService\u003c/span\u003e\n\u003c/span\u003e\u003c/span\u003e\u003cspan style=\"display:flex;\"\u003e\u003cspan\u003e\u003cspan style=\"color:#f92672\"\u003emetadata\u003c/span\u003e:\n\u003c/span\u003e\u003c/span\u003e\u003cspan style=\"display:flex;\"\u003e\u003cspan\u003e  \u003cspan style=\"color:#f92672\"\u003ename\u003c/span\u003e: \u003cspan style=\"color:#ae81ff\"\u003ehistory-map-service\u003c/span\u003e\n\u003c/span\u003e\u003c/span\u003e\u003cspan style=\"display:flex;\"\u003e\u003cspan\u003e\u003cspan style=\"color:#f92672\"\u003espec\u003c/span\u003e:\n\u003c/span\u003e\u003c/span\u003e\u003cspan style=\"display:flex;\"\u003e\u003cspan\u003e  \u003cspan style=\"color:#f92672\"\u003eselector\u003c/span\u003e:\n\u003c/span\u003e\u003c/span\u003e\u003cspan style=\"display:flex;\"\u003e\u003cspan\u003e    \u003cspan style=\"color:#f92672\"\u003eapp\u003c/span\u003e: \u003cspan style=\"color:#ae81ff\"\u003ehistory-map\u003c/span\u003e\n\u003c/span\u003e\u003c/span\u003e\u003cspan style=\"display:flex;\"\u003e\u003cspan\u003e  \u003cspan style=\"color:#f92672\"\u003eports\u003c/span\u003e:\n\u003c/span\u003e\u003c/span\u003e\u003cspan style=\"display:flex;\"\u003e\u003cspan\u003e  - \u003cspan style=\"color:#f92672\"\u003eport\u003c/span\u003e: \u003cspan style=\"color:#ae81ff\"\u003e80\u003c/span\u003e\n\u003c/span\u003e\u003c/span\u003e\u003cspan style=\"display:flex;\"\u003e\u003cspan\u003e    \u003cspan style=\"color:#f92672\"\u003etargetPort\u003c/span\u003e: \u003cspan style=\"color:#ae81ff\"\u003e3000\u003c/span\u003e\n\u003c/span\u003e\u003c/span\u003e\u003cspan style=\"display:flex;\"\u003e\u003cspan\u003e    \u003cspan style=\"color:#f92672\"\u003enodePort\u003c/span\u003e: \u003cspan style=\"color:#ae81ff\"\u003e30080\u003c/span\u003e\n\u003c/span\u003e\u003c/span\u003e\u003cspan style=\"display:flex;\"\u003e\u003cspan\u003e  \u003cspan style=\"color:#f92672\"\u003etype\u003c/span\u003e: \u003cspan style=\"color:#ae81ff\"\u003eNodePort\u003c/span\u003e\n\u003c/span\u003e\u003c/span\u003e\u003c/code\u003e\u003c/pre\u003e\u003c/div\u003e\u003cp\u003e\u003ccode\u003enodeName: k3s-master\u003c/code\u003eを指定しているのはhostPathがPodの動くノード上に存在する必要があるため。ProxmoxのNPM(Nginx Proxy Manager)から\nこのNodePortに向けてプロキシを設定している。\u003c/p\u003e","title":"歴史地図アプリを雑にk3sへデプロイした"},{"content":"TL;DR WSL2環境でExpo（React Native）のE2EテストをMaestroとDetoxで試みたが、どちらもWSL2とWindowsエミュレータの構造的な問題で動かなかった。\nかなり過言ではあるが、あえて感情的になるならば、Mobile開発においてMac以外は人権がない。というかあまりにもMac環境以外がだるすぎる。\n環境 OS: Windows + WSL2（Ubuntu） Expo SDK 54 / React Native 0.81.5 New Architecture有効 Androidエミュレータ: Windows側で動作（Medium Phone API 36） ADB: Windows側のものをWSL2から参照 Maestroを試みる インストール curl -Ls \u0026#34;https://get.maestro.mobile.dev\u0026#34; | bash export PATH=\u0026#34;$HOME/.maestro/bin:$PATH\u0026#34; ここで最初の罠。maestro --helpを叩くとAI系の全く別のCLIツールが応答した。同名の別アプリが先にPATHに入っていたため。$HOME/.maestro/binをPATHの先頭に置くことで解決。\nフローの準備 # .maestro/add_and_complete_task.yml appId: com.example.myapp --- - launchApp - tapOn: text: \u0026#34;追加\u0026#34; - inputText: \u0026#34;テストタスク\u0026#34; - tapOn: text: \u0026#34;追加する\u0026#34; - assertVisible: text: \u0026#34;NOW\u0026#34; 実行して即死 You have 0 devices connected, which is not enough to run 1 shards. エミュレータはWindows側で動いており、adb devicesにはemulator-5554が見えている。しかしMaestroはWSL2側でデバイスを探すため認識できない。\n--udid=emulator-5554を指定しても：\nDevice emulator-5554 was requested, but it is not connected. maestro start-device --platform=androidを試みると：\nThis command is not supported in Windows WSL. You can launch your emulator manually. 公式が明言してWSL非対応。\nDetoxを試みる Maestroが詰んだのでDetoxに切り替え。\nセットアップ npm i -D detox detox-cli jest @types/jest npx detox init APKビルドでOutOfMemoryError ERROR: D8: java.lang.OutOfMemoryError: Java heap space android/gradle.propertiesに以下を追記して解決：\norg.gradle.jvmargs=-Xmx4g -XX:MaxMetaspaceSize=512m .detoxrc.jsの設定 AVD名を確認：\nadb -s emulator-5554 emu avd name # Medium_Phone_API_36.1 android.emulatorタイプで設定後に実行すると：\nThere was no \u0026#34;emulator\u0026#34; executable file in directory: /home/user/Android/emulator WSL2側にはAndroid SDKのemulatorディレクトリが存在しない。エミュレータはWindows側で動いているため当然。\nandroid.attachedタイプに切り替え エミュレータはすでに起動済みでadb devicesに見えているので、attachedタイプで接続を試みる：\nattached: { type: \u0026#39;android.attached\u0026#39;, device: { adbName: \u0026#39;.*\u0026#39; } } 実行すると：\n\u0026#34;adb\u0026#34; -s emulator-5554 shell \u0026#34;ps | grep \\\u0026#34;com.example.myapp$\\\u0026#34;\u0026#34; failed with error = Error: Command failed (code=1) 根本原因 DetoxはWSL2側のadbでエミュレータ内のプロセスをps | grepで確認しようとする。しかしエミュレータのプロセスはWindows側で動いているため、WSL2からは見えない。WebSocket接続も確立できずタイムアウト。\nこれはWSL2の構造上の問題であり、設定でどうにかなるものではなさそう。\n結論：モバイル開発の人権マップ 環境 Android E2E iOS E2E Mac ✅ 最強、何も制約なし ✅ Xcodeも使える Linux (native) ✅ ギリいける ❌ 物理的に不可 Windows (native) △ PowerShellで頑張ればいける ❌ 物理的に不可 Windows + WSL2 ❌ エミュレータ周りで詰む ❌ 物理的に不可 WSL2は「Linuxっぽく使える」だけで「Linuxではない」。エミュレータのようにGPUやハードウェアアクセスが絡む処理は即死する。\n現実的な代替案 1. Windows PowerShellでDetoxをネイティブ実行 WSLを捨てて、PowerShellにNode/npmを入れてそっちで実行する。エミュレータと同じWindows環境なので繋がる。\n2. ユニットテストに留める E2Eを諦めて、ロジック層（hooks/など）のみJestでユニットテストする。環境問題ゼロ。\n3. CIでE2Eを動かす（EAS Workflows） ローカルは諦めてCIでだけ動かす。EAS Workflowsにはtype: maestroのビルトインジョブがあり、設定が簡単。\nしかしローカルでなくCIに任せるというのは・・・・。\n# .eas/workflows/e2e-android.yml jobs: build: type: build params: platform: android profile: e2e maestro_test: needs: [build] type: maestro params: build_id: ${{ needs.build.outputs.build_id }} flow_path: [\u0026#39;.maestro/home.yml\u0026#39;] 所感 Mobile開発をしたいならMac製品を買おう。 それ以外は買うなら苦労は覚悟しよう。\n参考 Expo公式 EAS Workflows E2E Maestro公式ドキュメント Detox公式ドキュメント Maestro CLI インストール ","permalink":"https://techblog.wasutech.dev/posts/fxxk-mobile-e2e-test/","summary":"\u003ch2 id=\"tldr\"\u003eTL;DR\u003c/h2\u003e\n\u003cp\u003eWSL2環境でExpo（React Native）のE2EテストをMaestroとDetoxで試みたが、どちらもWSL2とWindowsエミュレータの構造的な問題で動かなかった。\u003c/p\u003e\n\u003cp\u003eかなり過言ではあるが、あえて感情的になるならば、Mobile開発においてMac以外は人権がない。というかあまりにもMac環境以外がだるすぎる。\u003c/p\u003e\n\u003chr\u003e\n\u003ch2 id=\"環境\"\u003e環境\u003c/h2\u003e\n\u003cul\u003e\n\u003cli\u003eOS: Windows + WSL2（Ubuntu）\u003c/li\u003e\n\u003cli\u003eExpo SDK 54 / React Native 0.81.5\u003c/li\u003e\n\u003cli\u003eNew Architecture有効\u003c/li\u003e\n\u003cli\u003eAndroidエミュレータ: Windows側で動作（Medium Phone API 36）\u003c/li\u003e\n\u003cli\u003eADB: Windows側のものをWSL2から参照\u003c/li\u003e\n\u003c/ul\u003e\n\u003chr\u003e\n\u003ch2 id=\"maestroを試みる\"\u003eMaestroを試みる\u003c/h2\u003e\n\u003ch3 id=\"インストール\"\u003eインストール\u003c/h3\u003e\n\u003cdiv class=\"highlight\"\u003e\u003cpre tabindex=\"0\" style=\"color:#f8f8f2;background-color:#272822;-moz-tab-size:4;-o-tab-size:4;tab-size:4;\"\u003e\u003ccode class=\"language-bash\" data-lang=\"bash\"\u003e\u003cspan style=\"display:flex;\"\u003e\u003cspan\u003ecurl -Ls \u003cspan style=\"color:#e6db74\"\u003e\u0026#34;https://get.maestro.mobile.dev\u0026#34;\u003c/span\u003e | bash\n\u003c/span\u003e\u003c/span\u003e\u003cspan style=\"display:flex;\"\u003e\u003cspan\u003eexport PATH\u003cspan style=\"color:#f92672\"\u003e=\u003c/span\u003e\u003cspan style=\"color:#e6db74\"\u003e\u0026#34;\u003c/span\u003e$HOME\u003cspan style=\"color:#e6db74\"\u003e/.maestro/bin:\u003c/span\u003e$PATH\u003cspan style=\"color:#e6db74\"\u003e\u0026#34;\u003c/span\u003e\n\u003c/span\u003e\u003c/span\u003e\u003c/code\u003e\u003c/pre\u003e\u003c/div\u003e\u003cp\u003eここで最初の罠。\u003ccode\u003emaestro --help\u003c/code\u003eを叩くとAI系の全く別のCLIツールが応答した。同名の別アプリが先にPATHに入っていたため。\u003ccode\u003e$HOME/.maestro/bin\u003c/code\u003eをPATHの\u003cstrong\u003e先頭\u003c/strong\u003eに置くことで解決。\u003c/p\u003e\n\u003ch3 id=\"フローの準備\"\u003eフローの準備\u003c/h3\u003e\n\u003cdiv class=\"highlight\"\u003e\u003cpre tabindex=\"0\" style=\"color:#f8f8f2;background-color:#272822;-moz-tab-size:4;-o-tab-size:4;tab-size:4;\"\u003e\u003ccode class=\"language-yaml\" data-lang=\"yaml\"\u003e\u003cspan style=\"display:flex;\"\u003e\u003cspan\u003e\u003cspan style=\"color:#75715e\"\u003e# .maestro/add_and_complete_task.yml\u003c/span\u003e\n\u003c/span\u003e\u003c/span\u003e\u003cspan style=\"display:flex;\"\u003e\u003cspan\u003e\u003cspan style=\"color:#f92672\"\u003eappId\u003c/span\u003e: \u003cspan style=\"color:#ae81ff\"\u003ecom.example.myapp\u003c/span\u003e\n\u003c/span\u003e\u003c/span\u003e\u003cspan style=\"display:flex;\"\u003e\u003cspan\u003e---\n\u003c/span\u003e\u003c/span\u003e\u003cspan style=\"display:flex;\"\u003e\u003cspan\u003e- \u003cspan style=\"color:#ae81ff\"\u003elaunchApp\u003c/span\u003e\n\u003c/span\u003e\u003c/span\u003e\u003cspan style=\"display:flex;\"\u003e\u003cspan\u003e- \u003cspan style=\"color:#f92672\"\u003etapOn\u003c/span\u003e:\n\u003c/span\u003e\u003c/span\u003e\u003cspan style=\"display:flex;\"\u003e\u003cspan\u003e    \u003cspan style=\"color:#f92672\"\u003etext\u003c/span\u003e: \u003cspan style=\"color:#e6db74\"\u003e\u0026#34;追加\u0026#34;\u003c/span\u003e\n\u003c/span\u003e\u003c/span\u003e\u003cspan style=\"display:flex;\"\u003e\u003cspan\u003e- \u003cspan style=\"color:#f92672\"\u003einputText\u003c/span\u003e: \u003cspan style=\"color:#e6db74\"\u003e\u0026#34;テストタスク\u0026#34;\u003c/span\u003e\n\u003c/span\u003e\u003c/span\u003e\u003cspan style=\"display:flex;\"\u003e\u003cspan\u003e- \u003cspan style=\"color:#f92672\"\u003etapOn\u003c/span\u003e:\n\u003c/span\u003e\u003c/span\u003e\u003cspan style=\"display:flex;\"\u003e\u003cspan\u003e    \u003cspan style=\"color:#f92672\"\u003etext\u003c/span\u003e: \u003cspan style=\"color:#e6db74\"\u003e\u0026#34;追加する\u0026#34;\u003c/span\u003e\n\u003c/span\u003e\u003c/span\u003e\u003cspan style=\"display:flex;\"\u003e\u003cspan\u003e- \u003cspan style=\"color:#f92672\"\u003eassertVisible\u003c/span\u003e:\n\u003c/span\u003e\u003c/span\u003e\u003cspan style=\"display:flex;\"\u003e\u003cspan\u003e    \u003cspan style=\"color:#f92672\"\u003etext\u003c/span\u003e: \u003cspan style=\"color:#e6db74\"\u003e\u0026#34;NOW\u0026#34;\u003c/span\u003e\n\u003c/span\u003e\u003c/span\u003e\u003c/code\u003e\u003c/pre\u003e\u003c/div\u003e\u003ch3 id=\"実行して即死\"\u003e実行して即死\u003c/h3\u003e\n\u003cpre tabindex=\"0\"\u003e\u003ccode\u003eYou have 0 devices connected, which is not enough to run 1 shards.\n\u003c/code\u003e\u003c/pre\u003e\u003cp\u003eエミュレータはWindows側で動いており、\u003ccode\u003eadb devices\u003c/code\u003eには\u003ccode\u003eemulator-5554\u003c/code\u003eが見えている。しかしMaestroはWSL2側でデバイスを探すため認識できない。\u003c/p\u003e\n\u003cp\u003e\u003ccode\u003e--udid=emulator-5554\u003c/code\u003eを指定しても：\u003c/p\u003e\n\u003cpre tabindex=\"0\"\u003e\u003ccode\u003eDevice emulator-5554 was requested, but it is not connected.\n\u003c/code\u003e\u003c/pre\u003e\u003cp\u003e\u003ccode\u003emaestro start-device --platform=android\u003c/code\u003eを試みると：\u003c/p\u003e","title":"WSL2でExpo + E2Eテスト（MaestroとDetox）を試みて完全に詰んだ話"},{"content":"はじめに 業務でreact-native-pdfを使用した際、AndroidではPDFが正常に表示されるのにiOSでは表示されないという問題に遭遇しました。\nこの記事では、GitHubのissueで共有された解決策であるpatch-packageを使ったパッチ適用方法について解説します。\n問題の概要 環境 { \u0026#34;react-native-pdf\u0026#34;: \u0026#34;^6.7.7\u0026#34;, \u0026#34;react-native\u0026#34;: \u0026#34;0.80.1\u0026#34;, \u0026#34;react-native-blob-util\u0026#34;: \u0026#34;^0.22.2\u0026#34; } 症状 Android: PDF表示が正常に動作 iOS: PDFが表示されない この問題は、React Native 0.80以降でreact-native-pdfを使用した際に発生することが確認されています。\n参考: pdf is not displayed，Android is working fine, but there are problems with iOS #966\n解決策: patch-packageを使う GitHubのissueで@anhnguyen123さんが共有してくれたパッチファイルを適用することで、この問題を解決できます。\n1. patch-packageのインストール まず、patch-packageとpostinstall-postinstallをdevDependenciesとしてインストールします。\n# npmの場合 npm install --save-dev patch-package # yarnの場合 yarn add --dev patch-package postinstall-postinstall 参考: patch-package - npm\n2. package.jsonにpostinstallスクリプトを追加 package.jsonのscriptsセクションに、postinstallスクリプトを追加します。\n{ \u0026#34;scripts\u0026#34;: { \u0026#34;postinstall\u0026#34;: \u0026#34;patch-package\u0026#34; } } このスクリプトにより、npm installまたはyarn installを実行するたびに、自動的にパッチが適用されます。\n3. パッチファイルの配置 GitHubのissueからパッチファイルreact-native-pdf+6.7.7.patchをダウンロードし、プロジェクトルートにpatchesディレクトリを作成してそこに配置します。\nyour-project/ ├── patches/ │ └── react-native-pdf+6.7.7.patch ├── package.json └── ... 4. パッチの適用確認 依存関係を再インストールして、パッチが正しく適用されることを確認します。\n# node_modulesを削除して再インストール rm -rf node_modules npm install # または yarn install 正常にパッチが適用されると、ターミナルに以下のようなメッセージが表示されます。\npatch-package 8.0.0 Applying patches... react-native-pdf@6.7.7 ✔ 5. iOSのクリーンビルド パッチ適用後は、iOSのビルドキャッシュをクリアしてから再ビルドします。\ncd ios rm -rf Pods Podfile.lock pod install cd .. # キャッシュクリア npx react-native start --reset-cache # iOSビルド npx react-native run-ios # expo npx expo run:ios postinstallとは何か postinstallは、npmのライフサイクルスクリプトの一つで、npm installコマンドの実行後に自動的に実行されるスクリプトです。\nnpmライフサイクルスクリプトの順序 preinstall → install → postinstall → prepublish → preprepare → prepare → postprepare postinstallの主な用途 パッチの適用 (今回のケース)\npatch-packageを使った依存パッケージの修正 ビルドステップの実行\nTypeScriptのコンパイル ネイティブモジュールのビルド セットアップタスク\n設定ファイルの生成 環境の初期化 postinstall-postinstallパッケージの役割 yarn v1では、postinstallスクリプトがサブディレクトリのパッケージに対して実行されないという制限があります。postinstall-postinstallパッケージは、この問題を回避するためのワークアラウンドです。\n参考: patch-package - Why use postinstall-postinstall\nreact-native-pdfはなぜパッチを当てる必要があるのか 主な理由 React Nativeのバージョンアップへの追従遅れ React Nativeは頻繁にアップデートされますが、サードパーティライブラリの対応が追いつかないことがあります。react-native-pdfも例外ではありません。\niOS/Androidのプラットフォーム固有の問題 ネイティブコードを含むライブラリは、OS固有の問題に遭遇しやすく、特にiOSではビルドシステムやフレームワークの変更により互換性問題が発生します。\nメンテナンス状況 GitHubのissueを見ると、375個のopenなissuesが存在している(issues)\n作者のGithubページを見る限り更新が完全に停止しており、更新頻度よりメンテナなどを立ていないようなので\n新規プロジェクトなどはForkされたものなり代替パッケージを使ったほうが良さそう。\n具体的な技術的問題 React Native 0.78+での表示問題 (#919) React Native 0.80 New Architectureとの互換性問題 (#942) Expo SDK 54との互換性問題 (#969) パッチ適用のメリット・デメリット メリット 即座に問題を解決できる フォークを作成する必要がない チーム全体で同じ修正を共有できる 公式の修正を待つ必要がない デメリット ライブラリのバージョンアップ時に再度パッチが必要になる可能性 長期的なメンテナンスコスト 大規模な変更には不向き 参考: patch-package - npm (When to use postinstall-postinstall)\n自分でパッチを作成する方法 GitHubで共有されているパッチが使えない場合や、独自の修正が必要な場合は、自分でパッチを作成できます。\n手順 node_modules内のファイルを直接編集 # 例: iOS関連のファイルを修正 vim node_modules/react-native-pdf/ios/RCTPdf.m パッチファイルを生成 npx patch-package react-native-pdf これでpatches/react-native-pdf+6.7.7.patchというファイルが自動生成されます。\nGitにコミット git add patches/react-native-pdf+6.7.7.patch git commit -m \u0026#34;fix: iOSでPDFが表示されない問題を修正\u0026#34; チームメンバーへの共有 チームメンバーがnpm installまたはyarn installを実行すると、自動的にパッチが適用されます。\n参考: Comprehensive Guide to Patching React Native Packages\nまとめ react-native-pdfのiOS表示問題はpatch-packageで解決できる postinstallスクリプトを使うことで、チーム全体で自動的にパッチを適用できる ライブラリのメンテナンス状況によっては、パッチ適用が現実的な解決策となる 長期的には公式の修正を待つか、代替ライブラリの検討も視野に入れる 参考リンク react-native-pdf GitHub Issue #966 patch-package - npm patch-package - GitHub react-native-pdf - npm Comprehensive Guide to Patching React Native Packages - Medium ","permalink":"https://techblog.wasutech.dev/posts/expo-react-native-pdf-patch/","summary":"\u003ch2 id=\"はじめに\"\u003eはじめに\u003c/h2\u003e\n\u003cp\u003e業務で\u003ccode\u003ereact-native-pdf\u003c/code\u003eを使用した際、AndroidではPDFが正常に表示されるのにiOSでは表示されないという問題に遭遇しました。\u003c/p\u003e\n\u003cp\u003eこの記事では、GitHubのissueで共有された解決策である\u003ccode\u003epatch-package\u003c/code\u003eを使ったパッチ適用方法について解説します。\u003c/p\u003e\n\u003ch2 id=\"問題の概要\"\u003e問題の概要\u003c/h2\u003e\n\u003ch3 id=\"環境\"\u003e環境\u003c/h3\u003e\n\u003cdiv class=\"highlight\"\u003e\u003cpre tabindex=\"0\" style=\"color:#f8f8f2;background-color:#272822;-moz-tab-size:4;-o-tab-size:4;tab-size:4;\"\u003e\u003ccode class=\"language-json\" data-lang=\"json\"\u003e\u003cspan style=\"display:flex;\"\u003e\u003cspan\u003e{\n\u003c/span\u003e\u003c/span\u003e\u003cspan style=\"display:flex;\"\u003e\u003cspan\u003e  \u003cspan style=\"color:#f92672\"\u003e\u0026#34;react-native-pdf\u0026#34;\u003c/span\u003e: \u003cspan style=\"color:#e6db74\"\u003e\u0026#34;^6.7.7\u0026#34;\u003c/span\u003e,\n\u003c/span\u003e\u003c/span\u003e\u003cspan style=\"display:flex;\"\u003e\u003cspan\u003e  \u003cspan style=\"color:#f92672\"\u003e\u0026#34;react-native\u0026#34;\u003c/span\u003e: \u003cspan style=\"color:#e6db74\"\u003e\u0026#34;0.80.1\u0026#34;\u003c/span\u003e,\n\u003c/span\u003e\u003c/span\u003e\u003cspan style=\"display:flex;\"\u003e\u003cspan\u003e  \u003cspan style=\"color:#f92672\"\u003e\u0026#34;react-native-blob-util\u0026#34;\u003c/span\u003e: \u003cspan style=\"color:#e6db74\"\u003e\u0026#34;^0.22.2\u0026#34;\u003c/span\u003e\n\u003c/span\u003e\u003c/span\u003e\u003cspan style=\"display:flex;\"\u003e\u003cspan\u003e}\n\u003c/span\u003e\u003c/span\u003e\u003c/code\u003e\u003c/pre\u003e\u003c/div\u003e\u003ch3 id=\"症状\"\u003e症状\u003c/h3\u003e\n\u003cul\u003e\n\u003cli\u003eAndroid: PDF表示が正常に動作\u003c/li\u003e\n\u003cli\u003eiOS: PDFが表示されない\u003c/li\u003e\n\u003c/ul\u003e\n\u003cp\u003eこの問題は、React Native 0.80以降で\u003ccode\u003ereact-native-pdf\u003c/code\u003eを使用した際に発生することが確認されています。\u003c/p\u003e\n\u003cp\u003e参考: \u003ca href=\"https://github.com/wonday/react-native-pdf/issues/966\"\u003epdf is not displayed，Android is working fine, but there are problems with iOS #966\u003c/a\u003e\u003c/p\u003e\n\u003ch2 id=\"解決策-patch-packageを使う\"\u003e解決策: patch-packageを使う\u003c/h2\u003e\n\u003cp\u003eGitHubのissueで\u003ca href=\"https://github.com/wonday/react-native-pdf/issues/966\"\u003e@anhnguyen123\u003c/a\u003eさんが共有してくれたパッチファイルを適用することで、この問題を解決できます。\u003c/p\u003e\n\u003ch3 id=\"1-patch-packageのインストール\"\u003e1. patch-packageのインストール\u003c/h3\u003e\n\u003cp\u003eまず、\u003ccode\u003epatch-package\u003c/code\u003eと\u003ccode\u003epostinstall-postinstall\u003c/code\u003eをdevDependenciesとしてインストールします。\u003c/p\u003e\n\u003cdiv class=\"highlight\"\u003e\u003cpre tabindex=\"0\" style=\"color:#f8f8f2;background-color:#272822;-moz-tab-size:4;-o-tab-size:4;tab-size:4;\"\u003e\u003ccode class=\"language-bash\" data-lang=\"bash\"\u003e\u003cspan style=\"display:flex;\"\u003e\u003cspan\u003e\u003cspan style=\"color:#75715e\"\u003e# npmの場合\u003c/span\u003e\n\u003c/span\u003e\u003c/span\u003e\u003cspan style=\"display:flex;\"\u003e\u003cspan\u003enpm install --save-dev patch-package\n\u003c/span\u003e\u003c/span\u003e\u003cspan style=\"display:flex;\"\u003e\u003cspan\u003e\n\u003c/span\u003e\u003c/span\u003e\u003cspan style=\"display:flex;\"\u003e\u003cspan\u003e\u003cspan style=\"color:#75715e\"\u003e# yarnの場合\u003c/span\u003e\n\u003c/span\u003e\u003c/span\u003e\u003cspan style=\"display:flex;\"\u003e\u003cspan\u003eyarn add --dev patch-package postinstall-postinstall\n\u003c/span\u003e\u003c/span\u003e\u003c/code\u003e\u003c/pre\u003e\u003c/div\u003e\u003cp\u003e参考: \u003ca href=\"https://www.npmjs.com/package/patch-package\"\u003epatch-package - npm\u003c/a\u003e\u003c/p\u003e\n\u003ch3 id=\"2-packagejsonにpostinstallスクリプトを追加\"\u003e2. package.jsonにpostinstallスクリプトを追加\u003c/h3\u003e\n\u003cp\u003e\u003ccode\u003epackage.json\u003c/code\u003eの\u003ccode\u003escripts\u003c/code\u003eセクションに、\u003ccode\u003epostinstall\u003c/code\u003eスクリプトを追加します。\u003c/p\u003e\n\u003cdiv class=\"highlight\"\u003e\u003cpre tabindex=\"0\" style=\"color:#f8f8f2;background-color:#272822;-moz-tab-size:4;-o-tab-size:4;tab-size:4;\"\u003e\u003ccode class=\"language-json\" data-lang=\"json\"\u003e\u003cspan style=\"display:flex;\"\u003e\u003cspan\u003e{\n\u003c/span\u003e\u003c/span\u003e\u003cspan style=\"display:flex;\"\u003e\u003cspan\u003e  \u003cspan style=\"color:#f92672\"\u003e\u0026#34;scripts\u0026#34;\u003c/span\u003e: {\n\u003c/span\u003e\u003c/span\u003e\u003cspan style=\"display:flex;\"\u003e\u003cspan\u003e    \u003cspan style=\"color:#f92672\"\u003e\u0026#34;postinstall\u0026#34;\u003c/span\u003e: \u003cspan style=\"color:#e6db74\"\u003e\u0026#34;patch-package\u0026#34;\u003c/span\u003e\n\u003c/span\u003e\u003c/span\u003e\u003cspan style=\"display:flex;\"\u003e\u003cspan\u003e  }\n\u003c/span\u003e\u003c/span\u003e\u003cspan style=\"display:flex;\"\u003e\u003cspan\u003e}\n\u003c/span\u003e\u003c/span\u003e\u003c/code\u003e\u003c/pre\u003e\u003c/div\u003e\u003cp\u003eこのスクリプトにより、\u003ccode\u003enpm install\u003c/code\u003eまたは\u003ccode\u003eyarn install\u003c/code\u003eを実行するたびに、自動的にパッチが適用されます。\u003c/p\u003e\n\u003ch3 id=\"3-パッチファイルの配置\"\u003e3. パッチファイルの配置\u003c/h3\u003e\n\u003cp\u003eGitHubのissueからパッチファイル\u003ccode\u003ereact-native-pdf+6.7.7.patch\u003c/code\u003eをダウンロードし、プロジェクトルートに\u003ccode\u003epatches\u003c/code\u003eディレクトリを作成してそこに配置します。\u003c/p\u003e","title":"react-native-pdf 6.7.7のiOS表示問題をpatch-packageで解決する"},{"content":"はじめに 歴史的国境を可視化する地図アプリを作っていたら、「日本語で国名検索ができない」という問題に直面した。外部のGeoJSONデータは英語のみで、日本語プロパティがない。\nそこで、Gemini APIを使って効率的にデータを翻訳し、日本語検索を実装した手法を紹介する。\n問題: 外部GeoJSONデータには日本語がない 使用したデータソース:\n現代国境: Natural Earth (約200カ国) 歴史的国境: aourednik/historical-basemaps (18ファイル、紀元前2000年〜1920年) { \u0026#34;type\u0026#34;: \u0026#34;Feature\u0026#34;, \u0026#34;properties\u0026#34;: { \u0026#34;NAME\u0026#34;: \u0026#34;France\u0026#34;, \u0026#34;NAME_JA\u0026#34;: null // ← 日本語プロパティがない! }, \u0026#34;geometry\u0026#34;: { ... } } このままでは「フランス」で検索できない。\n解決策: 翻訳キャッシュを使った効率的なデータ拡張 アプローチ1: 愚直な方法 (非効率) 各ファイルごとに全データをLLMに投げる:\n// ❌ 非効率: 同じ国名を何度も翻訳 for (const file of geoJsonFiles) { const data = await fetch(file); const translated = await translateAll(data); // Franceを18回翻訳... await save(translated); } 問題点:\n同じ国名が複数ファイルに登場 → 重複翻訳 トークン消費が膨大 処理時間が長い アプローチ2: 翻訳キャッシュ方式 (効率的) ✅ 全ファイル共通の翻訳キャッシュを使い回す:\n// ✅ 効率的: 一度翻訳した国名は二度と翻訳しない const translationCache = {}; // { \u0026#34;France\u0026#34;: \u0026#34;フランス\u0026#34;, ... } for (const file of geoJsonFiles) { const data = await fetch(file); // 未翻訳の国名のみ抽出 const newNames = extractUntranslatedNames(data, translationCache); // 新規の国名だけ翻訳 if (newNames.length \u0026gt; 0) { const translations = await translate(newNames); Object.assign(translationCache, translations); } // キャッシュを使って適用 applyTranslations(data, translationCache); await save(data); } 実装: Node.jsスクリプト 完全なコード この手法をNode.jsスクリプトとして実装した。Gemini 2.5 Flash Liteを使用している。この程度の翻訳ならこれで十分。\n#!/usr/bin/env node import fs from \u0026#39;fs/promises\u0026#39;; import path from \u0026#39;path\u0026#39;; import { fileURLToPath } from \u0026#39;url\u0026#39;; const __filename = fileURLToPath(import.meta.url); const __dirname = path.dirname(__filename); const HISTORICAL_BASE_URL = \u0026#39;https://raw.githubusercontent.com/aourednik/historical-basemaps/master/geojson\u0026#39;; /** * Gemini Flash APIで翻訳 */ async function translateToJapanese(countryNames) { const apiKey = process.env.GEMINI_API_KEY; if (!apiKey) { throw new Error(\u0026#39;GEMINI_API_KEY環境変数が設定されていません\u0026#39;); } const prompt = `以下の国名・地域名を日本語に翻訳してください。 歴史的な国家名も含まれているため、適切な日本語表記を選んでください。 出力形式: JSONオブジェクト { \u0026#34;英語名\u0026#34;: \u0026#34;日本語名\u0026#34;, ... } 注意: - 翻訳できない場合は空文字列\u0026#34;\u0026#34;を返す - 歴史的な国名も考慮する(例: \u0026#34;Roman Empire\u0026#34; -\u0026gt; \u0026#34;ローマ帝国\u0026#34;) - JSONのみを出力し、説明文は不要 国名リスト: ${JSON.stringify(countryNames, null, 2)}`; const url = `https://generativelanguage.googleapis.com/v1beta/models/gemini-2.5-flash-lite:generateContent?key=${apiKey}`; const response = await fetch(url, { method: \u0026#39;POST\u0026#39;, headers: { \u0026#39;Content-Type\u0026#39;: \u0026#39;application/json\u0026#39; }, body: JSON.stringify({ contents: [{ parts: [{ text: prompt }] }], generationConfig: { temperature: 0.2, topK: 40, topP: 0.95, maxOutputTokens: 8192, } }) }); if (!response.ok) { const errorText = await response.text(); throw new Error(`Gemini API error: ${response.status}\\n${errorText}`); } const data = await response.json(); const content = data.candidates[0].content.parts[0].text; // JSONを抽出 let jsonText = content.trim(); if (jsonText.startsWith(\u0026#39;```\u0026#39;)) { jsonText = jsonText.replace(/^```(?:json)?\\n?/, \u0026#39;\u0026#39;).replace(/\\n?```$/, \u0026#39;\u0026#39;); } const jsonMatch = jsonText.match(/\\{[\\s\\S]*\\}/); if (!jsonMatch) { throw new Error(\u0026#39;JSON形式ではありません\u0026#39;); } return JSON.parse(jsonMatch[0]); } /** * GeoJSONに日本語名を追加 */ async function addJapaneseNames(geojson, translationCache = {}) { const features = geojson.features; // 未翻訳の国名を収集 const untranslatedNames = []; for (const feature of features) { const name = feature.properties.NAME; if (name \u0026amp;\u0026amp; !translationCache[name]) { untranslatedNames.push(name); } } // 新規の翻訳のみAPI呼び出し if (untranslatedNames.length \u0026gt; 0) { const uniqueNames = [...new Set(untranslatedNames)]; // バッチサイズ100で処理 const batchSize = 100; for (let i = 0; i \u0026lt; uniqueNames.length; i += batchSize) { const batch = uniqueNames.slice(i, i + batchSize); console.log(` 📦 バッチ ${Math.floor(i / batchSize) + 1}/${Math.ceil(uniqueNames.length / batchSize)} (${batch.length}件)`); try { const translations = await translateToJapanese(batch); Object.assign(translationCache, translations); // レート制限対策 if (i + batchSize \u0026lt; uniqueNames.length) { await new Promise(resolve =\u0026gt; setTimeout(resolve, 500)); } } catch (error) { console.error(` ❌ エラー:`, error.message); } } } // 翻訳を適用 let appliedCount = 0; for (const feature of features) { const name = feature.properties.NAME; if (name \u0026amp;\u0026amp; translationCache[name]) { feature.properties.NAME_JA = translationCache[name]; appliedCount++; } } console.log(` ✅ ${appliedCount}/${features.length}件に日本語名を適用`); return geojson; } /** * 翻訳キャッシュの読み込み/保存 */ async function loadTranslationCache() { const cachePath = path.resolve(__dirname, \u0026#39;../public/data/translation-cache.json\u0026#39;); try { const data = await fs.readFile(cachePath, \u0026#39;utf-8\u0026#39;); return JSON.parse(data); } catch (error) { return {}; } } async function saveTranslationCache(cache) { const cachePath = path.resolve(__dirname, \u0026#39;../public/data/translation-cache.json\u0026#39;); await fs.mkdir(path.dirname(cachePath), { recursive: true }); await fs.writeFile(cachePath, JSON.stringify(cache, null, 2), \u0026#39;utf-8\u0026#39;); } /** * メイン処理 */ async function main() { console.log(\u0026#39;🌍 GeoJSONファイルに日本語名を追加する\\n\u0026#39;); let translationCache = await loadTranslationCache(); console.log(`📦 翻訳キャッシュ: ${Object.keys(translationCache).length}件\\n`); const files = [ \u0026#39;world_bc2000.geojson\u0026#39;, \u0026#39;world_bc500.geojson\u0026#39;, // ... 他のファイル ]; for (const filename of files) { console.log(`⏳ ${filename} を処理中...`); const url = `${HISTORICAL_BASE_URL}/${filename}`; const response = await fetch(url); const geojson = await response.json(); const withJa = await addJapaneseNames(geojson, translationCache); // 保存 const outputPath = path.resolve(__dirname, `../public/data/historical/${filename}`); await fs.mkdir(path.dirname(outputPath), { recursive: true }); await fs.writeFile(outputPath, JSON.stringify(withJa, null, 2), \u0026#39;utf-8\u0026#39;); } await saveTranslationCache(translationCache); console.log(`\\n✨ 完了! 翻訳キャッシュ: ${Object.keys(translationCache).length}件`); } main().catch(console.error); 使い方 # APIキーを設定 export GEMINI_API_KEY=\u0026#34;your-api-key\u0026#34; # スクリプト実行 node scripts/add-japanese-names.mjs 結果: 圧倒的な効率化 実際に実行してみた結果がこちら。\n処理ログ 🌍 GeoJSONファイルに日本語名を追加する 📦 翻訳キャッシュ: 0件 ⏳ world_bc2000.geojson を処理中... 📦 バッチ 1/2 (100件) 🤖 100件の国名をGemini Flash APIで翻訳中... 📦 バッチ 2/2 (45件) 🤖 45件の国名をGemini Flash APIで翻訳中... ✅ 145/145件に日本語名を適用 ⏳ world_bc500.geojson を処理中... 📦 バッチ 1/1 (74件) // ← 新規74件のみ翻訳 🤖 74件の国名をGemini Flash APIで翻訳中... ✅ 189/189件に日本語名を適用 ⏳ world_bc323.geojson を処理中... 📦 バッチ 1/1 (12件) // ← さらに減少 🤖 12件の国名をGemini Flash APIで翻訳中... ✅ 156/156件に日本語名を適用 ... (以降はほぼキャッシュヒット) ✨ 完了! 翻訳キャッシュ: 847件 効率の比較 方式 翻訳回数 API呼び出し 処理時間 愚直な方法 約3,000回 約30回 約10分 キャッシュ方式 約850回 約9回 約2分 削減率: 約70%のトークン削減!\nポイント: なぜこんなに速いのか 1. 重複排除 // 18ファイル中、\u0026#34;France\u0026#34;は何度も登場 // 愚直な方法: 18回翻訳 // キャッシュ方式: 1回だけ翻訳 2. バッチ処理 // 100件ずつまとめて翻訳 const batch = uniqueNames.slice(i, i + 100); const translations = await translateToJapanese(batch); 3. 永続化されたキャッシュ // public/data/translation-cache.json { \u0026#34;France\u0026#34;: \u0026#34;フランス\u0026#34;, \u0026#34;Roman Empire\u0026#34;: \u0026#34;ローマ帝国\u0026#34;, \u0026#34;Mongol Empire\u0026#34;: \u0026#34;モンゴル帝国\u0026#34;, ... } 次回実行時もこのキャッシュを再利用でき。\nアプリケーション側の実装 翻訳済みGeoJSONをローカルに配置:\npublic/ data/ modern/ countries.geojson # 日本語付き historical/ world_bc2000.geojson # 日本語付き world_bc500.geojson # 日本語付き ... 検索実装:\n// src/components/SearchBar.tsx const suggestions = useMemo(() =\u0026gt; { const lowerQuery = query.toLowerCase(); return countries.filter((country) =\u0026gt; { const name = country.properties.NAME.toLowerCase(); const nameJa = country.properties.NAME_JA?.toLowerCase() || \u0026#34;\u0026#34;; // 英語・日本語両方で検索可能! return name.includes(lowerQuery) || nameJa.includes(lowerQuery); }); }, [query, countries]); まとめ この手法が有効なケース ✅ 大量の繰り返しデータの翻訳 ✅ データ拡張・メタデータ付与 ✅ 複数ファイルに同じエンティティが登場 ✅ オフライン処理が可能 キャッシュ方式のメリット トークン削減: 重複翻訳を排除 高速化: 2回目以降はほぼキャッシュヒット コスト削減: API呼び出し回数が激減 再実行可能: エラー時も途中から再開 応用例 この手法は翻訳以外にも使える:\nカテゴリ分類: LLMで一度分類したエンティティをキャッシュ 感情分析: 同じテキストの重複分析を排除 要約生成: 同じドキュメントの再要約を防止 参考リンク Gemini API ドキュメント Natural Earth データ aourednik/historical-basemaps ","permalink":"https://techblog.wasutech.dev/posts/generate-data-from-gemini/","summary":"\u003ch2 id=\"はじめに\"\u003eはじめに\u003c/h2\u003e\n\u003cp\u003e歴史的国境を可視化する地図アプリを作っていたら、「日本語で国名検索ができない」という問題に直面した。外部のGeoJSONデータは英語のみで、日本語プロパティがない。\u003c/p\u003e\n\u003cp\u003eそこで、\u003cstrong\u003eGemini APIを使って効率的にデータを翻訳し、日本語検索を実装した\u003c/strong\u003e手法を紹介する。\u003c/p\u003e\n\u003ch2 id=\"問題-外部geojsonデータには日本語がない\"\u003e問題: 外部GeoJSONデータには日本語がない\u003c/h2\u003e\n\u003cp\u003e使用したデータソース:\u003c/p\u003e\n\u003cul\u003e\n\u003cli\u003e\u003cstrong\u003e現代国境\u003c/strong\u003e: \u003ca href=\"https://www.naturalearthdata.com/\"\u003eNatural Earth\u003c/a\u003e (約200カ国)\u003c/li\u003e\n\u003cli\u003e\u003cstrong\u003e歴史的国境\u003c/strong\u003e: \u003ca href=\"https://github.com/aourednik/historical-basemaps\"\u003eaourednik/historical-basemaps\u003c/a\u003e (18ファイル、紀元前2000年〜1920年)\u003c/li\u003e\n\u003c/ul\u003e\n\u003cdiv class=\"highlight\"\u003e\u003cpre tabindex=\"0\" style=\"color:#f8f8f2;background-color:#272822;-moz-tab-size:4;-o-tab-size:4;tab-size:4;\"\u003e\u003ccode class=\"language-json\" data-lang=\"json\"\u003e\u003cspan style=\"display:flex;\"\u003e\u003cspan\u003e{\n\u003c/span\u003e\u003c/span\u003e\u003cspan style=\"display:flex;\"\u003e\u003cspan\u003e  \u003cspan style=\"color:#f92672\"\u003e\u0026#34;type\u0026#34;\u003c/span\u003e: \u003cspan style=\"color:#e6db74\"\u003e\u0026#34;Feature\u0026#34;\u003c/span\u003e,\n\u003c/span\u003e\u003c/span\u003e\u003cspan style=\"display:flex;\"\u003e\u003cspan\u003e  \u003cspan style=\"color:#f92672\"\u003e\u0026#34;properties\u0026#34;\u003c/span\u003e: {\n\u003c/span\u003e\u003c/span\u003e\u003cspan style=\"display:flex;\"\u003e\u003cspan\u003e    \u003cspan style=\"color:#f92672\"\u003e\u0026#34;NAME\u0026#34;\u003c/span\u003e: \u003cspan style=\"color:#e6db74\"\u003e\u0026#34;France\u0026#34;\u003c/span\u003e,\n\u003c/span\u003e\u003c/span\u003e\u003cspan style=\"display:flex;\"\u003e\u003cspan\u003e    \u003cspan style=\"color:#f92672\"\u003e\u0026#34;NAME_JA\u0026#34;\u003c/span\u003e: \u003cspan style=\"color:#66d9ef\"\u003enull\u003c/span\u003e  \u003cspan style=\"color:#75715e\"\u003e// ← 日本語プロパティがない!\n\u003c/span\u003e\u003c/span\u003e\u003c/span\u003e\u003cspan style=\"display:flex;\"\u003e\u003cspan\u003e\u003cspan style=\"color:#75715e\"\u003e\u003c/span\u003e  },\n\u003c/span\u003e\u003c/span\u003e\u003cspan style=\"display:flex;\"\u003e\u003cspan\u003e  \u003cspan style=\"color:#f92672\"\u003e\u0026#34;geometry\u0026#34;\u003c/span\u003e: { \u003cspan style=\"color:#960050;background-color:#1e0010\"\u003e...\u003c/span\u003e }\n\u003c/span\u003e\u003c/span\u003e\u003cspan style=\"display:flex;\"\u003e\u003cspan\u003e}\n\u003c/span\u003e\u003c/span\u003e\u003c/code\u003e\u003c/pre\u003e\u003c/div\u003e\u003cp\u003eこのままでは「フランス」で検索できない。\u003c/p\u003e\n\u003ch2 id=\"解決策-翻訳キャッシュを使った効率的なデータ拡張\"\u003e解決策: 翻訳キャッシュを使った効率的なデータ拡張\u003c/h2\u003e\n\u003ch3 id=\"アプローチ1-愚直な方法-非効率\"\u003eアプローチ1: 愚直な方法 (非効率)\u003c/h3\u003e\n\u003cp\u003e各ファイルごとに全データをLLMに投げる:\u003c/p\u003e\n\u003cdiv class=\"highlight\"\u003e\u003cpre tabindex=\"0\" style=\"color:#f8f8f2;background-color:#272822;-moz-tab-size:4;-o-tab-size:4;tab-size:4;\"\u003e\u003ccode class=\"language-javascript\" data-lang=\"javascript\"\u003e\u003cspan style=\"display:flex;\"\u003e\u003cspan\u003e\u003cspan style=\"color:#75715e\"\u003e// ❌ 非効率: 同じ国名を何度も翻訳\n\u003c/span\u003e\u003c/span\u003e\u003c/span\u003e\u003cspan style=\"display:flex;\"\u003e\u003cspan\u003e\u003cspan style=\"color:#75715e\"\u003e\u003c/span\u003e\u003cspan style=\"color:#66d9ef\"\u003efor\u003c/span\u003e (\u003cspan style=\"color:#66d9ef\"\u003econst\u003c/span\u003e \u003cspan style=\"color:#a6e22e\"\u003efile\u003c/span\u003e \u003cspan style=\"color:#66d9ef\"\u003eof\u003c/span\u003e \u003cspan style=\"color:#a6e22e\"\u003egeoJsonFiles\u003c/span\u003e) {\n\u003c/span\u003e\u003c/span\u003e\u003cspan style=\"display:flex;\"\u003e\u003cspan\u003e  \u003cspan style=\"color:#66d9ef\"\u003econst\u003c/span\u003e \u003cspan style=\"color:#a6e22e\"\u003edata\u003c/span\u003e \u003cspan style=\"color:#f92672\"\u003e=\u003c/span\u003e \u003cspan style=\"color:#66d9ef\"\u003eawait\u003c/span\u003e \u003cspan style=\"color:#a6e22e\"\u003efetch\u003c/span\u003e(\u003cspan style=\"color:#a6e22e\"\u003efile\u003c/span\u003e);\n\u003c/span\u003e\u003c/span\u003e\u003cspan style=\"display:flex;\"\u003e\u003cspan\u003e  \u003cspan style=\"color:#66d9ef\"\u003econst\u003c/span\u003e \u003cspan style=\"color:#a6e22e\"\u003etranslated\u003c/span\u003e \u003cspan style=\"color:#f92672\"\u003e=\u003c/span\u003e \u003cspan style=\"color:#66d9ef\"\u003eawait\u003c/span\u003e \u003cspan style=\"color:#a6e22e\"\u003etranslateAll\u003c/span\u003e(\u003cspan style=\"color:#a6e22e\"\u003edata\u003c/span\u003e); \u003cspan style=\"color:#75715e\"\u003e// Franceを18回翻訳...\n\u003c/span\u003e\u003c/span\u003e\u003c/span\u003e\u003cspan style=\"display:flex;\"\u003e\u003cspan\u003e\u003cspan style=\"color:#75715e\"\u003e\u003c/span\u003e  \u003cspan style=\"color:#66d9ef\"\u003eawait\u003c/span\u003e \u003cspan style=\"color:#a6e22e\"\u003esave\u003c/span\u003e(\u003cspan style=\"color:#a6e22e\"\u003etranslated\u003c/span\u003e);\n\u003c/span\u003e\u003c/span\u003e\u003cspan style=\"display:flex;\"\u003e\u003cspan\u003e}\n\u003c/span\u003e\u003c/span\u003e\u003c/code\u003e\u003c/pre\u003e\u003c/div\u003e\u003cp\u003e\u003cstrong\u003e問題点:\u003c/strong\u003e\u003c/p\u003e\n\u003cul\u003e\n\u003cli\u003e同じ国名が複数ファイルに登場 → 重複翻訳\u003c/li\u003e\n\u003cli\u003eトークン消費が膨大\u003c/li\u003e\n\u003cli\u003e処理時間が長い\u003c/li\u003e\n\u003c/ul\u003e\n\u003ch3 id=\"アプローチ2-翻訳キャッシュ方式-効率的-\"\u003eアプローチ2: 翻訳キャッシュ方式 (効率的) ✅\u003c/h3\u003e\n\u003cp\u003e\u003cstrong\u003e全ファイル共通の翻訳キャッシュを使い回す:\u003c/strong\u003e\u003c/p\u003e\n\u003cdiv class=\"highlight\"\u003e\u003cpre tabindex=\"0\" style=\"color:#f8f8f2;background-color:#272822;-moz-tab-size:4;-o-tab-size:4;tab-size:4;\"\u003e\u003ccode class=\"language-javascript\" data-lang=\"javascript\"\u003e\u003cspan style=\"display:flex;\"\u003e\u003cspan\u003e\u003cspan style=\"color:#75715e\"\u003e// ✅ 効率的: 一度翻訳した国名は二度と翻訳しない\n\u003c/span\u003e\u003c/span\u003e\u003c/span\u003e\u003cspan style=\"display:flex;\"\u003e\u003cspan\u003e\u003cspan style=\"color:#75715e\"\u003e\u003c/span\u003e\u003cspan style=\"color:#66d9ef\"\u003econst\u003c/span\u003e \u003cspan style=\"color:#a6e22e\"\u003etranslationCache\u003c/span\u003e \u003cspan style=\"color:#f92672\"\u003e=\u003c/span\u003e {}; \u003cspan style=\"color:#75715e\"\u003e// { \u0026#34;France\u0026#34;: \u0026#34;フランス\u0026#34;, ... }\n\u003c/span\u003e\u003c/span\u003e\u003c/span\u003e\u003cspan style=\"display:flex;\"\u003e\u003cspan\u003e\u003cspan style=\"color:#75715e\"\u003e\u003c/span\u003e\n\u003c/span\u003e\u003c/span\u003e\u003cspan style=\"display:flex;\"\u003e\u003cspan\u003e\u003cspan style=\"color:#66d9ef\"\u003efor\u003c/span\u003e (\u003cspan style=\"color:#66d9ef\"\u003econst\u003c/span\u003e \u003cspan style=\"color:#a6e22e\"\u003efile\u003c/span\u003e \u003cspan style=\"color:#66d9ef\"\u003eof\u003c/span\u003e \u003cspan style=\"color:#a6e22e\"\u003egeoJsonFiles\u003c/span\u003e) {\n\u003c/span\u003e\u003c/span\u003e\u003cspan style=\"display:flex;\"\u003e\u003cspan\u003e  \u003cspan style=\"color:#66d9ef\"\u003econst\u003c/span\u003e \u003cspan style=\"color:#a6e22e\"\u003edata\u003c/span\u003e \u003cspan style=\"color:#f92672\"\u003e=\u003c/span\u003e \u003cspan style=\"color:#66d9ef\"\u003eawait\u003c/span\u003e \u003cspan style=\"color:#a6e22e\"\u003efetch\u003c/span\u003e(\u003cspan style=\"color:#a6e22e\"\u003efile\u003c/span\u003e);\n\u003c/span\u003e\u003c/span\u003e\u003cspan style=\"display:flex;\"\u003e\u003cspan\u003e  \n\u003c/span\u003e\u003c/span\u003e\u003cspan style=\"display:flex;\"\u003e\u003cspan\u003e  \u003cspan style=\"color:#75715e\"\u003e// 未翻訳の国名のみ抽出\n\u003c/span\u003e\u003c/span\u003e\u003c/span\u003e\u003cspan style=\"display:flex;\"\u003e\u003cspan\u003e\u003cspan style=\"color:#75715e\"\u003e\u003c/span\u003e  \u003cspan style=\"color:#66d9ef\"\u003econst\u003c/span\u003e \u003cspan style=\"color:#a6e22e\"\u003enewNames\u003c/span\u003e \u003cspan style=\"color:#f92672\"\u003e=\u003c/span\u003e \u003cspan style=\"color:#a6e22e\"\u003eextractUntranslatedNames\u003c/span\u003e(\u003cspan style=\"color:#a6e22e\"\u003edata\u003c/span\u003e, \u003cspan style=\"color:#a6e22e\"\u003etranslationCache\u003c/span\u003e);\n\u003c/span\u003e\u003c/span\u003e\u003cspan style=\"display:flex;\"\u003e\u003cspan\u003e  \n\u003c/span\u003e\u003c/span\u003e\u003cspan style=\"display:flex;\"\u003e\u003cspan\u003e  \u003cspan style=\"color:#75715e\"\u003e// 新規の国名だけ翻訳\n\u003c/span\u003e\u003c/span\u003e\u003c/span\u003e\u003cspan style=\"display:flex;\"\u003e\u003cspan\u003e\u003cspan style=\"color:#75715e\"\u003e\u003c/span\u003e  \u003cspan style=\"color:#66d9ef\"\u003eif\u003c/span\u003e (\u003cspan style=\"color:#a6e22e\"\u003enewNames\u003c/span\u003e.\u003cspan style=\"color:#a6e22e\"\u003elength\u003c/span\u003e \u003cspan style=\"color:#f92672\"\u003e\u0026gt;\u003c/span\u003e \u003cspan style=\"color:#ae81ff\"\u003e0\u003c/span\u003e) {\n\u003c/span\u003e\u003c/span\u003e\u003cspan style=\"display:flex;\"\u003e\u003cspan\u003e    \u003cspan style=\"color:#66d9ef\"\u003econst\u003c/span\u003e \u003cspan style=\"color:#a6e22e\"\u003etranslations\u003c/span\u003e \u003cspan style=\"color:#f92672\"\u003e=\u003c/span\u003e \u003cspan style=\"color:#66d9ef\"\u003eawait\u003c/span\u003e \u003cspan style=\"color:#a6e22e\"\u003etranslate\u003c/span\u003e(\u003cspan style=\"color:#a6e22e\"\u003enewNames\u003c/span\u003e);\n\u003c/span\u003e\u003c/span\u003e\u003cspan style=\"display:flex;\"\u003e\u003cspan\u003e    Object.\u003cspan style=\"color:#a6e22e\"\u003eassign\u003c/span\u003e(\u003cspan style=\"color:#a6e22e\"\u003etranslationCache\u003c/span\u003e, \u003cspan style=\"color:#a6e22e\"\u003etranslations\u003c/span\u003e);\n\u003c/span\u003e\u003c/span\u003e\u003cspan style=\"display:flex;\"\u003e\u003cspan\u003e  }\n\u003c/span\u003e\u003c/span\u003e\u003cspan style=\"display:flex;\"\u003e\u003cspan\u003e  \n\u003c/span\u003e\u003c/span\u003e\u003cspan style=\"display:flex;\"\u003e\u003cspan\u003e  \u003cspan style=\"color:#75715e\"\u003e// キャッシュを使って適用\n\u003c/span\u003e\u003c/span\u003e\u003c/span\u003e\u003cspan style=\"display:flex;\"\u003e\u003cspan\u003e\u003cspan style=\"color:#75715e\"\u003e\u003c/span\u003e  \u003cspan style=\"color:#a6e22e\"\u003eapplyTranslations\u003c/span\u003e(\u003cspan style=\"color:#a6e22e\"\u003edata\u003c/span\u003e, \u003cspan style=\"color:#a6e22e\"\u003etranslationCache\u003c/span\u003e);\n\u003c/span\u003e\u003c/span\u003e\u003cspan style=\"display:flex;\"\u003e\u003cspan\u003e  \u003cspan style=\"color:#66d9ef\"\u003eawait\u003c/span\u003e \u003cspan style=\"color:#a6e22e\"\u003esave\u003c/span\u003e(\u003cspan style=\"color:#a6e22e\"\u003edata\u003c/span\u003e);\n\u003c/span\u003e\u003c/span\u003e\u003cspan style=\"display:flex;\"\u003e\u003cspan\u003e}\n\u003c/span\u003e\u003c/span\u003e\u003c/code\u003e\u003c/pre\u003e\u003c/div\u003e\u003ch2 id=\"実装-nodejsスクリプト\"\u003e実装: Node.jsスクリプト\u003c/h2\u003e\n\u003ch3 id=\"完全なコード\"\u003e完全なコード\u003c/h3\u003e\n\u003cp\u003eこの手法をNode.jsスクリプトとして実装した。Gemini 2.5 Flash Liteを使用している。この程度の翻訳ならこれで十分。\u003c/p\u003e","title":"歴史地図アプリに日本語検索を実装: GeoJSONデータの効率的な翻訳手法"},{"content":"はじめに バッチ処理で大量のデータ変換を行う際、PostgreSQLのCTE（Common Table Expression、WITH句）を多用していた時期がありました。複雑な変換処理を段階的に分割できて、コードの見通しも良くなる便利な機能です。\nしかし、実際の現場でCTEを使っているコードは意外と少ない。サブクエリや一時テーブルが使われているケースの方が圧倒的に多い印象です。\nこの記事では、実務でCTEを使って感じた強み・弱みと、「なぜ現場ではCTEが少ないのか」を考察します。特にPostgreSQL 12で大きく改善された最適化の仕組みについても解説します。\nCTEの基本おさらい CTEはWITH句を使って一時的な結果セットを定義し、メインクエリから参照できる機能です。\nWITH regional_sales AS ( SELECT region, SUM(amount) AS total_sales FROM orders GROUP BY region ) SELECT region, total_sales FROM regional_sales WHERE total_sales \u0026gt; 10000; サブクエリと似ていますが、名前をつけて再利用できる点が特徴です。変数のように扱えて、複雑なクエリを段階的に構築できます。\nCTEの強みと弱み 強み 1. 可読性・保守性の向上 ネストしたサブクエリ地獄を回避できます。処理を論理的なステップに分割して、各ステップに名前をつけられるため、コードレビューやメンテナンスが格段に楽になります。\n2. 再帰クエリが書ける WITH RECURSIVEを使えば、階層構造（組織図、カテゴリツリー）を扱えます。これはCTE独自の強みで、サブクエリでは実現できません。\n3. 複数箇所から参照できる 同じCTEを複数回参照できます（ただし最適化の観点で注意が必要、後述）。サブクエリだと同じ処理を重複して書く必要があります。\n4. デバッグしやすい 各CTEを個別に実行して中間結果を確認できます。サブクエリだと抜き出して実行するのが面倒です。\n5. 変換処理の分離 SELECT句での複雑な計算を先にCTEで処理しておけます。WHERE句で使いたいけど計算が複雑な場合に便利です。\n弱み 1. 親クエリのパラメータを参照できない サブクエリなら外側の列を参照できる（相関サブクエリ）のに対し、CTEは独立しているため参照できません。\n-- サブクエリなら可能 SELECT * FROM orders o WHERE amount \u0026gt; (SELECT AVG(amount) FROM orders WHERE region = o.region); -- CTEでは不可能（外側のo.regionを参照できない） 2. 大量データ・長時間処理には不向き メモリ上に保持されるため、巨大データだと辛い。一時テーブルならインデックスを作成したり統計情報を活用できます。\n実際、バッチ処理で数百万行のデータを扱う際、CTEよりも一時テーブルの方がパフォーマンスが良いケースが多かったです。\n3. PostgreSQL 11以前は「最適化バリア」になる これが最大の問題でした。次のセクションで詳しく解説します。\nPostgreSQL 11以前の「最適化バリア」問題 PostgreSQL 11以前では、CTEを使うと必ずマテリアライズ（結果の実体化）が発生しました。\n参考: CTE(With句) vs View in Postgres\n何が問題だったのか -- huge_tableに (col, id) の複合インデックスがあるとして WITH cte AS ( SELECT * FROM huge_table WHERE col \u0026lt; 100 ) SELECT * FROM cte WHERE id = 1; PostgreSQL 11以前の挙動:\nWHERE col \u0026lt; 100でインデックスは使える しかしその結果がマテリアライズされた時点で「ただのデータ」になる 次のWHERE id = 1は、マテリアライズされた結果に対するフィルタ 元テーブルの複合インデックス(col, id)が活かせない！ これが「最適化バリア」です。外側のWHERE条件が元のテーブルにプッシュダウンされず、複合インデックスが効かなくなります。\n理想的には以下のように最適化されるべきですが、PostgreSQL 11以前ではこれができませんでした：\n-- こう最適化されるべき SELECT * FROM huge_table WHERE col \u0026lt; 100 AND id = 1; -- 複合インデックス (col, id) がバッチリ効く 即座評価 vs 遅延評価 PostgreSQL 11以前のCTEは即座評価（eager evaluation）でした。CTEを定義した時点で結果を計算して保持します。変数に代入するイメージです。\n一方、サブクエリは遅延評価（lazy evaluation）で、外側の条件と統合して最適化できました。この違いが、「CTEは遅い」という評判の原因でした。\nPostgreSQL 12以降の進化 PostgreSQL 12（2019年10月リリース）で、CTEの挙動が大きく改善されました。\n参考: PostgreSQL 12以降のCTE最適化について\nデフォルトで遅延評価に PostgreSQL 12以降では、デフォルトでNOT MATERIALIZED（遅延評価）になりました。つまり、サブクエリのようにインライン展開されて最適化されます。\nWITH cte AS ( SELECT * FROM huge_table WHERE col \u0026lt; 100 ) SELECT * FROM cte WHERE id = 1; -- PostgreSQL 12以降は自動的にこう最適化される SELECT * FROM huge_table WHERE col \u0026lt; 100 AND id = 1; -- 複合インデックスが効く！ 賢い自動判断 オプティマイザが状況に応じて自動的にマテリアライズの要否を判断します。\nマテリアライズ「しない」条件:\n同じCTEが1回しか使われていない 非immutable関数が使われていない 逆に言えば、以下の場合は自動的にマテリアライズされます：\n複数回参照される場合 WITH cte AS ( SELECT expensive_calculation(id) FROM huge_table WHERE col \u0026lt; 100 ) SELECT * FROM cte WHERE id = 1 UNION ALL SELECT * FROM cte WHERE id = 2 UNION ALL SELECT * FROM cte WHERE id = 3; 同じ重い計算を3回やるより、1回計算して使い回す方が効率的です。オプティマイザが賢く判断してマテリアライズしてくれます。\n非immutable関数がある場合 WITH cte AS ( SELECT *, now() AS created_at FROM huge_table ) SELECT * FROM cte WHERE id = 1 UNION ALL SELECT * FROM cte WHERE id = 2; now()のような非immutable関数（呼び出すたびに結果が変わる可能性がある関数）を含む場合、必ずマテリアライズされます。\n明示的な指定も可能 必要に応じてMATERIALIZED/NOT MATERIALIZEDを明示的に指定できます。\n-- 明示的にマテリアライズ（PostgreSQL 11以前の挙動） WITH cte AS MATERIALIZED ( SELECT * FROM huge_table WHERE col \u0026lt; 100 ) SELECT * FROM cte WHERE id = 1; -- 明示的に遅延評価（複数回参照でもインライン展開） WITH cte AS NOT MATERIALIZED ( SELECT * FROM huge_table WHERE col \u0026lt; 100 ) SELECT * FROM cte WHERE id = 1 UNION ALL SELECT * FROM cte WHERE id = 2; 実務での使い分け PostgreSQL 12以降の改善で、CTEの性能問題は大幅に解決されました。それでも、適材適所は存在します。\nCTEを使うべき場合 中間的な変換処理 一時テーブルを作るほどではないが、複雑な変換を分割したい場合。データの抽出を先にしておきたいが、一時テーブルにするほどではないケース。\nWITH cleaned_data AS ( SELECT id, CASE WHEN status = \u0026#39;draft\u0026#39; THEN \u0026#39;pending\u0026#39; ELSE status END AS normalized_status, COALESCE(amount, 0) AS amount FROM raw_orders ), filtered_data AS ( SELECT * FROM cleaned_data WHERE normalized_status = \u0026#39;pending\u0026#39; ) SELECT * FROM filtered_data WHERE amount \u0026gt; 1000; 再帰クエリ 階層構造を扱う場合、CTEの独壇場です。\n複雑なクエリの可読性向上 処理を論理的なステップに分割することで、コードレビューやメンテナンスが楽になります。\nサブクエリの方が良い場合 親のパラメータを参照したい 相関サブクエリが必要な場合は、CTEでは実現できません。\nSELECT * FROM orders o WHERE amount \u0026gt; ( SELECT AVG(amount) FROM orders WHERE region = o.region -- 外側のo.regionを参照 ); 単純な絞り込み 単純なフィルタリングなら、わざわざCTEを使う必要はありません。\n一時テーブルの方が良い場合 大量データ・長時間処理 数百万行以上のデータを扱う場合、一時テーブルの方が安定します。\nインデックスを張りたい 一時テーブルならインデックスを作成して、後続の処理を高速化できます。\nCREATE TEMP TABLE tmp_orders AS SELECT * FROM orders WHERE created_at \u0026gt; \u0026#39;2025-01-01\u0026#39;; CREATE INDEX idx_tmp_orders_region ON tmp_orders(region); -- 後続処理でインデックスが効く SELECT * FROM tmp_orders WHERE region = \u0026#39;Asia\u0026#39;; 複数回の参照で異なる条件が必要 CTEだとマテリアライズされて最適化の余地がなくなる場合、一時テーブルの方が柔軟です。\n統計情報を活用したい 一時テーブルならANALYZEで統計情報を収集して、より良い実行計画を立てられます。\nまとめ CTEは便利な機能ですが、PostgreSQL 11以前の「最適化バリア」問題が、「CTEは遅い」という評判を生んだ一因でしょう。現場でCTEが少ないのは、この過去の評判を引きずっている可能性があります。\nPostgreSQL 12以降（2019年10月〜）では、遅延評価がデフォルトになり、オプティマイザが賢く判断してくれるようになりました。複合インデックスも効くようになり、性能問題は大幅に改善されています。\nただし、それでも適材適所は存在します：\nCTE: 可読性重視、中間的な変換処理、再帰クエリ サブクエリ: 親パラメータの参照、単純なフィルタ 一時テーブル: 大量データ、インデックス活用、複雑な後続処理 結局のところ、PostgreSQL 12以降なら性能面での差は小さくなったので、好みと場面次第という側面が強くなりました。ただし、PostgreSQL 11以前の環境や、数百万行を超える大規模バッチ処理では、まだ注意が必要です。\n個人的には、CTEを使う場面は増えましたが、「本当に重い処理」では今でも一時テーブルを使っています。厳密にやるなら実行計画（EXPLAIN ANALYZE）を確認するのがベストですが、多くの場合は直感と経験で判断しても問題ないでしょう。\n参考リンク CTE(With句) vs View in Postgres - .NETで作る！ PostgreSQL 12以降のCTE最適化について - SRA OSS Tech Blog ","permalink":"https://techblog.wasutech.dev/posts/pgsql-cte/","summary":"\u003ch2 id=\"はじめに\"\u003eはじめに\u003c/h2\u003e\n\u003cp\u003eバッチ処理で大量のデータ変換を行う際、PostgreSQLのCTE（Common Table Expression、WITH句）を多用していた時期がありました。複雑な変換処理を段階的に分割できて、コードの見通しも良くなる便利な機能です。\u003c/p\u003e\n\u003cp\u003eしかし、実際の現場でCTEを使っているコードは意外と少ない。サブクエリや一時テーブルが使われているケースの方が圧倒的に多い印象です。\u003c/p\u003e\n\u003cp\u003eこの記事では、実務でCTEを使って感じた強み・弱みと、「なぜ現場ではCTEが少ないのか」を考察します。特にPostgreSQL 12で大きく改善された最適化の仕組みについても解説します。\u003c/p\u003e\n\u003ch2 id=\"cteの基本おさらい\"\u003eCTEの基本おさらい\u003c/h2\u003e\n\u003cp\u003eCTEは\u003ccode\u003eWITH\u003c/code\u003e句を使って一時的な結果セットを定義し、メインクエリから参照できる機能です。\u003c/p\u003e\n\u003cdiv class=\"highlight\"\u003e\u003cpre tabindex=\"0\" style=\"color:#f8f8f2;background-color:#272822;-moz-tab-size:4;-o-tab-size:4;tab-size:4;\"\u003e\u003ccode class=\"language-sql\" data-lang=\"sql\"\u003e\u003cspan style=\"display:flex;\"\u003e\u003cspan\u003e\u003cspan style=\"color:#66d9ef\"\u003eWITH\u003c/span\u003e regional_sales \u003cspan style=\"color:#66d9ef\"\u003eAS\u003c/span\u003e (\n\u003c/span\u003e\u003c/span\u003e\u003cspan style=\"display:flex;\"\u003e\u003cspan\u003e  \u003cspan style=\"color:#66d9ef\"\u003eSELECT\u003c/span\u003e region, \u003cspan style=\"color:#66d9ef\"\u003eSUM\u003c/span\u003e(amount) \u003cspan style=\"color:#66d9ef\"\u003eAS\u003c/span\u003e total_sales\n\u003c/span\u003e\u003c/span\u003e\u003cspan style=\"display:flex;\"\u003e\u003cspan\u003e  \u003cspan style=\"color:#66d9ef\"\u003eFROM\u003c/span\u003e orders\n\u003c/span\u003e\u003c/span\u003e\u003cspan style=\"display:flex;\"\u003e\u003cspan\u003e  \u003cspan style=\"color:#66d9ef\"\u003eGROUP\u003c/span\u003e \u003cspan style=\"color:#66d9ef\"\u003eBY\u003c/span\u003e region\n\u003c/span\u003e\u003c/span\u003e\u003cspan style=\"display:flex;\"\u003e\u003cspan\u003e)\n\u003c/span\u003e\u003c/span\u003e\u003cspan style=\"display:flex;\"\u003e\u003cspan\u003e\u003cspan style=\"color:#66d9ef\"\u003eSELECT\u003c/span\u003e region, total_sales\n\u003c/span\u003e\u003c/span\u003e\u003cspan style=\"display:flex;\"\u003e\u003cspan\u003e\u003cspan style=\"color:#66d9ef\"\u003eFROM\u003c/span\u003e regional_sales\n\u003c/span\u003e\u003c/span\u003e\u003cspan style=\"display:flex;\"\u003e\u003cspan\u003e\u003cspan style=\"color:#66d9ef\"\u003eWHERE\u003c/span\u003e total_sales \u003cspan style=\"color:#f92672\"\u003e\u0026gt;\u003c/span\u003e \u003cspan style=\"color:#ae81ff\"\u003e10000\u003c/span\u003e;\n\u003c/span\u003e\u003c/span\u003e\u003c/code\u003e\u003c/pre\u003e\u003c/div\u003e\u003cp\u003eサブクエリと似ていますが、名前をつけて再利用できる点が特徴です。変数のように扱えて、複雑なクエリを段階的に構築できます。\u003c/p\u003e\n\u003ch2 id=\"cteの強みと弱み\"\u003eCTEの強みと弱み\u003c/h2\u003e\n\u003ch3 id=\"強み\"\u003e強み\u003c/h3\u003e\n\u003ch4 id=\"1-可読性保守性の向上\"\u003e1. 可読性・保守性の向上\u003c/h4\u003e\n\u003cp\u003eネストしたサブクエリ地獄を回避できます。処理を論理的なステップに分割して、各ステップに名前をつけられるため、コードレビューやメンテナンスが格段に楽になります。\u003c/p\u003e\n\u003ch4 id=\"2-再帰クエリが書ける\"\u003e2. 再帰クエリが書ける\u003c/h4\u003e\n\u003cp\u003e\u003ccode\u003eWITH RECURSIVE\u003c/code\u003eを使えば、階層構造（組織図、カテゴリツリー）を扱えます。これはCTE独自の強みで、サブクエリでは実現できません。\u003c/p\u003e\n\u003ch4 id=\"3-複数箇所から参照できる\"\u003e3. 複数箇所から参照できる\u003c/h4\u003e\n\u003cp\u003e同じCTEを複数回参照できます（ただし最適化の観点で注意が必要、後述）。サブクエリだと同じ処理を重複して書く必要があります。\u003c/p\u003e\n\u003ch4 id=\"4-デバッグしやすい\"\u003e4. デバッグしやすい\u003c/h4\u003e\n\u003cp\u003e各CTEを個別に実行して中間結果を確認できます。サブクエリだと抜き出して実行するのが面倒です。\u003c/p\u003e\n\u003ch4 id=\"5-変換処理の分離\"\u003e5. 変換処理の分離\u003c/h4\u003e\n\u003cp\u003eSELECT句での複雑な計算を先にCTEで処理しておけます。WHERE句で使いたいけど計算が複雑な場合に便利です。\u003c/p\u003e\n\u003ch3 id=\"弱み\"\u003e弱み\u003c/h3\u003e\n\u003ch4 id=\"1-親クエリのパラメータを参照できない\"\u003e1. 親クエリのパラメータを参照できない\u003c/h4\u003e\n\u003cp\u003eサブクエリなら外側の列を参照できる（相関サブクエリ）のに対し、CTEは独立しているため参照できません。\u003c/p\u003e\n\u003cdiv class=\"highlight\"\u003e\u003cpre tabindex=\"0\" style=\"color:#f8f8f2;background-color:#272822;-moz-tab-size:4;-o-tab-size:4;tab-size:4;\"\u003e\u003ccode class=\"language-sql\" data-lang=\"sql\"\u003e\u003cspan style=\"display:flex;\"\u003e\u003cspan\u003e\u003cspan style=\"color:#75715e\"\u003e-- サブクエリなら可能\n\u003c/span\u003e\u003c/span\u003e\u003c/span\u003e\u003cspan style=\"display:flex;\"\u003e\u003cspan\u003e\u003cspan style=\"color:#75715e\"\u003e\u003c/span\u003e\u003cspan style=\"color:#66d9ef\"\u003eSELECT\u003c/span\u003e \u003cspan style=\"color:#f92672\"\u003e*\u003c/span\u003e\n\u003c/span\u003e\u003c/span\u003e\u003cspan style=\"display:flex;\"\u003e\u003cspan\u003e\u003cspan style=\"color:#66d9ef\"\u003eFROM\u003c/span\u003e orders o\n\u003c/span\u003e\u003c/span\u003e\u003cspan style=\"display:flex;\"\u003e\u003cspan\u003e\u003cspan style=\"color:#66d9ef\"\u003eWHERE\u003c/span\u003e amount \u003cspan style=\"color:#f92672\"\u003e\u0026gt;\u003c/span\u003e (\u003cspan style=\"color:#66d9ef\"\u003eSELECT\u003c/span\u003e \u003cspan style=\"color:#66d9ef\"\u003eAVG\u003c/span\u003e(amount) \u003cspan style=\"color:#66d9ef\"\u003eFROM\u003c/span\u003e orders \u003cspan style=\"color:#66d9ef\"\u003eWHERE\u003c/span\u003e region \u003cspan style=\"color:#f92672\"\u003e=\u003c/span\u003e o.region);\n\u003c/span\u003e\u003c/span\u003e\u003cspan style=\"display:flex;\"\u003e\u003cspan\u003e\n\u003c/span\u003e\u003c/span\u003e\u003cspan style=\"display:flex;\"\u003e\u003cspan\u003e\u003cspan style=\"color:#75715e\"\u003e-- CTEでは不可能（外側のo.regionを参照できない）\n\u003c/span\u003e\u003c/span\u003e\u003c/span\u003e\u003c/code\u003e\u003c/pre\u003e\u003c/div\u003e\u003ch4 id=\"2-大量データ長時間処理には不向き\"\u003e2. 大量データ・長時間処理には不向き\u003c/h4\u003e\n\u003cp\u003eメモリ上に保持されるため、巨大データだと辛い。一時テーブルならインデックスを作成したり統計情報を活用できます。\u003c/p\u003e\n\u003cp\u003e実際、バッチ処理で数百万行のデータを扱う際、CTEよりも一時テーブルの方がパフォーマンスが良いケースが多かったです。\u003c/p\u003e\n\u003ch4 id=\"3-postgresql-11以前は最適化バリアになる\"\u003e3. PostgreSQL 11以前は「最適化バリア」になる\u003c/h4\u003e\n\u003cp\u003eこれが最大の問題でした。次のセクションで詳しく解説します。\u003c/p\u003e","title":"PostgreSQLのCTEが現場で少ない理由を実務経験から考える"},{"content":"私は普段React NativeでExpo触ってるので、AsyncStorageはよく使うんだけど、「そういえばAsyncStorageって裏側何やってんだろう？」って疑問が湧いてきたので調べてみることにした。\nAsyncStorageの裏側 AsyncStorageのバージョンによって実装が少し違う。\nAsyncStorage 2.0の実装 iOS/Androidのみ調査。\n公式ドキュメント: Where your data is stored - Async Storage\niOS (2.0) manifest.jsonファイルに保存される JSONファイル形式 パス: Documents/RCTAsyncLocalStorage_V1/manifest.json 詳細: 1024文字以下のデータはmanifest.jsonに、それより大きいデータは個別ファイル(MD5ハッシュ名)に保存される Android (2.0) SQLiteデータベースに保存される データベース名: RKStorage パス: /data/data/{package_name}/databases/RKStorage AsyncStorage 3.0 (next)の実装 公式ドキュメント: https://react-native-async-storage.github.io/3.0-next/\n対応プラットフォーム Android (SQLite) iOS (SQLite) ✨ macOS (SQLite) visionOS (legacy fallback, single database only) Web (IndexedDB backend) Windows (legacy fallback, single database only) iOS (3.0) SQLiteデータベースに変更された Androidと同じ実装に統一 パフォーマンスと安定性が向上 Android (3.0) 引き続きSQLite より洗練された実装 3.0からはiOSもAndroidも両方SQLiteになって、実装が統一されるそうだ。\n互換性 React Native 0.76以降が必要(iOS/Android) Kotlin 2.1.0 iOS minimum target: 13 Android minimum SDK: 24 なぜiOSでmanifest.jsonからSQLiteに変更したのか あくまでも推測ではあるがやってみた。\nmanifest.jsonの問題点 パフォーマンス JSON全体を読み込む必要がある データが大きくなると起動時の読み込みが遅い 部分的な読み書きができない 並行処理 ファイルロックの問題 複数の書き込みが競合しやすい データ整合性 JSON書き込み中にクラッシュするとデータが壊れる可能性 manifest.jsonで実際に起きた問題 Issue #88 週に約1,500件の頻度で「The folder \u0026ldquo;manifest.json\u0026rdquo; doesn\u0026rsquo;t exist」というクラッシュが報告されていました。\n参考URL: The folder “manifest.json” doesn’t exist · Issue #88 · react-native-async-storage/async-storage · GitHub\nIssue #897 Documentsフォルダに配置されるため、ユーザーがファイルアプリから機密情報を含むmanifest.jsonにアクセス可能でした。\n参考URL: iOS RCTAsyncLocalStorage_V1 folder still showing under Documents · Issue #897 · react-native-async-storage/async-storage · GitHub\nSQLiteのメリット パフォーマンス:\n必要なキーだけ読み込める インデックスが効く 大量データでも高速 トランザクション:\nACID特性が保証される データ破損のリスクが低い 並行処理:\nSQLiteはロック機構が優秀 複数スレッドからのアクセスに強い だから3.0で統一したんだろう。合理的な判断だと思う。\nExpo SDKとAsyncStorageのバージョン Expoを使ってる場合、AsyncStorageのバージョンはExpo SDKに依存する。\n私が今使ってるExpo SDK（54）だと、AsyncStorage 2.0系が入ってるはず。\nつまり：\niOSはmanifest.json AndroidはSQLite という非対称な状態。\n3.0はまだnextだから、正式リリースされたら両方SQLiteになる。\nAsyncStorage 3.0のインストール npmの場合 npm install @react-native-async-storage/async-storage@next yarnの場合 yarn add @react-native-async-storage/async-storage@next Androidの追加設定 android/build.gradleに以下を追加:\nallprojects { repositories { // ... others like google(), mavenCentral() maven { url = uri(project(\u0026#34;:react-native-async-storage_async-storage\u0026#34;).file(\u0026#34;local_repo\u0026#34;)) } } } iOSの追加設定 cd ios pod install 使い方 import { createAsyncStorage } from \u0026#34;@react-native-async-storage/async-storage\u0026#34;; // create a storage instance const storage = createAsyncStorage(\u0026#34;appDB\u0026#34;); async function demo() { await storage.setItem(\u0026#34;userToken\u0026#34;, \u0026#34;abc123\u0026#34;); const token = await storage.getItem(\u0026#34;userToken\u0026#34;); console.log(\u0026#34;Stored token:\u0026#34;, token); // abc123 await storage.removeItem(\u0026#34;userToken\u0026#34;); } AsyncStorage 2.0と3.0の移行 3.0に移行する時、データは自動で移行されるのか？\n公式ドキュメント見た感じ、マイグレーション処理は入ってそう。ただ、大量データがある場合は移行に時間かかるかもしれない。\nまあ、Expoが3.0対応した時に確認する感じか。\nAsyncStorageのデータ破損対策 AsyncStorage 2.0のiOS実装（manifest.json）だと、データ破損のリスクがある。\n実際、Issueとかで「AsyncStorageのデータが壊れた」って報告をたまに見る。\n対策\n重要なデータは複数のキーに分散して保存 バックアップ用のキーを別途用意 読み込み時にJSON.parseのエラーハンドリングをちゃんとする できれば3.0に移行する そもそもSqliteとかRealmを独自で使う try { const jsonString = await AsyncStorage.getItem(\u0026#39;important_data\u0026#39;); const data = jsonString ? JSON.parse(jsonString) : null; return data; } catch (error) { console.error(\u0026#39;AsyncStorage parse error:\u0026#39;, error); // バックアップから復元を試みる const backupString = await AsyncStorage.getItem(\u0026#39;important_data_backup\u0026#39;); if (backupString) { try { return JSON.parse(backupString); } catch (backupError) { // 諦める return null; } } return null; } ExpoでAsyncStorageを使う時の注意点 1. @react-native-async-storage/async-storageを使う 公式のAsyncStorageはなくなったのでコミュニティ版を使う。\nnpx expo install @react-native-async-storage/async-storage 2. バージョンを確認する 2.0系なのか3.0系なのかで実装が違う。\nnpx expo install @react-native-async-storage/async-storage --check 3. JSONのシリアライズ/デシリアライズは自分でやる AsyncStorageは文字列しか保存できない。\n// 保存 const data = { foo: \u0026#39;bar\u0026#39;, count: 42 }; await AsyncStorage.setItem(\u0026#39;key\u0026#39;, JSON.stringify(data)); // 読み込み const jsonString = await AsyncStorage.getItem(\u0026#39;key\u0026#39;); const data = jsonString ? JSON.parse(jsonString) : null; 4. multiGet/multiSetを使う 複数のキーを一度に読み書きする時は、multiGet/multiSetを使うと効率的。\n// 一つずつだと遅い const value1 = await AsyncStorage.getItem(\u0026#39;key1\u0026#39;); const value2 = await AsyncStorage.getItem(\u0026#39;key2\u0026#39;); // まとめて読む const values = await AsyncStorage.multiGet([\u0026#39;key1\u0026#39;, \u0026#39;key2\u0026#39;]); // [[\u0026#39;key1\u0026#39;, \u0026#39;value1\u0026#39;], [\u0026#39;key2\u0026#39;, \u0026#39;value2\u0026#39;]] 5. 大量データは避ける AsyncStorageは大量データには向いてない。\n目安：\n数百件のキー・バリューペアまで 1キーあたり数KB~数十KB程度 それ以上なら、WatermelonDBとかRealmとか、ちゃんとしたデータベース使った方が良い。\n結論 AsyncStorageの裏側、調べてみたら思ってたより複雑だった。\nAsyncStorage 2.0 iOS: manifest.json（JSON） Android: SQLite 非対称な実装 AsyncStorage 3.0 iOS/Android: 両方SQLite 実装が統一される パフォーマンスと安定性が向上 Expoで3.0が使えるようになったら、積極的に移行したい。SQLiteに統一されるのは良いことだと思う。\n参考文献 AsyncStorage 2.0 - Where data is stored AsyncStorage 3.0-next Documentation AsyncStorage Issue #88 - manifest.json folder error AsyncStorage Issue #897 - iOS RCTAsyncLocalStorage_V1 folder ","permalink":"https://techblog.wasutech.dev/posts/async-storage/","summary":"\u003cp\u003e私は普段React NativeでExpo触ってるので、AsyncStorageはよく使うんだけど、「そういえばAsyncStorageって裏側何やってんだろう？」って疑問が湧いてきたので調べてみることにした。\u003c/p\u003e\n\u003ch2 id=\"asyncstorageの裏側\"\u003eAsyncStorageの裏側\u003c/h2\u003e\n\u003cp\u003eAsyncStorageのバージョンによって実装が少し違う。\u003c/p\u003e\n\u003ch3 id=\"asyncstorage-20の実装\"\u003eAsyncStorage 2.0の実装\u003c/h3\u003e\n\u003cp\u003eiOS/Androidのみ調査。\u003c/p\u003e\n\u003cp\u003e公式ドキュメント: \u003ca href=\"https://react-native-async-storage.github.io/2.0/advanced/Where-data-stored/\"\u003eWhere your data is stored - Async Storage\u003c/a\u003e\u003c/p\u003e\n\u003ch4 id=\"ios-20\"\u003eiOS (2.0)\u003c/h4\u003e\n\u003cul\u003e\n\u003cli\u003e\u003ccode\u003emanifest.json\u003c/code\u003eファイルに保存される\u003c/li\u003e\n\u003cli\u003eJSONファイル形式\u003c/li\u003e\n\u003cli\u003eパス: \u003ccode\u003eDocuments/RCTAsyncLocalStorage_V1/manifest.json\u003c/code\u003e\u003c/li\u003e\n\u003cli\u003e詳細: 1024文字以下のデータは\u003ccode\u003emanifest.json\u003c/code\u003eに、それより大きいデータは個別ファイル(MD5ハッシュ名)に保存される\u003c/li\u003e\n\u003c/ul\u003e\n\u003ch4 id=\"android-20\"\u003eAndroid (2.0)\u003c/h4\u003e\n\u003cul\u003e\n\u003cli\u003eSQLiteデータベースに保存される\u003c/li\u003e\n\u003cli\u003eデータベース名: \u003ccode\u003eRKStorage\u003c/code\u003e\u003c/li\u003e\n\u003cli\u003eパス: \u003ccode\u003e/data/data/{package_name}/databases/RKStorage\u003c/code\u003e\u003c/li\u003e\n\u003c/ul\u003e\n\u003ch3 id=\"asyncstorage-30-nextの実装\"\u003eAsyncStorage 3.0 (next)の実装\u003c/h3\u003e\n\u003cp\u003e\u003cstrong\u003e公式ドキュメント\u003c/strong\u003e: \u003ca href=\"https://react-native-async-storage.github.io/3.0-next/\"\u003ehttps://react-native-async-storage.github.io/3.0-next/\u003c/a\u003e\u003c/p\u003e\n\u003ch4 id=\"対応プラットフォーム\"\u003e対応プラットフォーム\u003c/h4\u003e\n\u003cul\u003e\n\u003cli\u003eAndroid (SQLite)\u003c/li\u003e\n\u003cli\u003eiOS (SQLite) ✨\u003c/li\u003e\n\u003cli\u003emacOS (SQLite)\u003c/li\u003e\n\u003cli\u003evisionOS (legacy fallback, single database only)\u003c/li\u003e\n\u003cli\u003eWeb (IndexedDB backend)\u003c/li\u003e\n\u003cli\u003eWindows (legacy fallback, single database only)\u003c/li\u003e\n\u003c/ul\u003e\n\u003ch5 id=\"ios-30\"\u003eiOS (3.0)\u003c/h5\u003e\n\u003cul\u003e\n\u003cli\u003eSQLiteデータベースに変更された\u003c/li\u003e\n\u003cli\u003eAndroidと同じ実装に統一\u003c/li\u003e\n\u003cli\u003eパフォーマンスと安定性が向上\u003c/li\u003e\n\u003c/ul\u003e\n\u003ch5 id=\"android-30\"\u003eAndroid (3.0)\u003c/h5\u003e\n\u003cul\u003e\n\u003cli\u003e引き続きSQLite\u003c/li\u003e\n\u003cli\u003eより洗練された実装\u003c/li\u003e\n\u003c/ul\u003e\n\u003cp\u003e3.0からはiOSもAndroidも両方SQLiteになって、実装が統一されるそうだ。\u003c/p\u003e\n\u003ch5 id=\"互換性\"\u003e互換性\u003c/h5\u003e\n\u003cul\u003e\n\u003cli\u003eReact Native 0.76以降が必要(iOS/Android)\u003c/li\u003e\n\u003cli\u003eKotlin 2.1.0\u003c/li\u003e\n\u003cli\u003eiOS minimum target: 13\u003c/li\u003e\n\u003cli\u003eAndroid minimum SDK: 24\u003c/li\u003e\n\u003c/ul\u003e\n\u003ch2 id=\"なぜiosでmanifestjsonからsqliteに変更したのか\"\u003eなぜiOSでmanifest.jsonからSQLiteに変更したのか\u003c/h2\u003e\n\u003cp\u003eあくまでも推測ではあるがやってみた。\u003c/p\u003e","title":"AsyncStorageって裏側何やってんの？ - 2.0と3.0の実装の違いを調べてみた"},{"content":"はじめに Todoアプリを使っていると、毎日・毎週繰り返す定型タスクの登録が面倒に感じることはありませんか？\n「毎朝のルーチン」「週次ミーティングの準備タスク」など、同じタスクセットを何度も手入力するのは非効率です。この記事では、相対時間を使ったプリセット機能の実装方法を紹介します。\n実装したアプリのソースコード: https://github.com/your-repo (適宜修正してください)\n問題：絶対時間で期限を保存すると使い回せない 一般的なTodoアプリでプリセット機能を実装する場合、以下のような設計になりがちです：\n// ❌ よくある実装（絶対時間） interface PresetTask { text: string; dueDate: Date; // 2026-02-08 09:00:00 } この設計の問題点：\nプリセット作成時の日時が保存される 翌日読み込むと「昨日の9時」が期限になってしまう 毎回手動で期限を修正する必要がある 解決策：相対時間（dueHoursOffset）で管理する 代わりに、「今から何時間後」という相対的な時間で期限を管理します：\n// ✅ 相対時間ベースの設計 export interface PresetTask { id: string; text: string; priority?: Priority; dueHoursOffset?: number; // 現在時刻からの相対時間（時間単位） checklist?: string[]; } export interface Preset { id: string; name: string; tasks: PresetTask[]; createdAt: Date; } 公式ドキュメント:\ndate-fns addHours: https://date-fns.org/v4.1.0/docs/addHours 実装の全体像 1. プリセット作成時の実装 プリセット編集画面では、期限を「現在時刻から何時間後」として入力します：\n// screens/PresetEditScreen.tsx const TaskInputRow = ({ item, index, onTaskTextChange, onDueHoursOffsetChange, // ... }: { item: PresetTask; index: number; onTaskTextChange: (index: number, text: string) =\u0026gt; void; onDueHoursOffsetChange: (index: number, value: string) =\u0026gt; void; // ... }) =\u0026gt; { return ( \u0026lt;Card style={styles.taskCard}\u0026gt; \u0026lt;View style={styles.taskInputRow}\u0026gt; \u0026lt;TextInput label={`タスク ${index + 1}`} value={item.text} onChangeText={text =\u0026gt; onTaskTextChange(index, text)} mode=\u0026#34;outlined\u0026#34; style={styles.taskTextInput} autoComplete=\u0026#34;off\u0026#34; autoCorrect={false} /\u0026gt; \u0026lt;TextInput label=\u0026#34;期限(時間)\u0026#34; value={item.dueHoursOffset?.toString() || \u0026#39;\u0026#39;} onChangeText={value =\u0026gt; { // 数字以外を除去 const filteredValue = value.replace(/[^0-9]/g, \u0026#39;\u0026#39;); onDueHoursOffsetChange(index, filteredValue); }} keyboardType=\u0026#34;numeric\u0026#34; mode=\u0026#34;outlined\u0026#34; style={styles.dueOffsetInput} /\u0026gt; \u0026lt;/View\u0026gt; {/* ... */} \u0026lt;/Card\u0026gt; ); }; ポイント:\ndueHoursOffsetに数値（時間）を入力 「24」と入力すれば「24時間後」という意味 keyboardType=\u0026quot;numeric\u0026quot;で数字キーボードを表示 数字以外の文字はreplace(/[^0-9]/g, '')で除去 2. プリセット保存時の実装 入力されたデータをAsyncStorageに保存します：\n// screens/PresetEditScreen.tsx const handleSave = async () =\u0026gt; { if (!preset.name || preset.name.trim() === \u0026#39;\u0026#39;) { setSnackbarVisible(true); return; } try { const storedPresets = await AsyncStorage.getItem(PRESETS_STORAGE_KEY); let presets: Preset[] = storedPresets ? JSON.parse(storedPresets) : []; // 空のタスクとチェックリスト項目を除去 const tasks = (preset.tasks || []) .filter(t =\u0026gt; t.text.trim() !== \u0026#39;\u0026#39;) .map(t =\u0026gt; ({ ...t, checklist: (t.checklist || []).filter(c =\u0026gt; c.trim() !== \u0026#39;\u0026#39;), priority: t.priority || Priority.Medium, })); if (isNew) { const newPreset: Preset = { id: Date.now().toString(), name: preset.name.trim(), tasks: tasks, // dueHoursOffsetがそのまま保存される createdAt: new Date(), }; presets.push(newPreset); } else { presets = presets.map(p =\u0026gt; p.id === preset.id ? { ...p, name: preset.name!.trim(), tasks } : p ); } await AsyncStorage.setItem(PRESETS_STORAGE_KEY, JSON.stringify(presets)); navigation.goBack(); } catch (error) { console.error(\u0026#39;Failed to save preset.\u0026#39;, error); } }; 保存されるJSONの例:\n{ \u0026#34;id\u0026#34;: \u0026#34;1738995600000\u0026#34;, \u0026#34;name\u0026#34;: \u0026#34;朝のルーチン\u0026#34;, \u0026#34;tasks\u0026#34;: [ { \u0026#34;id\u0026#34;: \u0026#34;task-1\u0026#34;, \u0026#34;text\u0026#34;: \u0026#34;メールチェック\u0026#34;, \u0026#34;dueHoursOffset\u0026#34;: 1, \u0026#34;priority\u0026#34;: \u0026#34;高\u0026#34; }, { \u0026#34;id\u0026#34;: \u0026#34;task-2\u0026#34;, \u0026#34;text\u0026#34;: \u0026#34;日報作成\u0026#34;, \u0026#34;dueHoursOffset\u0026#34;: 8, \u0026#34;priority\u0026#34;: \u0026#34;中\u0026#34; } ], \u0026#34;createdAt\u0026#34;: \u0026#34;2026-02-08T00:00:00.000Z\u0026#34; } 3. プリセット読み込み時の実装（核心部分） 保存されたプリセットを読み込んでタスクを生成する処理が、この機能の最重要ポイントです：\n// screens/PresetsScreen.tsx import { addHours } from \u0026#39;date-fns\u0026#39;; const handleLoadPreset = (tasks: PresetTask[]) =\u0026gt; { const now = new Date(); // 現在時刻を取得 tasks.forEach(presetTask =\u0026gt; { let dueDate: Date | undefined = undefined; // dueHoursOffsetが設定されている場合のみ期限を計算 if (presetTask.dueHoursOffset !== undefined) { dueDate = addHours(now, presetTask.dueHoursOffset); } // TodoContextのaddTodoを呼び出してタスクを追加 addTodo( presetTask.text, dueDate, presetTask.checklist, presetTask.priority ); }); }; 動作の流れ:\nプリセット読み込み時刻: 2026-02-08 09:00 タスク1（メールチェック）: dueHoursOffset: 1 期限 = addHours(2026-02-08 09:00, 1) = 2026-02-08 10:00 タスク2（日報作成）: dueHoursOffset: 8 期限 = addHours(2026-02-08 09:00, 8) = 2026-02-08 17:00 翌日に同じプリセットを読み込んだ場合:\nプリセット読み込み時刻: 2026-02-09 09:00 タスク1（メールチェック）: 期限 = 2026-02-09 10:00 タスク2（日報作成）: 期限 = 2026-02-09 17:00 常に「今から」の相対時間で計算されるため、いつ読み込んでも適切な期限になります！\n4. date-fnsのaddHours関数について import { addHours } from \u0026#39;date-fns\u0026#39;; const now = new Date(\u0026#39;2026-02-08T09:00:00\u0026#39;); const future = addHours(now, 24); console.log(future); // 2026-02-09T09:00:00 date-fnsを選んだ理由:\nサマータイム対応が正確 タイムゾーン考慮が簡単 TypeScript対応が優れている Tree-shakingでバンドルサイズ削減 公式ドキュメント:\ndate-fns公式: https://date-fns.org/ 実装の工夫ポイント 1. 期限なしタスクにも対応 if (presetTask.dueHoursOffset !== undefined) { dueDate = addHours(now, presetTask.dueHoursOffset); } // dueHoursOffsetがundefinedなら、dueDateもundefinedのまま 期限を設定しないタスク（継続的なタスクなど）も扱えるようにしています。\n2. 入力値のバリデーション onChangeText={value =\u0026gt; { const filteredValue = value.replace(/[^0-9]/g, \u0026#39;\u0026#39;); onDueHoursOffsetChange(index, filteredValue); }} 数字以外の入力を除去することで、不正な値の保存を防いでいます。\n3. チェックリストも一緒にプリセット化 export interface PresetTask { id: string; text: string; priority?: Priority; dueHoursOffset?: number; checklist?: string[]; // ← チェックリストも含める } タスクに紐づくチェックリスト項目もプリセットに含めることで、より詳細なルーチンワークを定義できます。\n応用例 1. 毎朝のルーチン { name: \u0026#34;朝のルーチン\u0026#34;, tasks: [ { text: \u0026#34;メールチェック\u0026#34;, dueHoursOffset: 1 }, // 1時間後 { text: \u0026#34;Slackの未読確認\u0026#34;, dueHoursOffset: 1 }, // 1時間後 { text: \u0026#34;午前のタスク整理\u0026#34;, dueHoursOffset: 2 }, // 2時間後 { text: \u0026#34;昼休憩\u0026#34;, dueHoursOffset: 4 }, // 4時間後 ] } 2. 週次ミーティング準備 { name: \u0026#34;週次ミーティング準備\u0026#34;, tasks: [ { text: \u0026#34;資料作成\u0026#34;, dueHoursOffset: 48 }, // 2日後 { text: \u0026#34;レビュー依頼\u0026#34;, dueHoursOffset: 72 }, // 3日後 { text: \u0026#34;最終確認\u0026#34;, dueHoursOffset: 96 }, // 4日後 { text: \u0026#34;ミーティング参加\u0026#34;, dueHoursOffset: 120 }, // 5日後 ] } 3. プロジェクトキックオフ { name: \u0026#34;新規プロジェクト立ち上げ\u0026#34;, tasks: [ { text: \u0026#34;キックオフMTG\u0026#34;, dueHoursOffset: 24 }, { text: \u0026#34;要件定義\u0026#34;, dueHoursOffset: 168 }, // 1週間後 { text: \u0026#34;設計レビュー\u0026#34;, dueHoursOffset: 336 }, // 2週間後 { text: \u0026#34;実装開始\u0026#34;, dueHoursOffset: 504 }, // 3週間後 ] } パフォーマンスの考慮 プリセット読み込み時に複数のタスクを一度に追加すると、再レンダリングが複数回発生する可能性があります。\n現在の実装では、TodoContextのaddTodoを複数回呼び出していますが、React 18の自動バッチングにより、実際の再レンダリングは1回にまとめられます：\ntasks.forEach(presetTask =\u0026gt; { addTodo(...); // 複数回呼ばれても }); // ↑ React 18が自動的に1回の再レンダリングにまとめる 参考:\nReact 18 Automatic Batching: https://react.dev/blog/2022/03/29/react-v18#new-feature-automatic-batching 大量のタスク（100件以上）を一度に追加する場合は、バッチ追加用のAPIを実装することをおすすめします：\n// contexts/TodoContext.tsx に追加 const addTodoBatch = (tasks: Array\u0026lt;{ text: string; dueDate?: Date; checklist?: string[]; priority?: Priority; }\u0026gt;) =\u0026gt; { setTodos(prevTodos =\u0026gt; [ ...tasks.map(task =\u0026gt; ({ id: `${Date.now()}-${todoCounter++}-${Math.random()}`, text: task.text.trim(), status: \u0026#39;todo\u0026#39; as TodoStatus, createdAt: new Date(), dueDate: task.dueDate, checklist: task.checklist?.map((item, index) =\u0026gt; ({ id: `${Date.now()}-cl-${index}-${Math.random()}`, text: item, completed: false, })) || [], priority: task.priority || Priority.Medium, })), ...prevTodos, ]); }; まとめ 相対時間ベースのプリセット機能を実装することで：\n✅ 再利用性が高い: いつ読み込んでも適切な期限が設定される\n✅ 直感的: 「○時間後」という分かりやすい入力\n✅ 柔軟性: 短期タスクから長期プロジェクトまで対応\n✅ メンテナンス不要: プリセット自体の更新が不要\nこの設計パターンは、Todoアプリ以外にも応用可能です：\nリマインダーアプリ プロジェクト管理ツール 定期メンテナンススケジューラー 参考リンク date-fns公式ドキュメント: https://date-fns.org/ React Native Paper: https://callstack.github.io/react-native-paper/ AsyncStorage: https://react-native-async-storage.github.io/async-storage/ この実装について質問や改善案があれば、コメントでお知らせください！\n","permalink":"https://techblog.wasutech.dev/posts/expo-presets/","summary":"\u003ch2 id=\"はじめに\"\u003eはじめに\u003c/h2\u003e\n\u003cp\u003eTodoアプリを使っていると、毎日・毎週繰り返す定型タスクの登録が面倒に感じることはありませんか？\u003c/p\u003e\n\u003cp\u003e「毎朝のルーチン」「週次ミーティングの準備タスク」など、同じタスクセットを何度も手入力するのは非効率です。この記事では、\u003cstrong\u003e相対時間を使ったプリセット機能\u003c/strong\u003eの実装方法を紹介します。\u003c/p\u003e\n\u003cp\u003e実装したアプリのソースコード: \u003ca href=\"https://github.com/your-repo\"\u003ehttps://github.com/your-repo\u003c/a\u003e (適宜修正してください)\u003c/p\u003e\n\u003ch2 id=\"問題絶対時間で期限を保存すると使い回せない\"\u003e問題：絶対時間で期限を保存すると使い回せない\u003c/h2\u003e\n\u003cp\u003e一般的なTodoアプリでプリセット機能を実装する場合、以下のような設計になりがちです：\u003c/p\u003e\n\u003cdiv class=\"highlight\"\u003e\u003cpre tabindex=\"0\" style=\"color:#f8f8f2;background-color:#272822;-moz-tab-size:4;-o-tab-size:4;tab-size:4;\"\u003e\u003ccode class=\"language-typescript\" data-lang=\"typescript\"\u003e\u003cspan style=\"display:flex;\"\u003e\u003cspan\u003e\u003cspan style=\"color:#75715e\"\u003e// ❌ よくある実装（絶対時間）\n\u003c/span\u003e\u003c/span\u003e\u003c/span\u003e\u003cspan style=\"display:flex;\"\u003e\u003cspan\u003e\u003cspan style=\"color:#75715e\"\u003e\u003c/span\u003e\u003cspan style=\"color:#66d9ef\"\u003einterface\u003c/span\u003e \u003cspan style=\"color:#a6e22e\"\u003ePresetTask\u003c/span\u003e {\n\u003c/span\u003e\u003c/span\u003e\u003cspan style=\"display:flex;\"\u003e\u003cspan\u003e  \u003cspan style=\"color:#a6e22e\"\u003etext\u003c/span\u003e: \u003cspan style=\"color:#66d9ef\"\u003estring\u003c/span\u003e;\n\u003c/span\u003e\u003c/span\u003e\u003cspan style=\"display:flex;\"\u003e\u003cspan\u003e  \u003cspan style=\"color:#a6e22e\"\u003edueDate\u003c/span\u003e: \u003cspan style=\"color:#66d9ef\"\u003eDate\u003c/span\u003e; \u003cspan style=\"color:#75715e\"\u003e// 2026-02-08 09:00:00\n\u003c/span\u003e\u003c/span\u003e\u003c/span\u003e\u003cspan style=\"display:flex;\"\u003e\u003cspan\u003e\u003cspan style=\"color:#75715e\"\u003e\u003c/span\u003e}\n\u003c/span\u003e\u003c/span\u003e\u003c/code\u003e\u003c/pre\u003e\u003c/div\u003e\u003cp\u003eこの設計の問題点：\u003c/p\u003e\n\u003cul\u003e\n\u003cli\u003eプリセット作成時の日時が保存される\u003c/li\u003e\n\u003cli\u003e翌日読み込むと「昨日の9時」が期限になってしまう\u003c/li\u003e\n\u003cli\u003e毎回手動で期限を修正する必要がある\u003c/li\u003e\n\u003c/ul\u003e\n\u003ch2 id=\"解決策相対時間duehoursoffsetで管理する\"\u003e解決策：相対時間（dueHoursOffset）で管理する\u003c/h2\u003e\n\u003cp\u003e代わりに、\u003cstrong\u003e「今から何時間後」という相対的な時間\u003c/strong\u003eで期限を管理します：\u003c/p\u003e\n\u003cdiv class=\"highlight\"\u003e\u003cpre tabindex=\"0\" style=\"color:#f8f8f2;background-color:#272822;-moz-tab-size:4;-o-tab-size:4;tab-size:4;\"\u003e\u003ccode class=\"language-typescript\" data-lang=\"typescript\"\u003e\u003cspan style=\"display:flex;\"\u003e\u003cspan\u003e\u003cspan style=\"color:#75715e\"\u003e// ✅ 相対時間ベースの設計\n\u003c/span\u003e\u003c/span\u003e\u003c/span\u003e\u003cspan style=\"display:flex;\"\u003e\u003cspan\u003e\u003cspan style=\"color:#75715e\"\u003e\u003c/span\u003e\u003cspan style=\"color:#66d9ef\"\u003eexport\u003c/span\u003e \u003cspan style=\"color:#66d9ef\"\u003einterface\u003c/span\u003e \u003cspan style=\"color:#a6e22e\"\u003ePresetTask\u003c/span\u003e {\n\u003c/span\u003e\u003c/span\u003e\u003cspan style=\"display:flex;\"\u003e\u003cspan\u003e  \u003cspan style=\"color:#a6e22e\"\u003eid\u003c/span\u003e: \u003cspan style=\"color:#66d9ef\"\u003estring\u003c/span\u003e;\n\u003c/span\u003e\u003c/span\u003e\u003cspan style=\"display:flex;\"\u003e\u003cspan\u003e  \u003cspan style=\"color:#a6e22e\"\u003etext\u003c/span\u003e: \u003cspan style=\"color:#66d9ef\"\u003estring\u003c/span\u003e;\n\u003c/span\u003e\u003c/span\u003e\u003cspan style=\"display:flex;\"\u003e\u003cspan\u003e  \u003cspan style=\"color:#a6e22e\"\u003epriority?\u003c/span\u003e: \u003cspan style=\"color:#66d9ef\"\u003ePriority\u003c/span\u003e;\n\u003c/span\u003e\u003c/span\u003e\u003cspan style=\"display:flex;\"\u003e\u003cspan\u003e  \u003cspan style=\"color:#a6e22e\"\u003edueHoursOffset?\u003c/span\u003e: \u003cspan style=\"color:#66d9ef\"\u003enumber\u003c/span\u003e; \u003cspan style=\"color:#75715e\"\u003e// 現在時刻からの相対時間（時間単位）\n\u003c/span\u003e\u003c/span\u003e\u003c/span\u003e\u003cspan style=\"display:flex;\"\u003e\u003cspan\u003e\u003cspan style=\"color:#75715e\"\u003e\u003c/span\u003e  \u003cspan style=\"color:#a6e22e\"\u003echecklist?\u003c/span\u003e: \u003cspan style=\"color:#66d9ef\"\u003estring\u003c/span\u003e[];\n\u003c/span\u003e\u003c/span\u003e\u003cspan style=\"display:flex;\"\u003e\u003cspan\u003e}\n\u003c/span\u003e\u003c/span\u003e\u003cspan style=\"display:flex;\"\u003e\u003cspan\u003e\n\u003c/span\u003e\u003c/span\u003e\u003cspan style=\"display:flex;\"\u003e\u003cspan\u003e\u003cspan style=\"color:#66d9ef\"\u003eexport\u003c/span\u003e \u003cspan style=\"color:#66d9ef\"\u003einterface\u003c/span\u003e \u003cspan style=\"color:#a6e22e\"\u003ePreset\u003c/span\u003e {\n\u003c/span\u003e\u003c/span\u003e\u003cspan style=\"display:flex;\"\u003e\u003cspan\u003e  \u003cspan style=\"color:#a6e22e\"\u003eid\u003c/span\u003e: \u003cspan style=\"color:#66d9ef\"\u003estring\u003c/span\u003e;\n\u003c/span\u003e\u003c/span\u003e\u003cspan style=\"display:flex;\"\u003e\u003cspan\u003e  \u003cspan style=\"color:#a6e22e\"\u003ename\u003c/span\u003e: \u003cspan style=\"color:#66d9ef\"\u003estring\u003c/span\u003e;\n\u003c/span\u003e\u003c/span\u003e\u003cspan style=\"display:flex;\"\u003e\u003cspan\u003e  \u003cspan style=\"color:#a6e22e\"\u003etasks\u003c/span\u003e: \u003cspan style=\"color:#66d9ef\"\u003ePresetTask\u003c/span\u003e[];\n\u003c/span\u003e\u003c/span\u003e\u003cspan style=\"display:flex;\"\u003e\u003cspan\u003e  \u003cspan style=\"color:#a6e22e\"\u003ecreatedAt\u003c/span\u003e: \u003cspan style=\"color:#66d9ef\"\u003eDate\u003c/span\u003e;\n\u003c/span\u003e\u003c/span\u003e\u003cspan style=\"display:flex;\"\u003e\u003cspan\u003e}\n\u003c/span\u003e\u003c/span\u003e\u003c/code\u003e\u003c/pre\u003e\u003c/div\u003e\u003cp\u003e\u003cstrong\u003e公式ドキュメント:\u003c/strong\u003e\u003c/p\u003e\n\u003cul\u003e\n\u003cli\u003edate-fns \u003ccode\u003eaddHours\u003c/code\u003e: \u003ca href=\"https://date-fns.org/v4.1.0/docs/addHours\"\u003ehttps://date-fns.org/v4.1.0/docs/addHours\u003c/a\u003e\u003c/li\u003e\n\u003c/ul\u003e\n\u003ch2 id=\"実装の全体像\"\u003e実装の全体像\u003c/h2\u003e\n\u003ch3 id=\"1-プリセット作成時の実装\"\u003e1. プリセット作成時の実装\u003c/h3\u003e\n\u003cp\u003eプリセット編集画面では、期限を「現在時刻から何時間後」として入力します：\u003c/p\u003e\n\u003cdiv class=\"highlight\"\u003e\u003cpre tabindex=\"0\" style=\"color:#f8f8f2;background-color:#272822;-moz-tab-size:4;-o-tab-size:4;tab-size:4;\"\u003e\u003ccode class=\"language-typescript\" data-lang=\"typescript\"\u003e\u003cspan style=\"display:flex;\"\u003e\u003cspan\u003e\u003cspan style=\"color:#75715e\"\u003e// screens/PresetEditScreen.tsx\n\u003c/span\u003e\u003c/span\u003e\u003c/span\u003e\u003cspan style=\"display:flex;\"\u003e\u003cspan\u003e\u003cspan style=\"color:#75715e\"\u003e\u003c/span\u003e\u003cspan style=\"color:#66d9ef\"\u003econst\u003c/span\u003e \u003cspan style=\"color:#a6e22e\"\u003eTaskInputRow\u003c/span\u003e \u003cspan style=\"color:#f92672\"\u003e=\u003c/span\u003e ({\n\u003c/span\u003e\u003c/span\u003e\u003cspan style=\"display:flex;\"\u003e\u003cspan\u003e  \u003cspan style=\"color:#a6e22e\"\u003eitem\u003c/span\u003e,\n\u003c/span\u003e\u003c/span\u003e\u003cspan style=\"display:flex;\"\u003e\u003cspan\u003e  \u003cspan style=\"color:#a6e22e\"\u003eindex\u003c/span\u003e,\n\u003c/span\u003e\u003c/span\u003e\u003cspan style=\"display:flex;\"\u003e\u003cspan\u003e  \u003cspan style=\"color:#a6e22e\"\u003eonTaskTextChange\u003c/span\u003e,\n\u003c/span\u003e\u003c/span\u003e\u003cspan style=\"display:flex;\"\u003e\u003cspan\u003e  \u003cspan style=\"color:#a6e22e\"\u003eonDueHoursOffsetChange\u003c/span\u003e,\n\u003c/span\u003e\u003c/span\u003e\u003cspan style=\"display:flex;\"\u003e\u003cspan\u003e  \u003cspan style=\"color:#75715e\"\u003e// ...\n\u003c/span\u003e\u003c/span\u003e\u003c/span\u003e\u003cspan style=\"display:flex;\"\u003e\u003cspan\u003e\u003cspan style=\"color:#75715e\"\u003e\u003c/span\u003e}\u003cspan style=\"color:#f92672\"\u003e:\u003c/span\u003e {\n\u003c/span\u003e\u003c/span\u003e\u003cspan style=\"display:flex;\"\u003e\u003cspan\u003e  \u003cspan style=\"color:#a6e22e\"\u003eitem\u003c/span\u003e: \u003cspan style=\"color:#66d9ef\"\u003ePresetTask\u003c/span\u003e;\n\u003c/span\u003e\u003c/span\u003e\u003cspan style=\"display:flex;\"\u003e\u003cspan\u003e  \u003cspan style=\"color:#a6e22e\"\u003eindex\u003c/span\u003e: \u003cspan style=\"color:#66d9ef\"\u003enumber\u003c/span\u003e;\n\u003c/span\u003e\u003c/span\u003e\u003cspan style=\"display:flex;\"\u003e\u003cspan\u003e  \u003cspan style=\"color:#a6e22e\"\u003eonTaskTextChange\u003c/span\u003e\u003cspan style=\"color:#f92672\"\u003e:\u003c/span\u003e (\u003cspan style=\"color:#a6e22e\"\u003eindex\u003c/span\u003e: \u003cspan style=\"color:#66d9ef\"\u003enumber\u003c/span\u003e, \u003cspan style=\"color:#a6e22e\"\u003etext\u003c/span\u003e: \u003cspan style=\"color:#66d9ef\"\u003estring\u003c/span\u003e) \u003cspan style=\"color:#f92672\"\u003e=\u0026gt;\u003c/span\u003e \u003cspan style=\"color:#66d9ef\"\u003evoid\u003c/span\u003e;\n\u003c/span\u003e\u003c/span\u003e\u003cspan style=\"display:flex;\"\u003e\u003cspan\u003e  \u003cspan style=\"color:#a6e22e\"\u003eonDueHoursOffsetChange\u003c/span\u003e\u003cspan style=\"color:#f92672\"\u003e:\u003c/span\u003e (\u003cspan style=\"color:#a6e22e\"\u003eindex\u003c/span\u003e: \u003cspan style=\"color:#66d9ef\"\u003enumber\u003c/span\u003e, \u003cspan style=\"color:#a6e22e\"\u003evalue\u003c/span\u003e: \u003cspan style=\"color:#66d9ef\"\u003estring\u003c/span\u003e) \u003cspan style=\"color:#f92672\"\u003e=\u0026gt;\u003c/span\u003e \u003cspan style=\"color:#66d9ef\"\u003evoid\u003c/span\u003e;\n\u003c/span\u003e\u003c/span\u003e\u003cspan style=\"display:flex;\"\u003e\u003cspan\u003e  \u003cspan style=\"color:#75715e\"\u003e// ...\n\u003c/span\u003e\u003c/span\u003e\u003c/span\u003e\u003cspan style=\"display:flex;\"\u003e\u003cspan\u003e\u003cspan style=\"color:#75715e\"\u003e\u003c/span\u003e}) \u003cspan style=\"color:#f92672\"\u003e=\u0026gt;\u003c/span\u003e {\n\u003c/span\u003e\u003c/span\u003e\u003cspan style=\"display:flex;\"\u003e\u003cspan\u003e  \u003cspan style=\"color:#66d9ef\"\u003ereturn\u003c/span\u003e (\n\u003c/span\u003e\u003c/span\u003e\u003cspan style=\"display:flex;\"\u003e\u003cspan\u003e    \u0026lt;\u003cspan style=\"color:#f92672\"\u003eCard\u003c/span\u003e \u003cspan style=\"color:#a6e22e\"\u003estyle\u003c/span\u003e\u003cspan style=\"color:#f92672\"\u003e=\u003c/span\u003e{\u003cspan style=\"color:#a6e22e\"\u003estyles\u003c/span\u003e.\u003cspan style=\"color:#a6e22e\"\u003etaskCard\u003c/span\u003e}\u0026gt;\n\u003c/span\u003e\u003c/span\u003e\u003cspan style=\"display:flex;\"\u003e\u003cspan\u003e      \u0026lt;\u003cspan style=\"color:#f92672\"\u003eView\u003c/span\u003e \u003cspan style=\"color:#a6e22e\"\u003estyle\u003c/span\u003e\u003cspan style=\"color:#f92672\"\u003e=\u003c/span\u003e{\u003cspan style=\"color:#a6e22e\"\u003estyles\u003c/span\u003e.\u003cspan style=\"color:#a6e22e\"\u003etaskInputRow\u003c/span\u003e}\u0026gt;\n\u003c/span\u003e\u003c/span\u003e\u003cspan style=\"display:flex;\"\u003e\u003cspan\u003e        \u0026lt;\u003cspan style=\"color:#f92672\"\u003eTextInput\u003c/span\u003e\n\u003c/span\u003e\u003c/span\u003e\u003cspan style=\"display:flex;\"\u003e\u003cspan\u003e          \u003cspan style=\"color:#a6e22e\"\u003elabel\u003c/span\u003e\u003cspan style=\"color:#f92672\"\u003e=\u003c/span\u003e{\u003cspan style=\"color:#e6db74\"\u003e`タスク \u003c/span\u003e\u003cspan style=\"color:#e6db74\"\u003e${\u003c/span\u003e\u003cspan style=\"color:#a6e22e\"\u003eindex\u003c/span\u003e \u003cspan style=\"color:#f92672\"\u003e+\u003c/span\u003e \u003cspan style=\"color:#ae81ff\"\u003e1\u003c/span\u003e\u003cspan style=\"color:#e6db74\"\u003e}\u003c/span\u003e\u003cspan style=\"color:#e6db74\"\u003e`\u003c/span\u003e}\n\u003c/span\u003e\u003c/span\u003e\u003cspan style=\"display:flex;\"\u003e\u003cspan\u003e          \u003cspan style=\"color:#a6e22e\"\u003evalue\u003c/span\u003e\u003cspan style=\"color:#f92672\"\u003e=\u003c/span\u003e{\u003cspan style=\"color:#a6e22e\"\u003eitem\u003c/span\u003e.\u003cspan style=\"color:#a6e22e\"\u003etext\u003c/span\u003e}\n\u003c/span\u003e\u003c/span\u003e\u003cspan style=\"display:flex;\"\u003e\u003cspan\u003e          \u003cspan style=\"color:#a6e22e\"\u003eonChangeText\u003c/span\u003e\u003cspan style=\"color:#f92672\"\u003e=\u003c/span\u003e{\u003cspan style=\"color:#a6e22e\"\u003etext\u003c/span\u003e \u003cspan style=\"color:#f92672\"\u003e=\u0026gt;\u003c/span\u003e \u003cspan style=\"color:#a6e22e\"\u003eonTaskTextChange\u003c/span\u003e(\u003cspan style=\"color:#a6e22e\"\u003eindex\u003c/span\u003e, \u003cspan style=\"color:#a6e22e\"\u003etext\u003c/span\u003e)}\n\u003c/span\u003e\u003c/span\u003e\u003cspan style=\"display:flex;\"\u003e\u003cspan\u003e          \u003cspan style=\"color:#a6e22e\"\u003emode\u003c/span\u003e\u003cspan style=\"color:#f92672\"\u003e=\u003c/span\u003e\u003cspan style=\"color:#e6db74\"\u003e\u0026#34;outlined\u0026#34;\u003c/span\u003e\n\u003c/span\u003e\u003c/span\u003e\u003cspan style=\"display:flex;\"\u003e\u003cspan\u003e          \u003cspan style=\"color:#a6e22e\"\u003estyle\u003c/span\u003e\u003cspan style=\"color:#f92672\"\u003e=\u003c/span\u003e{\u003cspan style=\"color:#a6e22e\"\u003estyles\u003c/span\u003e.\u003cspan style=\"color:#a6e22e\"\u003etaskTextInput\u003c/span\u003e}\n\u003c/span\u003e\u003c/span\u003e\u003cspan style=\"display:flex;\"\u003e\u003cspan\u003e          \u003cspan style=\"color:#a6e22e\"\u003eautoComplete\u003c/span\u003e\u003cspan style=\"color:#f92672\"\u003e=\u003c/span\u003e\u003cspan style=\"color:#e6db74\"\u003e\u0026#34;off\u0026#34;\u003c/span\u003e\n\u003c/span\u003e\u003c/span\u003e\u003cspan style=\"display:flex;\"\u003e\u003cspan\u003e          \u003cspan style=\"color:#a6e22e\"\u003eautoCorrect\u003c/span\u003e\u003cspan style=\"color:#f92672\"\u003e=\u003c/span\u003e{\u003cspan style=\"color:#66d9ef\"\u003efalse\u003c/span\u003e}\n\u003c/span\u003e\u003c/span\u003e\u003cspan style=\"display:flex;\"\u003e\u003cspan\u003e        /\u0026gt;\n\u003c/span\u003e\u003c/span\u003e\u003cspan style=\"display:flex;\"\u003e\u003cspan\u003e        \u0026lt;\u003cspan style=\"color:#f92672\"\u003eTextInput\u003c/span\u003e\n\u003c/span\u003e\u003c/span\u003e\u003cspan style=\"display:flex;\"\u003e\u003cspan\u003e          \u003cspan style=\"color:#a6e22e\"\u003elabel\u003c/span\u003e\u003cspan style=\"color:#f92672\"\u003e=\u003c/span\u003e\u003cspan style=\"color:#e6db74\"\u003e\u0026#34;期限(時間)\u0026#34;\u003c/span\u003e\n\u003c/span\u003e\u003c/span\u003e\u003cspan style=\"display:flex;\"\u003e\u003cspan\u003e          \u003cspan style=\"color:#a6e22e\"\u003evalue\u003c/span\u003e\u003cspan style=\"color:#f92672\"\u003e=\u003c/span\u003e{\u003cspan style=\"color:#a6e22e\"\u003eitem\u003c/span\u003e.\u003cspan style=\"color:#a6e22e\"\u003edueHoursOffset\u003c/span\u003e\u003cspan style=\"color:#f92672\"\u003e?\u003c/span\u003e.\u003cspan style=\"color:#a6e22e\"\u003etoString\u003c/span\u003e() \u003cspan style=\"color:#f92672\"\u003e||\u003c/span\u003e \u003cspan style=\"color:#e6db74\"\u003e\u0026#39;\u0026#39;\u003c/span\u003e}\n\u003c/span\u003e\u003c/span\u003e\u003cspan style=\"display:flex;\"\u003e\u003cspan\u003e          \u003cspan style=\"color:#a6e22e\"\u003eonChangeText\u003c/span\u003e\u003cspan style=\"color:#f92672\"\u003e=\u003c/span\u003e{\u003cspan style=\"color:#a6e22e\"\u003evalue\u003c/span\u003e \u003cspan style=\"color:#f92672\"\u003e=\u0026gt;\u003c/span\u003e {\n\u003c/span\u003e\u003c/span\u003e\u003cspan style=\"display:flex;\"\u003e\u003cspan\u003e            \u003cspan style=\"color:#75715e\"\u003e// 数字以外を除去\n\u003c/span\u003e\u003c/span\u003e\u003c/span\u003e\u003cspan style=\"display:flex;\"\u003e\u003cspan\u003e\u003cspan style=\"color:#75715e\"\u003e\u003c/span\u003e            \u003cspan style=\"color:#66d9ef\"\u003econst\u003c/span\u003e \u003cspan style=\"color:#a6e22e\"\u003efilteredValue\u003c/span\u003e \u003cspan style=\"color:#f92672\"\u003e=\u003c/span\u003e \u003cspan style=\"color:#a6e22e\"\u003evalue\u003c/span\u003e.\u003cspan style=\"color:#a6e22e\"\u003ereplace\u003c/span\u003e(\u003cspan style=\"color:#e6db74\"\u003e/[^0-9]/g\u003c/span\u003e, \u003cspan style=\"color:#e6db74\"\u003e\u0026#39;\u0026#39;\u003c/span\u003e);\n\u003c/span\u003e\u003c/span\u003e\u003cspan style=\"display:flex;\"\u003e\u003cspan\u003e            \u003cspan style=\"color:#a6e22e\"\u003eonDueHoursOffsetChange\u003c/span\u003e(\u003cspan style=\"color:#a6e22e\"\u003eindex\u003c/span\u003e, \u003cspan style=\"color:#a6e22e\"\u003efilteredValue\u003c/span\u003e);\n\u003c/span\u003e\u003c/span\u003e\u003cspan style=\"display:flex;\"\u003e\u003cspan\u003e          }}\n\u003c/span\u003e\u003c/span\u003e\u003cspan style=\"display:flex;\"\u003e\u003cspan\u003e          \u003cspan style=\"color:#a6e22e\"\u003ekeyboardType\u003c/span\u003e\u003cspan style=\"color:#f92672\"\u003e=\u003c/span\u003e\u003cspan style=\"color:#e6db74\"\u003e\u0026#34;numeric\u0026#34;\u003c/span\u003e\n\u003c/span\u003e\u003c/span\u003e\u003cspan style=\"display:flex;\"\u003e\u003cspan\u003e          \u003cspan style=\"color:#a6e22e\"\u003emode\u003c/span\u003e\u003cspan style=\"color:#f92672\"\u003e=\u003c/span\u003e\u003cspan style=\"color:#e6db74\"\u003e\u0026#34;outlined\u0026#34;\u003c/span\u003e\n\u003c/span\u003e\u003c/span\u003e\u003cspan style=\"display:flex;\"\u003e\u003cspan\u003e          \u003cspan style=\"color:#a6e22e\"\u003estyle\u003c/span\u003e\u003cspan style=\"color:#f92672\"\u003e=\u003c/span\u003e{\u003cspan style=\"color:#a6e22e\"\u003estyles\u003c/span\u003e.\u003cspan style=\"color:#a6e22e\"\u003edueOffsetInput\u003c/span\u003e}\n\u003c/span\u003e\u003c/span\u003e\u003cspan style=\"display:flex;\"\u003e\u003cspan\u003e        /\u0026gt;\n\u003c/span\u003e\u003c/span\u003e\u003cspan style=\"display:flex;\"\u003e\u003cspan\u003e      \u0026lt;/\u003cspan style=\"color:#f92672\"\u003eView\u003c/span\u003e\u0026gt;\n\u003c/span\u003e\u003c/span\u003e\u003cspan style=\"display:flex;\"\u003e\u003cspan\u003e      {\u003cspan style=\"color:#75715e\"\u003e/* ... */\u003c/span\u003e}\n\u003c/span\u003e\u003c/span\u003e\u003cspan style=\"display:flex;\"\u003e\u003cspan\u003e    \u0026lt;/\u003cspan style=\"color:#f92672\"\u003eCard\u003c/span\u003e\u0026gt;\n\u003c/span\u003e\u003c/span\u003e\u003cspan style=\"display:flex;\"\u003e\u003cspan\u003e  );\n\u003c/span\u003e\u003c/span\u003e\u003cspan style=\"display:flex;\"\u003e\u003cspan\u003e};\n\u003c/span\u003e\u003c/span\u003e\u003c/code\u003e\u003c/pre\u003e\u003c/div\u003e\u003cp\u003e\u003cstrong\u003eポイント:\u003c/strong\u003e\u003c/p\u003e","title":"React NativeのTodoアプリで実装する相対時間ベースのプリセット機能"},{"content":"背景 約900行に肥大化したmypackage.elを整理し、機能ごとにファイル分割してメンテナンス性を向上させるリファクタリングを実施した。\n課題 単一ファイルの肥大化: mypackage.elが900行超えで見通しが悪い 機密情報の混在: API keyがコード内に散在 使っていない設定: コメントアウトされた設定が残存 パッケージの把握困難: 何を使っているか不明瞭 新しいディレクトリ構成 dotfiles/emacs/ ├── init.el # エントリーポイント ├── early-init.el # 起動高速化 ├── core/ │ ├── env.el # 環境変数・基本設定 │ ├── custom.el # UI基本設定 │ ├── keymap.el # キーバインド │ └── util.el # ユーティリティ関数 ├── packages/ │ ├── manager.el # straight.el設定 │ ├── core.el # 基盤パッケージ │ ├── completion.el # 補完系 (Vertico, Corfu) │ ├── search.el # 検索系 (Consult, Embark) │ ├── git.el # Magit等 │ ├── lsp.el # Eglot等 │ ├── languages.el # 言語別設定 │ ├── ai.el # GPTel, Ollama │ ├── writing.el # Denote, Org, Markdown │ ├── ui.el # テーマ、アイコン │ └── optional.el # たまに使うもの ├── templates/ # Tempelテンプレート └── docs/ └── README.md 重要な学び: require vs load 問題: requireでパッケージが読み込まれない 当初、init.elで(require 'completion)のように読み込んでいたが、以下の問題が発生:\nprovideのキャッシュ: 一度requireで読み込むと、featuresリストに記録され、再度requireしてもスキップされる キーバインドの上書き: keymap.elを先に読み込んでも、後からパッケージが上書き 解決策: loadを使用 ;; ❌ これだとキャッシュされる (require \u0026#39;completion) (require \u0026#39;git) ;; ✅ loadは毎回実行される (load (expand-file-name \u0026#34;packages/completion.el\u0026#34; dotfiles-emacs-dir)) (load (expand-file-name \u0026#34;packages/git.el\u0026#34; dotfiles-emacs-dir)) loadの利点:\nprovideの有無に関係なく確実に実行 設定変更後の再読み込みが確実 キーバインドなど、即座に実行したい設定に最適 最終的なinit.el ;;; init.el --- Wasu\u0026#39;s Emacs Configuration -*- lexical-binding: t; -*- ;; package.elを無効化 (setq package-enable-at-startup nil) ;; Load path (defvar dotfiles-emacs-dir (expand-file-name \u0026#34;~/dotfiles/emacs/\u0026#34;)) (add-to-list \u0026#39;load-path (expand-file-name \u0026#34;core\u0026#34; dotfiles-emacs-dir)) (add-to-list \u0026#39;load-path (expand-file-name \u0026#34;packages\u0026#34; dotfiles-emacs-dir)) ;; Core configuration (load (expand-file-name \u0026#34;core/env.el\u0026#34; dotfiles-emacs-dir)) (load (expand-file-name \u0026#34;core/custom.el\u0026#34; dotfiles-emacs-dir)) ;; Package management (load (expand-file-name \u0026#34;packages/manager.el\u0026#34; dotfiles-emacs-dir)) ;; Custom file (secrets) - パッケージより先に読み込む (setq custom-file (expand-file-name \u0026#34;config.el\u0026#34; user-emacs-directory)) (when (file-exists-p custom-file) (load custom-file)) ;; Core packages \u0026amp; Utils (load (expand-file-name \u0026#34;packages/core.el\u0026#34; dotfiles-emacs-dir)) (load (expand-file-name \u0026#34;core/util.el\u0026#34; dotfiles-emacs-dir)) ;; Packages (全てload) (load (expand-file-name \u0026#34;packages/completion.el\u0026#34; dotfiles-emacs-dir)) (load (expand-file-name \u0026#34;packages/search.el\u0026#34; dotfiles-emacs-dir)) (load (expand-file-name \u0026#34;packages/lsp.el\u0026#34; dotfiles-emacs-dir)) (load (expand-file-name \u0026#34;packages/languages.el\u0026#34; dotfiles-emacs-dir)) (load (expand-file-name \u0026#34;packages/ui.el\u0026#34; dotfiles-emacs-dir)) (load (expand-file-name \u0026#34;packages/git.el\u0026#34; dotfiles-emacs-dir)) (load (expand-file-name \u0026#34;packages/writing.el\u0026#34; dotfiles-emacs-dir)) (load (expand-file-name \u0026#34;packages/ai.el\u0026#34; dotfiles-emacs-dir)) (load (expand-file-name \u0026#34;packages/optional.el\u0026#34; dotfiles-emacs-dir)) ;; Font (optional) (let ((font-config (expand-file-name \u0026#34;core/font.el\u0026#34; dotfiles-emacs-dir))) (when (file-exists-p font-config) (load font-config))) ;; Keymap (最後に読み込んで上書きを防ぐ) (load (expand-file-name \u0026#34;core/keymap.el\u0026#34; dotfiles-emacs-dir)) (provide \u0026#39;init) ;;; init.el ends here 機密情報の分離 ~/.emacs.d/config.elに機密情報を集約:\n;;; config.el --- Private configuration ;; API Keys (setq gemini-api-key \u0026#34;your-key\u0026#34;) (setq habitica-uid \u0026#34;your-uid\u0026#34;) (setq habitica-token \u0026#34;your-token\u0026#34;) ;; Ollama (setq ollama-host \u0026#34;localhost\u0026#34;) (setq ollama-port 11434) (setq ollama-model \u0026#34;qwen2.5:7b-instruct\u0026#34;) ;; Mastodon (setq mastodon-instance-url \u0026#34;https://mstdn.jp/\u0026#34;) (setq mastodon-active-user \u0026#34;wasulisp\u0026#34;) (provide \u0026#39;config) このファイルは.emacs.dに配置するのでバージョン管理外で管理する。\nearly-init.elで起動高速化 ;;; early-init.el --- Early initialization ;; package.elを無効化 (straight.el使用のため) (setq package-enable-at-startup nil) ;; GC閾値を一時的に上げて起動高速化 (setq gc-cons-threshold most-positive-fixnum) ;; 起動後に閾値を戻す (add-hook \u0026#39;emacs-startup-hook (lambda () (setq gc-cons-threshold (* 16 1024 1024)))) (provide \u0026#39;early-init) これにより~/.emacs.d/elpaとの競合を防ぎ、起動を高速化する。\nパッケージ分割の例 中身について抜粋となる。\n詳しくはリポジトリを参照。\nGitHub - wasuken/dotfiles at dev\ncompletion.el ;;; completion.el --- Completion framework ;; Vertico - 縦型補完UI (use-package vertico :config (setq vertico-cycle t) (vertico-mode +1)) ;; Corfu - インライン補完 (use-package corfu :demand t :config (setq corfu-cycle t corfu-auto t corfu-auto-prefix 1) (global-corfu-mode +1)) ;; Orderless - 柔軟な検索 (use-package orderless :config (setq completion-styles \u0026#39;(orderless basic))) (provide \u0026#39;completion) git.el ;;; git.el --- Git integration (use-package magit :bind (\u0026#34;C-x g\u0026#34; . magit)) (use-package diff-hl :hook ((magit-pre-refresh . diff-hl-magit-pre-refresh) (magit-post-refresh . diff-hl-magit-post-refresh)) :config (global-diff-hl-mode +1)) (provide \u0026#39;git) セットアップ手順 # シンボリックリンク作成 ln -sf ~/dotfiles/emacs/init.el ~/.emacs.d/init.el ln -sf ~/dotfiles/emacs/early-init.el ~/.emacs.d/early-init.el # 機密情報ファイル作成 touch ~/.emacs.d/config.el # (API key等を記述) # Emacs起動 emacs トラブルシューティング キーバインドが効かない 原因: パッケージが後から上書き\n解決: keymap.elをinit.elの最後でload\nパッケージが見つからない 原因: requireのキャッシュ\n解決: loadを使用するか、完全再起動\npkill emacs emacs 環境変数が未定義 原因: config.elの読み込み順序\n解決: config.elをパッケージ読み込み前にload\n成果 ✅ 見通し向上: 機能ごとにファイル分割 ✅ 保守性向上: 変更箇所が明確 ✅ セキュリティ: 機密情報を分離 ✅ 動作安定: loadによる確実な読み込み まとめ Emacsの設定ファイルをモジュール化する際は、requireとloadの違いを理解することが重要。 特にキーバインドや即座に実行したい設定はloadを使用し、読み込み順序を制御することで、安定した環境を構築する。\n","permalink":"https://techblog.wasutech.dev/posts/emacs-refactor/","summary":"\u003ch2 id=\"背景\"\u003e背景\u003c/h2\u003e\n\u003cp\u003e約900行に肥大化した\u003ccode\u003emypackage.el\u003c/code\u003eを整理し、機能ごとにファイル分割してメンテナンス性を向上させるリファクタリングを実施した。\u003c/p\u003e\n\u003ch2 id=\"課題\"\u003e課題\u003c/h2\u003e\n\u003cul\u003e\n\u003cli\u003e\u003cstrong\u003e単一ファイルの肥大化\u003c/strong\u003e: \u003ccode\u003emypackage.el\u003c/code\u003eが900行超えで見通しが悪い\u003c/li\u003e\n\u003cli\u003e\u003cstrong\u003e機密情報の混在\u003c/strong\u003e: API keyがコード内に散在\u003c/li\u003e\n\u003cli\u003e\u003cstrong\u003e使っていない設定\u003c/strong\u003e: コメントアウトされた設定が残存\u003c/li\u003e\n\u003cli\u003e\u003cstrong\u003eパッケージの把握困難\u003c/strong\u003e: 何を使っているか不明瞭\u003c/li\u003e\n\u003c/ul\u003e\n\u003ch2 id=\"新しいディレクトリ構成\"\u003e新しいディレクトリ構成\u003c/h2\u003e\n\u003cpre tabindex=\"0\"\u003e\u003ccode\u003edotfiles/emacs/\n├── init.el                    # エントリーポイント\n├── early-init.el              # 起動高速化\n├── core/\n│   ├── env.el                 # 環境変数・基本設定\n│   ├── custom.el              # UI基本設定\n│   ├── keymap.el              # キーバインド\n│   └── util.el                # ユーティリティ関数\n├── packages/\n│   ├── manager.el             # straight.el設定\n│   ├── core.el                # 基盤パッケージ\n│   ├── completion.el          # 補完系 (Vertico, Corfu)\n│   ├── search.el              # 検索系 (Consult, Embark)\n│   ├── git.el                 # Magit等\n│   ├── lsp.el                 # Eglot等\n│   ├── languages.el           # 言語別設定\n│   ├── ai.el                  # GPTel, Ollama\n│   ├── writing.el             # Denote, Org, Markdown\n│   ├── ui.el                  # テーマ、アイコン\n│   └── optional.el            # たまに使うもの\n├── templates/                 # Tempelテンプレート\n└── docs/\n    └── README.md\n\u003c/code\u003e\u003c/pre\u003e\u003ch2 id=\"重要な学び-require-vs-load\"\u003e重要な学び: \u003ccode\u003erequire\u003c/code\u003e vs \u003ccode\u003eload\u003c/code\u003e\u003c/h2\u003e\n\u003ch3 id=\"問題-requireでパッケージが読み込まれない\"\u003e問題: \u003ccode\u003erequire\u003c/code\u003eでパッケージが読み込まれない\u003c/h3\u003e\n\u003cp\u003e当初、\u003ccode\u003einit.el\u003c/code\u003eで\u003ccode\u003e(require 'completion)\u003c/code\u003eのように読み込んでいたが、以下の問題が発生:\u003c/p\u003e","title":"Emacsのdotfilesをモジュール化してメンテナンス性を向上させた話"},{"content":"1. 概要 1.1 ゲームコンセプト 「三竦（さんすくみ）」は、犬・猿・雉の三すくみ関係を利用した追跡型対戦ゲーム。プレイヤーは召喚獣を配置して相手を攻撃しつつ、敵の召喚獣から逃げ切る戦略性とアクション性を兼ね備えたリアルタイムバトルゲーム。\n1.2 コアメカニクス 三すくみ関係: 犬 → 猿 → 雉 → 犬 追跡システム: 召喚獣は相手プレイヤーを自動追跡 相性バトル: 有利な召喚獣は相手を一方的に倒す 戦略的配置: 召喚位置とタイミングが勝敗を分ける 1.3 開発目標 シンプル: ルールが3分で理解できる 完成優先: 1ヶ月以内にプレイアブル版完成 Android専用: Expo使用、まずCPU対戦のみ 2. ゲーム仕様 2.1 基本ルール 勝利条件 HP制: 各プレイヤーHP 3 制限時間: 3分 勝敗判定: 相手のHPを0にした方が勝ち 3分経過時、HP多い方が勝ち 同点の場合は引き分け ゲームフロー graph TD A[ゲーム開始] --\u0026gt; B[3分タイマー開始] B --\u0026gt; C{ゲーム中} C --\u0026gt; D[プレイヤー移動] C --\u0026gt; E[召喚獣配置] C --\u0026gt; F[召喚獣追跡] F --\u0026gt; G{当たり判定} G --\u0026gt;|当たった| H[HP-1] G --\u0026gt;|外れた| C H --\u0026gt; I{HP=0?} I --\u0026gt;|Yes| J[ゲーム終了] I --\u0026gt;|No| C C --\u0026gt; K{3分経過?} K --\u0026gt;|Yes| J K --\u0026gt;|No| C J --\u0026gt; L[リザルト表示] 2.2 召喚獣仕様 三すくみ関係 graph LR A[犬] --\u0026gt;|勝つ| B[猿] B --\u0026gt;|勝つ| C[雉] C --\u0026gt;|勝つ| A パラメータ表 召喚獣 速度 寿命 クールダウン 特性 犬 🐕 速い 10秒 10秒 素早く追跡、短命 猿 🐒 中速 10秒 10秒 バランス型 雉 🐦 遅い 10秒 10秒 遅いが長持ち 共通ルール:\n同時召喚数: 合計3体まで 召喚位置: プレイヤー周囲に自動配置 追跡対象: 相手プレイヤーのみ 相性判定: 有利な相手を一方的に倒す 相性判定ロジック IF 召喚獣A.type が 召喚獣B.type に有利: 召喚獣Bを即座に消滅 召喚獣Aは継続 ELSE IF 召喚獣A.type が 召喚獣B.type に不利: 召喚獣Aを即座に消滅 召喚獣Bは継続 ELSE: 両方継続（同種同士は干渉しない） 2.3 マップ仕様 マップサイズ 画面固定: スクロールなし スマホサイズ対応: 縦長レイアウト プレイエリア: 画面上部〜中部（UI除く） 障害物 配置: ランダム生成 密度: 中程度（プレイエリアの20-30%） 役割: 逃げ道として活用 召喚獣の動きを阻害 戦略的な待ち伏せポイント graph TD A[マップ生成] --\u0026gt; B[障害物ランダム配置] B --\u0026gt; C{プレイヤー開始位置} C --\u0026gt; D[対角線上に配置] D --\u0026gt; E{経路チェック} E --\u0026gt;|到達可能| F[マップ確定] E --\u0026gt;|到達不可| B 3. 操作仕様 3.1 プレイヤー操作 移動 方式: バーチャルスティック（左下） 速度: 召喚獣と同速度 ダッシュ: なし 召喚 方式: ボタンタップのみ 配置: 選択後、プレイヤー周囲に自動配置 UI: クールダウン残り時間表示 召喚可能/不可の視覚的表示 3.2 操作フロー sequenceDiagram participant P as プレイヤー participant S as スティック participant B as 召喚ボタン participant G as ゲーム P-\u0026gt;\u0026gt;S: スティック操作 S-\u0026gt;\u0026gt;G: 移動ベクトル送信 G-\u0026gt;\u0026gt;G: プレイヤー位置更新 P-\u0026gt;\u0026gt;B: 召喚ボタンタップ B-\u0026gt;\u0026gt;G: 召喚リクエスト G-\u0026gt;\u0026gt;G: クールダウンチェック alt 召喚可能 G-\u0026gt;\u0026gt;G: 周囲に召喚獣配置 G-\u0026gt;\u0026gt;G: クールダウン開始 else クールダウン中 G-\u0026gt;\u0026gt;P: エラーフィードバック end 4. 画面仕様 4.1 画面遷移図 graph LR A[タイトル] --\u0026gt; B[メインメニュー] B --\u0026gt; C[チュートリアル] C --\u0026gt; D[ゲーム画面] B --\u0026gt; D D --\u0026gt; E[リザルト] E --\u0026gt; B B --\u0026gt; F[設定] F --\u0026gt; B 4.2 ゲーム画面レイアウト ┌─────────────────────────┐ │ HP表示（上部） │ │ プレイヤー: ❤❤❤ │ │ CPU: ❤❤❤ │ ├─────────────────────────┤ │ │ │ │ │ ゲームフィールド │ │ （メインエリア） │ │ │ │ │ │ │ ├─────────────────────────┤ │ [🐕 10s] [🐒 ✓] [🐦 5s] │ ← 召喚ボタン ├─────────────────────────┤ │ 🕹️ [⏸] │ ← スティック \u0026amp; ポーズ └─────────────────────────┘ 4.3 UI要素詳細 HP表示 位置: 画面最上部 形式: ハートアイコン × 残りHP数 色: プレイヤー（青）/ CPU（赤） 召喚ボタン 配置: 画面下部、3つ横並び 状態表示: Ready (✓): 召喚可能（明るく表示） Cooldown (Ns): 残り秒数表示（暗く表示） Max召喚数: 3体出している場合はグレーアウト タイマー 位置: 画面上部中央 形式: \u0026ldquo;2:45\u0026rdquo; のようなカウントダウン 警告: 残り30秒で赤色点滅 5. ゲームフロー 5.1 メインループ graph TD A[フレーム開始] --\u0026gt; B[入力処理] B --\u0026gt; C[プレイヤー移動] C --\u0026gt; D[召喚獣移動] D --\u0026gt; E[当たり判定] E --\u0026gt; F{衝突あり?} F --\u0026gt;|召喚獣 vs プレイヤー| G[HP減少] F --\u0026gt;|召喚獣 vs 召喚獣| H[相性判定] F --\u0026gt;|なし| I[状態更新] G --\u0026gt; I H --\u0026gt; I I --\u0026gt; J[クールダウン更新] J --\u0026gt; K[寿命チェック] K --\u0026gt; L[画面描画] L --\u0026gt; M{ゲーム終了?} M --\u0026gt;|No| A M --\u0026gt;|Yes| N[リザルト] 5.2 当たり判定フロー graph TD A[当たり判定開始] --\u0026gt; B{召喚獣 vs プレイヤー} B --\u0026gt;|衝突| C[プレイヤーHP-1] C --\u0026gt; D[召喚獣消滅] D --\u0026gt; E[無敵時間付与] A --\u0026gt; F{召喚獣 vs 召喚獣} F --\u0026gt;|衝突| G{相性チェック} G --\u0026gt;|有利| H[相手を消滅] G --\u0026gt;|不利| I[自分を消滅] G --\u0026gt;|同種| J[何もしない] A --\u0026gt; K{プレイヤー vs 障害物} K --\u0026gt;|衝突| L[移動をブロック] A --\u0026gt; M{召喚獣 vs 障害物} M --\u0026gt;|衝突| N[経路再計算] 5.3 CPU AI思考 graph TD A[AI思考開始] --\u0026gt; B{召喚可能?} B --\u0026gt;|No| C[移動のみ] B --\u0026gt;|Yes| D{プレイヤー距離} D --\u0026gt;|近い| E[カウンター召喚] D --\u0026gt;|遠い| F[ランダム召喚] E --\u0026gt; G{プレイヤーの召喚獣} G --\u0026gt;|犬| H[猿を召喚] G --\u0026gt;|猿| I[雉を召喚] G --\u0026gt;|雉| J[犬を召喚] G --\u0026gt;|なし| K[ランダム] C --\u0026gt; L[プレイヤーから逃げる] F --\u0026gt; M[攻撃的配置] H --\u0026gt; M I --\u0026gt; M J --\u0026gt; M K --\u0026gt; M 6. データ仕様 6.1 召喚獣データ構造 // 疑似コード（実装言語は別で相談） SummonData { id: string, // ユニークID type: \u0026#39;dog\u0026#39; | \u0026#39;monkey\u0026#39; | \u0026#39;pheasant\u0026#39;, x: number, // 座標 y: number, speed: number, // 移動速度 lifetime: number, // 残り寿命（秒） target: Player, // 追跡対象 owner: Player // 召喚主 } 6.2 プレイヤーデータ構造 PlayerData { x: number, y: number, hp: number, // 現在HP（最大3） velocity: {x, y}, // 移動ベクトル cooldowns: { dog: number, // 残りクールダウン（秒） monkey: number, pheasant: number }, activeSummons: number // 現在の召喚数 } 6.3 定数定義 CONSTANTS { GAME_DURATION: 180, // 3分 MAX_HP: 3, MAX_SUMMONS: 3, COOLDOWN_TIME: 10, SUMMON_LIFETIME: 10, PLAYER_SPEED: 100, SPEEDS: { dog: 120, // 速い monkey: 100, // 中速 pheasant: 80 // 遅い }, INVINCIBLE_TIME: 1 // 被弾後の無敵時間（秒） } 7. 実装優先度 7.1 MVP（最小限の実装） 目標: 1週間\nプレイヤー移動（スティック操作） 召喚獣1種類の追跡動作 基本的な当たり判定 HP管理 タイマー機能 7.2 Phase 2（コア機能） 目標: 2週間\n3種類の召喚獣実装 三すくみ判定 クールダウンシステム 障害物生成 CPU AI（基本） 7.3 Phase 3（仕上げ） 目標: 1週間\nUI/UX改善 チュートリアル（1-3画面） リザルト画面 設定画面 BGM/SE実装 グラフィック調整 7.4 スコープ外（将来対応） オンライン対戦 追加召喚獣 ランクマッチ リプレイ機能 マップエディタ 8. 技術スタック 8.1 開発環境 フレームワーク: Expo 言語: TypeScript / JavaScript プラットフォーム: Android専用 8.2 主要ライブラリ（想定） react-native-game-engine: ゲームループ react-native-joystick: バーチャルスティック matter-js or 自前実装: 当たり判定 8.3 アセット グラフィック: シンプルな図形（◯、△、□） BGM: フリー素材 SE: フリー素材 or 自作 8.5 アーキテクチャ設計指針 8.5.1 テスタブルな設計原則 目的: ゲームロジックとUIを分離し、Jestで確実にテストできる設計にする\n責務分離 以下のように責務を分けてください：\nModel層: ゲーム状態とロジック（クラスで実装、Reactに依存しない） View層: UIの描画のみ（Reactコンポーネント） Controller/Hook層: ModelとViewの橋渡し（カスタムフック） Modelは完全に独立してテスト可能にする。\n実装手順（TDD） まずゲームロジックのインターフェース（型定義）を設計 そのロジックのテストケースを先に書く ロジックを実装（テストがパスするまで） 最後にReactコンポーネントでUIを作成 Model層の制約 ロジッククラスは以下を満たす：\n外部依存なし（乱数、時刻、ストレージ等は引数で受け取る） 副作用は明示的なメソッド呼び出しのみ 状態変更は戻り値かイベントで通知（直接DOMやReact stateを触らない） import Reactやimport { View } from 'react-native'を含まない ファイル構成 app/ ├── models/ # ビジネスロジック（純粋TypeScript） │ ├── GameEngine.ts │ ├── Player.ts │ ├── Summon.ts │ ├── CollisionDetector.ts │ └── __tests__/ # Jestユニットテスト │ ├── GameEngine.test.ts │ ├── Player.test.ts │ └── Summon.test.ts ├── hooks/ # React統合層 │ ├── useGameEngine.ts │ └── useGameLoop.ts └── screens/ # UIコンポーネント ├── GameScreen.tsx ├── TutorialScreen.tsx └── ResultScreen.tsx 実装例 ❌ 悪い例（ロジックとUIが密結合）:\nexport default function GameScreen() { const [playerHP, setPlayerHP] = useState(3); const [summons, setSummons] = useState([]); const spawnSummon = (type) =\u0026gt; { if (summons.length \u0026gt;= 3) return; setSummons([...summons, { type, x: playerX, y: playerY }]); }; // ↑ テスト不可能 } ✅ 良い例（ロジック分離）:\n// models/GameEngine.ts (テスト可能) export class GameEngine { constructor( private state: GameState, private randomGen: () =\u0026gt; number = Math.random ) {} spawnSummon(type: SummonType, x: number, y: number): void { if (this.state.summons.length \u0026gt;= MAX_SUMMONS) { throw new Error(\u0026#39;Max summons reached\u0026#39;); } this.state.summons.push(new Summon(type, x, y)); } getState(): Readonly\u0026lt;GameState\u0026gt; { return { ...this.state }; } } // screens/GameScreen.tsx (UIのみ) export default function GameScreen() { const { engine, state } = useGameEngine(); return ( \u0026lt;View\u0026gt; \u0026lt;Button onPress={() =\u0026gt; engine.spawnSummon(\u0026#39;dog\u0026#39;, x, y)} /\u0026gt; \u0026lt;/View\u0026gt; ); } ゲームループの扱い ゲームループ（requestAnimationFrame等）はView層で管理 Model層はupdate(deltaTime: number)のような純粋関数で状態更新 タイマーやアニメーションフレームはテスト時にモック可能にする // hooks/useGameLoop.ts export function useGameLoop(engine: GameEngine) { useEffect(() =\u0026gt; { let lastTime = Date.now(); const loop = () =\u0026gt; { const now = Date.now(); const deltaTime = (now - lastTime) / 1000; engine.update(deltaTime); lastTime = now; requestAnimationFrame(loop); }; const id = requestAnimationFrame(loop); return () =\u0026gt; cancelAnimationFrame(id); }, [engine]); } 乱数・副作用の扱い Math.random()は直接使わず、コンストラクタで乱数生成器を注入 テスト時はシード固定の乱数生成器を使用 例: constructor(private rng: () =\u0026gt; number = Math.random) // テストコード例 describe(\u0026#39;GameEngine\u0026#39;, () =\u0026gt; { test(\u0026#39;障害物がランダムに生成される\u0026#39;, () =\u0026gt; { let seed = 0; const mockRandom = () =\u0026gt; { seed = (seed + 1) % 10; return seed / 10; }; const engine = new GameEngine({ ... }, mockRandom); engine.generateObstacles(); // 決定論的にテスト可能 expect(engine.getState().obstacles.length).toBe(5); }); }); 9. チュートリアル仕様 9.1 画面構成 graph LR A[画面1: 基本操作] --\u0026gt; B[画面2: 召喚獣] B --\u0026gt; C[画面3: 三すくみ] C --\u0026gt; D[スキップ可能] A --\u0026gt; D B --\u0026gt; D 9.2 内容 画面1: 基本操作\nスティックで移動 ボタンで召喚 目標: 相手のHPを0にする 画面2: 召喚獣\n3種類の召喚獣 クールダウン10秒 最大3体まで 画面3: 三すくみ\n犬 → 猿 → 雉 → 犬 有利な相手を倒せる 戦略的に使い分けよう 10. リザルト画面 10.1 表示内容 勝敗: WIN / LOSE / DRAW 最終HP: プレイヤー vs CPU プレイ時間: 実際の経過時間 召喚回数: 各召喚獣の使用回数 10.2 ボタン もう一度: ゲーム画面に戻る メニュー: タイトルに戻る 11. 設定画面 11.1 設定項目 BGM音量: スライダー（0-100%） SE音量: スライダー（0-100%） 難易度: 固定（将来拡張用） 12. 開発スケジュール gantt title 三竦 開発スケジュール dateFormat YYYY-MM-DD section Phase 1 プレイヤー移動 :2026-02-07, 2d 召喚獣追跡 :2026-02-09, 2d 当たり判定 :2026-02-11, 1d HP/タイマー :2026-02-12, 1d section Phase 2 3種召喚獣 :2026-02-13, 3d 三すくみ判定 :2026-02-16, 2d 障害物生成 :2026-02-18, 2d CPU AI :2026-02-20, 2d section Phase 3 UI改善 :2026-02-22, 2d チュートリアル :2026-02-24, 1d BGM/SE :2026-02-25, 1d 最終調整 :2026-02-26, 2d 13. 補足事項 13.1 デザイン方針 シンプル第一: 複雑な機能は後回し 完成優先: 拡張性より動作保証 自分が楽しい: 他人の評価は二の次 13.2 今後の拡張案（メモ） ターン制モード（別ゲームとして） 召喚獣の特殊能力 マルチプレイヤー ランキング機能 13.3 既知の課題 障害物の最適配置アルゴリズム CPU AIの賢さ調整 画面サイズ対応（機種依存） 13.4 開発時の注意事項 コード生成AIを使用する場合 このプロジェクトではGemini等のAIコード生成を活用する際、必ずセクション8.5「アーキテクチャ設計指針」に従うこと。\n特に以下を厳守：\nModel層にReactの依存を含めない テストファーストで実装する 外部依存（乱数、時刻）は注入可能にする レビュープロセス:\nAIでコード生成 Claudeで設計指針に照らしてレビュー 問題があれば修正指示 テスト通過を確認してマージ まとめ この要件定義書に基づき、シンプルで完成度の高い「三竦（さんすくみ）」を1ヶ月以内に完成させることを目標とする。\n次のアクション:\n技術スタックの詳細検討（別途相談） プロトタイプ作成開始 週次で進捗をブログ記事化 ","permalink":"https://techblog.wasutech.dev/posts/sansuku/","summary":"\u003ch2 id=\"1-概要\"\u003e1. 概要\u003c/h2\u003e\n\u003ch3 id=\"11-ゲームコンセプト\"\u003e1.1 ゲームコンセプト\u003c/h3\u003e\n\u003cp\u003e「三竦（さんすくみ）」は、犬・猿・雉の三すくみ関係を利用した追跡型対戦ゲーム。プレイヤーは召喚獣を配置して相手を攻撃しつつ、敵の召喚獣から逃げ切る戦略性とアクション性を兼ね備えたリアルタイムバトルゲーム。\u003c/p\u003e\n\u003ch3 id=\"12-コアメカニクス\"\u003e1.2 コアメカニクス\u003c/h3\u003e\n\u003cul\u003e\n\u003cli\u003e\u003cstrong\u003e三すくみ関係\u003c/strong\u003e: 犬 → 猿 → 雉 → 犬\u003c/li\u003e\n\u003cli\u003e\u003cstrong\u003e追跡システム\u003c/strong\u003e: 召喚獣は相手プレイヤーを自動追跡\u003c/li\u003e\n\u003cli\u003e\u003cstrong\u003e相性バトル\u003c/strong\u003e: 有利な召喚獣は相手を一方的に倒す\u003c/li\u003e\n\u003cli\u003e\u003cstrong\u003e戦略的配置\u003c/strong\u003e: 召喚位置とタイミングが勝敗を分ける\u003c/li\u003e\n\u003c/ul\u003e\n\u003ch3 id=\"13-開発目標\"\u003e1.3 開発目標\u003c/h3\u003e\n\u003cul\u003e\n\u003cli\u003e\u003cstrong\u003eシンプル\u003c/strong\u003e: ルールが3分で理解できる\u003c/li\u003e\n\u003cli\u003e\u003cstrong\u003e完成優先\u003c/strong\u003e: 1ヶ月以内にプレイアブル版完成\u003c/li\u003e\n\u003cli\u003e\u003cstrong\u003eAndroid専用\u003c/strong\u003e: Expo使用、まずCPU対戦のみ\u003c/li\u003e\n\u003c/ul\u003e\n\u003chr\u003e\n\u003ch2 id=\"2-ゲーム仕様\"\u003e2. ゲーム仕様\u003c/h2\u003e\n\u003ch3 id=\"21-基本ルール\"\u003e2.1 基本ルール\u003c/h3\u003e\n\u003ch4 id=\"勝利条件\"\u003e勝利条件\u003c/h4\u003e\n\u003cul\u003e\n\u003cli\u003e\u003cstrong\u003eHP制\u003c/strong\u003e: 各プレイヤーHP 3\u003c/li\u003e\n\u003cli\u003e\u003cstrong\u003e制限時間\u003c/strong\u003e: 3分\u003c/li\u003e\n\u003cli\u003e\u003cstrong\u003e勝敗判定\u003c/strong\u003e:\n\u003cul\u003e\n\u003cli\u003e相手のHPを0にした方が勝ち\u003c/li\u003e\n\u003cli\u003e3分経過時、HP多い方が勝ち\u003c/li\u003e\n\u003cli\u003e同点の場合は引き分け\u003c/li\u003e\n\u003c/ul\u003e\n\u003c/li\u003e\n\u003c/ul\u003e\n\u003ch4 id=\"ゲームフロー\"\u003eゲームフロー\u003c/h4\u003e\n\u003cpre tabindex=\"0\"\u003e\u003ccode class=\"language-mermaid\" data-lang=\"mermaid\"\u003egraph TD\n    A[ゲーム開始] --\u0026gt; B[3分タイマー開始]\n    B --\u0026gt; C{ゲーム中}\n    C --\u0026gt; D[プレイヤー移動]\n    C --\u0026gt; E[召喚獣配置]\n    C --\u0026gt; F[召喚獣追跡]\n    F --\u0026gt; G{当たり判定}\n    G --\u0026gt;|当たった| H[HP-1]\n    G --\u0026gt;|外れた| C\n    H --\u0026gt; I{HP=0?}\n    I --\u0026gt;|Yes| J[ゲーム終了]\n    I --\u0026gt;|No| C\n    C --\u0026gt; K{3分経過?}\n    K --\u0026gt;|Yes| J\n    K --\u0026gt;|No| C\n    J --\u0026gt; L[リザルト表示]\n\u003c/code\u003e\u003c/pre\u003e\u003ch3 id=\"22-召喚獣仕様\"\u003e2.2 召喚獣仕様\u003c/h3\u003e\n\u003ch4 id=\"三すくみ関係\"\u003e三すくみ関係\u003c/h4\u003e\n\u003cpre tabindex=\"0\"\u003e\u003ccode class=\"language-mermaid\" data-lang=\"mermaid\"\u003egraph LR\n    A[犬] --\u0026gt;|勝つ| B[猿]\n    B --\u0026gt;|勝つ| C[雉]\n    C --\u0026gt;|勝つ| A\n\u003c/code\u003e\u003c/pre\u003e\u003ch4 id=\"パラメータ表\"\u003eパラメータ表\u003c/h4\u003e\n\u003ctable\u003e\n  \u003cthead\u003e\n      \u003ctr\u003e\n          \u003cth\u003e召喚獣\u003c/th\u003e\n          \u003cth\u003e速度\u003c/th\u003e\n          \u003cth\u003e寿命\u003c/th\u003e\n          \u003cth\u003eクールダウン\u003c/th\u003e\n          \u003cth\u003e特性\u003c/th\u003e\n      \u003c/tr\u003e\n  \u003c/thead\u003e\n  \u003ctbody\u003e\n      \u003ctr\u003e\n          \u003ctd\u003e犬 🐕\u003c/td\u003e\n          \u003ctd\u003e速い\u003c/td\u003e\n          \u003ctd\u003e10秒\u003c/td\u003e\n          \u003ctd\u003e10秒\u003c/td\u003e\n          \u003ctd\u003e素早く追跡、短命\u003c/td\u003e\n      \u003c/tr\u003e\n      \u003ctr\u003e\n          \u003ctd\u003e猿 🐒\u003c/td\u003e\n          \u003ctd\u003e中速\u003c/td\u003e\n          \u003ctd\u003e10秒\u003c/td\u003e\n          \u003ctd\u003e10秒\u003c/td\u003e\n          \u003ctd\u003eバランス型\u003c/td\u003e\n      \u003c/tr\u003e\n      \u003ctr\u003e\n          \u003ctd\u003e雉 🐦\u003c/td\u003e\n          \u003ctd\u003e遅い\u003c/td\u003e\n          \u003ctd\u003e10秒\u003c/td\u003e\n          \u003ctd\u003e10秒\u003c/td\u003e\n          \u003ctd\u003e遅いが長持ち\u003c/td\u003e\n      \u003c/tr\u003e\n  \u003c/tbody\u003e\n\u003c/table\u003e\n\u003cp\u003e\u003cstrong\u003e共通ルール\u003c/strong\u003e:\u003c/p\u003e","title":"三竦（さんすくみ）要件定義書"},{"content":"やりたかったこと WSL2 環境で Expo を使った Todo アプリを作りたい。ただし、UI ライブラリの選定やナビゲーション設定など、細かい作業は Gemini に任せて効率化したい。\nGEMINI.md でルール管理 プロジェクトルートに GEMINI.md を作成し、Gemini に従ってほしいルールを記載しました。\n# Gemini AI Coding Rules ## Expo/React Native Specific - Use Expo SDK compatible packages only - Prefer `npx expo install` over `npm install` - Use functional components with hooks ## Expo Specific Rules - Use `@expo/vector-icons` instead of `react-native-vector-icons` - Never use packages that require native linking ## Tech Stack (Fixed) - Expo with TypeScript - React Native Paper for UI - AsyncStorage for persistence このファイルを事前に作っておくことで、Gemini が一貫した品質のコードを生成してくれます。\n段階的な開発 Phase 1: 画面分割とナビゲーション gemini \u0026#34;Update the todo app to add bottom tab navigation: ## Requirements - Create 3 screens: Tasks, Presets, History - Use React Navigation Bottom Tabs - Make checkbox smaller and closer to text Provide complete code for all files.\u0026#34; ハマったポイント:\nreact-native-vector-icons ではなく @expo/vector-icons を使う必要があった タブアイコンの route.name が日本語タブ名と一致していなかった 解決策を GEMINI.md に追記：\n### Implemented Feature: Tab Bar Icons for Navigation ハマったポイント: - `route.name` の比較文字列を日本語タブ名に修正 - `@expo/vector-icons` の import 方法を明記 Gemini 自身に実装記録を追記させることで、次回以降同じミスを防げます。\nPhase 2: プリセット機能 gemini \u0026#34;Implement preset management feature in PresetsScreen: ## Requirements - CRUD operations for presets - Store in AsyncStorage key \u0026#39;presets\u0026#39; - Dialog for creating/editing preset - Load button to add preset tasks to main list\u0026#34; ハマったポイント:\nTextInput の日本語入力で変換候補が消える autoComplete=\u0026quot;off\u0026quot; と autoCorrect={false} で解決 Phase 3: カレンダーと履歴 gemini \u0026#34;Implement history and calendar view: ## UI Layout (Top-Bottom Split) - Top 40%: Calendar with completion markers - Bottom 60%: Completed task list for selected date ## Requirements - Use react-native-calendars - Save completedAt timestamp - Mark dates with completed tasks\u0026#34; ハマったポイント:\nMarkedDates の型定義が必要 List.Subheader の配置位置（リストの最初に置く必要がある） GEMINI.md の自動更新 各フェーズ完了後、Gemini に実装記録を追記させます：\ngemini \u0026#34;GEMINI.md の Phase 3 に実装記録を追記してください： 実装内容: - react-native-calendars 使用 - completedAt フィールド追加 ハマったポイント: - MarkedDates の型定義が必要 - List.Subheader の配置位置 Phase 1 と同じフォーマットで追記してください。\u0026#34; これにより、GEMINI.md が開発ドキュメントとして自動的に成長していきます。\nトラブルシューティング：Jest 地獄 途中で Jest のテストを書こうとしましたが、transformIgnorePatterns の設定地獄にハマりました。\nCannot find module \u0026#39;@expo/vector-icons\u0026#39; Cannot find module \u0026#39;expo-asset\u0026#39; Cannot find module \u0026#39;expo-status-bar\u0026#39; ... 結論: 個人開発なら Jest は不要。E2E テスト（Maestro など）の方が実用的。\nJest を削除してスッキリしました。\n成果物 約半日で以下の機能を実装：\n✅ タスク追加・編集・削除・完了 ✅ プリセット管理（定型タスクの一括登録） ✅ カレンダービューで完了履歴を確認 ✅ React Native Paper による Material Design UI ✅ AsyncStorage によるデータ永続化 すべて Gemini CLI 経由で生成したコードです。\nGemini CLI 開発のコツ 1. GEMINI.md でルールを事前定義 技術スタック、コーディング規約、出力フォーマットを明記しておく。\n2. 段階的に機能追加 一度に全機能を依頼せず、Phase ごとに分割して実装。\n3. ハマったポイントを記録させる Gemini 自身に GEMINI.md へ追記させることで、同じミスを繰り返さない。\n4. 具体的な指示を出す 曖昧な指示ではなく、データ構造、UI レイアウト、ファイル構成まで明示する。\nまとめ Gemini CLI と GEMINI.md を使うことで、効率的に Expo アプリを開発できました。特に：\nUI ライブラリの選定や設定を任せられる ハマったポイントを記録して次に活かせる コード生成だけでなく、ドキュメント管理も自動化できる 次は Maestro で E2E テストを試してみる予定です。\n参考リンク Expo 公式ドキュメント React Native Paper React Navigation react-native-calendars ","permalink":"https://techblog.wasutech.dev/posts/gemini-cli-expo/","summary":"\u003ch2 id=\"やりたかったこと\"\u003eやりたかったこと\u003c/h2\u003e\n\u003cp\u003eWSL2 環境で Expo を使った Todo アプリを作りたい。ただし、UI ライブラリの選定やナビゲーション設定など、細かい作業は Gemini に任せて効率化したい。\u003c/p\u003e\n\u003ch2 id=\"geminimd-でルール管理\"\u003eGEMINI.md でルール管理\u003c/h2\u003e\n\u003cp\u003eプロジェクトルートに \u003ccode\u003eGEMINI.md\u003c/code\u003e を作成し、Gemini に従ってほしいルールを記載しました。\u003c/p\u003e\n\u003cdiv class=\"highlight\"\u003e\u003cpre tabindex=\"0\" style=\"color:#f8f8f2;background-color:#272822;-moz-tab-size:4;-o-tab-size:4;tab-size:4;\"\u003e\u003ccode class=\"language-markdown\" data-lang=\"markdown\"\u003e\u003cspan style=\"display:flex;\"\u003e\u003cspan\u003e# Gemini AI Coding Rules\n\u003c/span\u003e\u003c/span\u003e\u003cspan style=\"display:flex;\"\u003e\u003cspan\u003e\n\u003c/span\u003e\u003c/span\u003e\u003cspan style=\"display:flex;\"\u003e\u003cspan\u003e\u003cspan style=\"color:#75715e\"\u003e## Expo/React Native Specific\n\u003c/span\u003e\u003c/span\u003e\u003c/span\u003e\u003cspan style=\"display:flex;\"\u003e\u003cspan\u003e\u003cspan style=\"color:#75715e\"\u003e\u003c/span\u003e\u003cspan style=\"color:#66d9ef\"\u003e-\u003c/span\u003e Use Expo SDK compatible packages only\n\u003c/span\u003e\u003c/span\u003e\u003cspan style=\"display:flex;\"\u003e\u003cspan\u003e\u003cspan style=\"color:#66d9ef\"\u003e-\u003c/span\u003e Prefer \u003cspan style=\"color:#e6db74\"\u003e`npx expo install`\u003c/span\u003e over \u003cspan style=\"color:#e6db74\"\u003e`npm install`\u003c/span\u003e\n\u003c/span\u003e\u003c/span\u003e\u003cspan style=\"display:flex;\"\u003e\u003cspan\u003e\u003cspan style=\"color:#66d9ef\"\u003e-\u003c/span\u003e Use functional components with hooks\n\u003c/span\u003e\u003c/span\u003e\u003cspan style=\"display:flex;\"\u003e\u003cspan\u003e\n\u003c/span\u003e\u003c/span\u003e\u003cspan style=\"display:flex;\"\u003e\u003cspan\u003e\u003cspan style=\"color:#75715e\"\u003e## Expo Specific Rules\n\u003c/span\u003e\u003c/span\u003e\u003c/span\u003e\u003cspan style=\"display:flex;\"\u003e\u003cspan\u003e\u003cspan style=\"color:#75715e\"\u003e\u003c/span\u003e\u003cspan style=\"color:#66d9ef\"\u003e-\u003c/span\u003e Use \u003cspan style=\"color:#e6db74\"\u003e`@expo/vector-icons`\u003c/span\u003e instead of \u003cspan style=\"color:#e6db74\"\u003e`react-native-vector-icons`\u003c/span\u003e\n\u003c/span\u003e\u003c/span\u003e\u003cspan style=\"display:flex;\"\u003e\u003cspan\u003e\u003cspan style=\"color:#66d9ef\"\u003e-\u003c/span\u003e Never use packages that require native linking\n\u003c/span\u003e\u003c/span\u003e\u003cspan style=\"display:flex;\"\u003e\u003cspan\u003e\n\u003c/span\u003e\u003c/span\u003e\u003cspan style=\"display:flex;\"\u003e\u003cspan\u003e\u003cspan style=\"color:#75715e\"\u003e## Tech Stack (Fixed)\n\u003c/span\u003e\u003c/span\u003e\u003c/span\u003e\u003cspan style=\"display:flex;\"\u003e\u003cspan\u003e\u003cspan style=\"color:#75715e\"\u003e\u003c/span\u003e\u003cspan style=\"color:#66d9ef\"\u003e-\u003c/span\u003e Expo with TypeScript\n\u003c/span\u003e\u003c/span\u003e\u003cspan style=\"display:flex;\"\u003e\u003cspan\u003e\u003cspan style=\"color:#66d9ef\"\u003e-\u003c/span\u003e React Native Paper for UI\n\u003c/span\u003e\u003c/span\u003e\u003cspan style=\"display:flex;\"\u003e\u003cspan\u003e\u003cspan style=\"color:#66d9ef\"\u003e-\u003c/span\u003e AsyncStorage for persistence\n\u003c/span\u003e\u003c/span\u003e\u003c/code\u003e\u003c/pre\u003e\u003c/div\u003e\u003cp\u003eこのファイルを事前に作っておくことで、Gemini が一貫した品質のコードを生成してくれます。\u003c/p\u003e","title":"Gemini CLIでExpo Todoアプリを爆速開発した話"},{"content":"問題：AIは過去の失敗を忘れる Gemini や ChatGPT などの AI にコード生成を依頼するとき、こんな問題がありませんか？\n同じミスを何度も繰り返す 前回指摘したルールを忘れる プロジェクト固有の制約を無視する 毎回「Expo では react-native-vector-icons じゃなくて @expo/vector-icons を使って」と指示するのは面倒です。\n解決策：GEMINI.md でルールを管理 プロジェクトルートに GEMINI.md ファイルを作成し、AI に従ってほしいルールをすべて記載します。\n# Gemini AI Coding Rules ## General Principles - Always provide complete, working code - Include all necessary imports - Add TypeScript types for all functions ## Expo/React Native Specific - Use Expo SDK compatible packages only - Prefer `npx expo install` over `npm install` ## Expo Specific Rules - Use `@expo/vector-icons` instead of `react-native-vector-icons` - Import example: `import { MaterialCommunityIcons } from \u0026#39;@expo/vector-icons\u0026#39;;` AI に指示を出すときは、必ず「GEMINI.md を読んでから実装して」と伝えます。\ngemini \u0026#34;GEMINI.md を読んでから、タブナビゲーションを実装してください\u0026#34; GEMINI.md の構成 1. 基本ルール すべてのプロジェクトで共通のルールを記載。\n## General Principles - Always provide complete, working code - No placeholder code like \u0026#34;// Add your logic here\u0026#34; - Include error handling ## Output Format When generating code, always include: 1. Complete file content (not snippets) 2. Installation commands for dependencies 3. File path/name where code should be placed 2. プロジェクト固有のルール 技術スタックやデータ構造を明記。\n## Tech Stack (Fixed) - Expo with TypeScript - React Native Paper for UI - AsyncStorage for persistence ## Data Model \\`\\`\\`typescript interface Todo { id: string; text: string; completed: boolean; createdAt: Date; } \\`\\`\\` 3. フェーズ管理 段階的な開発計画を記載。\n# Phase 1: Navigation \u0026amp; UI Improvements ## Requirements - Use React Navigation Bottom Tabs - 3 tabs: Tasks, Presets, History - Make checkboxes smaller AI 自身に履歴を追記させる ここが重要なポイントです。実装完了後、AI 自身に GEMINI.md へ記録を追記させます。\ngemini \u0026#34;GEMINI.md の Phase 1 セクションに、今回実装した内容を追記してください： 実装内容: - タブナビゲーション実装 - @expo/vector-icons 使用 ハマったポイント: - route.name が日本語タブ名と一致していなかった - iconName の型を厳密に指定する必要があった Phase 1 の実装記録として追記してください。\u0026#34; すると、AI が以下のような記録を追記してくれます：\n### Implemented Feature: Tab Bar Icons for Navigation - **概要**: タブナビゲーションを実装。日本語タブ名に対応したアイコン表示。 - **使用したライブラリとバージョン**: - `@react-navigation/bottom-tabs`: `^7.10.1` - `@expo/vector-icons`: (Expo SDK に含まれる) - **ハマったポイントと解決策**: - **ハマったポイント**: `route.name` が日本語タブ名と一致していなかった - **解決策**: 比較文字列を日本語に修正し、型を厳密に指定 - **次回への引き継ぎ事項**: - デフォルトアイコンの UI/UX 検討が必要 GEMINI.md のメリット 1. 同じミスを繰り返さない ## Expo Specific Rules - Use `@expo/vector-icons` instead of `react-native-vector-icons` 一度ルールに追加すれば、AI は二度と間違えません。\n2. プロジェクトの歴史が残る # Phase 1: Completed (2026-01-31) # Phase 2: Completed (2026-01-31) # Phase 3: Completed (2026-01-31) いつ何を実装したか、どんな問題があったかが一目瞭然。\n3. 新しい開発者へのオンボーディング GEMINI.md を読めば：\nプロジェクトの技術スタック 過去にハマったポイント 開発の進行状況 がすべてわかります。人間の開発者にとっても有用なドキュメントになります。\n4. AI が自己学習する 実装記録を AI 自身に書かせることで、次回の実装時に「前回はこうハマったから気をつけよう」という判断ができるようになります。\n実践例：3 つのフェーズで開発 Phase 1: ナビゲーション gemini \u0026#34;GEMINI.md を読んで、Phase 1 を実装してください\u0026#34; 実装後：\ngemini \u0026#34;Phase 1 の実装記録を GEMINI.md に追記してください\u0026#34; Phase 2: プリセット機能 Phase 1 の記録があるので、AI は：\n@expo/vector-icons を使う TypeScript の型を厳密に定義する などを自動的に考慮してくれます。\nPhase 3: カレンダー Phase 1, 2 の記録から：\nreact-native-calendars のインストール方法 型定義の必要性 UI コンポーネントの配置 などを学習済み。\nGEMINI.md のテンプレート # Gemini AI Coding Rules ## General Principles - (共通ルール) ## Tech Stack - (使用技術) ## Data Model - (データ構造) --- # Phase 1: (機能名) ## Requirements - (要件) ## Implementation - (実装内容) ### Implemented Feature: (実装した機能) - **概要**: - **使用したライブラリとバージョン**: - **ハマったポイントと解決策**: - **次回への引き継ぎ事項**: --- # Phase 2: (次の機能) ... 注意点 1. AI はファイル編集できないことがある gemini \u0026#34;GEMINI.md に追記してください\u0026#34; と指示しても、出力だけして実際にファイルを編集しない場合があります。\nその場合は：\nAI の出力をコピペして手動で追記 または「完全なファイル内容を出力してください」と指示して置き換え 2. ファイルパスを明示する gemini \u0026#34;./GEMINI.md の Phase 3 に追記してください\u0026#34; 相対パスを明示すると、AI がファイルを特定しやすくなります。\n3. フォーマットを統一する Phase 1 で使ったフォーマットを Phase 2, 3 でも使うよう指示：\ngemini \u0026#34;Phase 1 と同じフォーマットで Phase 3 の記録を追記してください\u0026#34; まとめ GEMINI.md を使うことで：\n✅ AI が同じミスを繰り返さない ✅ プロジェクトの歴史が自動的に記録される ✅ 開発効率が大幅に向上する ✅ ドキュメント管理が自動化される AI を単なるコード生成ツールではなく、学習・改善していくチームメンバーとして扱えるようになります。\n次回のプロジェクトでは、ぜひ GEMINI.md を試してみてください。\n参考 今回の Todo アプリ開発の GEMINI.md は以下のような構成になりました：\nGeneral Principles Expo Specific Rules Phase 1: Navigation (実装記録付き) Phase 2: Preset Management Phase 3: History \u0026amp; Calendar 約 200 行のドキュメントが、AI と協力しながら自動的に育っていきました。\n","permalink":"https://techblog.wasutech.dev/posts/gemini-cli-history/","summary":"\u003ch2 id=\"問題aiは過去の失敗を忘れる\"\u003e問題：AIは過去の失敗を忘れる\u003c/h2\u003e\n\u003cp\u003eGemini や ChatGPT などの AI にコード生成を依頼するとき、こんな問題がありませんか？\u003c/p\u003e\n\u003cul\u003e\n\u003cli\u003e同じミスを何度も繰り返す\u003c/li\u003e\n\u003cli\u003e前回指摘したルールを忘れる\u003c/li\u003e\n\u003cli\u003eプロジェクト固有の制約を無視する\u003c/li\u003e\n\u003c/ul\u003e\n\u003cp\u003e毎回「Expo では react-native-vector-icons じゃなくて @expo/vector-icons を使って」と指示するのは面倒です。\u003c/p\u003e\n\u003ch2 id=\"解決策geminimd-でルールを管理\"\u003e解決策：GEMINI.md でルールを管理\u003c/h2\u003e\n\u003cp\u003eプロジェクトルートに \u003ccode\u003eGEMINI.md\u003c/code\u003e ファイルを作成し、AI に従ってほしいルールをすべて記載します。\u003c/p\u003e\n\u003cdiv class=\"highlight\"\u003e\u003cpre tabindex=\"0\" style=\"color:#f8f8f2;background-color:#272822;-moz-tab-size:4;-o-tab-size:4;tab-size:4;\"\u003e\u003ccode class=\"language-markdown\" data-lang=\"markdown\"\u003e\u003cspan style=\"display:flex;\"\u003e\u003cspan\u003e# Gemini AI Coding Rules\n\u003c/span\u003e\u003c/span\u003e\u003cspan style=\"display:flex;\"\u003e\u003cspan\u003e\n\u003c/span\u003e\u003c/span\u003e\u003cspan style=\"display:flex;\"\u003e\u003cspan\u003e\u003cspan style=\"color:#75715e\"\u003e## General Principles\n\u003c/span\u003e\u003c/span\u003e\u003c/span\u003e\u003cspan style=\"display:flex;\"\u003e\u003cspan\u003e\u003cspan style=\"color:#75715e\"\u003e\u003c/span\u003e\u003cspan style=\"color:#66d9ef\"\u003e-\u003c/span\u003e Always provide complete, working code\n\u003c/span\u003e\u003c/span\u003e\u003cspan style=\"display:flex;\"\u003e\u003cspan\u003e\u003cspan style=\"color:#66d9ef\"\u003e-\u003c/span\u003e Include all necessary imports\n\u003c/span\u003e\u003c/span\u003e\u003cspan style=\"display:flex;\"\u003e\u003cspan\u003e\u003cspan style=\"color:#66d9ef\"\u003e-\u003c/span\u003e Add TypeScript types for all functions\n\u003c/span\u003e\u003c/span\u003e\u003cspan style=\"display:flex;\"\u003e\u003cspan\u003e\n\u003c/span\u003e\u003c/span\u003e\u003cspan style=\"display:flex;\"\u003e\u003cspan\u003e\u003cspan style=\"color:#75715e\"\u003e## Expo/React Native Specific\n\u003c/span\u003e\u003c/span\u003e\u003c/span\u003e\u003cspan style=\"display:flex;\"\u003e\u003cspan\u003e\u003cspan style=\"color:#75715e\"\u003e\u003c/span\u003e\u003cspan style=\"color:#66d9ef\"\u003e-\u003c/span\u003e Use Expo SDK compatible packages only\n\u003c/span\u003e\u003c/span\u003e\u003cspan style=\"display:flex;\"\u003e\u003cspan\u003e\u003cspan style=\"color:#66d9ef\"\u003e-\u003c/span\u003e Prefer \u003cspan style=\"color:#e6db74\"\u003e`npx expo install`\u003c/span\u003e over \u003cspan style=\"color:#e6db74\"\u003e`npm install`\u003c/span\u003e\n\u003c/span\u003e\u003c/span\u003e\u003cspan style=\"display:flex;\"\u003e\u003cspan\u003e\n\u003c/span\u003e\u003c/span\u003e\u003cspan style=\"display:flex;\"\u003e\u003cspan\u003e\u003cspan style=\"color:#75715e\"\u003e## Expo Specific Rules\n\u003c/span\u003e\u003c/span\u003e\u003c/span\u003e\u003cspan style=\"display:flex;\"\u003e\u003cspan\u003e\u003cspan style=\"color:#75715e\"\u003e\u003c/span\u003e\u003cspan style=\"color:#66d9ef\"\u003e-\u003c/span\u003e Use \u003cspan style=\"color:#e6db74\"\u003e`@expo/vector-icons`\u003c/span\u003e instead of \u003cspan style=\"color:#e6db74\"\u003e`react-native-vector-icons`\u003c/span\u003e\n\u003c/span\u003e\u003c/span\u003e\u003cspan style=\"display:flex;\"\u003e\u003cspan\u003e\u003cspan style=\"color:#66d9ef\"\u003e-\u003c/span\u003e Import example: \u003cspan style=\"color:#e6db74\"\u003e`import { MaterialCommunityIcons } from \u0026#39;@expo/vector-icons\u0026#39;;`\u003c/span\u003e\n\u003c/span\u003e\u003c/span\u003e\u003c/code\u003e\u003c/pre\u003e\u003c/div\u003e\u003cp\u003eAI に指示を出すときは、必ず「GEMINI.md を読んでから実装して」と伝えます。\u003c/p\u003e","title":"GEMINI.mdでAIに開発履歴を管理させる方法"},{"content":"最近趣味の方でモバイル開発を始めた。 Android端末を普段遣いしている点、仕事上iOSのアプリ周りのリリースがクソだるいことを知っているため Expoで開発しつつも、Androidのみを想定した開発を行っている。 その延長線で引っかかった部分とかをメモに残そうと思ったので記事にした。\n問題：日本語入力で変換候補が消える Expo/React Native で Todo アプリを作っていたときTextInput で日本語を入力すると変換候補が一瞬で消えてしまう問題に遭遇。\n// 問題のあるコード const [text, setText] = useState(\u0026#39;\u0026#39;); \u0026lt;TextInput value={text} onChangeText={setText} /\u0026gt; 「あ」と入力しても変換候補が表示されず、即座に確定されてしまい、ローマ字入力も正常に動作しない。\n原因：State更新による再レンダリング React Native の TextInput は制御コンポーネント（value + onChangeText）として使うと、以下の流れで問題が発生する。\n日本語入力で「あ」と入力 OS が変換候補を表示するために内部バッファを保持 onChangeText が発火して State 更新 再レンダリングで TextInput が新しい value で再構築 controlled component としての value の強制が、IME の内部バッファと衝突する ← ここが問題！ 変換候補が消える autoComplete や autoCorrect が有効だと、OS の補完機能が value の強制にさらに抵抗するため、IME との同期がズレやすくなる。\n解決方法：autoComplete と autoCorrect を OFF // 修正後のコード \u0026lt;TextInput value={text} onChangeText={setText} autoComplete=\u0026#34;off\u0026#34; autoCorrect={false} /\u0026gt; この2つのプロパティを追加するだけで、IME が安定して動作した。\nなぜこれで解決するのか？ autoComplete=\u0026quot;off\u0026quot;: OS の入力補完機能を無効化し、value の強制に対する抵抗を減らす autoCorrect={false}: 自動修正機能を無効化する。ただし、Androidでは実質無効であることが報告されている（GitHub issue #18457） したがって、Android上で問題が解決したとすれば、実際には autoComplete=\u0026quot;off\u0026quot; だけが有効だった可能性が高い。autoCorrect={false} を倣って追加しておくのは、将来的にiOS対応を行った時のためのものとして捉えてよい。\n代替案：defaultValue + onEndEditing リアルタイム同期を放棄してよい場合は、非制御コンポーネントにする方法もある。\n\u0026lt;TextInput defaultValue={text} onEndEditing={(e) =\u0026gt; setText(e.nativeEvent.text)} /\u0026gt; この場合、value による強制が発生しないため、IME のバッファリセットは起こらない。 ただし、入力中の値がリアルタイムに取得できないため、UX が悪化する可能性があったり、管理しているデータの状態によっては解決しないこともあるので注意。\nまとめ 私が遭遇した問題に関してはReact Native の TextInput で日本語入力が壊れる問題はautoComplete=\u0026quot;off\u0026quot; と autoCorrect={false} を追加するだけで解決できた。 ただし、Androidでは autoCorrect={false} が実質無効であるため、実際には autoComplete=\u0026quot;off\u0026quot; が主たる解決策であった可能性が高い。 これは React Native の controlled component としての value 強制と IME の相性問題かもしれない。 またいつか別の記事にするかもしれないが、前述した通りデータの構成によってはこれらを追加しても意味がないこともある。 あくまでもアプローチの一つとして捉えてほしい。\n参考リンク React Native TextInput 公式ドキュメント autoCorrect Android で無効になっていない報告 #18457 autoCorrect のデフォルト値がAndroidで異なる報告 #20063 ","permalink":"https://techblog.wasutech.dev/posts/react-native-input-text-ime-bug/","summary":"\u003cp\u003e最近趣味の方でモバイル開発を始めた。\nAndroid端末を普段遣いしている点、仕事上iOSのアプリ周りのリリースがクソだるいことを知っているため\nExpoで開発しつつも、Androidのみを想定した開発を行っている。\nその延長線で引っかかった部分とかをメモに残そうと思ったので記事にした。\u003c/p\u003e\n\u003ch2 id=\"問題日本語入力で変換候補が消える\"\u003e問題：日本語入力で変換候補が消える\u003c/h2\u003e\n\u003cp\u003eExpo/React Native で Todo アプリを作っていたとき\u003ccode\u003eTextInput\u003c/code\u003e で日本語を入力すると変換候補が一瞬で消えてしまう問題に遭遇。\u003c/p\u003e\n\u003cdiv class=\"highlight\"\u003e\u003cpre tabindex=\"0\" style=\"color:#f8f8f2;background-color:#272822;-moz-tab-size:4;-o-tab-size:4;tab-size:4;\"\u003e\u003ccode class=\"language-tsx\" data-lang=\"tsx\"\u003e\u003cspan style=\"display:flex;\"\u003e\u003cspan\u003e\u003cspan style=\"color:#75715e\"\u003e// 問題のあるコード\n\u003c/span\u003e\u003c/span\u003e\u003c/span\u003e\u003cspan style=\"display:flex;\"\u003e\u003cspan\u003e\u003cspan style=\"color:#75715e\"\u003e\u003c/span\u003e\u003cspan style=\"color:#66d9ef\"\u003econst\u003c/span\u003e [\u003cspan style=\"color:#a6e22e\"\u003etext\u003c/span\u003e, \u003cspan style=\"color:#a6e22e\"\u003esetText\u003c/span\u003e] \u003cspan style=\"color:#f92672\"\u003e=\u003c/span\u003e \u003cspan style=\"color:#a6e22e\"\u003euseState\u003c/span\u003e(\u003cspan style=\"color:#e6db74\"\u003e\u0026#39;\u0026#39;\u003c/span\u003e);\n\u003c/span\u003e\u003c/span\u003e\u003cspan style=\"display:flex;\"\u003e\u003cspan\u003e\u0026lt;\u003cspan style=\"color:#f92672\"\u003eTextInput\u003c/span\u003e\n\u003c/span\u003e\u003c/span\u003e\u003cspan style=\"display:flex;\"\u003e\u003cspan\u003e  \u003cspan style=\"color:#a6e22e\"\u003evalue\u003c/span\u003e\u003cspan style=\"color:#f92672\"\u003e=\u003c/span\u003e{\u003cspan style=\"color:#a6e22e\"\u003etext\u003c/span\u003e}\n\u003c/span\u003e\u003c/span\u003e\u003cspan style=\"display:flex;\"\u003e\u003cspan\u003e  \u003cspan style=\"color:#a6e22e\"\u003eonChangeText\u003c/span\u003e\u003cspan style=\"color:#f92672\"\u003e=\u003c/span\u003e{\u003cspan style=\"color:#a6e22e\"\u003esetText\u003c/span\u003e}\n\u003c/span\u003e\u003c/span\u003e\u003cspan style=\"display:flex;\"\u003e\u003cspan\u003e/\u0026gt;\n\u003c/span\u003e\u003c/span\u003e\u003c/code\u003e\u003c/pre\u003e\u003c/div\u003e\u003cp\u003e「あ」と入力しても変換候補が表示されず、即座に確定されてしまい、ローマ字入力も正常に動作しない。\u003c/p\u003e\n\u003ch2 id=\"原因state更新による再レンダリング\"\u003e原因：State更新による再レンダリング\u003c/h2\u003e\n\u003cp\u003eReact Native の \u003ccode\u003eTextInput\u003c/code\u003e は制御コンポーネント（\u003ccode\u003evalue\u003c/code\u003e + \u003ccode\u003eonChangeText\u003c/code\u003e）として使うと、以下の流れで問題が発生する。\u003c/p\u003e\n\u003col\u003e\n\u003cli\u003e日本語入力で「あ」と入力\u003c/li\u003e\n\u003cli\u003eOS が変換候補を表示するために内部バッファを保持\u003c/li\u003e\n\u003cli\u003e\u003ccode\u003eonChangeText\u003c/code\u003e が発火して State 更新\u003c/li\u003e\n\u003cli\u003e再レンダリングで \u003ccode\u003eTextInput\u003c/code\u003e が新しい value で再構築\u003c/li\u003e\n\u003cli\u003e\u003cstrong\u003econtrolled component としての \u003ccode\u003evalue\u003c/code\u003e の強制が、IME の内部バッファと衝突する\u003c/strong\u003e ← ここが問題！\u003c/li\u003e\n\u003cli\u003e変換候補が消える\u003c/li\u003e\n\u003c/ol\u003e\n\u003cp\u003e\u003ccode\u003eautoComplete\u003c/code\u003e や \u003ccode\u003eautoCorrect\u003c/code\u003e が有効だと、OS の補完機能が \u003ccode\u003evalue\u003c/code\u003e の強制にさらに抵抗するため、IME との同期がズレやすくなる。\u003c/p\u003e\n\u003ch2 id=\"解決方法autocomplete-と-autocorrect-を-off\"\u003e解決方法：autoComplete と autoCorrect を OFF\u003c/h2\u003e\n\u003cdiv class=\"highlight\"\u003e\u003cpre tabindex=\"0\" style=\"color:#f8f8f2;background-color:#272822;-moz-tab-size:4;-o-tab-size:4;tab-size:4;\"\u003e\u003ccode class=\"language-tsx\" data-lang=\"tsx\"\u003e\u003cspan style=\"display:flex;\"\u003e\u003cspan\u003e\u003cspan style=\"color:#75715e\"\u003e// 修正後のコード\n\u003c/span\u003e\u003c/span\u003e\u003c/span\u003e\u003cspan style=\"display:flex;\"\u003e\u003cspan\u003e\u003cspan style=\"color:#75715e\"\u003e\u003c/span\u003e\u0026lt;\u003cspan style=\"color:#f92672\"\u003eTextInput\u003c/span\u003e\n\u003c/span\u003e\u003c/span\u003e\u003cspan style=\"display:flex;\"\u003e\u003cspan\u003e  \u003cspan style=\"color:#a6e22e\"\u003evalue\u003c/span\u003e\u003cspan style=\"color:#f92672\"\u003e=\u003c/span\u003e{\u003cspan style=\"color:#a6e22e\"\u003etext\u003c/span\u003e}\n\u003c/span\u003e\u003c/span\u003e\u003cspan style=\"display:flex;\"\u003e\u003cspan\u003e  \u003cspan style=\"color:#a6e22e\"\u003eonChangeText\u003c/span\u003e\u003cspan style=\"color:#f92672\"\u003e=\u003c/span\u003e{\u003cspan style=\"color:#a6e22e\"\u003esetText\u003c/span\u003e}\n\u003c/span\u003e\u003c/span\u003e\u003cspan style=\"display:flex;\"\u003e\u003cspan\u003e  \u003cspan style=\"color:#a6e22e\"\u003eautoComplete\u003c/span\u003e\u003cspan style=\"color:#f92672\"\u003e=\u003c/span\u003e\u003cspan style=\"color:#e6db74\"\u003e\u0026#34;off\u0026#34;\u003c/span\u003e\n\u003c/span\u003e\u003c/span\u003e\u003cspan style=\"display:flex;\"\u003e\u003cspan\u003e  \u003cspan style=\"color:#a6e22e\"\u003eautoCorrect\u003c/span\u003e\u003cspan style=\"color:#f92672\"\u003e=\u003c/span\u003e{\u003cspan style=\"color:#66d9ef\"\u003efalse\u003c/span\u003e}\n\u003c/span\u003e\u003c/span\u003e\u003cspan style=\"display:flex;\"\u003e\u003cspan\u003e/\u0026gt;\n\u003c/span\u003e\u003c/span\u003e\u003c/code\u003e\u003c/pre\u003e\u003c/div\u003e\u003cp\u003eこの2つのプロパティを追加するだけで、IME が安定して動作した。\u003c/p\u003e","title":"React NativeでTextInputの日本語入力が壊れる問題と解決方法"},{"content":"はじめに Proxmox上にJupyterLabのLXC環境を構築しました。当初はGeminiに任せて試行錯誤しましたが、最終的にベストプラクティスに辿り着いたので、その過程と解決策をまとめます。\n構築の基本方針 当初は「Root + グローバル環境」で構築しようとしましたが、最終的に**「専用ユーザー + 仮想環境（venv）」**による安全でクリーンな構成に落ち着きました。\n最終構成 OS: Ubuntu 24.04 LTS (LXC Container) ユーザー: jupyter (非Root運用) Jupyter: JupyterLab (v4.x) 環境: /opt/jupyter/venv (OSと分離した仮想環境) 環境構築手順 1. OSの準備 Ubuntu 24.04の最小構成に必要なパッケージをインストールします。\napt update \u0026amp;\u0026amp; apt upgrade -y apt install -y python3-full build-essential python3-fullが重要です。これがないと後述するPEP 668の問題に直面します。\n2. 専用ユーザーとディレクトリの作成 # 専用ユーザー作成 useradd -m -s /bin/bash jupyter # Jupyter本体用のディレクトリ準備 mkdir -p /opt/jupyter chown jupyter:jupyter /opt/jupyter 3. 仮想環境の構築 jupyterユーザーとして、OSの制限を受けない独立した環境を作ります。\nsu - jupyter python3 -m venv /opt/jupyter/venv source /opt/jupyter/venv/bin/activate # JupyterLabとカーネルのインストール pip install jupyterlab ipykernel pandas 4. systemdによるデーモン化 /etc/systemd/system/jupyter.serviceを作成します。\n[Unit] Description=JupyterLab Server After=network.target [Service] Type=simple User=jupyter Group=jupyter WorkingDirectory=/home/jupyter ExecStart=/opt/jupyter/venv/bin/jupyter-lab \\ --no-browser \\ --ip=0.0.0.0 \\ --ServerApp.token=\u0026#39;\u0026#39; \\ --ServerApp.password=\u0026#39;\u0026#39; \\ --ServerApp.allow_remote_access=True \\ --ServerApp.allow_origin=\u0026#39;*\u0026#39; Restart=always [Install] WantedBy=multi-user.target ※記事では全許可だが、実際は内部でも非推奨なので可能なら絞る。\nサービスの有効化と起動:\nsystemctl daemon-reload systemctl enable jupyter.service systemctl start jupyter.service 遭遇したトラブルと解決策 1. externally-managed-environment エラー 原因: PEP 668によるOS側のPython環境保護機能\n解決策: python3-full導入後にvenvを使用する。どうしてもグローバルにインストールしたい場合は/usr/lib/python3.12/EXTERNALLY-MANAGEDファイルを削除する方法もありますが非推奨です。\n2. WebSocket接続エラー エラーメッセージ: \u0026ldquo;A connection to the notebook server could not be established\u0026rdquo;\n原因: WebSocketのOriginチェックによる拒否\n解決策: 起動引数に--ServerApp.allow_origin='*'を追加\n3. ModuleNotFoundError: jupyter_server 原因: OS版とpip版のJupyterが衝突\n解決策:\napt remove python3-notebook python3-jupyter-core pip install --force-reinstall jupyterlab 4. invalid metadata entry 'name' 原因: メタデータの破損\n解決策: /usr/lib/python3.12/dist-packages/内の該当.dist-infoフォルダを削除\n# 例 rm -rf /usr/lib/python3.12/dist-packages/jupyter_core-*.dist-info 5. TypeError: warn() missing argument 原因: ライブラリのバージョン不一致\n解決策:\npip install --upgrade --force-reinstall jupyter-core jupyter-client プロジェクトごとのKernel追加 JupyterLab本体の環境を汚さず、プロジェクトごとに環境を使い分ける方法です。\n# 新しい環境を作成 python3 -m venv /path/to/project_env # 必要なパッケージをインストール /path/to/project_env/bin/pip install ipykernel numpy scipy # Jupyterに登録 /path/to/project_env/bin/python -m ipykernel install --user --name \u0026#34;project_name\u0026#34; JupyterLabのKernel選択画面に\u0026quot;project_name\u0026quot;が表示されるようになります。\nまとめ Proxmox上のLXCコンテナでJupyterLab環境を構築する際は、以下のポイントを押さえることで堅牢な環境が作れます:\nvenvによる環境分離: OSのPython環境と分離する 専用ユーザーでの運用: Root実行を避ける systemdによる管理: 自動起動と再起動を設定 Origin制限の緩和: WebSocket接続を許可する設定 Geminiに任せたときは各種エラーに遭遇しましたが、一つずつ解決していくことで最終的に安定した環境を構築できました。\n参考資料 JupyterLab Documentation PEP 668 – Marking Python base environments as \u0026ldquo;externally managed\u0026rdquo; ","permalink":"https://techblog.wasutech.dev/posts/proxmox-jupyter/","summary":"\u003ch2 id=\"はじめに\"\u003eはじめに\u003c/h2\u003e\n\u003cp\u003eProxmox上にJupyterLabのLXC環境を構築しました。当初はGeminiに任せて試行錯誤しましたが、最終的にベストプラクティスに辿り着いたので、その過程と解決策をまとめます。\u003c/p\u003e\n\u003ch2 id=\"構築の基本方針\"\u003e構築の基本方針\u003c/h2\u003e\n\u003cp\u003e当初は「Root + グローバル環境」で構築しようとしましたが、最終的に**「専用ユーザー + 仮想環境（venv）」**による安全でクリーンな構成に落ち着きました。\u003c/p\u003e\n\u003ch3 id=\"最終構成\"\u003e最終構成\u003c/h3\u003e\n\u003cul\u003e\n\u003cli\u003e\u003cstrong\u003eOS\u003c/strong\u003e: Ubuntu 24.04 LTS (LXC Container)\u003c/li\u003e\n\u003cli\u003e\u003cstrong\u003eユーザー\u003c/strong\u003e: \u003ccode\u003ejupyter\u003c/code\u003e (非Root運用)\u003c/li\u003e\n\u003cli\u003e\u003cstrong\u003eJupyter\u003c/strong\u003e: JupyterLab (v4.x)\u003c/li\u003e\n\u003cli\u003e\u003cstrong\u003e環境\u003c/strong\u003e: \u003ccode\u003e/opt/jupyter/venv\u003c/code\u003e (OSと分離した仮想環境)\u003c/li\u003e\n\u003c/ul\u003e\n\u003ch2 id=\"環境構築手順\"\u003e環境構築手順\u003c/h2\u003e\n\u003ch3 id=\"1-osの準備\"\u003e1. OSの準備\u003c/h3\u003e\n\u003cp\u003eUbuntu 24.04の最小構成に必要なパッケージをインストールします。\u003c/p\u003e\n\u003cdiv class=\"highlight\"\u003e\u003cpre tabindex=\"0\" style=\"color:#f8f8f2;background-color:#272822;-moz-tab-size:4;-o-tab-size:4;tab-size:4;\"\u003e\u003ccode class=\"language-bash\" data-lang=\"bash\"\u003e\u003cspan style=\"display:flex;\"\u003e\u003cspan\u003eapt update \u003cspan style=\"color:#f92672\"\u003e\u0026amp;\u0026amp;\u003c/span\u003e apt upgrade -y\n\u003c/span\u003e\u003c/span\u003e\u003cspan style=\"display:flex;\"\u003e\u003cspan\u003eapt install -y python3-full build-essential\n\u003c/span\u003e\u003c/span\u003e\u003c/code\u003e\u003c/pre\u003e\u003c/div\u003e\u003cp\u003e\u003ccode\u003epython3-full\u003c/code\u003eが重要です。これがないと後述するPEP 668の問題に直面します。\u003c/p\u003e\n\u003ch3 id=\"2-専用ユーザーとディレクトリの作成\"\u003e2. 専用ユーザーとディレクトリの作成\u003c/h3\u003e\n\u003cdiv class=\"highlight\"\u003e\u003cpre tabindex=\"0\" style=\"color:#f8f8f2;background-color:#272822;-moz-tab-size:4;-o-tab-size:4;tab-size:4;\"\u003e\u003ccode class=\"language-bash\" data-lang=\"bash\"\u003e\u003cspan style=\"display:flex;\"\u003e\u003cspan\u003e\u003cspan style=\"color:#75715e\"\u003e# 専用ユーザー作成\u003c/span\u003e\n\u003c/span\u003e\u003c/span\u003e\u003cspan style=\"display:flex;\"\u003e\u003cspan\u003euseradd -m -s /bin/bash jupyter\n\u003c/span\u003e\u003c/span\u003e\u003cspan style=\"display:flex;\"\u003e\u003cspan\u003e\n\u003c/span\u003e\u003c/span\u003e\u003cspan style=\"display:flex;\"\u003e\u003cspan\u003e\u003cspan style=\"color:#75715e\"\u003e# Jupyter本体用のディレクトリ準備\u003c/span\u003e\n\u003c/span\u003e\u003c/span\u003e\u003cspan style=\"display:flex;\"\u003e\u003cspan\u003emkdir -p /opt/jupyter\n\u003c/span\u003e\u003c/span\u003e\u003cspan style=\"display:flex;\"\u003e\u003cspan\u003echown jupyter:jupyter /opt/jupyter\n\u003c/span\u003e\u003c/span\u003e\u003c/code\u003e\u003c/pre\u003e\u003c/div\u003e\u003ch3 id=\"3-仮想環境の構築\"\u003e3. 仮想環境の構築\u003c/h3\u003e\n\u003cp\u003e\u003ccode\u003ejupyter\u003c/code\u003eユーザーとして、OSの制限を受けない独立した環境を作ります。\u003c/p\u003e\n\u003cdiv class=\"highlight\"\u003e\u003cpre tabindex=\"0\" style=\"color:#f8f8f2;background-color:#272822;-moz-tab-size:4;-o-tab-size:4;tab-size:4;\"\u003e\u003ccode class=\"language-bash\" data-lang=\"bash\"\u003e\u003cspan style=\"display:flex;\"\u003e\u003cspan\u003esu - jupyter\n\u003c/span\u003e\u003c/span\u003e\u003cspan style=\"display:flex;\"\u003e\u003cspan\u003epython3 -m venv /opt/jupyter/venv\n\u003c/span\u003e\u003c/span\u003e\u003cspan style=\"display:flex;\"\u003e\u003cspan\u003esource /opt/jupyter/venv/bin/activate\n\u003c/span\u003e\u003c/span\u003e\u003cspan style=\"display:flex;\"\u003e\u003cspan\u003e\n\u003c/span\u003e\u003c/span\u003e\u003cspan style=\"display:flex;\"\u003e\u003cspan\u003e\u003cspan style=\"color:#75715e\"\u003e# JupyterLabとカーネルのインストール\u003c/span\u003e\n\u003c/span\u003e\u003c/span\u003e\u003cspan style=\"display:flex;\"\u003e\u003cspan\u003epip install jupyterlab ipykernel pandas\n\u003c/span\u003e\u003c/span\u003e\u003c/code\u003e\u003c/pre\u003e\u003c/div\u003e\u003ch3 id=\"4-systemdによるデーモン化\"\u003e4. systemdによるデーモン化\u003c/h3\u003e\n\u003cp\u003e\u003ccode\u003e/etc/systemd/system/jupyter.service\u003c/code\u003eを作成します。\u003c/p\u003e","title":"Proxmox LXCコンテナでJupyterLab環境構築 - 試行錯誤とトラブルシューティング"},{"content":"はじめに 世界の国々の経済は、どのように変化しているのでしょうか？今回は、World Bank（世界銀行）が提供するオープンデータを使って、各国の産業構造の変化を可視化してみました。\nこの記事では、Pythonのpandasとmatplotlibを使って、1997年から2024年までの約30年間の産業構造の変化をグラフにする方法を紹介します。\n産業構造とは？ 経済学では、産業を3つに分類します：\n第一次産業：農業、林業、漁業など（自然から直接資源を得る産業） 第二次産業：製造業、建設業など（原材料を加工する産業） 第三次産業：サービス業、金融、小売など（形のないサービスを提供する産業） 国が経済発展すると、第一次産業から第二次産業へ、そして第三次産業へとシフトしていく傾向があります。これを「産業構造の高度化」と呼びます。\n使用したデータ World Bankが提供している以下のデータを使用しました：\n第一次産業: Agriculture, forestry, and fishing, value added (% of GDP) 第二次産業: Industry (including construction), value added (% of GDP) 第三次産業: Services, value added (% of GDP) これらは各産業がGDP（国内総生産）に占める割合を示しています。\n分析対象国 今回は、経済発展段階や地域が異なる10カ国を選びました：\n日本（JPN）: 先進国・アジア 中国（CHN）: 新興国・急成長 アメリカ（USA）: 先進国・北米 ドイツ（DEU）: 先進国・欧州 インド（IND）: 新興国・南アジア 韓国（KOR）: 先進国・アジア インドネシア（IDN）: 新興国・東南アジア ベトナム（VNM）: 新興国・急成長 シンガポール（SGP）: 先進国・都市国家 タイ（THA）: 新興国・東南アジア ポーランド（POL）: 中所得国・欧州 Pythonコード 以下が実際に使用したコードです。\n# 第一次: https://data.worldbank.org/indicator/NV.AGR.TOTL.ZS # 第二次: https://data.worldbank.org/indicator/NV.IND.TOTL.ZS # 第三次: https://data.worldbank.org/indicator/NV.SRV.TOTL.ZS # 産業付加価値GDP import pandas as pd import matplotlib.pyplot as plt # CSVデータ取得 df_1 = pd.read_csv(\u0026#39;1.csv\u0026#39;, skiprows=3) df_2 = pd.read_csv(\u0026#39;2.csv\u0026#39;, skiprows=3) df_3 = pd.read_csv(\u0026#39;3.csv\u0026#39;, skiprows=3) # 国名のマッピング country_names = { \u0026#39;JPN\u0026#39;: \u0026#39;Japan\u0026#39;, \u0026#39;CHN\u0026#39;: \u0026#39;China\u0026#39;, \u0026#39;USA\u0026#39;: \u0026#39;United States\u0026#39;, \u0026#39;DEU\u0026#39;: \u0026#39;Germany\u0026#39;, \u0026#39;IND\u0026#39;: \u0026#39;India\u0026#39;, \u0026#39;KOR\u0026#39;: \u0026#39;South Korea\u0026#39;, \u0026#39;IDN\u0026#39;: \u0026#39;Indonesia\u0026#39;, \u0026#39;VNM\u0026#39;: \u0026#39;Vietnam\u0026#39;, \u0026#39;SGP\u0026#39;: \u0026#39;Singapore\u0026#39;, \u0026#39;THA\u0026#39;: \u0026#39;Thailand\u0026#39;, \u0026#39;POL\u0026#39;: \u0026#39;Poland\u0026#39;, } def to_chart(df, codes, begin, end, title, filename): plt.figure(figsize=(14, 9)) for code in codes: data = df[df[\u0026#39;Country Code\u0026#39;] == code] years = [str(year) for year in range(begin, end)] dict_data = data[years].iloc[0].to_dict() years = list(dict_data.keys()) values = list(dict_data.values()) line = plt.plot(years, values, marker=\u0026#39;o\u0026#39;, linewidth=2, markersize=4) # 線の最初（スタート地点）に国名ラベルを表示 plt.text(years[0], values[0], f\u0026#39;{country_names.get(code, code)} \u0026#39;, verticalalignment=\u0026#39;center\u0026#39;, horizontalalignment=\u0026#39;right\u0026#39;, fontsize=9, color=line[0].get_color(), fontweight=\u0026#39;bold\u0026#39;) plt.xlabel(\u0026#39;Year\u0026#39;, fontsize=11) plt.ylabel(\u0026#39;Value (%)\u0026#39;, fontsize=11) plt.title(f\u0026#39;{title} ({begin}-{end})\u0026#39;, fontsize=13) plt.grid(True, alpha=0.3) plt.xticks(rotation=45) plt.tight_layout() # 画像として保存 plt.savefig(filename, dpi=300, bbox_inches=\u0026#39;tight\u0026#39;) plt.close() # 3つのグラフを生成 to_chart(df_1, list(country_names.keys()), 1997, 2024, \u0026#34;第一次産業 (Agriculture, forestry, and fishing)\u0026#34;, \u0026#34;chart_primary.png\u0026#34;) to_chart(df_2, list(country_names.keys()), 1997, 2024, \u0026#34;第二次産業 (Industry including construction)\u0026#34;, \u0026#34;chart_secondary.png\u0026#34;) to_chart(df_3, list(country_names.keys()), 1997, 2024, \u0026#34;第三次産業 (Services)\u0026#34;, \u0026#34;chart_tertiary.png\u0026#34;) 分析結果 第一次産業（農業・林業・漁業） 主な傾向：\n先進国は1%未満：日本、アメリカ、ドイツ、シンガポール、韓国などはほぼ横ばいで1%前後 新興国は減少傾向：中国（17% → 7%）、ベトナム（26% → 12%）、インド（24% → 16%）は大きく減少 途上国は高め：インドネシアやタイは約8-13%を維持 これは、経済発展に伴い農業中心から工業・サービス業へとシフトする典型的なパターンです。\n第二次産業（製造業・建設業） 主な傾向：\n中国の工業化：約45-47%で高水準を維持（世界の工場としての地位） インドネシアも高水準：約40-45%で推移 先進国は減少傾向：日本（34% → 29%）、アメリカ（23% → 18%）、ドイツ（28% → 27%） シンガポールの激減：約32% → 22%（金融・サービス中心へ転換） 興味深いのは、ベトナムやタイなどが約30-35%で安定していることです。これらの国々は製造業を維持しながら発展しています。\n第三次産業（サービス業） 主な傾向：\nアメリカが最高：約72-78%とサービス経済の典型 先進国は高い：日本（65% → 70%）、ドイツ（62% → 64%）、シンガポール（64% → 73%） 新興国は増加傾向：中国（35% → 56%）、インド（39% → 49%）、ベトナム（42% → 43%） 全ての国でサービス業の比率が増加または維持されており、「サービス経済化」が世界的なトレンドであることがわかります。\nデータから読み取れること 1. 経済発展のパターン 経済発展は以下のような段階を経ることが多いです：\n農業中心：第一次産業が主体（途上国） 工業化：第二次産業が成長（新興国） サービス経済化：第三次産業が主体（先進国） 今回のデータでも、この流れが明確に見て取れます。\n2. 中国の特異性 中国は経済規模が巨大でありながら、第二次産業の比率が約45%と非常に高い状態を維持しています。これは「世界の工場」としての役割を反映しています。\n3. 先進国の脱工業化 日本、アメリカ、ドイツなどの先進国では、製造業の比率が徐々に低下しています。これは：\n製造拠点の海外移転 サービス業（IT、金融、医療など）の成長 高付加価値産業へのシフト といった要因が考えられます。\n4. 新興国の急速な変化 ベトナムや中国では、わずか20-30年で産業構造が大きく変化しています。これは急速な経済成長と工業化を反映しています。\nWorld Bankデータの素晴らしさ 今回使用したWorld Bank Open Dataは、データ分析の練習に最適です：\n標準化されたデータ：国際比較が容易 長期時系列：数十年分のデータが利用可能 無料でオープン：誰でもダウンロード可能 豊富なカテゴリ：経済、教育、健康、環境など 簡単にアクセス：CSV、Excel、API対応 他にも以下のようなデータソースがあります：\nOECD.Stat: 先進国中心の詳細データ IMF Data: 金融・為替系データ Our World in Data: 美しい可視化 UN Data: 国連の統計 まとめ Pythonとpandasを使えば、世界経済のような大きなテーマでも、簡単にデータを可視化して分析できます。\n今回わかったことは：\n世界的に「サービス経済化」が進んでいる 新興国は急速に産業構造を転換している 先進国は製造業からサービス業へシフトしている 中国は「世界の工場」として高い工業比率を維持 「当たり前」のことでも、実際にデータで確認すると新しい発見があります。World Bankのデータは宝の山なので、ぜひ色々なテーマで分析してみてください！\n参考リンク World Bank Open Data pandas公式ドキュメント matplotlib公式ドキュメント ","permalink":"https://techblog.wasutech.dev/posts/gdp-by-sector/","summary":"\u003ch2 id=\"はじめに\"\u003eはじめに\u003c/h2\u003e\n\u003cp\u003e世界の国々の経済は、どのように変化しているのでしょうか？今回は、World Bank（世界銀行）が提供するオープンデータを使って、各国の産業構造の変化を可視化してみました。\u003c/p\u003e\n\u003cp\u003eこの記事では、Pythonの\u003ccode\u003epandas\u003c/code\u003eと\u003ccode\u003ematplotlib\u003c/code\u003eを使って、1997年から2024年までの約30年間の産業構造の変化をグラフにする方法を紹介します。\u003c/p\u003e\n\u003ch2 id=\"産業構造とは\"\u003e産業構造とは？\u003c/h2\u003e\n\u003cp\u003e経済学では、産業を3つに分類します：\u003c/p\u003e\n\u003cul\u003e\n\u003cli\u003e\u003cstrong\u003e第一次産業\u003c/strong\u003e：農業、林業、漁業など（自然から直接資源を得る産業）\u003c/li\u003e\n\u003cli\u003e\u003cstrong\u003e第二次産業\u003c/strong\u003e：製造業、建設業など（原材料を加工する産業）\u003c/li\u003e\n\u003cli\u003e\u003cstrong\u003e第三次産業\u003c/strong\u003e：サービス業、金融、小売など（形のないサービスを提供する産業）\u003c/li\u003e\n\u003c/ul\u003e\n\u003cp\u003e国が経済発展すると、第一次産業から第二次産業へ、そして第三次産業へとシフトしていく傾向があります。これを「産業構造の高度化」と呼びます。\u003c/p\u003e\n\u003ch2 id=\"使用したデータ\"\u003e使用したデータ\u003c/h2\u003e\n\u003cp\u003eWorld Bankが提供している以下のデータを使用しました：\u003c/p\u003e\n\u003cul\u003e\n\u003cli\u003e\u003cstrong\u003e第一次産業\u003c/strong\u003e: \u003ca href=\"https://data.worldbank.org/indicator/NV.AGR.TOTL.ZS\"\u003eAgriculture, forestry, and fishing, value added (% of GDP)\u003c/a\u003e\u003c/li\u003e\n\u003cli\u003e\u003cstrong\u003e第二次産業\u003c/strong\u003e: \u003ca href=\"https://data.worldbank.org/indicator/NV.IND.TOTL.ZS\"\u003eIndustry (including construction), value added (% of GDP)\u003c/a\u003e\u003c/li\u003e\n\u003cli\u003e\u003cstrong\u003e第三次産業\u003c/strong\u003e: \u003ca href=\"https://data.worldbank.org/indicator/NV.SRV.TOTL.ZS\"\u003eServices, value added (% of GDP)\u003c/a\u003e\u003c/li\u003e\n\u003c/ul\u003e\n\u003cp\u003eこれらは各産業がGDP（国内総生産）に占める割合を示しています。\u003c/p\u003e\n\u003ch2 id=\"分析対象国\"\u003e分析対象国\u003c/h2\u003e\n\u003cp\u003e今回は、経済発展段階や地域が異なる10カ国を選びました：\u003c/p\u003e\n\u003cul\u003e\n\u003cli\u003e\u003cstrong\u003e日本（JPN）\u003c/strong\u003e: 先進国・アジア\u003c/li\u003e\n\u003cli\u003e\u003cstrong\u003e中国（CHN）\u003c/strong\u003e: 新興国・急成長\u003c/li\u003e\n\u003cli\u003e\u003cstrong\u003eアメリカ（USA）\u003c/strong\u003e: 先進国・北米\u003c/li\u003e\n\u003cli\u003e\u003cstrong\u003eドイツ（DEU）\u003c/strong\u003e: 先進国・欧州\u003c/li\u003e\n\u003cli\u003e\u003cstrong\u003eインド（IND）\u003c/strong\u003e: 新興国・南アジア\u003c/li\u003e\n\u003cli\u003e\u003cstrong\u003e韓国（KOR）\u003c/strong\u003e: 先進国・アジア\u003c/li\u003e\n\u003cli\u003e\u003cstrong\u003eインドネシア（IDN）\u003c/strong\u003e: 新興国・東南アジア\u003c/li\u003e\n\u003cli\u003e\u003cstrong\u003eベトナム（VNM）\u003c/strong\u003e: 新興国・急成長\u003c/li\u003e\n\u003cli\u003e\u003cstrong\u003eシンガポール（SGP）\u003c/strong\u003e: 先進国・都市国家\u003c/li\u003e\n\u003cli\u003e\u003cstrong\u003eタイ（THA）\u003c/strong\u003e: 新興国・東南アジア\u003c/li\u003e\n\u003cli\u003e\u003cstrong\u003eポーランド（POL）\u003c/strong\u003e: 中所得国・欧州\u003c/li\u003e\n\u003c/ul\u003e\n\u003ch2 id=\"pythonコード\"\u003ePythonコード\u003c/h2\u003e\n\u003cp\u003e以下が実際に使用したコードです。\u003c/p\u003e\n\u003cdiv class=\"highlight\"\u003e\u003cpre tabindex=\"0\" style=\"color:#f8f8f2;background-color:#272822;-moz-tab-size:4;-o-tab-size:4;tab-size:4;\"\u003e\u003ccode class=\"language-python\" data-lang=\"python\"\u003e\u003cspan style=\"display:flex;\"\u003e\u003cspan\u003e\u003cspan style=\"color:#75715e\"\u003e# 第一次: https://data.worldbank.org/indicator/NV.AGR.TOTL.ZS\u003c/span\u003e\n\u003c/span\u003e\u003c/span\u003e\u003cspan style=\"display:flex;\"\u003e\u003cspan\u003e\u003cspan style=\"color:#75715e\"\u003e# 第二次: https://data.worldbank.org/indicator/NV.IND.TOTL.ZS\u003c/span\u003e\n\u003c/span\u003e\u003c/span\u003e\u003cspan style=\"display:flex;\"\u003e\u003cspan\u003e\u003cspan style=\"color:#75715e\"\u003e# 第三次: https://data.worldbank.org/indicator/NV.SRV.TOTL.ZS\u003c/span\u003e\n\u003c/span\u003e\u003c/span\u003e\u003cspan style=\"display:flex;\"\u003e\u003cspan\u003e\u003cspan style=\"color:#75715e\"\u003e# 産業付加価値GDP\u003c/span\u003e\n\u003c/span\u003e\u003c/span\u003e\u003cspan style=\"display:flex;\"\u003e\u003cspan\u003e\n\u003c/span\u003e\u003c/span\u003e\u003cspan style=\"display:flex;\"\u003e\u003cspan\u003e\u003cspan style=\"color:#f92672\"\u003eimport\u003c/span\u003e pandas \u003cspan style=\"color:#66d9ef\"\u003eas\u003c/span\u003e pd\n\u003c/span\u003e\u003c/span\u003e\u003cspan style=\"display:flex;\"\u003e\u003cspan\u003e\u003cspan style=\"color:#f92672\"\u003eimport\u003c/span\u003e matplotlib.pyplot \u003cspan style=\"color:#66d9ef\"\u003eas\u003c/span\u003e plt\n\u003c/span\u003e\u003c/span\u003e\u003cspan style=\"display:flex;\"\u003e\u003cspan\u003e\n\u003c/span\u003e\u003c/span\u003e\u003cspan style=\"display:flex;\"\u003e\u003cspan\u003e\u003cspan style=\"color:#75715e\"\u003e# CSVデータ取得\u003c/span\u003e\n\u003c/span\u003e\u003c/span\u003e\u003cspan style=\"display:flex;\"\u003e\u003cspan\u003edf_1 \u003cspan style=\"color:#f92672\"\u003e=\u003c/span\u003e pd\u003cspan style=\"color:#f92672\"\u003e.\u003c/span\u003eread_csv(\u003cspan style=\"color:#e6db74\"\u003e\u0026#39;1.csv\u0026#39;\u003c/span\u003e, skiprows\u003cspan style=\"color:#f92672\"\u003e=\u003c/span\u003e\u003cspan style=\"color:#ae81ff\"\u003e3\u003c/span\u003e)\n\u003c/span\u003e\u003c/span\u003e\u003cspan style=\"display:flex;\"\u003e\u003cspan\u003edf_2 \u003cspan style=\"color:#f92672\"\u003e=\u003c/span\u003e pd\u003cspan style=\"color:#f92672\"\u003e.\u003c/span\u003eread_csv(\u003cspan style=\"color:#e6db74\"\u003e\u0026#39;2.csv\u0026#39;\u003c/span\u003e, skiprows\u003cspan style=\"color:#f92672\"\u003e=\u003c/span\u003e\u003cspan style=\"color:#ae81ff\"\u003e3\u003c/span\u003e)\n\u003c/span\u003e\u003c/span\u003e\u003cspan style=\"display:flex;\"\u003e\u003cspan\u003edf_3 \u003cspan style=\"color:#f92672\"\u003e=\u003c/span\u003e pd\u003cspan style=\"color:#f92672\"\u003e.\u003c/span\u003eread_csv(\u003cspan style=\"color:#e6db74\"\u003e\u0026#39;3.csv\u0026#39;\u003c/span\u003e, skiprows\u003cspan style=\"color:#f92672\"\u003e=\u003c/span\u003e\u003cspan style=\"color:#ae81ff\"\u003e3\u003c/span\u003e)\n\u003c/span\u003e\u003c/span\u003e\u003cspan style=\"display:flex;\"\u003e\u003cspan\u003e\n\u003c/span\u003e\u003c/span\u003e\u003cspan style=\"display:flex;\"\u003e\u003cspan\u003e\u003cspan style=\"color:#75715e\"\u003e# 国名のマッピング\u003c/span\u003e\n\u003c/span\u003e\u003c/span\u003e\u003cspan style=\"display:flex;\"\u003e\u003cspan\u003ecountry_names \u003cspan style=\"color:#f92672\"\u003e=\u003c/span\u003e {\n\u003c/span\u003e\u003c/span\u003e\u003cspan style=\"display:flex;\"\u003e\u003cspan\u003e    \u003cspan style=\"color:#e6db74\"\u003e\u0026#39;JPN\u0026#39;\u003c/span\u003e: \u003cspan style=\"color:#e6db74\"\u003e\u0026#39;Japan\u0026#39;\u003c/span\u003e,\n\u003c/span\u003e\u003c/span\u003e\u003cspan style=\"display:flex;\"\u003e\u003cspan\u003e    \u003cspan style=\"color:#e6db74\"\u003e\u0026#39;CHN\u0026#39;\u003c/span\u003e: \u003cspan style=\"color:#e6db74\"\u003e\u0026#39;China\u0026#39;\u003c/span\u003e,\n\u003c/span\u003e\u003c/span\u003e\u003cspan style=\"display:flex;\"\u003e\u003cspan\u003e    \u003cspan style=\"color:#e6db74\"\u003e\u0026#39;USA\u0026#39;\u003c/span\u003e: \u003cspan style=\"color:#e6db74\"\u003e\u0026#39;United States\u0026#39;\u003c/span\u003e,\n\u003c/span\u003e\u003c/span\u003e\u003cspan style=\"display:flex;\"\u003e\u003cspan\u003e    \u003cspan style=\"color:#e6db74\"\u003e\u0026#39;DEU\u0026#39;\u003c/span\u003e: \u003cspan style=\"color:#e6db74\"\u003e\u0026#39;Germany\u0026#39;\u003c/span\u003e,\n\u003c/span\u003e\u003c/span\u003e\u003cspan style=\"display:flex;\"\u003e\u003cspan\u003e    \u003cspan style=\"color:#e6db74\"\u003e\u0026#39;IND\u0026#39;\u003c/span\u003e: \u003cspan style=\"color:#e6db74\"\u003e\u0026#39;India\u0026#39;\u003c/span\u003e,\n\u003c/span\u003e\u003c/span\u003e\u003cspan style=\"display:flex;\"\u003e\u003cspan\u003e    \u003cspan style=\"color:#e6db74\"\u003e\u0026#39;KOR\u0026#39;\u003c/span\u003e: \u003cspan style=\"color:#e6db74\"\u003e\u0026#39;South Korea\u0026#39;\u003c/span\u003e,\n\u003c/span\u003e\u003c/span\u003e\u003cspan style=\"display:flex;\"\u003e\u003cspan\u003e    \u003cspan style=\"color:#e6db74\"\u003e\u0026#39;IDN\u0026#39;\u003c/span\u003e: \u003cspan style=\"color:#e6db74\"\u003e\u0026#39;Indonesia\u0026#39;\u003c/span\u003e,\n\u003c/span\u003e\u003c/span\u003e\u003cspan style=\"display:flex;\"\u003e\u003cspan\u003e    \u003cspan style=\"color:#e6db74\"\u003e\u0026#39;VNM\u0026#39;\u003c/span\u003e: \u003cspan style=\"color:#e6db74\"\u003e\u0026#39;Vietnam\u0026#39;\u003c/span\u003e,\n\u003c/span\u003e\u003c/span\u003e\u003cspan style=\"display:flex;\"\u003e\u003cspan\u003e    \u003cspan style=\"color:#e6db74\"\u003e\u0026#39;SGP\u0026#39;\u003c/span\u003e: \u003cspan style=\"color:#e6db74\"\u003e\u0026#39;Singapore\u0026#39;\u003c/span\u003e,\n\u003c/span\u003e\u003c/span\u003e\u003cspan style=\"display:flex;\"\u003e\u003cspan\u003e    \u003cspan style=\"color:#e6db74\"\u003e\u0026#39;THA\u0026#39;\u003c/span\u003e: \u003cspan style=\"color:#e6db74\"\u003e\u0026#39;Thailand\u0026#39;\u003c/span\u003e,\n\u003c/span\u003e\u003c/span\u003e\u003cspan style=\"display:flex;\"\u003e\u003cspan\u003e    \u003cspan style=\"color:#e6db74\"\u003e\u0026#39;POL\u0026#39;\u003c/span\u003e: \u003cspan style=\"color:#e6db74\"\u003e\u0026#39;Poland\u0026#39;\u003c/span\u003e,\n\u003c/span\u003e\u003c/span\u003e\u003cspan style=\"display:flex;\"\u003e\u003cspan\u003e}\n\u003c/span\u003e\u003c/span\u003e\u003cspan style=\"display:flex;\"\u003e\u003cspan\u003e\n\u003c/span\u003e\u003c/span\u003e\u003cspan style=\"display:flex;\"\u003e\u003cspan\u003e\u003cspan style=\"color:#66d9ef\"\u003edef\u003c/span\u003e \u003cspan style=\"color:#a6e22e\"\u003eto_chart\u003c/span\u003e(df, codes, begin, end, title, filename):\n\u003c/span\u003e\u003c/span\u003e\u003cspan style=\"display:flex;\"\u003e\u003cspan\u003e    plt\u003cspan style=\"color:#f92672\"\u003e.\u003c/span\u003efigure(figsize\u003cspan style=\"color:#f92672\"\u003e=\u003c/span\u003e(\u003cspan style=\"color:#ae81ff\"\u003e14\u003c/span\u003e, \u003cspan style=\"color:#ae81ff\"\u003e9\u003c/span\u003e))\n\u003c/span\u003e\u003c/span\u003e\u003cspan style=\"display:flex;\"\u003e\u003cspan\u003e    \n\u003c/span\u003e\u003c/span\u003e\u003cspan style=\"display:flex;\"\u003e\u003cspan\u003e    \u003cspan style=\"color:#66d9ef\"\u003efor\u003c/span\u003e code \u003cspan style=\"color:#f92672\"\u003ein\u003c/span\u003e codes:\n\u003c/span\u003e\u003c/span\u003e\u003cspan style=\"display:flex;\"\u003e\u003cspan\u003e        data \u003cspan style=\"color:#f92672\"\u003e=\u003c/span\u003e df[df[\u003cspan style=\"color:#e6db74\"\u003e\u0026#39;Country Code\u0026#39;\u003c/span\u003e] \u003cspan style=\"color:#f92672\"\u003e==\u003c/span\u003e code]\n\u003c/span\u003e\u003c/span\u003e\u003cspan style=\"display:flex;\"\u003e\u003cspan\u003e        years \u003cspan style=\"color:#f92672\"\u003e=\u003c/span\u003e [str(year) \u003cspan style=\"color:#66d9ef\"\u003efor\u003c/span\u003e year \u003cspan style=\"color:#f92672\"\u003ein\u003c/span\u003e range(begin, end)]\n\u003c/span\u003e\u003c/span\u003e\u003cspan style=\"display:flex;\"\u003e\u003cspan\u003e        dict_data \u003cspan style=\"color:#f92672\"\u003e=\u003c/span\u003e data[years]\u003cspan style=\"color:#f92672\"\u003e.\u003c/span\u003eiloc[\u003cspan style=\"color:#ae81ff\"\u003e0\u003c/span\u003e]\u003cspan style=\"color:#f92672\"\u003e.\u003c/span\u003eto_dict()\n\u003c/span\u003e\u003c/span\u003e\u003cspan style=\"display:flex;\"\u003e\u003cspan\u003e    \n\u003c/span\u003e\u003c/span\u003e\u003cspan style=\"display:flex;\"\u003e\u003cspan\u003e        years \u003cspan style=\"color:#f92672\"\u003e=\u003c/span\u003e list(dict_data\u003cspan style=\"color:#f92672\"\u003e.\u003c/span\u003ekeys())\n\u003c/span\u003e\u003c/span\u003e\u003cspan style=\"display:flex;\"\u003e\u003cspan\u003e        values \u003cspan style=\"color:#f92672\"\u003e=\u003c/span\u003e list(dict_data\u003cspan style=\"color:#f92672\"\u003e.\u003c/span\u003evalues())\n\u003c/span\u003e\u003c/span\u003e\u003cspan style=\"display:flex;\"\u003e\u003cspan\u003e        line \u003cspan style=\"color:#f92672\"\u003e=\u003c/span\u003e plt\u003cspan style=\"color:#f92672\"\u003e.\u003c/span\u003eplot(years, values, marker\u003cspan style=\"color:#f92672\"\u003e=\u003c/span\u003e\u003cspan style=\"color:#e6db74\"\u003e\u0026#39;o\u0026#39;\u003c/span\u003e, linewidth\u003cspan style=\"color:#f92672\"\u003e=\u003c/span\u003e\u003cspan style=\"color:#ae81ff\"\u003e2\u003c/span\u003e, markersize\u003cspan style=\"color:#f92672\"\u003e=\u003c/span\u003e\u003cspan style=\"color:#ae81ff\"\u003e4\u003c/span\u003e)\n\u003c/span\u003e\u003c/span\u003e\u003cspan style=\"display:flex;\"\u003e\u003cspan\u003e        \n\u003c/span\u003e\u003c/span\u003e\u003cspan style=\"display:flex;\"\u003e\u003cspan\u003e        \u003cspan style=\"color:#75715e\"\u003e# 線の最初（スタート地点）に国名ラベルを表示\u003c/span\u003e\n\u003c/span\u003e\u003c/span\u003e\u003cspan style=\"display:flex;\"\u003e\u003cspan\u003e        plt\u003cspan style=\"color:#f92672\"\u003e.\u003c/span\u003etext(years[\u003cspan style=\"color:#ae81ff\"\u003e0\u003c/span\u003e], values[\u003cspan style=\"color:#ae81ff\"\u003e0\u003c/span\u003e], \u003cspan style=\"color:#e6db74\"\u003ef\u003c/span\u003e\u003cspan style=\"color:#e6db74\"\u003e\u0026#39;\u003c/span\u003e\u003cspan style=\"color:#e6db74\"\u003e{\u003c/span\u003ecountry_names\u003cspan style=\"color:#f92672\"\u003e.\u003c/span\u003eget(code, code)\u003cspan style=\"color:#e6db74\"\u003e}\u003c/span\u003e\u003cspan style=\"color:#e6db74\"\u003e \u0026#39;\u003c/span\u003e, \n\u003c/span\u003e\u003c/span\u003e\u003cspan style=\"display:flex;\"\u003e\u003cspan\u003e                verticalalignment\u003cspan style=\"color:#f92672\"\u003e=\u003c/span\u003e\u003cspan style=\"color:#e6db74\"\u003e\u0026#39;center\u0026#39;\u003c/span\u003e, horizontalalignment\u003cspan style=\"color:#f92672\"\u003e=\u003c/span\u003e\u003cspan style=\"color:#e6db74\"\u003e\u0026#39;right\u0026#39;\u003c/span\u003e,\n\u003c/span\u003e\u003c/span\u003e\u003cspan style=\"display:flex;\"\u003e\u003cspan\u003e                fontsize\u003cspan style=\"color:#f92672\"\u003e=\u003c/span\u003e\u003cspan style=\"color:#ae81ff\"\u003e9\u003c/span\u003e, color\u003cspan style=\"color:#f92672\"\u003e=\u003c/span\u003eline[\u003cspan style=\"color:#ae81ff\"\u003e0\u003c/span\u003e]\u003cspan style=\"color:#f92672\"\u003e.\u003c/span\u003eget_color(), fontweight\u003cspan style=\"color:#f92672\"\u003e=\u003c/span\u003e\u003cspan style=\"color:#e6db74\"\u003e\u0026#39;bold\u0026#39;\u003c/span\u003e)\n\u003c/span\u003e\u003c/span\u003e\u003cspan style=\"display:flex;\"\u003e\u003cspan\u003e    \n\u003c/span\u003e\u003c/span\u003e\u003cspan style=\"display:flex;\"\u003e\u003cspan\u003e    plt\u003cspan style=\"color:#f92672\"\u003e.\u003c/span\u003exlabel(\u003cspan style=\"color:#e6db74\"\u003e\u0026#39;Year\u0026#39;\u003c/span\u003e, fontsize\u003cspan style=\"color:#f92672\"\u003e=\u003c/span\u003e\u003cspan style=\"color:#ae81ff\"\u003e11\u003c/span\u003e)\n\u003c/span\u003e\u003c/span\u003e\u003cspan style=\"display:flex;\"\u003e\u003cspan\u003e    plt\u003cspan style=\"color:#f92672\"\u003e.\u003c/span\u003eylabel(\u003cspan style=\"color:#e6db74\"\u003e\u0026#39;Value (%)\u0026#39;\u003c/span\u003e, fontsize\u003cspan style=\"color:#f92672\"\u003e=\u003c/span\u003e\u003cspan style=\"color:#ae81ff\"\u003e11\u003c/span\u003e)\n\u003c/span\u003e\u003c/span\u003e\u003cspan style=\"display:flex;\"\u003e\u003cspan\u003e    plt\u003cspan style=\"color:#f92672\"\u003e.\u003c/span\u003etitle(\u003cspan style=\"color:#e6db74\"\u003ef\u003c/span\u003e\u003cspan style=\"color:#e6db74\"\u003e\u0026#39;\u003c/span\u003e\u003cspan style=\"color:#e6db74\"\u003e{\u003c/span\u003etitle\u003cspan style=\"color:#e6db74\"\u003e}\u003c/span\u003e\u003cspan style=\"color:#e6db74\"\u003e (\u003c/span\u003e\u003cspan style=\"color:#e6db74\"\u003e{\u003c/span\u003ebegin\u003cspan style=\"color:#e6db74\"\u003e}\u003c/span\u003e\u003cspan style=\"color:#e6db74\"\u003e-\u003c/span\u003e\u003cspan style=\"color:#e6db74\"\u003e{\u003c/span\u003eend\u003cspan style=\"color:#e6db74\"\u003e}\u003c/span\u003e\u003cspan style=\"color:#e6db74\"\u003e)\u0026#39;\u003c/span\u003e, fontsize\u003cspan style=\"color:#f92672\"\u003e=\u003c/span\u003e\u003cspan style=\"color:#ae81ff\"\u003e13\u003c/span\u003e)\n\u003c/span\u003e\u003c/span\u003e\u003cspan style=\"display:flex;\"\u003e\u003cspan\u003e    plt\u003cspan style=\"color:#f92672\"\u003e.\u003c/span\u003egrid(\u003cspan style=\"color:#66d9ef\"\u003eTrue\u003c/span\u003e, alpha\u003cspan style=\"color:#f92672\"\u003e=\u003c/span\u003e\u003cspan style=\"color:#ae81ff\"\u003e0.3\u003c/span\u003e)\n\u003c/span\u003e\u003c/span\u003e\u003cspan style=\"display:flex;\"\u003e\u003cspan\u003e    plt\u003cspan style=\"color:#f92672\"\u003e.\u003c/span\u003exticks(rotation\u003cspan style=\"color:#f92672\"\u003e=\u003c/span\u003e\u003cspan style=\"color:#ae81ff\"\u003e45\u003c/span\u003e)\n\u003c/span\u003e\u003c/span\u003e\u003cspan style=\"display:flex;\"\u003e\u003cspan\u003e    plt\u003cspan style=\"color:#f92672\"\u003e.\u003c/span\u003etight_layout()\n\u003c/span\u003e\u003c/span\u003e\u003cspan style=\"display:flex;\"\u003e\u003cspan\u003e    \n\u003c/span\u003e\u003c/span\u003e\u003cspan style=\"display:flex;\"\u003e\u003cspan\u003e    \u003cspan style=\"color:#75715e\"\u003e# 画像として保存\u003c/span\u003e\n\u003c/span\u003e\u003c/span\u003e\u003cspan style=\"display:flex;\"\u003e\u003cspan\u003e    plt\u003cspan style=\"color:#f92672\"\u003e.\u003c/span\u003esavefig(filename, dpi\u003cspan style=\"color:#f92672\"\u003e=\u003c/span\u003e\u003cspan style=\"color:#ae81ff\"\u003e300\u003c/span\u003e, bbox_inches\u003cspan style=\"color:#f92672\"\u003e=\u003c/span\u003e\u003cspan style=\"color:#e6db74\"\u003e\u0026#39;tight\u0026#39;\u003c/span\u003e)\n\u003c/span\u003e\u003c/span\u003e\u003cspan style=\"display:flex;\"\u003e\u003cspan\u003e    plt\u003cspan style=\"color:#f92672\"\u003e.\u003c/span\u003eclose()\n\u003c/span\u003e\u003c/span\u003e\u003cspan style=\"display:flex;\"\u003e\u003cspan\u003e\n\u003c/span\u003e\u003c/span\u003e\u003cspan style=\"display:flex;\"\u003e\u003cspan\u003e\u003cspan style=\"color:#75715e\"\u003e# 3つのグラフを生成\u003c/span\u003e\n\u003c/span\u003e\u003c/span\u003e\u003cspan style=\"display:flex;\"\u003e\u003cspan\u003eto_chart(df_1, list(country_names\u003cspan style=\"color:#f92672\"\u003e.\u003c/span\u003ekeys()), \u003cspan style=\"color:#ae81ff\"\u003e1997\u003c/span\u003e, \u003cspan style=\"color:#ae81ff\"\u003e2024\u003c/span\u003e, \n\u003c/span\u003e\u003c/span\u003e\u003cspan style=\"display:flex;\"\u003e\u003cspan\u003e         \u003cspan style=\"color:#e6db74\"\u003e\u0026#34;第一次産業 (Agriculture, forestry, and fishing)\u0026#34;\u003c/span\u003e, \u003cspan style=\"color:#e6db74\"\u003e\u0026#34;chart_primary.png\u0026#34;\u003c/span\u003e)\n\u003c/span\u003e\u003c/span\u003e\u003cspan style=\"display:flex;\"\u003e\u003cspan\u003eto_chart(df_2, list(country_names\u003cspan style=\"color:#f92672\"\u003e.\u003c/span\u003ekeys()), \u003cspan style=\"color:#ae81ff\"\u003e1997\u003c/span\u003e, \u003cspan style=\"color:#ae81ff\"\u003e2024\u003c/span\u003e, \n\u003c/span\u003e\u003c/span\u003e\u003cspan style=\"display:flex;\"\u003e\u003cspan\u003e         \u003cspan style=\"color:#e6db74\"\u003e\u0026#34;第二次産業 (Industry including construction)\u0026#34;\u003c/span\u003e, \u003cspan style=\"color:#e6db74\"\u003e\u0026#34;chart_secondary.png\u0026#34;\u003c/span\u003e)\n\u003c/span\u003e\u003c/span\u003e\u003cspan style=\"display:flex;\"\u003e\u003cspan\u003eto_chart(df_3, list(country_names\u003cspan style=\"color:#f92672\"\u003e.\u003c/span\u003ekeys()), \u003cspan style=\"color:#ae81ff\"\u003e1997\u003c/span\u003e, \u003cspan style=\"color:#ae81ff\"\u003e2024\u003c/span\u003e, \n\u003c/span\u003e\u003c/span\u003e\u003cspan style=\"display:flex;\"\u003e\u003cspan\u003e         \u003cspan style=\"color:#e6db74\"\u003e\u0026#34;第三次産業 (Services)\u0026#34;\u003c/span\u003e, \u003cspan style=\"color:#e6db74\"\u003e\u0026#34;chart_tertiary.png\u0026#34;\u003c/span\u003e)\n\u003c/span\u003e\u003c/span\u003e\u003c/code\u003e\u003c/pre\u003e\u003c/div\u003e\u003ch2 id=\"分析結果\"\u003e分析結果\u003c/h2\u003e\n\u003ch3 id=\"第一次産業農業林業漁業\"\u003e第一次産業（農業・林業・漁業）\u003c/h3\u003e\n\u003cp\u003e\u003cimg alt=\"第一次産業のGDP比率\" loading=\"lazy\" src=\"/posts/gdp-by-sector/1.png\"\u003e\u003c/p\u003e","title":"PythonとWorld Bankデータで世界の産業構造を可視化する方法"},{"content":"はじめに 「自宅サーバーを構築したが、1台落ちただけで家族全員のネットが止まった」 そんな苦い経験（特にDNS/DHCP周りでの同期失敗）を経て、今回はRaspberry Pi 6台（＋α）を駆使した「高可用性（HA）」に特化した自宅ネットワークを再設計します。\n今回のコンセプトは「速度よりも、止まらないこと」。 北欧神話の神々の名を冠した6台のラズパイによる、3層の冗長化レイヤーを構築します。\n1. ネットワーク全体像 上位ルーターとはWi-Fiで接続し、内部ネットワークは有線L2スイッチを中心に構成します。物理的に役割を分離することで、障害時の原因切り分けを容易にしています。\n階層化の設計案 Edge層 (L1): インターネットへの門番。KeepalivedでゲートウェイIPを共有。 Core層 (L2): DHCPやDNS、認証など、NWの頭脳となる機能を同期。 Service層 (L3): ファイルサーバーなどの実データをレプリケーションして保持。 2. サーバー構成表：北欧神話の神々 物理筐体 ホスト名 役割 冗長化の仕組み Pi 3 (A) Odin 主系Gateway / VPN Keepalived (VIP: 192.168.1.1) Pi 3 (B) Frigg 副系Gateway / VPN Odinと仮想IPを共有 Pi 3 (C) Huginn DNS / DHCP Primary ISC-DHCP Failover / Gravity Sync Pi 3 (D) Muninn DNS / DHCP Secondary Huginnとリアルタイム同期 Pi 3 (E) Mjolnir ストレージ (NAS) GlusterFS + Keepalived (VIP: .200) Pi 3 (F) Gungnir ストレージ (NAS) Mjolnirとデータレプリケーション 3. 過去の失敗を防ぐ「守り」の技術選定 ① DNS/DHCP：仮想IPに頼らない 過去、DNSやDHCPを仮想IP（Keepalived）で制御しようとして失敗した経験から、今回はプロトコル標準の冗長化を採用します。\nDNS: クライアントに最初から .10 と .11 の2つのIPを配布する。 DHCP: Keepalivedは使わず、isc-dhcp-server の同期プロトコルを利用してリース情報を常に一致させる。 ② Gateway：Wi-Fi WANの死活監視 上位ルーターとラズパイ間をWi-Fiで繋ぐ場合、「Wi-Fiだけ切れてOSは動いている」状態が一番危険です。 Keepalivedのスクリプトで、**「外部8.8.8.8へのPing失敗時に優先度を下げて即座にバックアップ機へ切り替える」**設定を組み込みます。\n③ ストレージ：リアルタイムミラーリング GlusterFS を採用。Mjolnir にファイルを書き込むと、ネットワーク越しに Gungnir にも同時に書き込まれます。どちらかのSDカードが死んでも、データはもう一方に確実に残ります。\n4. ユーザー体験：端末から見た景色 Wi-Fi APに接続したスマホやPCなどのクライアントからは、以下のようなシンプルな設定に見えます。\nDefault Gateway: 192.168.1.1 (Odin/Friggのどちらかが応答) DNS Servers: 192.168.1.10, 192.168.1.11 File Server: 192.168.1.200 (Mjolnir/Gungnirのどちらかが応答) 裏側でどのラズパイが倒れても、家族は誰も気づかない――それがこの構成のゴールです。\nおわりに この構成は、ラズパイ3の資産を最大限に活用しつつ、家庭内インフラとしての堅牢性を極限まで高めたものです。各ノードのセットアップ手順については、次回の記事で詳解します。\n","permalink":"https://techblog.wasutech.dev/posts/new-home-nw/","summary":"\u003ch2 id=\"はじめに\"\u003eはじめに\u003c/h2\u003e\n\u003cp\u003e「自宅サーバーを構築したが、1台落ちただけで家族全員のネットが止まった」\nそんな苦い経験（特にDNS/DHCP周りでの同期失敗）を経て、今回は\u003cstrong\u003eRaspberry Pi 6台（＋α）を駆使した「高可用性（HA）」に特化した自宅ネットワーク\u003c/strong\u003eを再設計します。\u003c/p\u003e\n\u003cp\u003e今回のコンセプトは「速度よりも、止まらないこと」。\n北欧神話の神々の名を冠した6台のラズパイによる、3層の冗長化レイヤーを構築します。\u003c/p\u003e\n\u003chr\u003e\n\u003ch2 id=\"1-ネットワーク全体像\"\u003e1. ネットワーク全体像\u003c/h2\u003e\n\u003cp\u003e上位ルーターとはWi-Fiで接続し、内部ネットワークは有線L2スイッチを中心に構成します。物理的に役割を分離することで、障害時の原因切り分けを容易にしています。\u003c/p\u003e\n\u003ch3 id=\"階層化の設計案\"\u003e階層化の設計案\u003c/h3\u003e\n\u003col\u003e\n\u003cli\u003e\u003cstrong\u003eEdge層 (L1)\u003c/strong\u003e: インターネットへの門番。KeepalivedでゲートウェイIPを共有。\u003c/li\u003e\n\u003cli\u003e\u003cstrong\u003eCore層 (L2)\u003c/strong\u003e: DHCPやDNS、認証など、NWの頭脳となる機能を同期。\u003c/li\u003e\n\u003cli\u003e\u003cstrong\u003eService層 (L3)\u003c/strong\u003e: ファイルサーバーなどの実データをレプリケーションして保持。\u003c/li\u003e\n\u003c/ol\u003e\n\u003chr\u003e\n\u003ch2 id=\"2-サーバー構成表北欧神話の神々\"\u003e2. サーバー構成表：北欧神話の神々\u003c/h2\u003e\n\u003ctable\u003e\n  \u003cthead\u003e\n      \u003ctr\u003e\n          \u003cth\u003e物理筐体\u003c/th\u003e\n          \u003cth\u003eホスト名\u003c/th\u003e\n          \u003cth\u003e役割\u003c/th\u003e\n          \u003cth\u003e冗長化の仕組み\u003c/th\u003e\n      \u003c/tr\u003e\n  \u003c/thead\u003e\n  \u003ctbody\u003e\n      \u003ctr\u003e\n          \u003ctd\u003e\u003cstrong\u003ePi 3 (A)\u003c/strong\u003e\u003c/td\u003e\n          \u003ctd\u003e\u003cstrong\u003eOdin\u003c/strong\u003e\u003c/td\u003e\n          \u003ctd\u003e主系Gateway / VPN\u003c/td\u003e\n          \u003ctd\u003e\u003cstrong\u003eKeepalived (VIP: 192.168.1.1)\u003c/strong\u003e\u003c/td\u003e\n      \u003c/tr\u003e\n      \u003ctr\u003e\n          \u003ctd\u003e\u003cstrong\u003ePi 3 (B)\u003c/strong\u003e\u003c/td\u003e\n          \u003ctd\u003e\u003cstrong\u003eFrigg\u003c/strong\u003e\u003c/td\u003e\n          \u003ctd\u003e副系Gateway / VPN\u003c/td\u003e\n          \u003ctd\u003eOdinと仮想IPを共有\u003c/td\u003e\n      \u003c/tr\u003e\n      \u003ctr\u003e\n          \u003ctd\u003e\u003cstrong\u003ePi 3 (C)\u003c/strong\u003e\u003c/td\u003e\n          \u003ctd\u003e\u003cstrong\u003eHuginn\u003c/strong\u003e\u003c/td\u003e\n          \u003ctd\u003eDNS / DHCP Primary\u003c/td\u003e\n          \u003ctd\u003e\u003cstrong\u003eISC-DHCP Failover / Gravity Sync\u003c/strong\u003e\u003c/td\u003e\n      \u003c/tr\u003e\n      \u003ctr\u003e\n          \u003ctd\u003e\u003cstrong\u003ePi 3 (D)\u003c/strong\u003e\u003c/td\u003e\n          \u003ctd\u003e\u003cstrong\u003eMuninn\u003c/strong\u003e\u003c/td\u003e\n          \u003ctd\u003eDNS / DHCP Secondary\u003c/td\u003e\n          \u003ctd\u003eHuginnとリアルタイム同期\u003c/td\u003e\n      \u003c/tr\u003e\n      \u003ctr\u003e\n          \u003ctd\u003e\u003cstrong\u003ePi 3 (E)\u003c/strong\u003e\u003c/td\u003e\n          \u003ctd\u003e\u003cstrong\u003eMjolnir\u003c/strong\u003e\u003c/td\u003e\n          \u003ctd\u003eストレージ (NAS)\u003c/td\u003e\n          \u003ctd\u003e\u003cstrong\u003eGlusterFS + Keepalived (VIP: .200)\u003c/strong\u003e\u003c/td\u003e\n      \u003c/tr\u003e\n      \u003ctr\u003e\n          \u003ctd\u003e\u003cstrong\u003ePi 3 (F)\u003c/strong\u003e\u003c/td\u003e\n          \u003ctd\u003e\u003cstrong\u003eGungnir\u003c/strong\u003e\u003c/td\u003e\n          \u003ctd\u003eストレージ (NAS)\u003c/td\u003e\n          \u003ctd\u003eMjolnirとデータレプリケーション\u003c/td\u003e\n      \u003c/tr\u003e\n  \u003c/tbody\u003e\n\u003c/table\u003e\n\u003chr\u003e\n\u003ch2 id=\"3-過去の失敗を防ぐ守りの技術選定\"\u003e3. 過去の失敗を防ぐ「守り」の技術選定\u003c/h2\u003e\n\u003ch3 id=\"-dnsdhcp仮想ipに頼らない\"\u003e① DNS/DHCP：仮想IPに頼らない\u003c/h3\u003e\n\u003cp\u003e過去、DNSやDHCPを仮想IP（Keepalived）で制御しようとして失敗した経験から、今回は\u003cstrong\u003eプロトコル標準の冗長化\u003c/strong\u003eを採用します。\u003c/p\u003e","title":"ラズパイ6台で作る、絶対に止まらない最強の自宅ネットワーク冗長化計画"},{"content":"はじめに - 「円安=物価高」という通説への挑戦 「円安だから物価が上がる」――ニュースで繰り返されるこのフレーズ。本当にそうなのか？統計総局の消費者物価指数（CPI）と為替レートのデータを使って、この仮説を検証してみた。\nデータ準備 使用データ 消費者物価指数：統計総局『tmi2020a.csv』（2020年基準） 為替レート：みずほ銀行『quote.csv』（日次データを月次平均化） 期間：2023年1月〜2026年1月（3年間） 前処理 import pandas as pd import matplotlib.pyplot as plt from scipy.stats import linregress # CPI読み込み（ヘッダー5行スキップ） cpi_df = pd.read_csv(\u0026#39;./tmi/tmi2020a.csv\u0026#39;) cpi_clean = cpi_df.iloc[5:].copy().reset_index(drop=True) cpi_clean[\u0026#39;エネルギー\u0026#39;] = pd.to_numeric(cpi_clean[\u0026#39;エネルギー\u0026#39;], errors=\u0026#39;coerce\u0026#39;) # 為替読み込み（日次→月次平均） fx_df = pd.read_csv(\u0026#39;./doru/quote.csv\u0026#39;, encoding=\u0026#39;utf-8\u0026#39;) fx_clean = fx_df.iloc[2:].copy() fx_clean[\u0026#39;日付\u0026#39;] = pd.to_datetime(fx_clean.iloc[:, 0], format=\u0026#39;%Y/%m/%d\u0026#39;) fx_clean[\u0026#39;USD\u0026#39;] = pd.to_numeric(fx_clean.iloc[:, 1], errors=\u0026#39;coerce\u0026#39;) fx_clean[\u0026#39;年月\u0026#39;] = fx_clean[\u0026#39;日付\u0026#39;].dt.strftime(\u0026#39;%Y%m\u0026#39;) monthly_fx = fx_clean.groupby(\u0026#39;年月\u0026#39;)[\u0026#39;USD\u0026#39;].mean().reset_index() monthly_fx.columns = [\u0026#39;年月\u0026#39;, \u0026#39;ドル円\u0026#39;] # データ結合 recent = cpi_clean[cpi_clean[\u0026#39;類・品目\u0026#39;] \u0026gt;= \u0026#39;202301\u0026#39;].copy() data = recent.merge(monthly_fx, left_on=\u0026#39;類・品目\u0026#39;, right_on=\u0026#39;年月\u0026#39;, how=\u0026#39;left\u0026#39;) まず全体像を把握する グラフから見える3つの真実 1. エネルギー価格の激しい変動\nオレンジ線を見ると、2023年初頭の135から2023年秋には104まで急落（-23%）。その後も上下を繰り返し、最終的に122で着地。地政学リスクがそのまま価格に反映されている。\n2. 食料価格の不可逆的上昇\nピンク線は2023年から2025年にかけてほぼ一直線に上昇（109→127、+16%）。一度上がった食品価格は下がらない構造的問題が見える。\n3. 総合指数の「マイルド感」\n青線は安定的に上昇（104→112、+7%）。しかし国民が実感する物価高は、日常的に買う食料品の16%上昇の方。統計と実感の乖離がここに現れている。\nエネルギー価格と為替の関係を探る 注目すべき3つの局面 1. 2024年7月：円安ピーク（158円）→ エネルギー高騰（127）\n日米金利差拡大により円安加速。同時期にOPEC減産継続でエネルギー価格も上昇。一見すると因果関係があるように見える。\n2. 2024年9月：円高転換（144円）→ エネルギー急落（114）\n日銀の政策修正期待で円高に。同時期に世界景気減速懸念でエネルギー価格も急落。ここでも連動しているように見える。\n3. 2025年5月：円高継続（145円）→ エネルギー急騰（129）← ★矛盾★\n為替は144円台で円高維持。しかしエネルギー価格は129まで急騰。ここで「見せかけの相関」が露呈する。\n統計分析：相関係数が示す真実 # 相関分析 correlation = data[\u0026#39;エネルギー\u0026#39;].corr(data[\u0026#39;ドル円\u0026#39;]) print(f\u0026#34;相関係数: {correlation:.3f}\u0026#34;) # 0.100 # 回帰分析 x = data[\u0026#39;ドル円\u0026#39;].values y = data[\u0026#39;エネルギー\u0026#39;].values slope, intercept, r_value, p_value, std_err = linregress(x, y) print(f\u0026#34;回帰式: エネルギー = {slope:.3f} × ドル円 + {intercept:.2f}\u0026#34;) print(f\u0026#34;R² = {r_value**2:.3f} (説明力: {r_value**2*100:.1f}%)\u0026#34;) print(f\u0026#34;p値 = {p_value:.6f}\u0026#34;) 結果 相関係数: 0.100 回帰式: エネルギー = 0.117 × ドル円 + 102.95 R² = 0.010 (説明力: 1.0%) p値 = 0.641264 解釈：為替はエネルギー価格のわずか1%しか説明できない\nドル円が10円上がっても、エネルギー指数は1.17しか上がらない p値 \u0026gt; 0.05 → 統計的に有意でない 結論：円安とエネルギー価格に因果関係はほぼ無い 残差分析：為替では説明できない価格変動 2025年5月の異常値（+9.12） 回帰式による予測値：119.9\n実測値：129.0\n差分：+9.12（為替では説明不可能）\nこの時期に何が起きていたか？ OPEC+の減産延長決定（2025年4月） イラン・イスラエル緊張激化 世界的な供給制約 → 地政学リスクが価格を押し上げた\nグラフ下段の赤いバーが「為替モデルでは説明できない価格変動」を示している。2025年5月の+9.12という巨大な残差は、為替以外の要因（OPEC政策・地政学リスク）が支配的であることを物語っている。\n結論：エネルギー価格の本当のドライバー データが示した真実 円安の影響は極めて限定的（説明力1%） 本当のドライバーは地政学リスクとOPEC政策 為替と価格が連動して見えるのは「見せかけの相関」 「円安→物価高」という通説の落とし穴 メディアが「円安で物価高」と報じるとき、それは同時に起きた別々の現象を因果関係と誤認している可能性がある。\n実際には：\n円安の原因：日米金利差、リスクオフ エネルギー高の原因：OPEC減産、中東情勢 この2つが偶然同時期に発生しただけ。\n教訓 相関関係 ≠ 因果関係\n目に見える相関に飛びつく前に、統計的検証が必要だ。今回の分析は、その重要性を改めて示している。\n技術ノート 環境 Python: 3.11.6 | packaged by conda-forge | (main, Oct 3 2023, 10:40:35) [GCC 12.3.0] pandas: 2.3.3 matplotlib: 3.10.8 scipy: 1.17.0 numpy: 2.4.1 データソース 消費者物価指数 東京都区部 1 品目別価格指数（1970年1月～最新月） | ファイル | 統計データを探す | 政府統計の総合窓口 ヒストリカルデータ | みずほ銀行 次回予告：食料価格の不可逆的上昇を解剖する\n今回はエネルギーに焦点を当てたが、CPIデータには「食料指数が2023年から2025年にかけて+16%上昇」という別の重要なシグナルが含まれている。次回はこの構造的問題を掘り下げる。\n","permalink":"https://techblog.wasutech.dev/posts/study-stats-eco/","summary":"\u003ch2 id=\"はじめに---円安物価高という通説への挑戦\"\u003eはじめに - 「円安=物価高」という通説への挑戦\u003c/h2\u003e\n\u003cp\u003e「円安だから物価が上がる」――ニュースで繰り返されるこのフレーズ。本当にそうなのか？統計総局の消費者物価指数（CPI）と為替レートのデータを使って、この仮説を検証してみた。\u003c/p\u003e\n\u003ch2 id=\"データ準備\"\u003eデータ準備\u003c/h2\u003e\n\u003ch3 id=\"使用データ\"\u003e使用データ\u003c/h3\u003e\n\u003cul\u003e\n\u003cli\u003e\u003cstrong\u003e消費者物価指数\u003c/strong\u003e：統計総局『tmi2020a.csv』（2020年基準）\u003c/li\u003e\n\u003cli\u003e\u003cstrong\u003e為替レート\u003c/strong\u003e：みずほ銀行『quote.csv』（日次データを月次平均化）\u003c/li\u003e\n\u003cli\u003e\u003cstrong\u003e期間\u003c/strong\u003e：2023年1月〜2026年1月（3年間）\u003c/li\u003e\n\u003c/ul\u003e\n\u003ch3 id=\"前処理\"\u003e前処理\u003c/h3\u003e\n\u003cdiv class=\"highlight\"\u003e\u003cpre tabindex=\"0\" style=\"color:#f8f8f2;background-color:#272822;-moz-tab-size:4;-o-tab-size:4;tab-size:4;\"\u003e\u003ccode class=\"language-python\" data-lang=\"python\"\u003e\u003cspan style=\"display:flex;\"\u003e\u003cspan\u003e\u003cspan style=\"color:#f92672\"\u003eimport\u003c/span\u003e pandas \u003cspan style=\"color:#66d9ef\"\u003eas\u003c/span\u003e pd\n\u003c/span\u003e\u003c/span\u003e\u003cspan style=\"display:flex;\"\u003e\u003cspan\u003e\u003cspan style=\"color:#f92672\"\u003eimport\u003c/span\u003e matplotlib.pyplot \u003cspan style=\"color:#66d9ef\"\u003eas\u003c/span\u003e plt\n\u003c/span\u003e\u003c/span\u003e\u003cspan style=\"display:flex;\"\u003e\u003cspan\u003e\u003cspan style=\"color:#f92672\"\u003efrom\u003c/span\u003e scipy.stats \u003cspan style=\"color:#f92672\"\u003eimport\u003c/span\u003e linregress\n\u003c/span\u003e\u003c/span\u003e\u003cspan style=\"display:flex;\"\u003e\u003cspan\u003e\n\u003c/span\u003e\u003c/span\u003e\u003cspan style=\"display:flex;\"\u003e\u003cspan\u003e\u003cspan style=\"color:#75715e\"\u003e# CPI読み込み（ヘッダー5行スキップ）\u003c/span\u003e\n\u003c/span\u003e\u003c/span\u003e\u003cspan style=\"display:flex;\"\u003e\u003cspan\u003ecpi_df \u003cspan style=\"color:#f92672\"\u003e=\u003c/span\u003e pd\u003cspan style=\"color:#f92672\"\u003e.\u003c/span\u003eread_csv(\u003cspan style=\"color:#e6db74\"\u003e\u0026#39;./tmi/tmi2020a.csv\u0026#39;\u003c/span\u003e)\n\u003c/span\u003e\u003c/span\u003e\u003cspan style=\"display:flex;\"\u003e\u003cspan\u003ecpi_clean \u003cspan style=\"color:#f92672\"\u003e=\u003c/span\u003e cpi_df\u003cspan style=\"color:#f92672\"\u003e.\u003c/span\u003eiloc[\u003cspan style=\"color:#ae81ff\"\u003e5\u003c/span\u003e:]\u003cspan style=\"color:#f92672\"\u003e.\u003c/span\u003ecopy()\u003cspan style=\"color:#f92672\"\u003e.\u003c/span\u003ereset_index(drop\u003cspan style=\"color:#f92672\"\u003e=\u003c/span\u003e\u003cspan style=\"color:#66d9ef\"\u003eTrue\u003c/span\u003e)\n\u003c/span\u003e\u003c/span\u003e\u003cspan style=\"display:flex;\"\u003e\u003cspan\u003ecpi_clean[\u003cspan style=\"color:#e6db74\"\u003e\u0026#39;エネルギー\u0026#39;\u003c/span\u003e] \u003cspan style=\"color:#f92672\"\u003e=\u003c/span\u003e pd\u003cspan style=\"color:#f92672\"\u003e.\u003c/span\u003eto_numeric(cpi_clean[\u003cspan style=\"color:#e6db74\"\u003e\u0026#39;エネルギー\u0026#39;\u003c/span\u003e], errors\u003cspan style=\"color:#f92672\"\u003e=\u003c/span\u003e\u003cspan style=\"color:#e6db74\"\u003e\u0026#39;coerce\u0026#39;\u003c/span\u003e)\n\u003c/span\u003e\u003c/span\u003e\u003cspan style=\"display:flex;\"\u003e\u003cspan\u003e\n\u003c/span\u003e\u003c/span\u003e\u003cspan style=\"display:flex;\"\u003e\u003cspan\u003e\u003cspan style=\"color:#75715e\"\u003e# 為替読み込み（日次→月次平均）\u003c/span\u003e\n\u003c/span\u003e\u003c/span\u003e\u003cspan style=\"display:flex;\"\u003e\u003cspan\u003efx_df \u003cspan style=\"color:#f92672\"\u003e=\u003c/span\u003e pd\u003cspan style=\"color:#f92672\"\u003e.\u003c/span\u003eread_csv(\u003cspan style=\"color:#e6db74\"\u003e\u0026#39;./doru/quote.csv\u0026#39;\u003c/span\u003e, encoding\u003cspan style=\"color:#f92672\"\u003e=\u003c/span\u003e\u003cspan style=\"color:#e6db74\"\u003e\u0026#39;utf-8\u0026#39;\u003c/span\u003e)\n\u003c/span\u003e\u003c/span\u003e\u003cspan style=\"display:flex;\"\u003e\u003cspan\u003efx_clean \u003cspan style=\"color:#f92672\"\u003e=\u003c/span\u003e fx_df\u003cspan style=\"color:#f92672\"\u003e.\u003c/span\u003eiloc[\u003cspan style=\"color:#ae81ff\"\u003e2\u003c/span\u003e:]\u003cspan style=\"color:#f92672\"\u003e.\u003c/span\u003ecopy()\n\u003c/span\u003e\u003c/span\u003e\u003cspan style=\"display:flex;\"\u003e\u003cspan\u003efx_clean[\u003cspan style=\"color:#e6db74\"\u003e\u0026#39;日付\u0026#39;\u003c/span\u003e] \u003cspan style=\"color:#f92672\"\u003e=\u003c/span\u003e pd\u003cspan style=\"color:#f92672\"\u003e.\u003c/span\u003eto_datetime(fx_clean\u003cspan style=\"color:#f92672\"\u003e.\u003c/span\u003eiloc[:, \u003cspan style=\"color:#ae81ff\"\u003e0\u003c/span\u003e], format\u003cspan style=\"color:#f92672\"\u003e=\u003c/span\u003e\u003cspan style=\"color:#e6db74\"\u003e\u0026#39;%Y/%m/\u003c/span\u003e\u003cspan style=\"color:#e6db74\"\u003e%d\u003c/span\u003e\u003cspan style=\"color:#e6db74\"\u003e\u0026#39;\u003c/span\u003e)\n\u003c/span\u003e\u003c/span\u003e\u003cspan style=\"display:flex;\"\u003e\u003cspan\u003efx_clean[\u003cspan style=\"color:#e6db74\"\u003e\u0026#39;USD\u0026#39;\u003c/span\u003e] \u003cspan style=\"color:#f92672\"\u003e=\u003c/span\u003e pd\u003cspan style=\"color:#f92672\"\u003e.\u003c/span\u003eto_numeric(fx_clean\u003cspan style=\"color:#f92672\"\u003e.\u003c/span\u003eiloc[:, \u003cspan style=\"color:#ae81ff\"\u003e1\u003c/span\u003e], errors\u003cspan style=\"color:#f92672\"\u003e=\u003c/span\u003e\u003cspan style=\"color:#e6db74\"\u003e\u0026#39;coerce\u0026#39;\u003c/span\u003e)\n\u003c/span\u003e\u003c/span\u003e\u003cspan style=\"display:flex;\"\u003e\u003cspan\u003efx_clean[\u003cspan style=\"color:#e6db74\"\u003e\u0026#39;年月\u0026#39;\u003c/span\u003e] \u003cspan style=\"color:#f92672\"\u003e=\u003c/span\u003e fx_clean[\u003cspan style=\"color:#e6db74\"\u003e\u0026#39;日付\u0026#39;\u003c/span\u003e]\u003cspan style=\"color:#f92672\"\u003e.\u003c/span\u003edt\u003cspan style=\"color:#f92672\"\u003e.\u003c/span\u003estrftime(\u003cspan style=\"color:#e6db74\"\u003e\u0026#39;%Y%m\u0026#39;\u003c/span\u003e)\n\u003c/span\u003e\u003c/span\u003e\u003cspan style=\"display:flex;\"\u003e\u003cspan\u003emonthly_fx \u003cspan style=\"color:#f92672\"\u003e=\u003c/span\u003e fx_clean\u003cspan style=\"color:#f92672\"\u003e.\u003c/span\u003egroupby(\u003cspan style=\"color:#e6db74\"\u003e\u0026#39;年月\u0026#39;\u003c/span\u003e)[\u003cspan style=\"color:#e6db74\"\u003e\u0026#39;USD\u0026#39;\u003c/span\u003e]\u003cspan style=\"color:#f92672\"\u003e.\u003c/span\u003emean()\u003cspan style=\"color:#f92672\"\u003e.\u003c/span\u003ereset_index()\n\u003c/span\u003e\u003c/span\u003e\u003cspan style=\"display:flex;\"\u003e\u003cspan\u003emonthly_fx\u003cspan style=\"color:#f92672\"\u003e.\u003c/span\u003ecolumns \u003cspan style=\"color:#f92672\"\u003e=\u003c/span\u003e [\u003cspan style=\"color:#e6db74\"\u003e\u0026#39;年月\u0026#39;\u003c/span\u003e, \u003cspan style=\"color:#e6db74\"\u003e\u0026#39;ドル円\u0026#39;\u003c/span\u003e]\n\u003c/span\u003e\u003c/span\u003e\u003cspan style=\"display:flex;\"\u003e\u003cspan\u003e\n\u003c/span\u003e\u003c/span\u003e\u003cspan style=\"display:flex;\"\u003e\u003cspan\u003e\u003cspan style=\"color:#75715e\"\u003e# データ結合\u003c/span\u003e\n\u003c/span\u003e\u003c/span\u003e\u003cspan style=\"display:flex;\"\u003e\u003cspan\u003erecent \u003cspan style=\"color:#f92672\"\u003e=\u003c/span\u003e cpi_clean[cpi_clean[\u003cspan style=\"color:#e6db74\"\u003e\u0026#39;類・品目\u0026#39;\u003c/span\u003e] \u003cspan style=\"color:#f92672\"\u003e\u0026gt;=\u003c/span\u003e \u003cspan style=\"color:#e6db74\"\u003e\u0026#39;202301\u0026#39;\u003c/span\u003e]\u003cspan style=\"color:#f92672\"\u003e.\u003c/span\u003ecopy()\n\u003c/span\u003e\u003c/span\u003e\u003cspan style=\"display:flex;\"\u003e\u003cspan\u003edata \u003cspan style=\"color:#f92672\"\u003e=\u003c/span\u003e recent\u003cspan style=\"color:#f92672\"\u003e.\u003c/span\u003emerge(monthly_fx, left_on\u003cspan style=\"color:#f92672\"\u003e=\u003c/span\u003e\u003cspan style=\"color:#e6db74\"\u003e\u0026#39;類・品目\u0026#39;\u003c/span\u003e, right_on\u003cspan style=\"color:#f92672\"\u003e=\u003c/span\u003e\u003cspan style=\"color:#e6db74\"\u003e\u0026#39;年月\u0026#39;\u003c/span\u003e, how\u003cspan style=\"color:#f92672\"\u003e=\u003c/span\u003e\u003cspan style=\"color:#e6db74\"\u003e\u0026#39;left\u0026#39;\u003c/span\u003e)\n\u003c/span\u003e\u003c/span\u003e\u003c/code\u003e\u003c/pre\u003e\u003c/div\u003e\u003ch2 id=\"まず全体像を把握する\"\u003eまず全体像を把握する\u003c/h2\u003e\n\u003cp\u003e\u003cimg alt=\"消費者物価指数の推移（2023-2025）\" loading=\"lazy\" src=\"/posts/study-stats-eco/output_2_0.png\"\u003e\u003c/p\u003e\n\u003ch3 id=\"グラフから見える3つの真実\"\u003eグラフから見える3つの真実\u003c/h3\u003e\n\u003cp\u003e\u003cstrong\u003e1. エネルギー価格の激しい変動\u003c/strong\u003e\u003cbr\u003e\nオレンジ線を見ると、2023年初頭の135から2023年秋には104まで急落（-23%）。その後も上下を繰り返し、最終的に122で着地。\u003cstrong\u003e地政学リスクがそのまま価格に反映されている。\u003c/strong\u003e\u003c/p\u003e\n\u003cp\u003e\u003cstrong\u003e2. 食料価格の不可逆的上昇\u003c/strong\u003e\u003cbr\u003e\nピンク線は2023年から2025年にかけてほぼ一直線に上昇（109→127、+16%）。\u003cstrong\u003e一度上がった食品価格は下がらない構造的問題が見える。\u003c/strong\u003e\u003c/p\u003e\n\u003cp\u003e\u003cstrong\u003e3. 総合指数の「マイルド感」\u003c/strong\u003e\u003cbr\u003e\n青線は安定的に上昇（104→112、+7%）。しかし国民が実感する物価高は、日常的に買う食料品の16%上昇の方。\u003cstrong\u003e統計と実感の乖離がここに現れている。\u003c/strong\u003e\u003c/p\u003e","title":"データが暴く物価高騰の真実 - エネルギー価格と為替の相関分析で見えた意外な結論"},{"content":"概要 このマニュアルでは、Proxmox上のK3s環境で「設定を試しまくる→壊れる→完全に元に戻す」という実験サイクルを安全に行うためのセットアップと運用方法を説明します。\n前提条件 Proxmox VE環境 VM上にK3sがインストール済み kubectlが使用可能 1. Dashboard環境の構築 1.0 Helmのインストール まずはHelmをインストールします：\n# 公式スクリプトでインストール（推奨） curl https://raw.githubusercontent.com/helm/helm/main/scripts/get-helm-3 | bash # インストール確認 helm version # 必要なリポジトリを追加 helm repo add bitnami https://charts.bitnami.com/bitnami helm repo add kubernetes-dashboard https://kubernetes.github.io/dashboard/ helm repo update 重要: Helmでエラーが出る場合は、kubeconfigの設定を確認してください：\n# kubeconfigを設定 export KUBECONFIG=/etc/rancher/k3s/k3s.yaml # または永続的に設定 echo \u0026#39;export KUBECONFIG=/etc/rancher/k3s/k3s.yaml\u0026#39; \u0026gt;\u0026gt; ~/.bashrc source ~/.bashrc # 動作確認 kubectl get nodes 1.1 公式Kubernetes Dashboard インストール # kubernetes-dashboard namespaceを作成 kubectl create namespace kubernetes-dashboard # Dashboardをインストール helm install kubernetes-dashboard kubernetes-dashboard/kubernetes-dashboard -n kubernetes-dashboard アクセス方法 方法A: ポートフォワード（開発用）\n# フォアグラウンドで実行（ログが見やすい） kubectl port-forward -n kubernetes-dashboard service/kubernetes-dashboard-kong-proxy 8443:443 # バックグラウンドで実行 kubectl port-forward -n kubernetes-dashboard service/kubernetes-dashboard-kong-proxy 8443:443 \u0026amp; 方法B: NodePort（推奨）\n# ServiceをNodePortに変更 kubectl patch svc kubernetes-dashboard-kong-proxy -n kubernetes-dashboard -p \u0026#39;{\u0026#34;spec\u0026#34;:{\u0026#34;type\u0026#34;:\u0026#34;NodePort\u0026#34;}}\u0026#39; # 割り当てられたポートを確認 kubectl get svc kubernetes-dashboard-kong-proxy -n kubernetes-dashboard # ブラウザでアクセス # https://\u0026lt;k3s-master-ip\u0026gt;:\u0026lt;nodeport\u0026gt; 認証設定 管理者用のサービスアカウントとトークンを作成：\n# サービスアカウント作成 kubectl create serviceaccount dashboard-admin-sa -n kubernetes-dashboard kubectl create clusterrolebinding dashboard-admin-sa --clusterrole=cluster-admin --serviceaccount=kubernetes-dashboard:dashboard-admin-sa # トークン生成（都度実行推奨） kubectl -n kubernetes-dashboard create token dashboard-admin-sa # 長時間有効なトークンが必要な場合 kubectl -n kubernetes-dashboard create token dashboard-admin-sa --duration=24h セキュリティベストプラクティス: トークンは都度生成することを推奨します。デフォルトで1時間の有効期限があり、セキュリティ的により安全です。\nアクセス確認 ブラウザでDashboardにアクセス ログイン画面で「Token」を選択 上記で生成したトークンを入力 証明書警告が出た場合は「詳細設定」→「続行」で進む トラブルシューティング エラー: \u0026ldquo;400 Bad Request - The plain HTTP request was sent to HTTPS port\u0026rdquo;\nHTTPSでアクセスしてください: https:// を使用 このエラーはサービスが正常に動作している証拠です エラー: \u0026ldquo;502 Bad Gateway\u0026rdquo;\nサービスが起動していない可能性があります kubectl get pods -n kubernetes-dashboard でPod状態を確認 エラー: namespaces \u0026ldquo;kubernetes-dashboard\u0026rdquo; not found\nデフォルトnamespaceにインストールされている可能性があります kubectl get svc --all-namespaces | grep dashboard で確認 アクセス: https://localhost:8443 (ポートフォワード) または https://: (NodePort)\n1.2 Lens（推奨デスクトップアプリ） Lens公式サイトからダウンロード インストール後、kubeconfigを読み込み 直感的なGUIでクラスター管理が可能 1.3 k9s（ターミナルUI） # インストール（各OS対応） # macOS brew install k9s # Linux curl -sS https://webinstall.dev/k9s | bash # 起動 k9s 2. バックアップ・リストア戦略 2.1 レイヤー別復元戦略 レベル 方法 復元時間 粒度 用途 L1: VM全体 Proxmoxスナップショット 1-2分 粗い 大規模な変更前 L2: K8s設定 kubectl yaml出力 30秒-5分 中程度 アプリデプロイ前 L3: 個別アプリ Helmバックアップ 10-30秒 細かい 設定変更前 2.2 Proxmoxスナップショット運用 スナップショット作成 # VMのスナップショット作成 qm snapshot \u0026lt;vmid\u0026gt; clean-k3s-$(date +%Y%m%d-%H%M) # スナップショット一覧確認 qm listsnapshot \u0026lt;vmid\u0026gt; 復元 # 特定のスナップショットに復元 qm rollback \u0026lt;vmid\u0026gt; clean-k3s-20240119-1400 # VM再起動 qm reboot \u0026lt;vmid\u0026gt; 2.3 Kubernetes設定レベルバックアップ 全体バックアップスクリプト #!/bin/bash # backup-k8s.sh BACKUP_DIR=\u0026#34;./k8s-backups\u0026#34; TIMESTAMP=$(date +%Y%m%d-%H%M%S) BACKUP_PATH=\u0026#34;$BACKUP_DIR/backup-$TIMESTAMP\u0026#34; mkdir -p \u0026#34;$BACKUP_PATH\u0026#34; # 全リソースバックアップ echo \u0026#34;Backing up all resources...\u0026#34; kubectl get all --all-namespaces -o yaml \u0026gt; \u0026#34;$BACKUP_PATH/all-resources.yaml\u0026#34; # 重要な設定別バックアップ kubectl get configmaps --all-namespaces -o yaml \u0026gt; \u0026#34;$BACKUP_PATH/configmaps.yaml\u0026#34; kubectl get secrets --all-namespaces -o yaml \u0026gt; \u0026#34;$BACKUP_PATH/secrets.yaml\u0026#34; kubectl get persistentvolumes -o yaml \u0026gt; \u0026#34;$BACKUP_PATH/pv.yaml\u0026#34; kubectl get persistentvolumeclaims --all-namespaces -o yaml \u0026gt; \u0026#34;$BACKUP_PATH/pvc.yaml\u0026#34; # Helmリリース一覧 helm list --all-namespaces -o yaml \u0026gt; \u0026#34;$BACKUP_PATH/helm-releases.yaml\u0026#34; echo \u0026#34;Backup completed: $BACKUP_PATH\u0026#34; 復元スクリプト #!/bin/bash # restore-k8s.sh if [ -z \u0026#34;$1\u0026#34; ]; then echo \u0026#34;Usage: $0 \u0026lt;backup-timestamp\u0026gt;\u0026#34; echo \u0026#34;Available backups:\u0026#34; ls -1 ./k8s-backups/ | grep backup- exit 1 fi BACKUP_PATH=\u0026#34;./k8s-backups/backup-$1\u0026#34; if [ ! -d \u0026#34;$BACKUP_PATH\u0026#34; ]; then echo \u0026#34;Backup not found: $BACKUP_PATH\u0026#34; exit 1 fi # 既存リソースの削除（注意） read -p \u0026#34;This will delete existing resources. Continue? (y/N): \u0026#34; confirm if [ \u0026#34;$confirm\u0026#34; != \u0026#34;y\u0026#34; ]; then echo \u0026#34;Aborted.\u0026#34; exit 1 fi # 復元実行 echo \u0026#34;Restoring from $BACKUP_PATH...\u0026#34; kubectl delete --all deployments --all-namespaces kubectl delete --all services --all-namespaces kubectl delete --all configmaps --all-namespaces kubectl apply -f \u0026#34;$BACKUP_PATH/all-resources.yaml\u0026#34; echo \u0026#34;Restore completed.\u0026#34; 2.4 個別アプリケーションバックアップ Helmを使った管理 # アプリケーションの現在の設定を取得 helm get values my-app \u0026gt; my-app-backup-values.yaml # 復元 helm upgrade my-app bitnami/nginx -f my-app-backup-values.yaml 3. 実験フローの実践 3.1 実験前の準備 # 1. Proxmoxスナップショット作成 qm snapshot \u0026lt;vmid\u0026gt; before-experiment-$(date +%Y%m%d-%H%M) # 2. K8s設定バックアップ ./backup-k8s.sh # 3. 現在のHelmリリース確認 helm list --all-namespaces 3.2 実験中のモニタリング # k9sでリアルタイム監視 k9s # または特定リソースの監視 kubectl get pods --all-namespaces -w 3.3 問題発生時の復元手順 パターン1: アプリレベルの問題 # Helmで個別復元 helm rollback my-app 1 パターン2: 複数リソースの問題 # K8s設定レベルで復元 ./restore-k8s.sh 20240119-140500 パターン3: システム全体の問題 # Proxmoxスナップショットで復元 qm rollback \u0026lt;vmid\u0026gt; before-experiment-20240119-1400 qm reboot \u0026lt;vmid\u0026gt; 4. 効率的な実験のTips 4.1 namespace分離戦略 # 実験用namespaceを作成 kubectl create namespace experiment # 実験はこのnamespace内で実行 helm install test-app bitnami/nginx -n experiment # 実験終了後、namespace削除で全クリーンアップ kubectl delete namespace experiment 4.2 定期的なクリーンアップ # 古いスナップショットの削除（手動確認後） qm delsnapshot \u0026lt;vmid\u0026gt; old-snapshot-name # 古いバックアップファイルの削除 find ./k8s-backups -type d -mtime +30 -exec rm -rf {} \\; 4.3 よく使うコマンドのエイリアス # ~/.bashrc or ~/.zshrc に追加 alias k=\u0026#39;kubectl\u0026#39; alias kgp=\u0026#39;kubectl get pods\u0026#39; alias kgs=\u0026#39;kubectl get services\u0026#39; alias kdp=\u0026#39;kubectl describe pod\u0026#39; alias kaf=\u0026#39;kubectl apply -f\u0026#39; alias kdf=\u0026#39;kubectl delete -f\u0026#39; 5. トラブルシューティング 5.1 よくある問題 Q: スナップショット復元後、k3sが起動しない\n# k3sサービスの状態確認 sudo systemctl status k3s # 手動再起動 sudo systemctl restart k3s Q: kubectl接続できない\n# kubeconfigの確認 export KUBECONFIG=/etc/rancher/k3s/k3s.yaml # または sudo cp /etc/rancher/k3s/k3s.yaml ~/.kube/config sudo chown $USER:$USER ~/.kube/config Q: PersistentVolumeが復元されない\nPVは物理ストレージと連動するため、VM復元では不整合が起こる可能性 重要なデータは別途バックアップを推奨 5.2 緊急時対応 システム全体が応答しない場合：\nProxmoxコンソールから直接アクセス 最新の安定スナップショットに復元 k3sの手動再インストール（最終手段） 6. 運用ルール 6.1 バックアップのタイミング 毎日: 自動でK8s設定バックアップ 実験前: 必ずProxmoxスナップショット 週次: 古いバックアップのクリーンアップ 6.2 実験の記録 # 実験ログの記録 echo \u0026#34;$(date): Starting experiment with new ingress config\u0026#34; \u0026gt;\u0026gt; experiment.log 6.3 安全な実験のガイドライン 本番環境では絶対に実験しない 重要な設定変更前は必ずバックアップ 実験用namespaceを活用 変更内容をGitで管理 まとめ この環境により、安全に「実験→破壊→復元」のサイクルを高速で回すことが可能です。Proxmoxスナップショット、Kubernetes設定バックアップ、Helm管理を組み合わせることで、様々なレベルの復元オプションを用意し、効率的な学習と開発を支援します。\n","permalink":"https://techblog.wasutech.dev/posts/k8s-backup-restore/","summary":"Proxmox上のK3s環境で安全に実験・破壊・復元サイクルを回すための完全ガイド","title":"K3s実験環境構築マニュアル：安全に実験→破壊→復元のサイクルを回す"},{"content":"問題概要 整数で表される小惑星の配列 asteroids が与えられる。各小惑星について：\n絶対値：大きさ 符号：方向（正=右、負=左） 全て同じ速度で移動 衝突ルール：\n小さい方が爆発 同じ大きさなら両方爆発 同じ方向に移動する小惑星は衝突しない 全ての衝突後の状態を返せ。\n失敗した実装 from collections import deque class Solution: def asteroidCollision(self, asteroids: List[int]) -\u0026gt; List[int]: stack = [] for aster in asteroids: if len(stack) \u0026lt;= 0 or (aster \u0026lt;= 0) == (stack[-1] \u0026lt;= 0): stack.append(aster) else: while len(stack) \u0026gt; 0 and (aster \u0026lt;= 0) != (stack[-1] \u0026lt;= 0) and abs(aster) \u0026gt; abs(stack[-1]): stack.pop() if len(stack) \u0026lt;= 0 or (stack[-1] \u0026lt;= 0) == (aster \u0026lt;= 0): stack.append(aster) elif abs(aster) == abs(stack[-1]): stack.pop() return stack 問題点 同じ条件判定 if len(stack) \u0026lt;= 0 or (stack[-1] \u0026lt;= 0) == (aster \u0026lt;= 0) が2箇所に重複 制御フローが複雑で読みにくい (aster \u0026lt;= 0) != (stack[-1] \u0026lt;= 0) は「符号が異なる」を検出するが、衝突しないケースも含む 最適解 class Solution: def asteroidCollision(self, asteroids: List[int]) -\u0026gt; List[int]: stack = [] for asteroid in asteroids: while stack and asteroid \u0026lt; 0 \u0026lt; stack[-1]: # 右向き vs 左向きの衝突が発生 if abs(stack[-1]) \u0026lt; abs(asteroid): # 右向きが小さい → 爆発して次の右向きとも衝突判定 stack.pop() continue elif abs(stack[-1]) == abs(asteroid): # 同じ大きさ → 両方爆発 stack.pop() break else: # 衝突しなかった or 左向きが勝った stack.append(asteroid) return stack わかったこと 1. ループ条件の本質 asteroid \u0026lt; 0 \u0026lt; stack[-1] これは (asteroid \u0026lt; 0) and (0 \u0026lt; stack[-1]) と同じ。つまり：\nasteroid が左向き（負） stack[-1] が右向き（正） 2. パターンを表で整理すると明確になる asteroid stack[-1] 条件 衝突する？ 理由 5 (右) 3 (右) False ❌ 同じ方向 -5 (左) -3 (左) False ❌ 同じ方向 5 (右) -3 (左) False ❌ 右が後ろから追いかける -5 (左) 3 (右) True ✅ 正面衝突 衝突するパターンは1つだけ：左向きが右向きに突っ込む場合のみ。\n3. while-elseの活用 Pythonの while-else 構文：\nbreak で抜けた → else ブロックは実行されない break せずループ終了 → else ブロックが実行される この問題では：\nbreak = 右向きが勝った or 引き分け → 新しい小惑星は追加しない else = 衝突しなかった or 左向きが全て破壊 → 新しい小惑星を追加 4. 条件を絞り込む重要性 失敗実装では「符号が異なる」という広い条件を使い、後で追加判定が必要だった。\n最適解では「衝突する唯一のケース」を直接表現することで、ロジックがシンプルになった。\n学び パターンを表で整理すると、本質的な条件が見える ループ条件は狭く絞るほど、内部ロジックがシンプルになる while-else は状態管理をエレガントに表現できる 計算量 時間計算量：O(n) - 各小惑星は最大1回スタックに追加され、1回削除される 空間計算量：O(n) - 最悪の場合、全ての小惑星がスタックに残る ","permalink":"https://techblog.wasutech.dev/posts/leetcode-735/","summary":"\u003ch2 id=\"問題概要\"\u003e問題概要\u003c/h2\u003e\n\u003cp\u003e整数で表される小惑星の配列 \u003ccode\u003easteroids\u003c/code\u003e が与えられる。各小惑星について：\u003c/p\u003e\n\u003cul\u003e\n\u003cli\u003e絶対値：大きさ\u003c/li\u003e\n\u003cli\u003e符号：方向（正=右、負=左）\u003c/li\u003e\n\u003cli\u003e全て同じ速度で移動\u003c/li\u003e\n\u003c/ul\u003e\n\u003cp\u003e衝突ルール：\u003c/p\u003e\n\u003cul\u003e\n\u003cli\u003e小さい方が爆発\u003c/li\u003e\n\u003cli\u003e同じ大きさなら両方爆発\u003c/li\u003e\n\u003cli\u003e同じ方向に移動する小惑星は衝突しない\u003c/li\u003e\n\u003c/ul\u003e\n\u003cp\u003e全ての衝突後の状態を返せ。\u003c/p\u003e\n\u003ch2 id=\"失敗した実装\"\u003e失敗した実装\u003c/h2\u003e\n\u003cdiv class=\"highlight\"\u003e\u003cpre tabindex=\"0\" style=\"color:#f8f8f2;background-color:#272822;-moz-tab-size:4;-o-tab-size:4;tab-size:4;\"\u003e\u003ccode class=\"language-python\" data-lang=\"python\"\u003e\u003cspan style=\"display:flex;\"\u003e\u003cspan\u003e\u003cspan style=\"color:#f92672\"\u003efrom\u003c/span\u003e collections \u003cspan style=\"color:#f92672\"\u003eimport\u003c/span\u003e deque\n\u003c/span\u003e\u003c/span\u003e\u003cspan style=\"display:flex;\"\u003e\u003cspan\u003e\n\u003c/span\u003e\u003c/span\u003e\u003cspan style=\"display:flex;\"\u003e\u003cspan\u003e\u003cspan style=\"color:#66d9ef\"\u003eclass\u003c/span\u003e \u003cspan style=\"color:#a6e22e\"\u003eSolution\u003c/span\u003e:\n\u003c/span\u003e\u003c/span\u003e\u003cspan style=\"display:flex;\"\u003e\u003cspan\u003e    \u003cspan style=\"color:#66d9ef\"\u003edef\u003c/span\u003e \u003cspan style=\"color:#a6e22e\"\u003easteroidCollision\u003c/span\u003e(self, asteroids: List[int]) \u003cspan style=\"color:#f92672\"\u003e-\u0026gt;\u003c/span\u003e List[int]:\n\u003c/span\u003e\u003c/span\u003e\u003cspan style=\"display:flex;\"\u003e\u003cspan\u003e        stack \u003cspan style=\"color:#f92672\"\u003e=\u003c/span\u003e []\n\u003c/span\u003e\u003c/span\u003e\u003cspan style=\"display:flex;\"\u003e\u003cspan\u003e        \u003cspan style=\"color:#66d9ef\"\u003efor\u003c/span\u003e aster \u003cspan style=\"color:#f92672\"\u003ein\u003c/span\u003e asteroids:\n\u003c/span\u003e\u003c/span\u003e\u003cspan style=\"display:flex;\"\u003e\u003cspan\u003e            \u003cspan style=\"color:#66d9ef\"\u003eif\u003c/span\u003e len(stack) \u003cspan style=\"color:#f92672\"\u003e\u0026lt;=\u003c/span\u003e \u003cspan style=\"color:#ae81ff\"\u003e0\u003c/span\u003e \u003cspan style=\"color:#f92672\"\u003eor\u003c/span\u003e (aster \u003cspan style=\"color:#f92672\"\u003e\u0026lt;=\u003c/span\u003e \u003cspan style=\"color:#ae81ff\"\u003e0\u003c/span\u003e) \u003cspan style=\"color:#f92672\"\u003e==\u003c/span\u003e (stack[\u003cspan style=\"color:#f92672\"\u003e-\u003c/span\u003e\u003cspan style=\"color:#ae81ff\"\u003e1\u003c/span\u003e] \u003cspan style=\"color:#f92672\"\u003e\u0026lt;=\u003c/span\u003e \u003cspan style=\"color:#ae81ff\"\u003e0\u003c/span\u003e):\n\u003c/span\u003e\u003c/span\u003e\u003cspan style=\"display:flex;\"\u003e\u003cspan\u003e                stack\u003cspan style=\"color:#f92672\"\u003e.\u003c/span\u003eappend(aster)\n\u003c/span\u003e\u003c/span\u003e\u003cspan style=\"display:flex;\"\u003e\u003cspan\u003e            \u003cspan style=\"color:#66d9ef\"\u003eelse\u003c/span\u003e:\n\u003c/span\u003e\u003c/span\u003e\u003cspan style=\"display:flex;\"\u003e\u003cspan\u003e                \u003cspan style=\"color:#66d9ef\"\u003ewhile\u003c/span\u003e len(stack) \u003cspan style=\"color:#f92672\"\u003e\u0026gt;\u003c/span\u003e \u003cspan style=\"color:#ae81ff\"\u003e0\u003c/span\u003e \u003cspan style=\"color:#f92672\"\u003eand\u003c/span\u003e (aster \u003cspan style=\"color:#f92672\"\u003e\u0026lt;=\u003c/span\u003e \u003cspan style=\"color:#ae81ff\"\u003e0\u003c/span\u003e) \u003cspan style=\"color:#f92672\"\u003e!=\u003c/span\u003e (stack[\u003cspan style=\"color:#f92672\"\u003e-\u003c/span\u003e\u003cspan style=\"color:#ae81ff\"\u003e1\u003c/span\u003e] \u003cspan style=\"color:#f92672\"\u003e\u0026lt;=\u003c/span\u003e \u003cspan style=\"color:#ae81ff\"\u003e0\u003c/span\u003e) \u003cspan style=\"color:#f92672\"\u003eand\u003c/span\u003e abs(aster) \u003cspan style=\"color:#f92672\"\u003e\u0026gt;\u003c/span\u003e abs(stack[\u003cspan style=\"color:#f92672\"\u003e-\u003c/span\u003e\u003cspan style=\"color:#ae81ff\"\u003e1\u003c/span\u003e]):\n\u003c/span\u003e\u003c/span\u003e\u003cspan style=\"display:flex;\"\u003e\u003cspan\u003e                    stack\u003cspan style=\"color:#f92672\"\u003e.\u003c/span\u003epop()\n\u003c/span\u003e\u003c/span\u003e\u003cspan style=\"display:flex;\"\u003e\u003cspan\u003e                \u003cspan style=\"color:#66d9ef\"\u003eif\u003c/span\u003e len(stack) \u003cspan style=\"color:#f92672\"\u003e\u0026lt;=\u003c/span\u003e \u003cspan style=\"color:#ae81ff\"\u003e0\u003c/span\u003e \u003cspan style=\"color:#f92672\"\u003eor\u003c/span\u003e (stack[\u003cspan style=\"color:#f92672\"\u003e-\u003c/span\u003e\u003cspan style=\"color:#ae81ff\"\u003e1\u003c/span\u003e] \u003cspan style=\"color:#f92672\"\u003e\u0026lt;=\u003c/span\u003e \u003cspan style=\"color:#ae81ff\"\u003e0\u003c/span\u003e) \u003cspan style=\"color:#f92672\"\u003e==\u003c/span\u003e (aster \u003cspan style=\"color:#f92672\"\u003e\u0026lt;=\u003c/span\u003e \u003cspan style=\"color:#ae81ff\"\u003e0\u003c/span\u003e):\n\u003c/span\u003e\u003c/span\u003e\u003cspan style=\"display:flex;\"\u003e\u003cspan\u003e                    stack\u003cspan style=\"color:#f92672\"\u003e.\u003c/span\u003eappend(aster)\n\u003c/span\u003e\u003c/span\u003e\u003cspan style=\"display:flex;\"\u003e\u003cspan\u003e                \u003cspan style=\"color:#66d9ef\"\u003eelif\u003c/span\u003e abs(aster) \u003cspan style=\"color:#f92672\"\u003e==\u003c/span\u003e abs(stack[\u003cspan style=\"color:#f92672\"\u003e-\u003c/span\u003e\u003cspan style=\"color:#ae81ff\"\u003e1\u003c/span\u003e]):\n\u003c/span\u003e\u003c/span\u003e\u003cspan style=\"display:flex;\"\u003e\u003cspan\u003e                    stack\u003cspan style=\"color:#f92672\"\u003e.\u003c/span\u003epop()\n\u003c/span\u003e\u003c/span\u003e\u003cspan style=\"display:flex;\"\u003e\u003cspan\u003e        \u003cspan style=\"color:#66d9ef\"\u003ereturn\u003c/span\u003e stack\n\u003c/span\u003e\u003c/span\u003e\u003c/code\u003e\u003c/pre\u003e\u003c/div\u003e\u003ch3 id=\"問題点\"\u003e問題点\u003c/h3\u003e\n\u003cul\u003e\n\u003cli\u003e同じ条件判定 \u003ccode\u003eif len(stack) \u0026lt;= 0 or (stack[-1] \u0026lt;= 0) == (aster \u0026lt;= 0)\u003c/code\u003e が2箇所に重複\u003c/li\u003e\n\u003cli\u003e制御フローが複雑で読みにくい\u003c/li\u003e\n\u003cli\u003e\u003ccode\u003e(aster \u0026lt;= 0) != (stack[-1] \u0026lt;= 0)\u003c/code\u003e は「符号が異なる」を検出するが、衝突しないケースも含む\u003c/li\u003e\n\u003c/ul\u003e\n\u003ch2 id=\"最適解\"\u003e最適解\u003c/h2\u003e\n\u003cdiv class=\"highlight\"\u003e\u003cpre tabindex=\"0\" style=\"color:#f8f8f2;background-color:#272822;-moz-tab-size:4;-o-tab-size:4;tab-size:4;\"\u003e\u003ccode class=\"language-python\" data-lang=\"python\"\u003e\u003cspan style=\"display:flex;\"\u003e\u003cspan\u003e\u003cspan style=\"color:#66d9ef\"\u003eclass\u003c/span\u003e \u003cspan style=\"color:#a6e22e\"\u003eSolution\u003c/span\u003e:\n\u003c/span\u003e\u003c/span\u003e\u003cspan style=\"display:flex;\"\u003e\u003cspan\u003e    \u003cspan style=\"color:#66d9ef\"\u003edef\u003c/span\u003e \u003cspan style=\"color:#a6e22e\"\u003easteroidCollision\u003c/span\u003e(self, asteroids: List[int]) \u003cspan style=\"color:#f92672\"\u003e-\u0026gt;\u003c/span\u003e List[int]:\n\u003c/span\u003e\u003c/span\u003e\u003cspan style=\"display:flex;\"\u003e\u003cspan\u003e        stack \u003cspan style=\"color:#f92672\"\u003e=\u003c/span\u003e []\n\u003c/span\u003e\u003c/span\u003e\u003cspan style=\"display:flex;\"\u003e\u003cspan\u003e        \n\u003c/span\u003e\u003c/span\u003e\u003cspan style=\"display:flex;\"\u003e\u003cspan\u003e        \u003cspan style=\"color:#66d9ef\"\u003efor\u003c/span\u003e asteroid \u003cspan style=\"color:#f92672\"\u003ein\u003c/span\u003e asteroids:\n\u003c/span\u003e\u003c/span\u003e\u003cspan style=\"display:flex;\"\u003e\u003cspan\u003e            \u003cspan style=\"color:#66d9ef\"\u003ewhile\u003c/span\u003e stack \u003cspan style=\"color:#f92672\"\u003eand\u003c/span\u003e asteroid \u003cspan style=\"color:#f92672\"\u003e\u0026lt;\u003c/span\u003e \u003cspan style=\"color:#ae81ff\"\u003e0\u003c/span\u003e \u003cspan style=\"color:#f92672\"\u003e\u0026lt;\u003c/span\u003e stack[\u003cspan style=\"color:#f92672\"\u003e-\u003c/span\u003e\u003cspan style=\"color:#ae81ff\"\u003e1\u003c/span\u003e]:\n\u003c/span\u003e\u003c/span\u003e\u003cspan style=\"display:flex;\"\u003e\u003cspan\u003e                \u003cspan style=\"color:#75715e\"\u003e# 右向き vs 左向きの衝突が発生\u003c/span\u003e\n\u003c/span\u003e\u003c/span\u003e\u003cspan style=\"display:flex;\"\u003e\u003cspan\u003e                \u003cspan style=\"color:#66d9ef\"\u003eif\u003c/span\u003e abs(stack[\u003cspan style=\"color:#f92672\"\u003e-\u003c/span\u003e\u003cspan style=\"color:#ae81ff\"\u003e1\u003c/span\u003e]) \u003cspan style=\"color:#f92672\"\u003e\u0026lt;\u003c/span\u003e abs(asteroid):\n\u003c/span\u003e\u003c/span\u003e\u003cspan style=\"display:flex;\"\u003e\u003cspan\u003e                    \u003cspan style=\"color:#75715e\"\u003e# 右向きが小さい → 爆発して次の右向きとも衝突判定\u003c/span\u003e\n\u003c/span\u003e\u003c/span\u003e\u003cspan style=\"display:flex;\"\u003e\u003cspan\u003e                    stack\u003cspan style=\"color:#f92672\"\u003e.\u003c/span\u003epop()\n\u003c/span\u003e\u003c/span\u003e\u003cspan style=\"display:flex;\"\u003e\u003cspan\u003e                    \u003cspan style=\"color:#66d9ef\"\u003econtinue\u003c/span\u003e\n\u003c/span\u003e\u003c/span\u003e\u003cspan style=\"display:flex;\"\u003e\u003cspan\u003e                \u003cspan style=\"color:#66d9ef\"\u003eelif\u003c/span\u003e abs(stack[\u003cspan style=\"color:#f92672\"\u003e-\u003c/span\u003e\u003cspan style=\"color:#ae81ff\"\u003e1\u003c/span\u003e]) \u003cspan style=\"color:#f92672\"\u003e==\u003c/span\u003e abs(asteroid):\n\u003c/span\u003e\u003c/span\u003e\u003cspan style=\"display:flex;\"\u003e\u003cspan\u003e                    \u003cspan style=\"color:#75715e\"\u003e# 同じ大きさ → 両方爆発\u003c/span\u003e\n\u003c/span\u003e\u003c/span\u003e\u003cspan style=\"display:flex;\"\u003e\u003cspan\u003e                    stack\u003cspan style=\"color:#f92672\"\u003e.\u003c/span\u003epop()\n\u003c/span\u003e\u003c/span\u003e\u003cspan style=\"display:flex;\"\u003e\u003cspan\u003e                \u003cspan style=\"color:#66d9ef\"\u003ebreak\u003c/span\u003e\n\u003c/span\u003e\u003c/span\u003e\u003cspan style=\"display:flex;\"\u003e\u003cspan\u003e            \u003cspan style=\"color:#66d9ef\"\u003eelse\u003c/span\u003e:\n\u003c/span\u003e\u003c/span\u003e\u003cspan style=\"display:flex;\"\u003e\u003cspan\u003e                \u003cspan style=\"color:#75715e\"\u003e# 衝突しなかった or 左向きが勝った\u003c/span\u003e\n\u003c/span\u003e\u003c/span\u003e\u003cspan style=\"display:flex;\"\u003e\u003cspan\u003e                stack\u003cspan style=\"color:#f92672\"\u003e.\u003c/span\u003eappend(asteroid)\n\u003c/span\u003e\u003c/span\u003e\u003cspan style=\"display:flex;\"\u003e\u003cspan\u003e        \n\u003c/span\u003e\u003c/span\u003e\u003cspan style=\"display:flex;\"\u003e\u003cspan\u003e        \u003cspan style=\"color:#66d9ef\"\u003ereturn\u003c/span\u003e stack\n\u003c/span\u003e\u003c/span\u003e\u003c/code\u003e\u003c/pre\u003e\u003c/div\u003e\u003ch2 id=\"わかったこと\"\u003eわかったこと\u003c/h2\u003e\n\u003ch3 id=\"1-ループ条件の本質\"\u003e1. ループ条件の本質\u003c/h3\u003e\n\u003cdiv class=\"highlight\"\u003e\u003cpre tabindex=\"0\" style=\"color:#f8f8f2;background-color:#272822;-moz-tab-size:4;-o-tab-size:4;tab-size:4;\"\u003e\u003ccode class=\"language-python\" data-lang=\"python\"\u003e\u003cspan style=\"display:flex;\"\u003e\u003cspan\u003easteroid \u003cspan style=\"color:#f92672\"\u003e\u0026lt;\u003c/span\u003e \u003cspan style=\"color:#ae81ff\"\u003e0\u003c/span\u003e \u003cspan style=\"color:#f92672\"\u003e\u0026lt;\u003c/span\u003e stack[\u003cspan style=\"color:#f92672\"\u003e-\u003c/span\u003e\u003cspan style=\"color:#ae81ff\"\u003e1\u003c/span\u003e]\n\u003c/span\u003e\u003c/span\u003e\u003c/code\u003e\u003c/pre\u003e\u003c/div\u003e\u003cp\u003eこれは \u003ccode\u003e(asteroid \u0026lt; 0) and (0 \u0026lt; stack[-1])\u003c/code\u003e と同じ。つまり：\u003c/p\u003e","title":"LeetCode 735: Asteroid Collision - スタックで衝突判定を美しく解く"},{"content":"2.1 プロジェクト構造 go-network-programming/ ├── go.mod ├── go.sum ├── main.go ├── packet.go ├── node.go ├── link.go ├── network_stats.go # 新規追加 └── bandwidth_limiter.go # 新規追加 この章では、ネットワークに時間の概念を本格的に導入します。実際のネットワークのように、帯域幅制限、パケット処理時間、スループット測定を実装し、大きなファイルの送信をシミュレートします。\n2.2 ネットワーク統計の追加 ネットワークの性能を測定するための統計機能を追加します。\nファイル名: ./network_stats.go\npackage main import ( \u0026#34;fmt\u0026#34; \u0026#34;sync\u0026#34; \u0026#34;time\u0026#34; ) // NetworkStats はネットワークの統計情報を管理する // 実際のネットワークモニタリングツールのような機能を提供 type NetworkStats struct { mu sync.RWMutex startTime time.Time totalPacketsSent int64 totalPacketsRecv int64 totalBytesSent int64 totalBytesRecv int64 packetLossCount int64 lastUpdateTime time.Time } // NewNetworkStats は新しい統計オブジェクトを作成 func NewNetworkStats() *NetworkStats { return \u0026amp;NetworkStats{ startTime: time.Now(), lastUpdateTime: time.Now(), } } // RecordSentPacket は送信パケットを記録 func (ns *NetworkStats) RecordSentPacket(packet *Packet) { ns.mu.Lock() defer ns.mu.Unlock() ns.totalPacketsSent++ ns.totalBytesSent += int64(packet.Size) ns.lastUpdateTime = time.Now() } // RecordReceivedPacket は受信パケットを記録 func (ns *NetworkStats) RecordReceivedPacket(packet *Packet) { ns.mu.Lock() defer ns.mu.Unlock() ns.totalPacketsRecv++ ns.totalBytesRecv += int64(packet.Size) ns.lastUpdateTime = time.Now() } // RecordPacketLoss はパケット損失を記録 func (ns *NetworkStats) RecordPacketLoss() { ns.mu.Lock() defer ns.mu.Unlock() ns.packetLossCount++ ns.lastUpdateTime = time.Now() } // GetThroughput は現在のスループットを計算（bps: bits per second） func (ns *NetworkStats) GetThroughput() float64 { ns.mu.RLock() defer ns.mu.RUnlock() duration := time.Since(ns.startTime).Seconds() if duration == 0 { return 0 } // バイト数をビット数に変換（1バイト = 8ビット） totalBits := float64(ns.totalBytesSent) * 8 return totalBits / duration } // GetPacketLossRate はパケット損失率を計算（0.0-1.0） func (ns *NetworkStats) GetPacketLossRate() float64 { ns.mu.RLock() defer ns.mu.RUnlock() if ns.totalPacketsSent == 0 { return 0.0 } return float64(ns.packetLossCount) / float64(ns.totalPacketsSent) } // Print は統計情報を表示 func (ns *NetworkStats) Print() { ns.mu.RLock() defer ns.mu.RUnlock() duration := time.Since(ns.startTime) throughputBps := ns.GetThroughput() throughputKbps := throughputBps / 1000 lossRate := ns.GetPacketLossRate() * 100 fmt.Printf(\u0026#34;=== Network Statistics ===\\n\u0026#34;) fmt.Printf(\u0026#34;Duration: %v\\n\u0026#34;, duration.Round(time.Millisecond)) fmt.Printf(\u0026#34;Packets Sent: %d\\n\u0026#34;, ns.totalPacketsSent) fmt.Printf(\u0026#34;Packets Received: %d\\n\u0026#34;, ns.totalPacketsRecv) fmt.Printf(\u0026#34;Bytes Sent: %d\\n\u0026#34;, ns.totalBytesSent) fmt.Printf(\u0026#34;Bytes Received: %d\\n\u0026#34;, ns.totalBytesRecv) fmt.Printf(\u0026#34;Throughput: %.2f Kbps\\n\u0026#34;, throughputKbps) fmt.Printf(\u0026#34;Packet Loss Rate: %.2f%%\\n\u0026#34;, lossRate) fmt.Printf(\u0026#34;=========================\\n\u0026#34;) } 2.3 帯域幅制限機能の実装 実際のネットワークのように、帯域幅制限を実装します。\nファイル名: ./bandwidth_limiter.go\npackage main import ( \u0026#34;sync\u0026#34; \u0026#34;time\u0026#34; ) // BandwidthLimiter は帯域幅制限を実装する // トークンバケットアルゴリズムを使用してトラフィック制御を行う type BandwidthLimiter struct { mu sync.Mutex maxBandwidth int64 // 最大帯域幅（bytes per second） bucketSize int64 // バケットサイズ（bytes） tokens int64 // 現在のトークン数 lastRefill time.Time // 最後にトークンを補充した時刻 refillRate int64 // 1秒あたりのトークン補充量 } // NewBandwidthLimiter は新しい帯域幅制限器を作成 // maxBandwidthはbytes per secondで指定 func NewBandwidthLimiter(maxBandwidth int64) *BandwidthLimiter { return \u0026amp;BandwidthLimiter{ maxBandwidth: maxBandwidth, bucketSize: maxBandwidth * 2, // 2秒分のバケットサイズ tokens: maxBandwidth * 2, // 初期状態では満タン lastRefill: time.Now(), refillRate: maxBandwidth, } } // TryConsume は指定されたバイト数を消費できるかチェック // 消費できる場合はtrueを返し、トークンを減らす func (bl *BandwidthLimiter) TryConsume(bytes int64) bool { bl.mu.Lock() defer bl.mu.Unlock() bl.refillTokens() if bl.tokens \u0026gt;= bytes { bl.tokens -= bytes return true } return false } // WaitAndConsume は指定されたバイト数を消費するまで待機 // 必要に応じてブロックして、確実に消費する func (bl *BandwidthLimiter) WaitAndConsume(bytes int64) { for { if bl.TryConsume(bytes) { return } // トークンが不足している場合は少し待つ time.Sleep(10 * time.Millisecond) } } // refillTokens は時間経過に応じてトークンを補充 func (bl *BandwidthLimiter) refillTokens() { now := time.Now() elapsed := now.Sub(bl.lastRefill).Seconds() if elapsed \u0026gt; 0 { // 経過時間に応じてトークンを補充 tokensToAdd := int64(elapsed * float64(bl.refillRate)) bl.tokens += tokensToAdd // バケットサイズを超えないように制限 if bl.tokens \u0026gt; bl.bucketSize { bl.tokens = bl.bucketSize } bl.lastRefill = now } } // GetCurrentTokens は現在のトークン数を返す（デバッグ用） func (bl *BandwidthLimiter) GetCurrentTokens() int64 { bl.mu.Lock() defer bl.mu.Unlock() bl.refillTokens() return bl.tokens } 2.4 改良されたリンクの実装 帯域幅制限と統計機能を持つリンクに改良します。\nファイル名: ./link.go (更新)\npackage main import ( \u0026#34;fmt\u0026#34; \u0026#34;math/rand\u0026#34; \u0026#34;time\u0026#34; \u0026#34;github.com/google/uuid\u0026#34; ) // Link はノード間の接続を表現する（改良版） type Link struct { ID string NodeA *Node NodeB *Node Bandwidth int64 // 帯域幅（bytes per second） Latency time.Duration // 基本遅延時間 PacketLoss float64 // パケット損失率 channel chan *Packet running bool stats *NetworkStats // 統計情報 limiter *BandwidthLimiter // 帯域幅制限器 } // NewLink は新しいリンクを生成する（改良版） func NewLink(nodeA, nodeB *Node, bandwidthMbps int, latency time.Duration) *Link { // MbpsをBytes per secondに変換（1Mbps = 125,000 bytes/sec） bandwidthBps := int64(bandwidthMbps * 125000) link := \u0026amp;Link{ ID: uuid.New().String(), NodeA: nodeA, NodeB: nodeB, Bandwidth: bandwidthBps, Latency: latency, PacketLoss: 0.0, channel: make(chan *Packet, 100), // より大きなバッファ running: false, stats: NewNetworkStats(), limiter: NewBandwidthLimiter(bandwidthBps), } nodeA.AddLink(link) nodeB.AddLink(link) return link } // SetPacketLoss はパケット損失率を設定 func (l *Link) SetPacketLoss(lossRate float64) { l.PacketLoss = lossRate } // Start はリンクの動作を開始する func (l *Link) Start() { if l.running { return } l.running = true go l.forwardPackets() fmt.Printf(\u0026#34;Link between %s and %s started (Bandwidth: %d Mbps, Latency: %v)\\n\u0026#34;, l.NodeA.Name, l.NodeB.Name, l.Bandwidth/125000, l.Latency) } // Stop はリンクの動作を停止する func (l *Link) Stop() { if !l.running { return } l.running = false close(l.channel) // 終了時に統計情報を表示 fmt.Printf(\u0026#34;Link between %s and %s stopped\\n\u0026#34;, l.NodeA.Name, l.NodeB.Name) l.stats.Print() } // Send はリンクを通じてパケットを送信する func (l *Link) Send(packet *Packet) error { if !l.running { return fmt.Errorf(\u0026#34;link is not running\u0026#34;) } // 統計情報に記録 l.stats.RecordSentPacket(packet) select { case l.channel \u0026lt;- packet: return nil case \u0026lt;-time.After(100 * time.Millisecond): return fmt.Errorf(\u0026#34;link congested\u0026#34;) } } // CanReach は指定された宛先に到達可能かチェックする func (l *Link) CanReach(destination string) bool { return l.NodeA.Name == destination || l.NodeB.Name == destination } // forwardPackets はパケット転送のメインループ（改良版） func (l *Link) forwardPackets() { for l.running { select { case packet := \u0026lt;-l.channel: if packet != nil { // パケット損失をシミュレート if l.PacketLoss \u0026gt; 0 \u0026amp;\u0026amp; rand.Float64() \u0026lt; l.PacketLoss { l.stats.RecordPacketLoss() fmt.Printf(\u0026#34;Packet lost due to network error: %s\\n\u0026#34;, packet.ID[:8]) continue } // 帯域幅制限を適用 // パケットサイズ分のトークンを消費するまで待機 l.limiter.WaitAndConsume(int64(packet.Size)) // 実際の送信時間を計算（帯域幅による遅延） transmissionTime := time.Duration(float64(packet.Size) / float64(l.Bandwidth) * float64(time.Second)) // 基本遅延 + 送信時間 totalDelay := l.Latency + transmissionTime time.Sleep(totalDelay) // 宛先ノードを決定 var targetNode *Node if packet.Destination == l.NodeA.Name { targetNode = l.NodeA } else if packet.Destination == l.NodeB.Name { targetNode = l.NodeB } else { // ブロードキャスト的な動作 if packet.Source != l.NodeA.Name { targetNode = l.NodeA } else { targetNode = l.NodeB } } // パケットを配送 if targetNode != nil \u0026amp;\u0026amp; targetNode.running { select { case targetNode.inbox \u0026lt;- packet: l.stats.RecordReceivedPacket(packet) fmt.Printf(\u0026#34;Packet delivered to %s: %s (delay: %v)\\n\u0026#34;, targetNode.Name, packet.ID[:8], totalDelay) case \u0026lt;-time.After(10 * time.Millisecond): fmt.Printf(\u0026#34;Failed to deliver packet to %s (queue full)\\n\u0026#34;, targetNode.Name) l.stats.RecordPacketLoss() } } } default: time.Sleep(1 * time.Millisecond) } } } // PrintStats は統計情報を表示 func (l *Link) PrintStats() { fmt.Printf(\u0026#34;=== Link Stats: %s \u0026lt;-\u0026gt; %s ===\\n\u0026#34;, l.NodeA.Name, l.NodeB.Name) l.stats.Print() } func (l *Link) String() string { return fmt.Sprintf(\u0026#34;Link{%s \u0026lt;-\u0026gt; %s, %dMbps, %v latency}\u0026#34;, l.NodeA.Name, l.NodeB.Name, l.Bandwidth/125000, l.Latency) } 2.5 大きなファイル送信のテスト 複数のパケットで構成される大きなファイルの送信をテストします。\nファイル名: ./main.go (更新)\npackage main import ( \u0026#34;fmt\u0026#34; \u0026#34;time\u0026#34; ) // sendLargeFile は大きなファイルを複数のパケットに分割して送信 func sendLargeFile(sender *Node, receiver string, fileSize int, chunkSize int) { fmt.Printf(\u0026#34;%s sends %d bytes file to %s (chunk size: %d)\\n\u0026#34;, sender.Name, fileSize, receiver, chunkSize) totalChunks := (fileSize + chunkSize - 1) / chunkSize // 切り上げ計算 for i := 0; i \u0026lt; totalChunks; i++ { remainingSize := fileSize - (i * chunkSize) currentChunkSize := chunkSize if remainingSize \u0026lt; chunkSize { currentChunkSize = remainingSize } // ダミーデータを作成 data := make([]byte, currentChunkSize) for j := range data { data[j] = byte(i % 256) // チャンク番号に基づく値 } err := sender.Send(receiver, data) if err != nil { fmt.Printf(\u0026#34;Error sending chunk %d: %v\\n\u0026#34;, i+1, err) continue } fmt.Printf(\u0026#34;Sent chunk %d/%d (%d bytes)\\n\u0026#34;, i+1, totalChunks, currentChunkSize) // 少し間隔を空けて送信（実際のアプリケーションのように） time.Sleep(5 * time.Millisecond) } } // receiveFile は複数のパケットを受信してファイルを再構築 func receiveFile(receiver *Node, expectedChunks int) int { fmt.Printf(\u0026#34;%s starts receiving file (%d chunks expected)\\n\u0026#34;, receiver.Name, expectedChunks) receivedChunks := 0 totalBytes := 0 for receivedChunks \u0026lt; expectedChunks { packet := receiver.Receive() if packet != nil { receivedChunks++ totalBytes += packet.Size fmt.Printf(\u0026#34;Received chunk %d (%d bytes) from %s\\n\u0026#34;, receivedChunks, packet.Size, packet.Source) } else { fmt.Println(\u0026#34;Timeout waiting for packet\u0026#34;) break } } fmt.Printf(\u0026#34;File reception complete: %d chunks, %d total bytes\\n\u0026#34;, receivedChunks, totalBytes) return totalBytes } func main() { fmt.Println(\u0026#34;=== ネットワーク時間シミュレーション ===\u0026#34;) // ノードを作成 alice := NewNode(\u0026#34;Alice\u0026#34;) bob := NewNode(\u0026#34;Bob\u0026#34;) // 10Mbps、50ms遅延のリンクを作成（実際のインターネット接続のような設定） link := NewLink(alice, bob, 10, 50*time.Millisecond) // パケット損失を1%に設定 link.SetPacketLoss(0.01) // システム開始 alice.Start() bob.Start() link.Start() // 大きなファイル（1MB）を1KB単位で送信 fileSize := 1024 * 1024 // 1MB chunkSize := 1024 // 1KB expectedChunks := fileSize / chunkSize // 送信開始時刻を記録 startTime := time.Now() // 別goroutineでファイルを受信 receiveDone := make(chan int) go func() { receivedBytes := receiveFile(bob, expectedChunks) receiveDone \u0026lt;- receivedBytes }() // ファイルを送信 sendLargeFile(alice, \u0026#34;Bob\u0026#34;, fileSize, chunkSize) // 受信完了を待つ receivedBytes := \u0026lt;-receiveDone // 転送時間と統計を表示 transferTime := time.Since(startTime) actualThroughput := float64(receivedBytes*8) / transferTime.Seconds() / 1000 // Kbps fmt.Printf(\u0026#34;\\n=== Transfer Results ===\\n\u0026#34;) fmt.Printf(\u0026#34;Transfer Time: %v\\n\u0026#34;, transferTime.Round(time.Millisecond)) fmt.Printf(\u0026#34;Expected Bytes: %d\\n\u0026#34;, fileSize) fmt.Printf(\u0026#34;Received Bytes: %d\\n\u0026#34;, receivedBytes) fmt.Printf(\u0026#34;Actual Throughput: %.2f Kbps\\n\u0026#34;, actualThroughput) fmt.Printf(\u0026#34;Expected Throughput: %d Kbps (10 Mbps = 10,000 Kbps)\\n\u0026#34;, 10*1000) // しばらく待ってからシステムを停止 time.Sleep(100 * time.Millisecond) alice.Stop() bob.Stop() link.Stop() } 2.6 期待される出力例 === ネットワーク時間シミュレーション === Link added to node Alice Link added to node Bob Node Alice started Node Bob started Link between Alice and Bob started (Bandwidth: 10 Mbps, Latency: 50ms) Alice sends 1048576 bytes file to Bob (chunk size: 1024) Bob starts receiving file (1024 chunks expected) Sent chunk 1/1024 (1024 bytes) Packet delivered to Bob: a1b2c3d4 (delay: 50.8192ms) Received chunk 1 (1024 bytes) from Alice Sent chunk 2/1024 (1024 bytes) ... Packet lost due to network error: x9y8z7w6 ... File reception complete: 1019 chunks, 1043456 total bytes === Transfer Results === Transfer Time: 891ms Expected Bytes: 1048576 Received Bytes: 1043456 Actual Throughput: 9372.45 Kbps Expected Throughput: 10000 Kbps (10 Mbps = 10,000 Kbps) === Network Statistics === Duration: 891ms Packets Sent: 1024 Packets Received: 1019 Bytes Sent: 1048576 Bytes Received: 1043456 Throughput: 9372.45 Kbps Packet Loss Rate: 0.49% ========================= 2.7 重要な概念の解説 2.7.1 帯域幅制限 トークンバケットアルゴリズム: 一定速度でトークンを補充し、パケット送信時にトークンを消費 実際のネットワーク機器で使用されているのと同じ原理 2.7.2 伝送遅延 基本遅延: 物理的な信号伝播時間（光ファイバー、銅線など） 送信遅延: パケットサイズと帯域幅による遅延 2.7.3 スループット測定 理論値vs実測値: パケット損失や処理遅延により実測値は理論値を下回る Mbps vs MBps: 1 Mbps = 1,000,000 bps = 125,000 Bytes/sec 2.8 練習問題 異なる帯域幅でのテスト: 1Mbps、100Mbpsのリンクを作成し、転送時間の違いを確認してください。\nパケット損失の影響: 損失率を0%, 1%, 5%に変更して、スループットへの影響を測定してください。\n複数同時転送: 同じリンクで複数のファイルを同時に転送し、帯域幅の共有を確認してください。\n2.9 次章への準備 第3章では、複数のノードを接続するスイッチを実装し、MACアドレスによる転送を学習します。ローカルエリアネットワーク（LAN）の基本的な動作を再現していきます。\n","permalink":"https://techblog.wasutech.dev/posts/go-network-2/","summary":"\u003ch2 id=\"21-プロジェクト構造\"\u003e2.1 プロジェクト構造\u003c/h2\u003e\n\u003cpre tabindex=\"0\"\u003e\u003ccode\u003ego-network-programming/\n├── go.mod\n├── go.sum\n├── main.go\n├── packet.go\n├── node.go\n├── link.go\n├── network_stats.go    # 新規追加\n└── bandwidth_limiter.go # 新規追加\n\u003c/code\u003e\u003c/pre\u003e\u003cp\u003eこの章では、ネットワークに\u003cstrong\u003e時間\u003c/strong\u003eの概念を本格的に導入します。実際のネットワークのように、帯域幅制限、パケット処理時間、スループット測定を実装し、大きなファイルの送信をシミュレートします。\u003c/p\u003e\n\u003ch2 id=\"22-ネットワーク統計の追加\"\u003e2.2 ネットワーク統計の追加\u003c/h2\u003e\n\u003cp\u003eネットワークの性能を測定するための統計機能を追加します。\u003c/p\u003e\n\u003cp\u003e\u003cstrong\u003eファイル名: \u003ccode\u003e./network_stats.go\u003c/code\u003e\u003c/strong\u003e\u003c/p\u003e\n\u003cdiv class=\"highlight\"\u003e\u003cpre tabindex=\"0\" style=\"color:#f8f8f2;background-color:#272822;-moz-tab-size:4;-o-tab-size:4;tab-size:4;\"\u003e\u003ccode class=\"language-go\" data-lang=\"go\"\u003e\u003cspan style=\"display:flex;\"\u003e\u003cspan\u003e\u003cspan style=\"color:#f92672\"\u003epackage\u003c/span\u003e \u003cspan style=\"color:#a6e22e\"\u003emain\u003c/span\u003e\n\u003c/span\u003e\u003c/span\u003e\u003cspan style=\"display:flex;\"\u003e\u003cspan\u003e\n\u003c/span\u003e\u003c/span\u003e\u003cspan style=\"display:flex;\"\u003e\u003cspan\u003e\u003cspan style=\"color:#f92672\"\u003eimport\u003c/span\u003e (\n\u003c/span\u003e\u003c/span\u003e\u003cspan style=\"display:flex;\"\u003e\u003cspan\u003e    \u003cspan style=\"color:#e6db74\"\u003e\u0026#34;fmt\u0026#34;\u003c/span\u003e\n\u003c/span\u003e\u003c/span\u003e\u003cspan style=\"display:flex;\"\u003e\u003cspan\u003e    \u003cspan style=\"color:#e6db74\"\u003e\u0026#34;sync\u0026#34;\u003c/span\u003e\n\u003c/span\u003e\u003c/span\u003e\u003cspan style=\"display:flex;\"\u003e\u003cspan\u003e    \u003cspan style=\"color:#e6db74\"\u003e\u0026#34;time\u0026#34;\u003c/span\u003e\n\u003c/span\u003e\u003c/span\u003e\u003cspan style=\"display:flex;\"\u003e\u003cspan\u003e)\n\u003c/span\u003e\u003c/span\u003e\u003cspan style=\"display:flex;\"\u003e\u003cspan\u003e\n\u003c/span\u003e\u003c/span\u003e\u003cspan style=\"display:flex;\"\u003e\u003cspan\u003e\u003cspan style=\"color:#75715e\"\u003e// NetworkStats はネットワークの統計情報を管理する\u003c/span\u003e\n\u003c/span\u003e\u003c/span\u003e\u003cspan style=\"display:flex;\"\u003e\u003cspan\u003e\u003cspan style=\"color:#75715e\"\u003e// 実際のネットワークモニタリングツールのような機能を提供\u003c/span\u003e\n\u003c/span\u003e\u003c/span\u003e\u003cspan style=\"display:flex;\"\u003e\u003cspan\u003e\u003cspan style=\"color:#66d9ef\"\u003etype\u003c/span\u003e \u003cspan style=\"color:#a6e22e\"\u003eNetworkStats\u003c/span\u003e \u003cspan style=\"color:#66d9ef\"\u003estruct\u003c/span\u003e {\n\u003c/span\u003e\u003c/span\u003e\u003cspan style=\"display:flex;\"\u003e\u003cspan\u003e    \u003cspan style=\"color:#a6e22e\"\u003emu\u003c/span\u003e                \u003cspan style=\"color:#a6e22e\"\u003esync\u003c/span\u003e.\u003cspan style=\"color:#a6e22e\"\u003eRWMutex\u003c/span\u003e\n\u003c/span\u003e\u003c/span\u003e\u003cspan style=\"display:flex;\"\u003e\u003cspan\u003e    \u003cspan style=\"color:#a6e22e\"\u003estartTime\u003c/span\u003e         \u003cspan style=\"color:#a6e22e\"\u003etime\u003c/span\u003e.\u003cspan style=\"color:#a6e22e\"\u003eTime\u003c/span\u003e\n\u003c/span\u003e\u003c/span\u003e\u003cspan style=\"display:flex;\"\u003e\u003cspan\u003e    \u003cspan style=\"color:#a6e22e\"\u003etotalPacketsSent\u003c/span\u003e  \u003cspan style=\"color:#66d9ef\"\u003eint64\u003c/span\u003e\n\u003c/span\u003e\u003c/span\u003e\u003cspan style=\"display:flex;\"\u003e\u003cspan\u003e    \u003cspan style=\"color:#a6e22e\"\u003etotalPacketsRecv\u003c/span\u003e  \u003cspan style=\"color:#66d9ef\"\u003eint64\u003c/span\u003e\n\u003c/span\u003e\u003c/span\u003e\u003cspan style=\"display:flex;\"\u003e\u003cspan\u003e    \u003cspan style=\"color:#a6e22e\"\u003etotalBytesSent\u003c/span\u003e    \u003cspan style=\"color:#66d9ef\"\u003eint64\u003c/span\u003e\n\u003c/span\u003e\u003c/span\u003e\u003cspan style=\"display:flex;\"\u003e\u003cspan\u003e    \u003cspan style=\"color:#a6e22e\"\u003etotalBytesRecv\u003c/span\u003e    \u003cspan style=\"color:#66d9ef\"\u003eint64\u003c/span\u003e\n\u003c/span\u003e\u003c/span\u003e\u003cspan style=\"display:flex;\"\u003e\u003cspan\u003e    \u003cspan style=\"color:#a6e22e\"\u003epacketLossCount\u003c/span\u003e   \u003cspan style=\"color:#66d9ef\"\u003eint64\u003c/span\u003e\n\u003c/span\u003e\u003c/span\u003e\u003cspan style=\"display:flex;\"\u003e\u003cspan\u003e    \u003cspan style=\"color:#a6e22e\"\u003elastUpdateTime\u003c/span\u003e    \u003cspan style=\"color:#a6e22e\"\u003etime\u003c/span\u003e.\u003cspan style=\"color:#a6e22e\"\u003eTime\u003c/span\u003e\n\u003c/span\u003e\u003c/span\u003e\u003cspan style=\"display:flex;\"\u003e\u003cspan\u003e}\n\u003c/span\u003e\u003c/span\u003e\u003cspan style=\"display:flex;\"\u003e\u003cspan\u003e\n\u003c/span\u003e\u003c/span\u003e\u003cspan style=\"display:flex;\"\u003e\u003cspan\u003e\u003cspan style=\"color:#75715e\"\u003e// NewNetworkStats は新しい統計オブジェクトを作成\u003c/span\u003e\n\u003c/span\u003e\u003c/span\u003e\u003cspan style=\"display:flex;\"\u003e\u003cspan\u003e\u003cspan style=\"color:#66d9ef\"\u003efunc\u003c/span\u003e \u003cspan style=\"color:#a6e22e\"\u003eNewNetworkStats\u003c/span\u003e() \u003cspan style=\"color:#f92672\"\u003e*\u003c/span\u003e\u003cspan style=\"color:#a6e22e\"\u003eNetworkStats\u003c/span\u003e {\n\u003c/span\u003e\u003c/span\u003e\u003cspan style=\"display:flex;\"\u003e\u003cspan\u003e    \u003cspan style=\"color:#66d9ef\"\u003ereturn\u003c/span\u003e \u003cspan style=\"color:#f92672\"\u003e\u0026amp;\u003c/span\u003e\u003cspan style=\"color:#a6e22e\"\u003eNetworkStats\u003c/span\u003e{\n\u003c/span\u003e\u003c/span\u003e\u003cspan style=\"display:flex;\"\u003e\u003cspan\u003e        \u003cspan style=\"color:#a6e22e\"\u003estartTime\u003c/span\u003e:      \u003cspan style=\"color:#a6e22e\"\u003etime\u003c/span\u003e.\u003cspan style=\"color:#a6e22e\"\u003eNow\u003c/span\u003e(),\n\u003c/span\u003e\u003c/span\u003e\u003cspan style=\"display:flex;\"\u003e\u003cspan\u003e        \u003cspan style=\"color:#a6e22e\"\u003elastUpdateTime\u003c/span\u003e: \u003cspan style=\"color:#a6e22e\"\u003etime\u003c/span\u003e.\u003cspan style=\"color:#a6e22e\"\u003eNow\u003c/span\u003e(),\n\u003c/span\u003e\u003c/span\u003e\u003cspan style=\"display:flex;\"\u003e\u003cspan\u003e    }\n\u003c/span\u003e\u003c/span\u003e\u003cspan style=\"display:flex;\"\u003e\u003cspan\u003e}\n\u003c/span\u003e\u003c/span\u003e\u003cspan style=\"display:flex;\"\u003e\u003cspan\u003e\n\u003c/span\u003e\u003c/span\u003e\u003cspan style=\"display:flex;\"\u003e\u003cspan\u003e\u003cspan style=\"color:#75715e\"\u003e// RecordSentPacket は送信パケットを記録\u003c/span\u003e\n\u003c/span\u003e\u003c/span\u003e\u003cspan style=\"display:flex;\"\u003e\u003cspan\u003e\u003cspan style=\"color:#66d9ef\"\u003efunc\u003c/span\u003e (\u003cspan style=\"color:#a6e22e\"\u003ens\u003c/span\u003e \u003cspan style=\"color:#f92672\"\u003e*\u003c/span\u003e\u003cspan style=\"color:#a6e22e\"\u003eNetworkStats\u003c/span\u003e) \u003cspan style=\"color:#a6e22e\"\u003eRecordSentPacket\u003c/span\u003e(\u003cspan style=\"color:#a6e22e\"\u003epacket\u003c/span\u003e \u003cspan style=\"color:#f92672\"\u003e*\u003c/span\u003e\u003cspan style=\"color:#a6e22e\"\u003ePacket\u003c/span\u003e) {\n\u003c/span\u003e\u003c/span\u003e\u003cspan style=\"display:flex;\"\u003e\u003cspan\u003e    \u003cspan style=\"color:#a6e22e\"\u003ens\u003c/span\u003e.\u003cspan style=\"color:#a6e22e\"\u003emu\u003c/span\u003e.\u003cspan style=\"color:#a6e22e\"\u003eLock\u003c/span\u003e()\n\u003c/span\u003e\u003c/span\u003e\u003cspan style=\"display:flex;\"\u003e\u003cspan\u003e    \u003cspan style=\"color:#66d9ef\"\u003edefer\u003c/span\u003e \u003cspan style=\"color:#a6e22e\"\u003ens\u003c/span\u003e.\u003cspan style=\"color:#a6e22e\"\u003emu\u003c/span\u003e.\u003cspan style=\"color:#a6e22e\"\u003eUnlock\u003c/span\u003e()\n\u003c/span\u003e\u003c/span\u003e\u003cspan style=\"display:flex;\"\u003e\u003cspan\u003e    \n\u003c/span\u003e\u003c/span\u003e\u003cspan style=\"display:flex;\"\u003e\u003cspan\u003e    \u003cspan style=\"color:#a6e22e\"\u003ens\u003c/span\u003e.\u003cspan style=\"color:#a6e22e\"\u003etotalPacketsSent\u003c/span\u003e\u003cspan style=\"color:#f92672\"\u003e++\u003c/span\u003e\n\u003c/span\u003e\u003c/span\u003e\u003cspan style=\"display:flex;\"\u003e\u003cspan\u003e    \u003cspan style=\"color:#a6e22e\"\u003ens\u003c/span\u003e.\u003cspan style=\"color:#a6e22e\"\u003etotalBytesSent\u003c/span\u003e \u003cspan style=\"color:#f92672\"\u003e+=\u003c/span\u003e int64(\u003cspan style=\"color:#a6e22e\"\u003epacket\u003c/span\u003e.\u003cspan style=\"color:#a6e22e\"\u003eSize\u003c/span\u003e)\n\u003c/span\u003e\u003c/span\u003e\u003cspan style=\"display:flex;\"\u003e\u003cspan\u003e    \u003cspan style=\"color:#a6e22e\"\u003ens\u003c/span\u003e.\u003cspan style=\"color:#a6e22e\"\u003elastUpdateTime\u003c/span\u003e = \u003cspan style=\"color:#a6e22e\"\u003etime\u003c/span\u003e.\u003cspan style=\"color:#a6e22e\"\u003eNow\u003c/span\u003e()\n\u003c/span\u003e\u003c/span\u003e\u003cspan style=\"display:flex;\"\u003e\u003cspan\u003e}\n\u003c/span\u003e\u003c/span\u003e\u003cspan style=\"display:flex;\"\u003e\u003cspan\u003e\n\u003c/span\u003e\u003c/span\u003e\u003cspan style=\"display:flex;\"\u003e\u003cspan\u003e\u003cspan style=\"color:#75715e\"\u003e// RecordReceivedPacket は受信パケットを記録\u003c/span\u003e\n\u003c/span\u003e\u003c/span\u003e\u003cspan style=\"display:flex;\"\u003e\u003cspan\u003e\u003cspan style=\"color:#66d9ef\"\u003efunc\u003c/span\u003e (\u003cspan style=\"color:#a6e22e\"\u003ens\u003c/span\u003e \u003cspan style=\"color:#f92672\"\u003e*\u003c/span\u003e\u003cspan style=\"color:#a6e22e\"\u003eNetworkStats\u003c/span\u003e) \u003cspan style=\"color:#a6e22e\"\u003eRecordReceivedPacket\u003c/span\u003e(\u003cspan style=\"color:#a6e22e\"\u003epacket\u003c/span\u003e \u003cspan style=\"color:#f92672\"\u003e*\u003c/span\u003e\u003cspan style=\"color:#a6e22e\"\u003ePacket\u003c/span\u003e) {\n\u003c/span\u003e\u003c/span\u003e\u003cspan style=\"display:flex;\"\u003e\u003cspan\u003e    \u003cspan style=\"color:#a6e22e\"\u003ens\u003c/span\u003e.\u003cspan style=\"color:#a6e22e\"\u003emu\u003c/span\u003e.\u003cspan style=\"color:#a6e22e\"\u003eLock\u003c/span\u003e()\n\u003c/span\u003e\u003c/span\u003e\u003cspan style=\"display:flex;\"\u003e\u003cspan\u003e    \u003cspan style=\"color:#66d9ef\"\u003edefer\u003c/span\u003e \u003cspan style=\"color:#a6e22e\"\u003ens\u003c/span\u003e.\u003cspan style=\"color:#a6e22e\"\u003emu\u003c/span\u003e.\u003cspan style=\"color:#a6e22e\"\u003eUnlock\u003c/span\u003e()\n\u003c/span\u003e\u003c/span\u003e\u003cspan style=\"display:flex;\"\u003e\u003cspan\u003e    \n\u003c/span\u003e\u003c/span\u003e\u003cspan style=\"display:flex;\"\u003e\u003cspan\u003e    \u003cspan style=\"color:#a6e22e\"\u003ens\u003c/span\u003e.\u003cspan style=\"color:#a6e22e\"\u003etotalPacketsRecv\u003c/span\u003e\u003cspan style=\"color:#f92672\"\u003e++\u003c/span\u003e\n\u003c/span\u003e\u003c/span\u003e\u003cspan style=\"display:flex;\"\u003e\u003cspan\u003e    \u003cspan style=\"color:#a6e22e\"\u003ens\u003c/span\u003e.\u003cspan style=\"color:#a6e22e\"\u003etotalBytesRecv\u003c/span\u003e \u003cspan style=\"color:#f92672\"\u003e+=\u003c/span\u003e int64(\u003cspan style=\"color:#a6e22e\"\u003epacket\u003c/span\u003e.\u003cspan style=\"color:#a6e22e\"\u003eSize\u003c/span\u003e)\n\u003c/span\u003e\u003c/span\u003e\u003cspan style=\"display:flex;\"\u003e\u003cspan\u003e    \u003cspan style=\"color:#a6e22e\"\u003ens\u003c/span\u003e.\u003cspan style=\"color:#a6e22e\"\u003elastUpdateTime\u003c/span\u003e = \u003cspan style=\"color:#a6e22e\"\u003etime\u003c/span\u003e.\u003cspan style=\"color:#a6e22e\"\u003eNow\u003c/span\u003e()\n\u003c/span\u003e\u003c/span\u003e\u003cspan style=\"display:flex;\"\u003e\u003cspan\u003e}\n\u003c/span\u003e\u003c/span\u003e\u003cspan style=\"display:flex;\"\u003e\u003cspan\u003e\n\u003c/span\u003e\u003c/span\u003e\u003cspan style=\"display:flex;\"\u003e\u003cspan\u003e\u003cspan style=\"color:#75715e\"\u003e// RecordPacketLoss はパケット損失を記録\u003c/span\u003e\n\u003c/span\u003e\u003c/span\u003e\u003cspan style=\"display:flex;\"\u003e\u003cspan\u003e\u003cspan style=\"color:#66d9ef\"\u003efunc\u003c/span\u003e (\u003cspan style=\"color:#a6e22e\"\u003ens\u003c/span\u003e \u003cspan style=\"color:#f92672\"\u003e*\u003c/span\u003e\u003cspan style=\"color:#a6e22e\"\u003eNetworkStats\u003c/span\u003e) \u003cspan style=\"color:#a6e22e\"\u003eRecordPacketLoss\u003c/span\u003e() {\n\u003c/span\u003e\u003c/span\u003e\u003cspan style=\"display:flex;\"\u003e\u003cspan\u003e    \u003cspan style=\"color:#a6e22e\"\u003ens\u003c/span\u003e.\u003cspan style=\"color:#a6e22e\"\u003emu\u003c/span\u003e.\u003cspan style=\"color:#a6e22e\"\u003eLock\u003c/span\u003e()\n\u003c/span\u003e\u003c/span\u003e\u003cspan style=\"display:flex;\"\u003e\u003cspan\u003e    \u003cspan style=\"color:#66d9ef\"\u003edefer\u003c/span\u003e \u003cspan style=\"color:#a6e22e\"\u003ens\u003c/span\u003e.\u003cspan style=\"color:#a6e22e\"\u003emu\u003c/span\u003e.\u003cspan style=\"color:#a6e22e\"\u003eUnlock\u003c/span\u003e()\n\u003c/span\u003e\u003c/span\u003e\u003cspan style=\"display:flex;\"\u003e\u003cspan\u003e    \n\u003c/span\u003e\u003c/span\u003e\u003cspan style=\"display:flex;\"\u003e\u003cspan\u003e    \u003cspan style=\"color:#a6e22e\"\u003ens\u003c/span\u003e.\u003cspan style=\"color:#a6e22e\"\u003epacketLossCount\u003c/span\u003e\u003cspan style=\"color:#f92672\"\u003e++\u003c/span\u003e\n\u003c/span\u003e\u003c/span\u003e\u003cspan style=\"display:flex;\"\u003e\u003cspan\u003e    \u003cspan style=\"color:#a6e22e\"\u003ens\u003c/span\u003e.\u003cspan style=\"color:#a6e22e\"\u003elastUpdateTime\u003c/span\u003e = \u003cspan style=\"color:#a6e22e\"\u003etime\u003c/span\u003e.\u003cspan style=\"color:#a6e22e\"\u003eNow\u003c/span\u003e()\n\u003c/span\u003e\u003c/span\u003e\u003cspan style=\"display:flex;\"\u003e\u003cspan\u003e}\n\u003c/span\u003e\u003c/span\u003e\u003cspan style=\"display:flex;\"\u003e\u003cspan\u003e\n\u003c/span\u003e\u003c/span\u003e\u003cspan style=\"display:flex;\"\u003e\u003cspan\u003e\u003cspan style=\"color:#75715e\"\u003e// GetThroughput は現在のスループットを計算（bps: bits per second）\u003c/span\u003e\n\u003c/span\u003e\u003c/span\u003e\u003cspan style=\"display:flex;\"\u003e\u003cspan\u003e\u003cspan style=\"color:#66d9ef\"\u003efunc\u003c/span\u003e (\u003cspan style=\"color:#a6e22e\"\u003ens\u003c/span\u003e \u003cspan style=\"color:#f92672\"\u003e*\u003c/span\u003e\u003cspan style=\"color:#a6e22e\"\u003eNetworkStats\u003c/span\u003e) \u003cspan style=\"color:#a6e22e\"\u003eGetThroughput\u003c/span\u003e() \u003cspan style=\"color:#66d9ef\"\u003efloat64\u003c/span\u003e {\n\u003c/span\u003e\u003c/span\u003e\u003cspan style=\"display:flex;\"\u003e\u003cspan\u003e    \u003cspan style=\"color:#a6e22e\"\u003ens\u003c/span\u003e.\u003cspan style=\"color:#a6e22e\"\u003emu\u003c/span\u003e.\u003cspan style=\"color:#a6e22e\"\u003eRLock\u003c/span\u003e()\n\u003c/span\u003e\u003c/span\u003e\u003cspan style=\"display:flex;\"\u003e\u003cspan\u003e    \u003cspan style=\"color:#66d9ef\"\u003edefer\u003c/span\u003e \u003cspan style=\"color:#a6e22e\"\u003ens\u003c/span\u003e.\u003cspan style=\"color:#a6e22e\"\u003emu\u003c/span\u003e.\u003cspan style=\"color:#a6e22e\"\u003eRUnlock\u003c/span\u003e()\n\u003c/span\u003e\u003c/span\u003e\u003cspan style=\"display:flex;\"\u003e\u003cspan\u003e    \n\u003c/span\u003e\u003c/span\u003e\u003cspan style=\"display:flex;\"\u003e\u003cspan\u003e    \u003cspan style=\"color:#a6e22e\"\u003eduration\u003c/span\u003e \u003cspan style=\"color:#f92672\"\u003e:=\u003c/span\u003e \u003cspan style=\"color:#a6e22e\"\u003etime\u003c/span\u003e.\u003cspan style=\"color:#a6e22e\"\u003eSince\u003c/span\u003e(\u003cspan style=\"color:#a6e22e\"\u003ens\u003c/span\u003e.\u003cspan style=\"color:#a6e22e\"\u003estartTime\u003c/span\u003e).\u003cspan style=\"color:#a6e22e\"\u003eSeconds\u003c/span\u003e()\n\u003c/span\u003e\u003c/span\u003e\u003cspan style=\"display:flex;\"\u003e\u003cspan\u003e    \u003cspan style=\"color:#66d9ef\"\u003eif\u003c/span\u003e \u003cspan style=\"color:#a6e22e\"\u003eduration\u003c/span\u003e \u003cspan style=\"color:#f92672\"\u003e==\u003c/span\u003e \u003cspan style=\"color:#ae81ff\"\u003e0\u003c/span\u003e {\n\u003c/span\u003e\u003c/span\u003e\u003cspan style=\"display:flex;\"\u003e\u003cspan\u003e        \u003cspan style=\"color:#66d9ef\"\u003ereturn\u003c/span\u003e \u003cspan style=\"color:#ae81ff\"\u003e0\u003c/span\u003e\n\u003c/span\u003e\u003c/span\u003e\u003cspan style=\"display:flex;\"\u003e\u003cspan\u003e    }\n\u003c/span\u003e\u003c/span\u003e\u003cspan style=\"display:flex;\"\u003e\u003cspan\u003e    \n\u003c/span\u003e\u003c/span\u003e\u003cspan style=\"display:flex;\"\u003e\u003cspan\u003e    \u003cspan style=\"color:#75715e\"\u003e// バイト数をビット数に変換（1バイト = 8ビット）\u003c/span\u003e\n\u003c/span\u003e\u003c/span\u003e\u003cspan style=\"display:flex;\"\u003e\u003cspan\u003e    \u003cspan style=\"color:#a6e22e\"\u003etotalBits\u003c/span\u003e \u003cspan style=\"color:#f92672\"\u003e:=\u003c/span\u003e float64(\u003cspan style=\"color:#a6e22e\"\u003ens\u003c/span\u003e.\u003cspan style=\"color:#a6e22e\"\u003etotalBytesSent\u003c/span\u003e) \u003cspan style=\"color:#f92672\"\u003e*\u003c/span\u003e \u003cspan style=\"color:#ae81ff\"\u003e8\u003c/span\u003e\n\u003c/span\u003e\u003c/span\u003e\u003cspan style=\"display:flex;\"\u003e\u003cspan\u003e    \u003cspan style=\"color:#66d9ef\"\u003ereturn\u003c/span\u003e \u003cspan style=\"color:#a6e22e\"\u003etotalBits\u003c/span\u003e \u003cspan style=\"color:#f92672\"\u003e/\u003c/span\u003e \u003cspan style=\"color:#a6e22e\"\u003eduration\u003c/span\u003e\n\u003c/span\u003e\u003c/span\u003e\u003cspan style=\"display:flex;\"\u003e\u003cspan\u003e}\n\u003c/span\u003e\u003c/span\u003e\u003cspan style=\"display:flex;\"\u003e\u003cspan\u003e\n\u003c/span\u003e\u003c/span\u003e\u003cspan style=\"display:flex;\"\u003e\u003cspan\u003e\u003cspan style=\"color:#75715e\"\u003e// GetPacketLossRate はパケット損失率を計算（0.0-1.0）\u003c/span\u003e\n\u003c/span\u003e\u003c/span\u003e\u003cspan style=\"display:flex;\"\u003e\u003cspan\u003e\u003cspan style=\"color:#66d9ef\"\u003efunc\u003c/span\u003e (\u003cspan style=\"color:#a6e22e\"\u003ens\u003c/span\u003e \u003cspan style=\"color:#f92672\"\u003e*\u003c/span\u003e\u003cspan style=\"color:#a6e22e\"\u003eNetworkStats\u003c/span\u003e) \u003cspan style=\"color:#a6e22e\"\u003eGetPacketLossRate\u003c/span\u003e() \u003cspan style=\"color:#66d9ef\"\u003efloat64\u003c/span\u003e {\n\u003c/span\u003e\u003c/span\u003e\u003cspan style=\"display:flex;\"\u003e\u003cspan\u003e    \u003cspan style=\"color:#a6e22e\"\u003ens\u003c/span\u003e.\u003cspan style=\"color:#a6e22e\"\u003emu\u003c/span\u003e.\u003cspan style=\"color:#a6e22e\"\u003eRLock\u003c/span\u003e()\n\u003c/span\u003e\u003c/span\u003e\u003cspan style=\"display:flex;\"\u003e\u003cspan\u003e    \u003cspan style=\"color:#66d9ef\"\u003edefer\u003c/span\u003e \u003cspan style=\"color:#a6e22e\"\u003ens\u003c/span\u003e.\u003cspan style=\"color:#a6e22e\"\u003emu\u003c/span\u003e.\u003cspan style=\"color:#a6e22e\"\u003eRUnlock\u003c/span\u003e()\n\u003c/span\u003e\u003c/span\u003e\u003cspan style=\"display:flex;\"\u003e\u003cspan\u003e    \n\u003c/span\u003e\u003c/span\u003e\u003cspan style=\"display:flex;\"\u003e\u003cspan\u003e    \u003cspan style=\"color:#66d9ef\"\u003eif\u003c/span\u003e \u003cspan style=\"color:#a6e22e\"\u003ens\u003c/span\u003e.\u003cspan style=\"color:#a6e22e\"\u003etotalPacketsSent\u003c/span\u003e \u003cspan style=\"color:#f92672\"\u003e==\u003c/span\u003e \u003cspan style=\"color:#ae81ff\"\u003e0\u003c/span\u003e {\n\u003c/span\u003e\u003c/span\u003e\u003cspan style=\"display:flex;\"\u003e\u003cspan\u003e        \u003cspan style=\"color:#66d9ef\"\u003ereturn\u003c/span\u003e \u003cspan style=\"color:#ae81ff\"\u003e0.0\u003c/span\u003e\n\u003c/span\u003e\u003c/span\u003e\u003cspan style=\"display:flex;\"\u003e\u003cspan\u003e    }\n\u003c/span\u003e\u003c/span\u003e\u003cspan style=\"display:flex;\"\u003e\u003cspan\u003e    \n\u003c/span\u003e\u003c/span\u003e\u003cspan style=\"display:flex;\"\u003e\u003cspan\u003e    \u003cspan style=\"color:#66d9ef\"\u003ereturn\u003c/span\u003e float64(\u003cspan style=\"color:#a6e22e\"\u003ens\u003c/span\u003e.\u003cspan style=\"color:#a6e22e\"\u003epacketLossCount\u003c/span\u003e) \u003cspan style=\"color:#f92672\"\u003e/\u003c/span\u003e float64(\u003cspan style=\"color:#a6e22e\"\u003ens\u003c/span\u003e.\u003cspan style=\"color:#a6e22e\"\u003etotalPacketsSent\u003c/span\u003e)\n\u003c/span\u003e\u003c/span\u003e\u003cspan style=\"display:flex;\"\u003e\u003cspan\u003e}\n\u003c/span\u003e\u003c/span\u003e\u003cspan style=\"display:flex;\"\u003e\u003cspan\u003e\n\u003c/span\u003e\u003c/span\u003e\u003cspan style=\"display:flex;\"\u003e\u003cspan\u003e\u003cspan style=\"color:#75715e\"\u003e// Print は統計情報を表示\u003c/span\u003e\n\u003c/span\u003e\u003c/span\u003e\u003cspan style=\"display:flex;\"\u003e\u003cspan\u003e\u003cspan style=\"color:#66d9ef\"\u003efunc\u003c/span\u003e (\u003cspan style=\"color:#a6e22e\"\u003ens\u003c/span\u003e \u003cspan style=\"color:#f92672\"\u003e*\u003c/span\u003e\u003cspan style=\"color:#a6e22e\"\u003eNetworkStats\u003c/span\u003e) \u003cspan style=\"color:#a6e22e\"\u003ePrint\u003c/span\u003e() {\n\u003c/span\u003e\u003c/span\u003e\u003cspan style=\"display:flex;\"\u003e\u003cspan\u003e    \u003cspan style=\"color:#a6e22e\"\u003ens\u003c/span\u003e.\u003cspan style=\"color:#a6e22e\"\u003emu\u003c/span\u003e.\u003cspan style=\"color:#a6e22e\"\u003eRLock\u003c/span\u003e()\n\u003c/span\u003e\u003c/span\u003e\u003cspan style=\"display:flex;\"\u003e\u003cspan\u003e    \u003cspan style=\"color:#66d9ef\"\u003edefer\u003c/span\u003e \u003cspan style=\"color:#a6e22e\"\u003ens\u003c/span\u003e.\u003cspan style=\"color:#a6e22e\"\u003emu\u003c/span\u003e.\u003cspan style=\"color:#a6e22e\"\u003eRUnlock\u003c/span\u003e()\n\u003c/span\u003e\u003c/span\u003e\u003cspan style=\"display:flex;\"\u003e\u003cspan\u003e    \n\u003c/span\u003e\u003c/span\u003e\u003cspan style=\"display:flex;\"\u003e\u003cspan\u003e    \u003cspan style=\"color:#a6e22e\"\u003eduration\u003c/span\u003e \u003cspan style=\"color:#f92672\"\u003e:=\u003c/span\u003e \u003cspan style=\"color:#a6e22e\"\u003etime\u003c/span\u003e.\u003cspan style=\"color:#a6e22e\"\u003eSince\u003c/span\u003e(\u003cspan style=\"color:#a6e22e\"\u003ens\u003c/span\u003e.\u003cspan style=\"color:#a6e22e\"\u003estartTime\u003c/span\u003e)\n\u003c/span\u003e\u003c/span\u003e\u003cspan style=\"display:flex;\"\u003e\u003cspan\u003e    \u003cspan style=\"color:#a6e22e\"\u003ethroughputBps\u003c/span\u003e \u003cspan style=\"color:#f92672\"\u003e:=\u003c/span\u003e \u003cspan style=\"color:#a6e22e\"\u003ens\u003c/span\u003e.\u003cspan style=\"color:#a6e22e\"\u003eGetThroughput\u003c/span\u003e()\n\u003c/span\u003e\u003c/span\u003e\u003cspan style=\"display:flex;\"\u003e\u003cspan\u003e    \u003cspan style=\"color:#a6e22e\"\u003ethroughputKbps\u003c/span\u003e \u003cspan style=\"color:#f92672\"\u003e:=\u003c/span\u003e \u003cspan style=\"color:#a6e22e\"\u003ethroughputBps\u003c/span\u003e \u003cspan style=\"color:#f92672\"\u003e/\u003c/span\u003e \u003cspan style=\"color:#ae81ff\"\u003e1000\u003c/span\u003e\n\u003c/span\u003e\u003c/span\u003e\u003cspan style=\"display:flex;\"\u003e\u003cspan\u003e    \u003cspan style=\"color:#a6e22e\"\u003elossRate\u003c/span\u003e \u003cspan style=\"color:#f92672\"\u003e:=\u003c/span\u003e \u003cspan style=\"color:#a6e22e\"\u003ens\u003c/span\u003e.\u003cspan style=\"color:#a6e22e\"\u003eGetPacketLossRate\u003c/span\u003e() \u003cspan style=\"color:#f92672\"\u003e*\u003c/span\u003e \u003cspan style=\"color:#ae81ff\"\u003e100\u003c/span\u003e\n\u003c/span\u003e\u003c/span\u003e\u003cspan style=\"display:flex;\"\u003e\u003cspan\u003e    \n\u003c/span\u003e\u003c/span\u003e\u003cspan style=\"display:flex;\"\u003e\u003cspan\u003e    \u003cspan style=\"color:#a6e22e\"\u003efmt\u003c/span\u003e.\u003cspan style=\"color:#a6e22e\"\u003ePrintf\u003c/span\u003e(\u003cspan style=\"color:#e6db74\"\u003e\u0026#34;=== Network Statistics ===\\n\u0026#34;\u003c/span\u003e)\n\u003c/span\u003e\u003c/span\u003e\u003cspan style=\"display:flex;\"\u003e\u003cspan\u003e    \u003cspan style=\"color:#a6e22e\"\u003efmt\u003c/span\u003e.\u003cspan style=\"color:#a6e22e\"\u003ePrintf\u003c/span\u003e(\u003cspan style=\"color:#e6db74\"\u003e\u0026#34;Duration: %v\\n\u0026#34;\u003c/span\u003e, \u003cspan style=\"color:#a6e22e\"\u003eduration\u003c/span\u003e.\u003cspan style=\"color:#a6e22e\"\u003eRound\u003c/span\u003e(\u003cspan style=\"color:#a6e22e\"\u003etime\u003c/span\u003e.\u003cspan style=\"color:#a6e22e\"\u003eMillisecond\u003c/span\u003e))\n\u003c/span\u003e\u003c/span\u003e\u003cspan style=\"display:flex;\"\u003e\u003cspan\u003e    \u003cspan style=\"color:#a6e22e\"\u003efmt\u003c/span\u003e.\u003cspan style=\"color:#a6e22e\"\u003ePrintf\u003c/span\u003e(\u003cspan style=\"color:#e6db74\"\u003e\u0026#34;Packets Sent: %d\\n\u0026#34;\u003c/span\u003e, \u003cspan style=\"color:#a6e22e\"\u003ens\u003c/span\u003e.\u003cspan style=\"color:#a6e22e\"\u003etotalPacketsSent\u003c/span\u003e)\n\u003c/span\u003e\u003c/span\u003e\u003cspan style=\"display:flex;\"\u003e\u003cspan\u003e    \u003cspan style=\"color:#a6e22e\"\u003efmt\u003c/span\u003e.\u003cspan style=\"color:#a6e22e\"\u003ePrintf\u003c/span\u003e(\u003cspan style=\"color:#e6db74\"\u003e\u0026#34;Packets Received: %d\\n\u0026#34;\u003c/span\u003e, \u003cspan style=\"color:#a6e22e\"\u003ens\u003c/span\u003e.\u003cspan style=\"color:#a6e22e\"\u003etotalPacketsRecv\u003c/span\u003e)\n\u003c/span\u003e\u003c/span\u003e\u003cspan style=\"display:flex;\"\u003e\u003cspan\u003e    \u003cspan style=\"color:#a6e22e\"\u003efmt\u003c/span\u003e.\u003cspan style=\"color:#a6e22e\"\u003ePrintf\u003c/span\u003e(\u003cspan style=\"color:#e6db74\"\u003e\u0026#34;Bytes Sent: %d\\n\u0026#34;\u003c/span\u003e, \u003cspan style=\"color:#a6e22e\"\u003ens\u003c/span\u003e.\u003cspan style=\"color:#a6e22e\"\u003etotalBytesSent\u003c/span\u003e)\n\u003c/span\u003e\u003c/span\u003e\u003cspan style=\"display:flex;\"\u003e\u003cspan\u003e    \u003cspan style=\"color:#a6e22e\"\u003efmt\u003c/span\u003e.\u003cspan style=\"color:#a6e22e\"\u003ePrintf\u003c/span\u003e(\u003cspan style=\"color:#e6db74\"\u003e\u0026#34;Bytes Received: %d\\n\u0026#34;\u003c/span\u003e, \u003cspan style=\"color:#a6e22e\"\u003ens\u003c/span\u003e.\u003cspan style=\"color:#a6e22e\"\u003etotalBytesRecv\u003c/span\u003e)\n\u003c/span\u003e\u003c/span\u003e\u003cspan style=\"display:flex;\"\u003e\u003cspan\u003e    \u003cspan style=\"color:#a6e22e\"\u003efmt\u003c/span\u003e.\u003cspan style=\"color:#a6e22e\"\u003ePrintf\u003c/span\u003e(\u003cspan style=\"color:#e6db74\"\u003e\u0026#34;Throughput: %.2f Kbps\\n\u0026#34;\u003c/span\u003e, \u003cspan style=\"color:#a6e22e\"\u003ethroughputKbps\u003c/span\u003e)\n\u003c/span\u003e\u003c/span\u003e\u003cspan style=\"display:flex;\"\u003e\u003cspan\u003e    \u003cspan style=\"color:#a6e22e\"\u003efmt\u003c/span\u003e.\u003cspan style=\"color:#a6e22e\"\u003ePrintf\u003c/span\u003e(\u003cspan style=\"color:#e6db74\"\u003e\u0026#34;Packet Loss Rate: %.2f%%\\n\u0026#34;\u003c/span\u003e, \u003cspan style=\"color:#a6e22e\"\u003elossRate\u003c/span\u003e)\n\u003c/span\u003e\u003c/span\u003e\u003cspan style=\"display:flex;\"\u003e\u003cspan\u003e    \u003cspan style=\"color:#a6e22e\"\u003efmt\u003c/span\u003e.\u003cspan style=\"color:#a6e22e\"\u003ePrintf\u003c/span\u003e(\u003cspan style=\"color:#e6db74\"\u003e\u0026#34;=========================\\n\u0026#34;\u003c/span\u003e)\n\u003c/span\u003e\u003c/span\u003e\u003cspan style=\"display:flex;\"\u003e\u003cspan\u003e}\n\u003c/span\u003e\u003c/span\u003e\u003c/code\u003e\u003c/pre\u003e\u003c/div\u003e\u003ch2 id=\"23-帯域幅制限機能の実装\"\u003e2.3 帯域幅制限機能の実装\u003c/h2\u003e\n\u003cp\u003e実際のネットワークのように、帯域幅制限を実装します。\u003c/p\u003e","title":"Go言語でネットワークプログラミングを学ぶ - 第2章"},{"content":"3.1 プロジェクト構造 go-network-programming/ ├── go.mod ├── go.sum ├── main.go ├── packet.go ├── node.go ├── link.go ├── network_stats.go ├── bandwidth_limiter.go ├── mac_address.go # 新規追加 ├── ethernet_frame.go # 新規追加 └── switch.go # 新規追加 この章では、スイッチを実装して複数のノードを接続できるローカルネットワークを構築します。また、MACアドレスを導入してイーサネットレベルでの通信を実現します。\n3.2 MACアドレスの実装 MACアドレス（Media Access Control Address）は、ネットワークインターフェースの物理アドレスです。\nファイル名: ./mac_address.go\npackage main import ( \u0026#34;fmt\u0026#34; \u0026#34;math/rand\u0026#34; \u0026#34;strconv\u0026#34; \u0026#34;strings\u0026#34; ) // MACAddress はMAC（Media Access Control）アドレスを表現する // 実際のイーサネットで使用される6バイトの物理アドレス type MACAddress struct { bytes [6]byte // 6バイトのMACAアドレス（例：aa:bb:cc:dd:ee:ff） } // NewMACAddress は指定されたバイト配列からMACアドレスを作成 func NewMACAddress(bytes [6]byte) MACAddress { return MACAddress{bytes: bytes} } // ParseMACAddress は文字列からMACアドレスを解析 // 例：ParseMACAddress(\u0026#34;aa:bb:cc:dd:ee:ff\u0026#34;) func ParseMACAddress(s string) (MACAddress, error) { parts := strings.Split(s, \u0026#34;:\u0026#34;) if len(parts) != 6 { return MACAddress{}, fmt.Errorf(\u0026#34;invalid MAC address format: %s\u0026#34;, s) } var mac MACAddress for i, part := range parts { val, err := strconv.ParseUint(part, 16, 8) if err != nil { return MACAddress{}, fmt.Errorf(\u0026#34;invalid hex value in MAC address: %s\u0026#34;, part) } mac.bytes[i] = byte(val) } return mac, nil } // RandomMACAddress はランダムなMACアドレスを生成 // ユニキャスト、ローカル管理アドレスとして生成 func RandomMACAddress() MACAddress { var mac MACAddress for i := 0; i \u0026lt; 6; i++ { mac.bytes[i] = byte(rand.Intn(256)) } // ユニキャスト（LSBを0に）、ローカル管理（2番目のLSBを1に）に設定 mac.bytes[0] = (mac.bytes[0] \u0026amp; 0xFC) | 0x02 return mac } // String はMACアドレスの文字列表現を返す func (mac MACAddress) String() string { return fmt.Sprintf(\u0026#34;%02x:%02x:%02x:%02x:%02x:%02x\u0026#34;, mac.bytes[0], mac.bytes[1], mac.bytes[2], mac.bytes[3], mac.bytes[4], mac.bytes[5]) } // Equals は2つのMACアドレスが等しいかチェック func (mac MACAddress) Equals(other MACAddress) bool { return mac.bytes == other.bytes } // IsUnicast はユニキャストアドレスかチェック func (mac MACAddress) IsUnicast() bool { return (mac.bytes[0] \u0026amp; 0x01) == 0 } // IsBroadcast はブロードキャストアドレスかチェック func (mac MACAddress) IsBroadcast() bool { return mac.bytes == [6]byte{0xFF, 0xFF, 0xFF, 0xFF, 0xFF, 0xFF} } // IsMulticast はマルチキャストアドレスかチェック func (mac MACAddress) IsMulticast() bool { return (mac.bytes[0] \u0026amp; 0x01) == 1 \u0026amp;\u0026amp; !mac.IsBroadcast() } // BroadcastMAC はブロードキャストMACアドレスを返す func BroadcastMAC() MACAddress { return MACAddress{bytes: [6]byte{0xFF, 0xFF, 0xFF, 0xFF, 0xFF, 0xFF}} } 3.3 イーサネットフレームの実装 MACアドレスを含むイーサネットフレーム構造を実装します。\nファイル名: ./ethernet_frame.go\npackage main import ( \u0026#34;fmt\u0026#34; \u0026#34;time\u0026#34; \u0026#34;github.com/google/uuid\u0026#34; ) // EtherType はイーサネットフレームのタイプを定義 type EtherType uint16 const ( EtherTypeIPv4 EtherType = 0x0800 // IPv4 EtherTypeARP EtherType = 0x0806 // ARP EtherTypeIPv6 EtherType = 0x86DD // IPv6 EtherTypeData EtherType = 0x9999 // 独自データタイプ（学習用） ) // EthernetFrame はイーサネットフレームを表現する // 実際のイーサネット通信で使用されるフレーム構造 type EthernetFrame struct { ID string // フレーム識別子（デバッグ用） Destination MACAddress // 宛先MACアドレス Source MACAddress // 送信元MACアドレス EtherType EtherType // フレームタイプ Payload []byte // ペイロード（上位層データ） Size int // フレーム全体のサイズ Timestamp time.Time // フレーム生成時刻 } // NewEthernetFrame は新しいイーサネットフレームを作成 func NewEthernetFrame(src, dst MACAddress, etherType EtherType, payload []byte) *EthernetFrame { frame := \u0026amp;EthernetFrame{ ID: uuid.New().String(), Destination: dst, Source: src, EtherType: etherType, Payload: payload, Size: 14 + len(payload), // イーサネットヘッダ14バイト + ペイロード Timestamp: time.Now(), } return frame } // String はフレームの文字列表現を返す func (frame *EthernetFrame) String() string { return fmt.Sprintf(\u0026#34;EthernetFrame{ID: %s, %s -\u0026gt; %s, Type: 0x%04x, Size: %d bytes}\u0026#34;, frame.ID[:8], frame.Source, frame.Destination, frame.EtherType, frame.Size) } // IsUnicast はユニキャストフレームかチェック func (frame *EthernetFrame) IsUnicast() bool { return frame.Destination.IsUnicast() } // IsBroadcast はブロードキャストフレームかチェック func (frame *EthernetFrame) IsBroadcast() bool { return frame.Destination.IsBroadcast() } // IsMulticast はマルチキャストフレームかチェック func (frame *EthernetFrame) IsMulticast() bool { return frame.Destination.IsMulticast() } // Clone はフレームのコピーを作成（ブロードキャスト時に使用） func (frame *EthernetFrame) Clone() *EthernetFrame { newFrame := *frame newFrame.ID = uuid.New().String() // 新しいIDを生成 // ペイロードをコピー newFrame.Payload = make([]byte, len(frame.Payload)) copy(newFrame.Payload, frame.Payload) return \u0026amp;newFrame } 3.4 ノードの拡張（MACアドレス対応） ノードにMACアドレス機能を追加します。\nファイル名: ./node.go (更新)\npackage main import ( \u0026#34;fmt\u0026#34; \u0026#34;time\u0026#34; \u0026#34;github.com/google/uuid\u0026#34; ) // Node はネットワーク上のデバイスを表現する（MACアドレス対応版） type Node struct { ID string // ノードID Name string // ノード名 MACAddr MACAddress // MACアドレス inbox chan *EthernetFrame // 受信用フレームキュー outbox chan *EthernetFrame // 送信用フレームキュー links map[string]*Link // 接続リンク switchRef *Switch // 接続されているスイッチ（あれば） running bool // 動作状態 stats *NetworkStats // 統計情報 } // NewNode は新しいノードを作成（MACアドレス自動生成） func NewNode(name string) *Node { return \u0026amp;Node{ ID: uuid.New().String(), Name: name, MACAddr: RandomMACAddress(), inbox: make(chan *EthernetFrame, 100), outbox: make(chan *EthernetFrame, 100), links: make(map[string]*Link), switchRef: nil, running: false, stats: NewNetworkStats(), } } // NewNodeWithMAC は指定されたMACアドレスでノードを作成 func NewNodeWithMAC(name string, macAddr MACAddress) *Node { return \u0026amp;Node{ ID: uuid.New().String(), Name: name, MACAddr: macAddr, inbox: make(chan *EthernetFrame, 100), outbox: make(chan *EthernetFrame, 100), links: make(map[string]*Link), switchRef: nil, running: false, stats: NewNetworkStats(), } } // Start はノードの動作を開始 func (n *Node) Start() { if n.running { return } n.running = true go n.processFrames() fmt.Printf(\u0026#34;Node %s started (MAC: %s)\\n\u0026#34;, n.Name, n.MACAddr) } // Stop はノードの動作を停止 func (n *Node) Stop() { if !n.running { return } n.running = false close(n.inbox) close(n.outbox) fmt.Printf(\u0026#34;Node %s stopped\\n\u0026#34;, n.Name) n.stats.Print() } // SendFrame は指定された宛先MACアドレスにフレームを送信 func (n *Node) SendFrame(dstMAC MACAddress, data []byte) error { if !n.running { return fmt.Errorf(\u0026#34;node %s is not running\u0026#34;, n.Name) } frame := NewEthernetFrame(n.MACAddr, dstMAC, EtherTypeData, data) // スイッチに接続されている場合はスイッチ経由で送信 if n.switchRef != nil { return n.switchRef.SendFrame(frame, n) } // 直接リンクがある場合はリンク経由で送信 for _, link := range n.links { if link.CanReachMAC(dstMAC) { return link.SendFrame(frame) } } return fmt.Errorf(\u0026#34;no route to MAC address %s\u0026#34;, dstMAC) } // SendData は名前指定でデータを送信（簡易版） func (n *Node) SendData(destinationName string, data []byte) error { // 簡易的に名前からMACアドレスを推測 // 実際のネットワークではARP等でMACアドレスを解決する if n.switchRef != nil { targetMAC := n.switchRef.FindMACByName(destinationName) if targetMAC != nil { return n.SendFrame(*targetMAC, data) } } return fmt.Errorf(\u0026#34;cannot resolve MAC address for %s\u0026#34;, destinationName) } // Broadcast はブロードキャストでデータを送信 func (n *Node) Broadcast(data []byte) error { if !n.running { return fmt.Errorf(\u0026#34;node %s is not running\u0026#34;, n.Name) } frame := NewEthernetFrame(n.MACAddr, BroadcastMAC(), EtherTypeData, data) if n.switchRef != nil { return n.switchRef.SendFrame(frame, n) } return fmt.Errorf(\u0026#34;no switch connected for broadcast\u0026#34;) } // ReceiveFrame は受信したフレームを返す func (n *Node) ReceiveFrame() *EthernetFrame { if !n.running { return nil } select { case frame := \u0026lt;-n.inbox: return frame case \u0026lt;-time.After(1 * time.Second): return nil } } // ConnectToSwitch はスイッチに接続 func (n *Node) ConnectToSwitch(sw *Switch) { n.switchRef = sw sw.ConnectNode(n) } // AddLink はリンクを追加 func (n *Node) AddLink(link *Link) { n.links[link.ID] = link fmt.Printf(\u0026#34;Link added to node %s\\n\u0026#34;, n.Name) } // processFrames はフレーム処理のメインループ func (n *Node) processFrames() { for n.running { select { case frame := \u0026lt;-n.outbox: if frame != nil { fmt.Printf(\u0026#34;Node %s processing outgoing: %s\\n\u0026#34;, n.Name, frame) } default: time.Sleep(10 * time.Millisecond) } } } func (n *Node) String() string { return fmt.Sprintf(\u0026#34;Node{Name: %s, MAC: %s, ID: %s}\u0026#34;, n.Name, n.MACAddr, n.ID[:8]) } 3.5 スイッチの実装 複数のノードを接続するイーサネットスイッチを実装します。\nファイル名: ./switch.go\npackage main import ( \u0026#34;fmt\u0026#34; \u0026#34;sync\u0026#34; \u0026#34;time\u0026#34; \u0026#34;github.com/google/uuid\u0026#34; ) // SwitchPort はスイッチのポートを表現 type SwitchPort struct { ID string Number int Node *Node Link *Link Active bool Stats *NetworkStats } // Switch はイーサネットスイッチを実装 // 複数のノードを接続し、MACアドレスベースでフレームを転送 type Switch struct { ID string Name string Ports map[int]*SwitchPort // ポート番号 -\u0026gt; ポート MACTable map[MACAddress]*SwitchPort // MACアドレス -\u0026gt; ポート（学習テーブル） BcastDomain []*SwitchPort // ブロードキャストドメイン running bool mu sync.RWMutex // MACテーブルの排他制御 stats *NetworkStats } // NewSwitch は新しいスイッチを作成 func NewSwitch(name string) *Switch { return \u0026amp;Switch{ ID: uuid.New().String(), Name: name, Ports: make(map[int]*SwitchPort), MACTable: make(map[MACAddress]*SwitchPort), BcastDomain: make([]*SwitchPort, 0), running: false, stats: NewNetworkStats(), } } // Start はスイッチの動作を開始 func (sw *Switch) Start() { if sw.running { return } sw.running = true // ブロードキャストドメインを更新 sw.updateBroadcastDomain() fmt.Printf(\u0026#34;Switch %s started (%d ports)\\n\u0026#34;, sw.Name, len(sw.Ports)) } // Stop はスイッチの動作を停止 func (sw *Switch) Stop() { if !sw.running { return } sw.running = false fmt.Printf(\u0026#34;Switch %s stopped\\n\u0026#34;, sw.Name) sw.PrintMACTable() sw.stats.Print() } // ConnectNode はノードをスイッチに接続 func (sw *Switch) ConnectNode(node *Node) *SwitchPort { portNum := len(sw.Ports) + 1 port := \u0026amp;SwitchPort{ ID: uuid.New().String(), Number: portNum, Node: node, Active: true, Stats: NewNetworkStats(), } sw.Ports[portNum] = port sw.updateBroadcastDomain() fmt.Printf(\u0026#34;Node %s connected to switch %s port %d\\n\u0026#34;, node.Name, sw.Name, portNum) return port } // SendFrame はフレームを送信（スイッチの中核機能） func (sw *Switch) SendFrame(frame *EthernetFrame, sourceNode *Node) error { if !sw.running { return fmt.Errorf(\u0026#34;switch %s is not running\u0026#34;, sw.Name) } // 送信元ノードのポートを特定 var sourcePort *SwitchPort for _, port := range sw.Ports { if port.Node == sourceNode { sourcePort = port break } } if sourcePort == nil { return fmt.Errorf(\u0026#34;source node not connected to switch\u0026#34;) } // MACアドレス学習：送信元MACアドレスをテーブルに記録 sw.learnMAC(frame.Source, sourcePort) // 統計情報を更新 sw.stats.RecordSentPacket(\u0026amp;Packet{ Size: frame.Size, Source: sourceNode.Name, Destination: frame.Destination.String(), }) // フレームの配送処理 if frame.IsBroadcast() { return sw.broadcastFrame(frame, sourcePort) } else { return sw.forwardFrame(frame, sourcePort) } } // learnMAC はMACアドレスを学習テーブルに記録 func (sw *Switch) learnMAC(macAddr MACAddress, port *SwitchPort) { sw.mu.Lock() defer sw.mu.Unlock() if existingPort, exists := sw.MACTable[macAddr]; exists { if existingPort != port { fmt.Printf(\u0026#34;Switch %s: MAC %s moved from port %d to port %d\\n\u0026#34;, sw.Name, macAddr, existingPort.Number, port.Number) } } else { fmt.Printf(\u0026#34;Switch %s: Learned MAC %s on port %d\\n\u0026#34;, sw.Name, macAddr, port.Number) } sw.MACTable[macAddr] = port } // forwardFrame はユニキャストフレームを転送 func (sw *Switch) forwardFrame(frame *EthernetFrame, sourcePort *SwitchPort) error { sw.mu.RLock() targetPort, known := sw.MACTable[frame.Destination] sw.mu.RUnlock() if known \u0026amp;\u0026amp; targetPort.Active { // 宛先MACアドレスが学習済み：該当ポートにのみ送信 return sw.deliverToPort(frame, targetPort, sourcePort) } else { // 宛先MACアドレス未学習：ブロードキャスト（フラッディング） fmt.Printf(\u0026#34;Switch %s: Unknown destination %s, flooding\\n\u0026#34;, sw.Name, frame.Destination) return sw.broadcastFrame(frame, sourcePort) } } // broadcastFrame はフレームをブロードキャスト func (sw *Switch) broadcastFrame(frame *EthernetFrame, sourcePort *SwitchPort) error { fmt.Printf(\u0026#34;Switch %s: Broadcasting frame %s\\n\u0026#34;, sw.Name, frame.ID[:8]) var lastError error successCount := 0 // 送信元ポート以外の全ポートに転送 for _, port := range sw.BcastDomain { if port != sourcePort \u0026amp;\u0026amp; port.Active { // フレームをクローンして送信 clonedFrame := frame.Clone() if err := sw.deliverToPort(clonedFrame, port, sourcePort); err != nil { lastError = err } else { successCount++ } } } if successCount == 0 \u0026amp;\u0026amp; lastError != nil { return lastError } return nil } // deliverToPort は指定されたポートにフレームを配送 func (sw *Switch) deliverToPort(frame *EthernetFrame, targetPort *SwitchPort, sourcePort *SwitchPort) error { if targetPort.Node == nil || !targetPort.Node.running { return fmt.Errorf(\u0026#34;target port %d is not active\u0026#34;, targetPort.Number) } // フレームをノードの受信キューに配送 select { case targetPort.Node.inbox \u0026lt;- frame: targetPort.Stats.RecordReceivedPacket(\u0026amp;Packet{ Size: frame.Size, Source: frame.Source.String(), Destination: targetPort.Node.Name, }) fmt.Printf(\u0026#34;Switch %s: Frame delivered from port %d to port %d\\n\u0026#34;, sw.Name, sourcePort.Number, targetPort.Number) return nil case \u0026lt;-time.After(10 * time.Millisecond): return fmt.Errorf(\u0026#34;failed to deliver frame to port %d (queue full)\u0026#34;, targetPort.Number) } } // FindMACByName は名前からMACアドレスを検索（簡易版） func (sw *Switch) FindMACByName(name string) *MACAddress { for _, port := range sw.Ports { if port.Node != nil \u0026amp;\u0026amp; port.Node.Name == name { return \u0026amp;port.Node.MACAddr } } return nil } // updateBroadcastDomain はブロードキャストドメインを更新 func (sw *Switch) updateBroadcastDomain() { sw.BcastDomain = make([]*SwitchPort, 0, len(sw.Ports)) for _, port := range sw.Ports { if port.Active { sw.BcastDomain = append(sw.BcastDomain, port) } } } // PrintMACTable はMACアドレステーブルを表示 func (sw *Switch) PrintMACTable() { sw.mu.RLock() defer sw.mu.RUnlock() fmt.Printf(\u0026#34;=== MAC Address Table: %s ===\\n\u0026#34;, sw.Name) if len(sw.MACTable) == 0 { fmt.Println(\u0026#34;No MAC addresses learned\u0026#34;) } else { for mac, port := range sw.MACTable { fmt.Printf(\u0026#34; %s -\u0026gt; Port %d (%s)\\n\u0026#34;, mac, port.Number, port.Node.Name) } } fmt.Println(\u0026#34;===============================\u0026#34;) } func (sw *Switch) String() string { return fmt.Sprintf(\u0026#34;Switch{Name: %s, Ports: %d, MACs: %d}\u0026#34;, sw.Name, len(sw.Ports), len(sw.MACTable)) } 3.6 メイン関数での動作テスト 複数ノードをスイッチで接続するテストコードです。\nファイル名: ./main.go (更新)\npackage main import ( \u0026#34;fmt\u0026#34; \u0026#34;time\u0026#34; ) func main() { fmt.Println(\u0026#34;=== スイッチネットワークシミュレーション ===\u0026#34;) // スイッチを作成 switch1 := NewSwitch(\u0026#34;SW1\u0026#34;) // 4つのノードを作成 alice := NewNode(\u0026#34;Alice\u0026#34;) bob := NewNode(\u0026#34;Bob\u0026#34;) charlie := NewNode(\u0026#34;Charlie\u0026#34;) david := NewNode(\u0026#34;David\u0026#34;) // ノードをスイッチに接続 alice.ConnectToSwitch(switch1) bob.ConnectToSwitch(switch1) charlie.ConnectToSwitch(switch1) david.ConnectToSwitch(switch1) // システム開始 switch1.Start() alice.Start() bob.Start() charlie.Start() david.Start() fmt.Printf(\u0026#34;\\n=== 初期状態のMACテーブル ===\\n\u0026#34;) switch1.PrintMACTable() // AliceがBobに送信（ユニキャスト） fmt.Printf(\u0026#34;\\n=== Test 1: AliceからBobへユニキャスト ===\\n\u0026#34;) message1 := []byte(\u0026#34;Hello Bob, this is Alice!\u0026#34;) err := alice.SendData(\u0026#34;Bob\u0026#34;, message1) if err != nil { fmt.Printf(\u0026#34;Error: %v\\n\u0026#34;, err) } time.Sleep(100 * time.Millisecond) received1 := bob.ReceiveFrame() if received1 != nil { fmt.Printf(\u0026#34;Bob received: %s\\n\u0026#34;, string(received1.Payload)) } // BobがAliceに返信（学習済みMACアドレス宛） fmt.Printf(\u0026#34;\\n=== Test 2: BobからAliceへ返信 ===\\n\u0026#34;) message2 := []byte(\u0026#34;Hi Alice, nice to hear from you!\u0026#34;) err = bob.SendData(\u0026#34;Alice\u0026#34;, message2) if err != nil { fmt.Printf(\u0026#34;Error: %v\\n\u0026#34;, err) } time.Sleep(100 * time.Millisecond) received2 := alice.ReceiveFrame() if received2 != nil { fmt.Printf(\u0026#34;Alice received: %s\\n\u0026#34;, string(received2.Payload)) } // Charlieがブロードキャスト fmt.Printf(\u0026#34;\\n=== Test 3: Charlieからブロードキャスト ===\\n\u0026#34;) message3 := []byte(\u0026#34;Hello everyone! This is Charlie speaking.\u0026#34;) err = charlie.Broadcast(message3) if err != nil { fmt.Printf(\u0026#34;Error: %v\\n\u0026#34;, err) } time.Sleep(100 * time.Millisecond) // 全員が受信確認 fmt.Println(\u0026#34;Checking broadcast reception:\u0026#34;) if frame := alice.ReceiveFrame(); frame != nil { fmt.Printf(\u0026#34; Alice received: %s\\n\u0026#34;, string(frame.Payload)) } if frame := bob.ReceiveFrame(); frame != nil { fmt.Printf(\u0026#34; Bob received: %s\\n\u0026#34;, string(frame.Payload)) } if frame := david.ReceiveFrame(); frame != nil { fmt.Printf(\u0026#34; David received: %s\\n\u0026#34;, string(frame.Payload)) } // DavidがCharlie宛に送信（学習済み） fmt.Printf(\u0026#34;\\n=== Test 4: DavidからCharlie宛（学習済み） ===\\n\u0026#34;) message4 := []byte(\u0026#34;Charlie, this is David responding!\u0026#34;) err = david.SendData(\u0026#34;Charlie\u0026#34;, message4) if err != nil { fmt.Printf(\u0026#34;Error: %v\\n\u0026#34;, err) } time.Sleep(100 * time.Millisecond) received4 := charlie.ReceiveFrame() if received4 != nil { fmt.Printf(\u0026#34;Charlie received: %s\\n\u0026#34;, string(received4.Payload)) } // 最終状態のMACテーブルを表示 fmt.Printf(\u0026#34;\\n=== 最終状態のMACテーブル ===\\n\u0026#34;) switch1.PrintMACTable() // システム停止 fmt.Printf(\u0026#34;\\n=== システム終了 ===\\n\u0026#34;) alice.Stop() bob.Stop() charlie.Stop() david.Stop() switch1.Stop() } 3.7 期待される出力例 === スイッチネットワークシミュレーション === Node Alice connected to switch SW1 port 1 Node Bob connected to switch SW1 port 2 Node Charlie connected to switch SW1 port 3 Node David connected to switch SW1 port 4 Switch SW1 started (4 ports) Node Alice started (MAC: 02:a1:b2:c3:d4:e5) Node Bob started (MAC: 02:f6:g7:h8:i9:j0) Node Charlie started (MAC: 02:k1:l2:m3:n4:o5) Node David started (MAC: 02:p6:q7:r8:s9:t0) === 初期状態のMACテーブル === === MAC Address Table: SW1 === No MAC addresses learned =============================== === Test 1: AliceからBobへユニキャスト === Switch SW1: Learned MAC 02:a1:b2:c3:d4:e5 on port 1 Switch SW1: Unknown destination 02:f6:g7:h8:i9:j0, flooding Switch SW1: Broadcasting frame 12345678 Switch SW1: Frame delivered from port 1 to port 2 Switch SW1: Frame delivered from port 1 to port 3 Switch SW1: Frame delivered from port 1 to port 4 Bob received: Hello Bob, this is Alice! === Test 2: BobからAliceへ返信 === Switch SW1: Learned MAC 02:f6:g7:h8:i9:j0 on port 2 Switch SW1: Frame delivered from port 2 to port 1 Alice received: Hi Alice, nice to hear from you! === Test 3: Charlieからブロードキャスト === Switch SW1: Learned MAC 02:k1:l2:m3:n4:o5 on port 3 Switch SW1: Broadcasting frame abcdef12 Switch SW1: Frame delivered from port 3 to port 1 Switch SW1: Frame delivered from port 3 to port 2 Switch SW1: Frame delivered from port 3 to port 4 Checking broadcast reception: Alice received: Hello everyone! This is Charlie speaking. Bob received: Hello everyone! This is Charlie speaking. David received: Hello everyone! This is Charlie speaking. === Test 4: DavidからCharlie宛（学習済み） === Switch SW1: Learned MAC 02:p6:q7:r8:s9:t0 on port 4 Switch SW1: Frame delivered from port 4 to port 3 Charlie received: Charlie, this is David responding! === 最終状態のMACテーブル === === MAC Address Table: SW1 === 02:a1:b2:c3:d4:e5 -\u0026gt; Port 1 (Alice) 02:f6:g7:h8:i9:j0 -\u0026gt; Port 2 (Bob) 02:k1:l2:m3:n4:o5 -\u0026gt; Port 3 (Charlie) 02:p6:q7:r8:s9:t0 -\u0026gt; Port 4 (David) =============================== === システム終了 === Node Alice stopped === Network Statistics === Duration: 1.234s Packets Sent: 2 Packets Received: 2 Bytes Sent: 89 Bytes Received: 89 Throughput: 576.45 Kbps Packet Loss Rate: 0.00% ========================= Node Bob stopped Node Charlie stopped Node David stopped Switch SW1 stopped === MAC Address Table: SW1 === 02:a1:b2:c3:d4:e5 -\u0026gt; Port 1 (Alice) 02:f6:g7:h8:i9:j0 -\u0026gt; Port 2 (Bob) 02:k1:l2:m3:n4:o5 -\u0026gt; Port 3 (Charlie) 02:p6:q7:r8:s9:t0 -\u0026gt; Port 4 (David) =============================== === Network Statistics === Duration: 1.234s Packets Sent: 4 Packets Received: 7 Bytes Sent: 234 Bytes Received: 678 Throughput: 4.4 Mbps Packet Loss Rate: 0.00% ========================= 3.8 重要な概念の解説 3.8.1 MACアドレス学習 スイッチは受信フレームの送信元MACアドレスを自動的に学習します：\n初回通信時: MACアドレステーブルは空 フレーム受信時: 送信元MACアドレスを受信ポートと関連付けて記録 転送決定時: 宛先MACアドレスがテーブルにあれば該当ポートのみに送信 3.8.2 フラッディング（未学習アドレス処理） 宛先MACアドレスがテーブルに未登録の場合：\n全ポートに転送：送信元ポート以外の全てのアクティブポートに送信 学習機会の提供：宛先ノードからの応答でMACアドレスを学習 実際のスイッチ動作：初期状態や新規参加ノードで発生 3.8.3 ブロードキャスト通信 MACアドレス ff:ff:ff:ff:ff:ff への送信：\n全ノードが受信：ネットワーク内の全デバイスに配信 ARP、DHCPで使用：アドレス解決、IP自動設定で必須 ブロードキャストストーム注意：ループがあると無限に転送され続ける 3.8.4 並行処理とスレッドセーフティ // MACテーブルへの安全なアクセス sw.mu.Lock() // 書き込み時はロック sw.MACTable[mac] = port sw.mu.Unlock() sw.mu.RLock() // 読み込み時は読み込み専用ロック port := sw.MACTable[mac] sw.mu.RUnlock() 複数のノードが同時にフレームを送信してもデータ競合が発生しません。\n3.9 実行方法 # プロジェクトディレクトリで実行 go run . 3.10 練習問題 3.10.1 基本課題 5ノード接続: 5つ目のノード（Eve）を追加し、全ノードでの通信をテストしてください。\nMACアドレス衝突: 同じMACアドレスを持つ2つのノードを作成し、スイッチの動作を観察してください。\n統計情報拡張: 各ポートごとの送受信フレーム数を記録する機能を追加してください。\n3.10.2 応用課題 VLAN実装: 異なるVLANに属するノード間での通信を制限する機能を実装してください。\nMACアドレステーブルのエージング: 一定時間使用されていないMACアドレスエントリを自動削除する機能を追加してください。\nポートミラーリング: 特定ポートの全トラフィックを監視ポートにコピーする機能を実装してください。\n3.10.3 サンプル解答（5ノード接続） // main.goに追加 eve := NewNode(\u0026#34;Eve\u0026#34;) eve.ConnectToSwitch(switch1) eve.Start() // EveからAlice宛にメッセージ message5 := []byte(\u0026#34;Hello Alice, this is Eve!\u0026#34;) err = eve.SendData(\u0026#34;Alice\u0026#34;, message5) if err != nil { fmt.Printf(\u0026#34;Error: %v\\n\u0026#34;, err) } 3.11 実装のポイントと最適化 3.11.1 メモリ効率 // フレームクローン時のメモリ使用量に注意 func (frame *EthernetFrame) Clone() *EthernetFrame { newFrame := *frame // 構造体コピー newFrame.Payload = make([]byte, len(frame.Payload)) // ペイロードは新規作成 copy(newFrame.Payload, frame.Payload) // データコピー return \u0026amp;newFrame } 3.11.2 パフォーマンス考慮 チャネルバッファサイズ: ノードの受信キューを適切なサイズに設定 タイムアウト設定: ネットワーク遅延を考慮した配送タイムアウト 並行処理: 複数フレームの同時処理でスループット向上 3.12 現実のネットワークとの対応 実装要素 現実の対応 MACアドレス イーサネットNICの物理アドレス EthernetFrame 802.3イーサネットフレーム Switch レイヤー2スイッチ（Catalyst等） MACテーブル CAM（Content Addressable Memory） フラッディング 未知ユニキャストフレーム処理 ブロードキャスト 同一VLAN内での一斉配信 3.13 次章への準備 第4章では、MACアドレス学習の詳細とループ回避機能を実装します：\nスパニングツリープロトコル（STP）: ループ検出と回避 BPDU（Bridge Protocol Data Unit）: スイッチ間通信 ブロードキャストストーム: 無限ループの危険性 ポート状態管理: Blocking、Learning、Forwarding状態 ","permalink":"https://techblog.wasutech.dev/posts/go-network-3/","summary":"\u003ch2 id=\"31-プロジェクト構造\"\u003e3.1 プロジェクト構造\u003c/h2\u003e\n\u003cpre tabindex=\"0\"\u003e\u003ccode\u003ego-network-programming/\n├── go.mod\n├── go.sum\n├── main.go\n├── packet.go\n├── node.go\n├── link.go\n├── network_stats.go\n├── bandwidth_limiter.go\n├── mac_address.go    # 新規追加\n├── ethernet_frame.go # 新規追加\n└── switch.go         # 新規追加\n\u003c/code\u003e\u003c/pre\u003e\u003cp\u003eこの章では、\u003cstrong\u003eスイッチ\u003c/strong\u003eを実装して複数のノードを接続できるローカルネットワークを構築します。また、\u003cstrong\u003eMACアドレス\u003c/strong\u003eを導入してイーサネットレベルでの通信を実現します。\u003c/p\u003e\n\u003ch2 id=\"32-macアドレスの実装\"\u003e3.2 MACアドレスの実装\u003c/h2\u003e\n\u003cp\u003eMACアドレス（Media Access Control Address）は、ネットワークインターフェースの物理アドレスです。\u003c/p\u003e\n\u003cp\u003e\u003cstrong\u003eファイル名: \u003ccode\u003e./mac_address.go\u003c/code\u003e\u003c/strong\u003e\u003c/p\u003e\n\u003cdiv class=\"highlight\"\u003e\u003cpre tabindex=\"0\" style=\"color:#f8f8f2;background-color:#272822;-moz-tab-size:4;-o-tab-size:4;tab-size:4;\"\u003e\u003ccode class=\"language-go\" data-lang=\"go\"\u003e\u003cspan style=\"display:flex;\"\u003e\u003cspan\u003e\u003cspan style=\"color:#f92672\"\u003epackage\u003c/span\u003e \u003cspan style=\"color:#a6e22e\"\u003emain\u003c/span\u003e\n\u003c/span\u003e\u003c/span\u003e\u003cspan style=\"display:flex;\"\u003e\u003cspan\u003e\n\u003c/span\u003e\u003c/span\u003e\u003cspan style=\"display:flex;\"\u003e\u003cspan\u003e\u003cspan style=\"color:#f92672\"\u003eimport\u003c/span\u003e (\n\u003c/span\u003e\u003c/span\u003e\u003cspan style=\"display:flex;\"\u003e\u003cspan\u003e    \u003cspan style=\"color:#e6db74\"\u003e\u0026#34;fmt\u0026#34;\u003c/span\u003e\n\u003c/span\u003e\u003c/span\u003e\u003cspan style=\"display:flex;\"\u003e\u003cspan\u003e    \u003cspan style=\"color:#e6db74\"\u003e\u0026#34;math/rand\u0026#34;\u003c/span\u003e\n\u003c/span\u003e\u003c/span\u003e\u003cspan style=\"display:flex;\"\u003e\u003cspan\u003e    \u003cspan style=\"color:#e6db74\"\u003e\u0026#34;strconv\u0026#34;\u003c/span\u003e\n\u003c/span\u003e\u003c/span\u003e\u003cspan style=\"display:flex;\"\u003e\u003cspan\u003e    \u003cspan style=\"color:#e6db74\"\u003e\u0026#34;strings\u0026#34;\u003c/span\u003e\n\u003c/span\u003e\u003c/span\u003e\u003cspan style=\"display:flex;\"\u003e\u003cspan\u003e)\n\u003c/span\u003e\u003c/span\u003e\u003cspan style=\"display:flex;\"\u003e\u003cspan\u003e\n\u003c/span\u003e\u003c/span\u003e\u003cspan style=\"display:flex;\"\u003e\u003cspan\u003e\u003cspan style=\"color:#75715e\"\u003e// MACAddress はMAC（Media Access Control）アドレスを表現する\u003c/span\u003e\n\u003c/span\u003e\u003c/span\u003e\u003cspan style=\"display:flex;\"\u003e\u003cspan\u003e\u003cspan style=\"color:#75715e\"\u003e// 実際のイーサネットで使用される6バイトの物理アドレス\u003c/span\u003e\n\u003c/span\u003e\u003c/span\u003e\u003cspan style=\"display:flex;\"\u003e\u003cspan\u003e\u003cspan style=\"color:#66d9ef\"\u003etype\u003c/span\u003e \u003cspan style=\"color:#a6e22e\"\u003eMACAddress\u003c/span\u003e \u003cspan style=\"color:#66d9ef\"\u003estruct\u003c/span\u003e {\n\u003c/span\u003e\u003c/span\u003e\u003cspan style=\"display:flex;\"\u003e\u003cspan\u003e    \u003cspan style=\"color:#a6e22e\"\u003ebytes\u003c/span\u003e [\u003cspan style=\"color:#ae81ff\"\u003e6\u003c/span\u003e]\u003cspan style=\"color:#66d9ef\"\u003ebyte\u003c/span\u003e \u003cspan style=\"color:#75715e\"\u003e// 6バイトのMACAアドレス（例：aa:bb:cc:dd:ee:ff）\u003c/span\u003e\n\u003c/span\u003e\u003c/span\u003e\u003cspan style=\"display:flex;\"\u003e\u003cspan\u003e}\n\u003c/span\u003e\u003c/span\u003e\u003cspan style=\"display:flex;\"\u003e\u003cspan\u003e\n\u003c/span\u003e\u003c/span\u003e\u003cspan style=\"display:flex;\"\u003e\u003cspan\u003e\u003cspan style=\"color:#75715e\"\u003e// NewMACAddress は指定されたバイト配列からMACアドレスを作成\u003c/span\u003e\n\u003c/span\u003e\u003c/span\u003e\u003cspan style=\"display:flex;\"\u003e\u003cspan\u003e\u003cspan style=\"color:#66d9ef\"\u003efunc\u003c/span\u003e \u003cspan style=\"color:#a6e22e\"\u003eNewMACAddress\u003c/span\u003e(\u003cspan style=\"color:#a6e22e\"\u003ebytes\u003c/span\u003e [\u003cspan style=\"color:#ae81ff\"\u003e6\u003c/span\u003e]\u003cspan style=\"color:#66d9ef\"\u003ebyte\u003c/span\u003e) \u003cspan style=\"color:#a6e22e\"\u003eMACAddress\u003c/span\u003e {\n\u003c/span\u003e\u003c/span\u003e\u003cspan style=\"display:flex;\"\u003e\u003cspan\u003e    \u003cspan style=\"color:#66d9ef\"\u003ereturn\u003c/span\u003e \u003cspan style=\"color:#a6e22e\"\u003eMACAddress\u003c/span\u003e{\u003cspan style=\"color:#a6e22e\"\u003ebytes\u003c/span\u003e: \u003cspan style=\"color:#a6e22e\"\u003ebytes\u003c/span\u003e}\n\u003c/span\u003e\u003c/span\u003e\u003cspan style=\"display:flex;\"\u003e\u003cspan\u003e}\n\u003c/span\u003e\u003c/span\u003e\u003cspan style=\"display:flex;\"\u003e\u003cspan\u003e\n\u003c/span\u003e\u003c/span\u003e\u003cspan style=\"display:flex;\"\u003e\u003cspan\u003e\u003cspan style=\"color:#75715e\"\u003e// ParseMACAddress は文字列からMACアドレスを解析\u003c/span\u003e\n\u003c/span\u003e\u003c/span\u003e\u003cspan style=\"display:flex;\"\u003e\u003cspan\u003e\u003cspan style=\"color:#75715e\"\u003e// 例：ParseMACAddress(\u0026#34;aa:bb:cc:dd:ee:ff\u0026#34;)\u003c/span\u003e\n\u003c/span\u003e\u003c/span\u003e\u003cspan style=\"display:flex;\"\u003e\u003cspan\u003e\u003cspan style=\"color:#66d9ef\"\u003efunc\u003c/span\u003e \u003cspan style=\"color:#a6e22e\"\u003eParseMACAddress\u003c/span\u003e(\u003cspan style=\"color:#a6e22e\"\u003es\u003c/span\u003e \u003cspan style=\"color:#66d9ef\"\u003estring\u003c/span\u003e) (\u003cspan style=\"color:#a6e22e\"\u003eMACAddress\u003c/span\u003e, \u003cspan style=\"color:#66d9ef\"\u003eerror\u003c/span\u003e) {\n\u003c/span\u003e\u003c/span\u003e\u003cspan style=\"display:flex;\"\u003e\u003cspan\u003e    \u003cspan style=\"color:#a6e22e\"\u003eparts\u003c/span\u003e \u003cspan style=\"color:#f92672\"\u003e:=\u003c/span\u003e \u003cspan style=\"color:#a6e22e\"\u003estrings\u003c/span\u003e.\u003cspan style=\"color:#a6e22e\"\u003eSplit\u003c/span\u003e(\u003cspan style=\"color:#a6e22e\"\u003es\u003c/span\u003e, \u003cspan style=\"color:#e6db74\"\u003e\u0026#34;:\u0026#34;\u003c/span\u003e)\n\u003c/span\u003e\u003c/span\u003e\u003cspan style=\"display:flex;\"\u003e\u003cspan\u003e    \u003cspan style=\"color:#66d9ef\"\u003eif\u003c/span\u003e len(\u003cspan style=\"color:#a6e22e\"\u003eparts\u003c/span\u003e) \u003cspan style=\"color:#f92672\"\u003e!=\u003c/span\u003e \u003cspan style=\"color:#ae81ff\"\u003e6\u003c/span\u003e {\n\u003c/span\u003e\u003c/span\u003e\u003cspan style=\"display:flex;\"\u003e\u003cspan\u003e        \u003cspan style=\"color:#66d9ef\"\u003ereturn\u003c/span\u003e \u003cspan style=\"color:#a6e22e\"\u003eMACAddress\u003c/span\u003e{}, \u003cspan style=\"color:#a6e22e\"\u003efmt\u003c/span\u003e.\u003cspan style=\"color:#a6e22e\"\u003eErrorf\u003c/span\u003e(\u003cspan style=\"color:#e6db74\"\u003e\u0026#34;invalid MAC address format: %s\u0026#34;\u003c/span\u003e, \u003cspan style=\"color:#a6e22e\"\u003es\u003c/span\u003e)\n\u003c/span\u003e\u003c/span\u003e\u003cspan style=\"display:flex;\"\u003e\u003cspan\u003e    }\n\u003c/span\u003e\u003c/span\u003e\u003cspan style=\"display:flex;\"\u003e\u003cspan\u003e    \n\u003c/span\u003e\u003c/span\u003e\u003cspan style=\"display:flex;\"\u003e\u003cspan\u003e    \u003cspan style=\"color:#66d9ef\"\u003evar\u003c/span\u003e \u003cspan style=\"color:#a6e22e\"\u003emac\u003c/span\u003e \u003cspan style=\"color:#a6e22e\"\u003eMACAddress\u003c/span\u003e\n\u003c/span\u003e\u003c/span\u003e\u003cspan style=\"display:flex;\"\u003e\u003cspan\u003e    \u003cspan style=\"color:#66d9ef\"\u003efor\u003c/span\u003e \u003cspan style=\"color:#a6e22e\"\u003ei\u003c/span\u003e, \u003cspan style=\"color:#a6e22e\"\u003epart\u003c/span\u003e \u003cspan style=\"color:#f92672\"\u003e:=\u003c/span\u003e \u003cspan style=\"color:#66d9ef\"\u003erange\u003c/span\u003e \u003cspan style=\"color:#a6e22e\"\u003eparts\u003c/span\u003e {\n\u003c/span\u003e\u003c/span\u003e\u003cspan style=\"display:flex;\"\u003e\u003cspan\u003e        \u003cspan style=\"color:#a6e22e\"\u003eval\u003c/span\u003e, \u003cspan style=\"color:#a6e22e\"\u003eerr\u003c/span\u003e \u003cspan style=\"color:#f92672\"\u003e:=\u003c/span\u003e \u003cspan style=\"color:#a6e22e\"\u003estrconv\u003c/span\u003e.\u003cspan style=\"color:#a6e22e\"\u003eParseUint\u003c/span\u003e(\u003cspan style=\"color:#a6e22e\"\u003epart\u003c/span\u003e, \u003cspan style=\"color:#ae81ff\"\u003e16\u003c/span\u003e, \u003cspan style=\"color:#ae81ff\"\u003e8\u003c/span\u003e)\n\u003c/span\u003e\u003c/span\u003e\u003cspan style=\"display:flex;\"\u003e\u003cspan\u003e        \u003cspan style=\"color:#66d9ef\"\u003eif\u003c/span\u003e \u003cspan style=\"color:#a6e22e\"\u003eerr\u003c/span\u003e \u003cspan style=\"color:#f92672\"\u003e!=\u003c/span\u003e \u003cspan style=\"color:#66d9ef\"\u003enil\u003c/span\u003e {\n\u003c/span\u003e\u003c/span\u003e\u003cspan style=\"display:flex;\"\u003e\u003cspan\u003e            \u003cspan style=\"color:#66d9ef\"\u003ereturn\u003c/span\u003e \u003cspan style=\"color:#a6e22e\"\u003eMACAddress\u003c/span\u003e{}, \u003cspan style=\"color:#a6e22e\"\u003efmt\u003c/span\u003e.\u003cspan style=\"color:#a6e22e\"\u003eErrorf\u003c/span\u003e(\u003cspan style=\"color:#e6db74\"\u003e\u0026#34;invalid hex value in MAC address: %s\u0026#34;\u003c/span\u003e, \u003cspan style=\"color:#a6e22e\"\u003epart\u003c/span\u003e)\n\u003c/span\u003e\u003c/span\u003e\u003cspan style=\"display:flex;\"\u003e\u003cspan\u003e        }\n\u003c/span\u003e\u003c/span\u003e\u003cspan style=\"display:flex;\"\u003e\u003cspan\u003e        \u003cspan style=\"color:#a6e22e\"\u003emac\u003c/span\u003e.\u003cspan style=\"color:#a6e22e\"\u003ebytes\u003c/span\u003e[\u003cspan style=\"color:#a6e22e\"\u003ei\u003c/span\u003e] = byte(\u003cspan style=\"color:#a6e22e\"\u003eval\u003c/span\u003e)\n\u003c/span\u003e\u003c/span\u003e\u003cspan style=\"display:flex;\"\u003e\u003cspan\u003e    }\n\u003c/span\u003e\u003c/span\u003e\u003cspan style=\"display:flex;\"\u003e\u003cspan\u003e    \n\u003c/span\u003e\u003c/span\u003e\u003cspan style=\"display:flex;\"\u003e\u003cspan\u003e    \u003cspan style=\"color:#66d9ef\"\u003ereturn\u003c/span\u003e \u003cspan style=\"color:#a6e22e\"\u003emac\u003c/span\u003e, \u003cspan style=\"color:#66d9ef\"\u003enil\u003c/span\u003e\n\u003c/span\u003e\u003c/span\u003e\u003cspan style=\"display:flex;\"\u003e\u003cspan\u003e}\n\u003c/span\u003e\u003c/span\u003e\u003cspan style=\"display:flex;\"\u003e\u003cspan\u003e\n\u003c/span\u003e\u003c/span\u003e\u003cspan style=\"display:flex;\"\u003e\u003cspan\u003e\u003cspan style=\"color:#75715e\"\u003e// RandomMACAddress はランダムなMACアドレスを生成\u003c/span\u003e\n\u003c/span\u003e\u003c/span\u003e\u003cspan style=\"display:flex;\"\u003e\u003cspan\u003e\u003cspan style=\"color:#75715e\"\u003e// ユニキャスト、ローカル管理アドレスとして生成\u003c/span\u003e\n\u003c/span\u003e\u003c/span\u003e\u003cspan style=\"display:flex;\"\u003e\u003cspan\u003e\u003cspan style=\"color:#66d9ef\"\u003efunc\u003c/span\u003e \u003cspan style=\"color:#a6e22e\"\u003eRandomMACAddress\u003c/span\u003e() \u003cspan style=\"color:#a6e22e\"\u003eMACAddress\u003c/span\u003e {\n\u003c/span\u003e\u003c/span\u003e\u003cspan style=\"display:flex;\"\u003e\u003cspan\u003e    \u003cspan style=\"color:#66d9ef\"\u003evar\u003c/span\u003e \u003cspan style=\"color:#a6e22e\"\u003emac\u003c/span\u003e \u003cspan style=\"color:#a6e22e\"\u003eMACAddress\u003c/span\u003e\n\u003c/span\u003e\u003c/span\u003e\u003cspan style=\"display:flex;\"\u003e\u003cspan\u003e    \u003cspan style=\"color:#66d9ef\"\u003efor\u003c/span\u003e \u003cspan style=\"color:#a6e22e\"\u003ei\u003c/span\u003e \u003cspan style=\"color:#f92672\"\u003e:=\u003c/span\u003e \u003cspan style=\"color:#ae81ff\"\u003e0\u003c/span\u003e; \u003cspan style=\"color:#a6e22e\"\u003ei\u003c/span\u003e \u0026lt; \u003cspan style=\"color:#ae81ff\"\u003e6\u003c/span\u003e; \u003cspan style=\"color:#a6e22e\"\u003ei\u003c/span\u003e\u003cspan style=\"color:#f92672\"\u003e++\u003c/span\u003e {\n\u003c/span\u003e\u003c/span\u003e\u003cspan style=\"display:flex;\"\u003e\u003cspan\u003e        \u003cspan style=\"color:#a6e22e\"\u003emac\u003c/span\u003e.\u003cspan style=\"color:#a6e22e\"\u003ebytes\u003c/span\u003e[\u003cspan style=\"color:#a6e22e\"\u003ei\u003c/span\u003e] = byte(\u003cspan style=\"color:#a6e22e\"\u003erand\u003c/span\u003e.\u003cspan style=\"color:#a6e22e\"\u003eIntn\u003c/span\u003e(\u003cspan style=\"color:#ae81ff\"\u003e256\u003c/span\u003e))\n\u003c/span\u003e\u003c/span\u003e\u003cspan style=\"display:flex;\"\u003e\u003cspan\u003e    }\n\u003c/span\u003e\u003c/span\u003e\u003cspan style=\"display:flex;\"\u003e\u003cspan\u003e    \n\u003c/span\u003e\u003c/span\u003e\u003cspan style=\"display:flex;\"\u003e\u003cspan\u003e    \u003cspan style=\"color:#75715e\"\u003e// ユニキャスト（LSBを0に）、ローカル管理（2番目のLSBを1に）に設定\u003c/span\u003e\n\u003c/span\u003e\u003c/span\u003e\u003cspan style=\"display:flex;\"\u003e\u003cspan\u003e    \u003cspan style=\"color:#a6e22e\"\u003emac\u003c/span\u003e.\u003cspan style=\"color:#a6e22e\"\u003ebytes\u003c/span\u003e[\u003cspan style=\"color:#ae81ff\"\u003e0\u003c/span\u003e] = (\u003cspan style=\"color:#a6e22e\"\u003emac\u003c/span\u003e.\u003cspan style=\"color:#a6e22e\"\u003ebytes\u003c/span\u003e[\u003cspan style=\"color:#ae81ff\"\u003e0\u003c/span\u003e] \u003cspan style=\"color:#f92672\"\u003e\u0026amp;\u003c/span\u003e \u003cspan style=\"color:#ae81ff\"\u003e0xFC\u003c/span\u003e) | \u003cspan style=\"color:#ae81ff\"\u003e0x02\u003c/span\u003e\n\u003c/span\u003e\u003c/span\u003e\u003cspan style=\"display:flex;\"\u003e\u003cspan\u003e    \n\u003c/span\u003e\u003c/span\u003e\u003cspan style=\"display:flex;\"\u003e\u003cspan\u003e    \u003cspan style=\"color:#66d9ef\"\u003ereturn\u003c/span\u003e \u003cspan style=\"color:#a6e22e\"\u003emac\u003c/span\u003e\n\u003c/span\u003e\u003c/span\u003e\u003cspan style=\"display:flex;\"\u003e\u003cspan\u003e}\n\u003c/span\u003e\u003c/span\u003e\u003cspan style=\"display:flex;\"\u003e\u003cspan\u003e\n\u003c/span\u003e\u003c/span\u003e\u003cspan style=\"display:flex;\"\u003e\u003cspan\u003e\u003cspan style=\"color:#75715e\"\u003e// String はMACアドレスの文字列表現を返す\u003c/span\u003e\n\u003c/span\u003e\u003c/span\u003e\u003cspan style=\"display:flex;\"\u003e\u003cspan\u003e\u003cspan style=\"color:#66d9ef\"\u003efunc\u003c/span\u003e (\u003cspan style=\"color:#a6e22e\"\u003emac\u003c/span\u003e \u003cspan style=\"color:#a6e22e\"\u003eMACAddress\u003c/span\u003e) \u003cspan style=\"color:#a6e22e\"\u003eString\u003c/span\u003e() \u003cspan style=\"color:#66d9ef\"\u003estring\u003c/span\u003e {\n\u003c/span\u003e\u003c/span\u003e\u003cspan style=\"display:flex;\"\u003e\u003cspan\u003e    \u003cspan style=\"color:#66d9ef\"\u003ereturn\u003c/span\u003e \u003cspan style=\"color:#a6e22e\"\u003efmt\u003c/span\u003e.\u003cspan style=\"color:#a6e22e\"\u003eSprintf\u003c/span\u003e(\u003cspan style=\"color:#e6db74\"\u003e\u0026#34;%02x:%02x:%02x:%02x:%02x:%02x\u0026#34;\u003c/span\u003e,\n\u003c/span\u003e\u003c/span\u003e\u003cspan style=\"display:flex;\"\u003e\u003cspan\u003e        \u003cspan style=\"color:#a6e22e\"\u003emac\u003c/span\u003e.\u003cspan style=\"color:#a6e22e\"\u003ebytes\u003c/span\u003e[\u003cspan style=\"color:#ae81ff\"\u003e0\u003c/span\u003e], \u003cspan style=\"color:#a6e22e\"\u003emac\u003c/span\u003e.\u003cspan style=\"color:#a6e22e\"\u003ebytes\u003c/span\u003e[\u003cspan style=\"color:#ae81ff\"\u003e1\u003c/span\u003e], \u003cspan style=\"color:#a6e22e\"\u003emac\u003c/span\u003e.\u003cspan style=\"color:#a6e22e\"\u003ebytes\u003c/span\u003e[\u003cspan style=\"color:#ae81ff\"\u003e2\u003c/span\u003e],\n\u003c/span\u003e\u003c/span\u003e\u003cspan style=\"display:flex;\"\u003e\u003cspan\u003e        \u003cspan style=\"color:#a6e22e\"\u003emac\u003c/span\u003e.\u003cspan style=\"color:#a6e22e\"\u003ebytes\u003c/span\u003e[\u003cspan style=\"color:#ae81ff\"\u003e3\u003c/span\u003e], \u003cspan style=\"color:#a6e22e\"\u003emac\u003c/span\u003e.\u003cspan style=\"color:#a6e22e\"\u003ebytes\u003c/span\u003e[\u003cspan style=\"color:#ae81ff\"\u003e4\u003c/span\u003e], \u003cspan style=\"color:#a6e22e\"\u003emac\u003c/span\u003e.\u003cspan style=\"color:#a6e22e\"\u003ebytes\u003c/span\u003e[\u003cspan style=\"color:#ae81ff\"\u003e5\u003c/span\u003e])\n\u003c/span\u003e\u003c/span\u003e\u003cspan style=\"display:flex;\"\u003e\u003cspan\u003e}\n\u003c/span\u003e\u003c/span\u003e\u003cspan style=\"display:flex;\"\u003e\u003cspan\u003e\n\u003c/span\u003e\u003c/span\u003e\u003cspan style=\"display:flex;\"\u003e\u003cspan\u003e\u003cspan style=\"color:#75715e\"\u003e// Equals は2つのMACアドレスが等しいかチェック\u003c/span\u003e\n\u003c/span\u003e\u003c/span\u003e\u003cspan style=\"display:flex;\"\u003e\u003cspan\u003e\u003cspan style=\"color:#66d9ef\"\u003efunc\u003c/span\u003e (\u003cspan style=\"color:#a6e22e\"\u003emac\u003c/span\u003e \u003cspan style=\"color:#a6e22e\"\u003eMACAddress\u003c/span\u003e) \u003cspan style=\"color:#a6e22e\"\u003eEquals\u003c/span\u003e(\u003cspan style=\"color:#a6e22e\"\u003eother\u003c/span\u003e \u003cspan style=\"color:#a6e22e\"\u003eMACAddress\u003c/span\u003e) \u003cspan style=\"color:#66d9ef\"\u003ebool\u003c/span\u003e {\n\u003c/span\u003e\u003c/span\u003e\u003cspan style=\"display:flex;\"\u003e\u003cspan\u003e    \u003cspan style=\"color:#66d9ef\"\u003ereturn\u003c/span\u003e \u003cspan style=\"color:#a6e22e\"\u003emac\u003c/span\u003e.\u003cspan style=\"color:#a6e22e\"\u003ebytes\u003c/span\u003e \u003cspan style=\"color:#f92672\"\u003e==\u003c/span\u003e \u003cspan style=\"color:#a6e22e\"\u003eother\u003c/span\u003e.\u003cspan style=\"color:#a6e22e\"\u003ebytes\u003c/span\u003e\n\u003c/span\u003e\u003c/span\u003e\u003cspan style=\"display:flex;\"\u003e\u003cspan\u003e}\n\u003c/span\u003e\u003c/span\u003e\u003cspan style=\"display:flex;\"\u003e\u003cspan\u003e\n\u003c/span\u003e\u003c/span\u003e\u003cspan style=\"display:flex;\"\u003e\u003cspan\u003e\u003cspan style=\"color:#75715e\"\u003e// IsUnicast はユニキャストアドレスかチェック\u003c/span\u003e\n\u003c/span\u003e\u003c/span\u003e\u003cspan style=\"display:flex;\"\u003e\u003cspan\u003e\u003cspan style=\"color:#66d9ef\"\u003efunc\u003c/span\u003e (\u003cspan style=\"color:#a6e22e\"\u003emac\u003c/span\u003e \u003cspan style=\"color:#a6e22e\"\u003eMACAddress\u003c/span\u003e) \u003cspan style=\"color:#a6e22e\"\u003eIsUnicast\u003c/span\u003e() \u003cspan style=\"color:#66d9ef\"\u003ebool\u003c/span\u003e {\n\u003c/span\u003e\u003c/span\u003e\u003cspan style=\"display:flex;\"\u003e\u003cspan\u003e    \u003cspan style=\"color:#66d9ef\"\u003ereturn\u003c/span\u003e (\u003cspan style=\"color:#a6e22e\"\u003emac\u003c/span\u003e.\u003cspan style=\"color:#a6e22e\"\u003ebytes\u003c/span\u003e[\u003cspan style=\"color:#ae81ff\"\u003e0\u003c/span\u003e] \u003cspan style=\"color:#f92672\"\u003e\u0026amp;\u003c/span\u003e \u003cspan style=\"color:#ae81ff\"\u003e0x01\u003c/span\u003e) \u003cspan style=\"color:#f92672\"\u003e==\u003c/span\u003e \u003cspan style=\"color:#ae81ff\"\u003e0\u003c/span\u003e\n\u003c/span\u003e\u003c/span\u003e\u003cspan style=\"display:flex;\"\u003e\u003cspan\u003e}\n\u003c/span\u003e\u003c/span\u003e\u003cspan style=\"display:flex;\"\u003e\u003cspan\u003e\n\u003c/span\u003e\u003c/span\u003e\u003cspan style=\"display:flex;\"\u003e\u003cspan\u003e\u003cspan style=\"color:#75715e\"\u003e// IsBroadcast はブロードキャストアドレスかチェック\u003c/span\u003e\n\u003c/span\u003e\u003c/span\u003e\u003cspan style=\"display:flex;\"\u003e\u003cspan\u003e\u003cspan style=\"color:#66d9ef\"\u003efunc\u003c/span\u003e (\u003cspan style=\"color:#a6e22e\"\u003emac\u003c/span\u003e \u003cspan style=\"color:#a6e22e\"\u003eMACAddress\u003c/span\u003e) \u003cspan style=\"color:#a6e22e\"\u003eIsBroadcast\u003c/span\u003e() \u003cspan style=\"color:#66d9ef\"\u003ebool\u003c/span\u003e {\n\u003c/span\u003e\u003c/span\u003e\u003cspan style=\"display:flex;\"\u003e\u003cspan\u003e    \u003cspan style=\"color:#66d9ef\"\u003ereturn\u003c/span\u003e \u003cspan style=\"color:#a6e22e\"\u003emac\u003c/span\u003e.\u003cspan style=\"color:#a6e22e\"\u003ebytes\u003c/span\u003e \u003cspan style=\"color:#f92672\"\u003e==\u003c/span\u003e [\u003cspan style=\"color:#ae81ff\"\u003e6\u003c/span\u003e]\u003cspan style=\"color:#66d9ef\"\u003ebyte\u003c/span\u003e{\u003cspan style=\"color:#ae81ff\"\u003e0xFF\u003c/span\u003e, \u003cspan style=\"color:#ae81ff\"\u003e0xFF\u003c/span\u003e, \u003cspan style=\"color:#ae81ff\"\u003e0xFF\u003c/span\u003e, \u003cspan style=\"color:#ae81ff\"\u003e0xFF\u003c/span\u003e, \u003cspan style=\"color:#ae81ff\"\u003e0xFF\u003c/span\u003e, \u003cspan style=\"color:#ae81ff\"\u003e0xFF\u003c/span\u003e}\n\u003c/span\u003e\u003c/span\u003e\u003cspan style=\"display:flex;\"\u003e\u003cspan\u003e}\n\u003c/span\u003e\u003c/span\u003e\u003cspan style=\"display:flex;\"\u003e\u003cspan\u003e\n\u003c/span\u003e\u003c/span\u003e\u003cspan style=\"display:flex;\"\u003e\u003cspan\u003e\u003cspan style=\"color:#75715e\"\u003e// IsMulticast はマルチキャストアドレスかチェック\u003c/span\u003e\n\u003c/span\u003e\u003c/span\u003e\u003cspan style=\"display:flex;\"\u003e\u003cspan\u003e\u003cspan style=\"color:#66d9ef\"\u003efunc\u003c/span\u003e (\u003cspan style=\"color:#a6e22e\"\u003emac\u003c/span\u003e \u003cspan style=\"color:#a6e22e\"\u003eMACAddress\u003c/span\u003e) \u003cspan style=\"color:#a6e22e\"\u003eIsMulticast\u003c/span\u003e() \u003cspan style=\"color:#66d9ef\"\u003ebool\u003c/span\u003e {\n\u003c/span\u003e\u003c/span\u003e\u003cspan style=\"display:flex;\"\u003e\u003cspan\u003e    \u003cspan style=\"color:#66d9ef\"\u003ereturn\u003c/span\u003e (\u003cspan style=\"color:#a6e22e\"\u003emac\u003c/span\u003e.\u003cspan style=\"color:#a6e22e\"\u003ebytes\u003c/span\u003e[\u003cspan style=\"color:#ae81ff\"\u003e0\u003c/span\u003e] \u003cspan style=\"color:#f92672\"\u003e\u0026amp;\u003c/span\u003e \u003cspan style=\"color:#ae81ff\"\u003e0x01\u003c/span\u003e) \u003cspan style=\"color:#f92672\"\u003e==\u003c/span\u003e \u003cspan style=\"color:#ae81ff\"\u003e1\u003c/span\u003e \u003cspan style=\"color:#f92672\"\u003e\u0026amp;\u0026amp;\u003c/span\u003e !\u003cspan style=\"color:#a6e22e\"\u003emac\u003c/span\u003e.\u003cspan style=\"color:#a6e22e\"\u003eIsBroadcast\u003c/span\u003e()\n\u003c/span\u003e\u003c/span\u003e\u003cspan style=\"display:flex;\"\u003e\u003cspan\u003e}\n\u003c/span\u003e\u003c/span\u003e\u003cspan style=\"display:flex;\"\u003e\u003cspan\u003e\n\u003c/span\u003e\u003c/span\u003e\u003cspan style=\"display:flex;\"\u003e\u003cspan\u003e\u003cspan style=\"color:#75715e\"\u003e// BroadcastMAC はブロードキャストMACアドレスを返す\u003c/span\u003e\n\u003c/span\u003e\u003c/span\u003e\u003cspan style=\"display:flex;\"\u003e\u003cspan\u003e\u003cspan style=\"color:#66d9ef\"\u003efunc\u003c/span\u003e \u003cspan style=\"color:#a6e22e\"\u003eBroadcastMAC\u003c/span\u003e() \u003cspan style=\"color:#a6e22e\"\u003eMACAddress\u003c/span\u003e {\n\u003c/span\u003e\u003c/span\u003e\u003cspan style=\"display:flex;\"\u003e\u003cspan\u003e    \u003cspan style=\"color:#66d9ef\"\u003ereturn\u003c/span\u003e \u003cspan style=\"color:#a6e22e\"\u003eMACAddress\u003c/span\u003e{\u003cspan style=\"color:#a6e22e\"\u003ebytes\u003c/span\u003e: [\u003cspan style=\"color:#ae81ff\"\u003e6\u003c/span\u003e]\u003cspan style=\"color:#66d9ef\"\u003ebyte\u003c/span\u003e{\u003cspan style=\"color:#ae81ff\"\u003e0xFF\u003c/span\u003e, \u003cspan style=\"color:#ae81ff\"\u003e0xFF\u003c/span\u003e, \u003cspan style=\"color:#ae81ff\"\u003e0xFF\u003c/span\u003e, \u003cspan style=\"color:#ae81ff\"\u003e0xFF\u003c/span\u003e, \u003cspan style=\"color:#ae81ff\"\u003e0xFF\u003c/span\u003e, \u003cspan style=\"color:#ae81ff\"\u003e0xFF\u003c/span\u003e}}\n\u003c/span\u003e\u003c/span\u003e\u003cspan style=\"display:flex;\"\u003e\u003cspan\u003e}\n\u003c/span\u003e\u003c/span\u003e\u003c/code\u003e\u003c/pre\u003e\u003c/div\u003e\u003ch2 id=\"33-イーサネットフレームの実装\"\u003e3.3 イーサネットフレームの実装\u003c/h2\u003e\n\u003cp\u003eMACアドレスを含むイーサネットフレーム構造を実装します。\u003c/p\u003e","title":"Go言語でネットワークプログラミングを学ぶ - 第3章"},{"content":"参考・インスピレーション元 この教材は以下のサイトの構成を参考に、Go言語での実装として新たに構築したものです：\nCoNeCo｜コンピュータネットワーク with Colab: https://www.conecolab.com/ 作者：中山悠（東京農工大学准教授） ライセンス：CC-BY-SA Google Colabを使用したPython実装によるネットワーク学習教材 本教材は上記の教育アプローチにインスピレーションを受けつつ、Go言語での独自実装として作成しています。\nGo言語でネットワークプログラミングを学ぶ 第0章：環境構築とネットワーク基礎概念 0.1 環境構築 # Go 1.21以上をインストール go version # プロジェクト初期化 mkdir go-network-programming cd go-network-programming go mod init go-network-programming # 必要なパッケージ go get github.com/google/uuid go get gonum.org/v1/gonum/graph 0.2 なぜGo言語なのか？ ネットワークプログラミングにおけるGo言語の利点：\n並行処理のサポート：goroutineによる軽量な並行処理 型安全性：プロトコルの違いをコンパイル時に検証 シンプルな文法：複雑な仕様を直感的なコードで表現 標準ライブラリ：充実したネットワーク関連パッケージ 0.3 学習対象の基本概念 ノード (Node) ネットワーク上のデバイス（PC、スマートフォン、ルーターなど） パケットを送受信する機能 一意のアドレスを持つ リンク (Link) ノード間の接続 帯域幅、遅延、エラー率などの特性を持つ 双方向または単方向の通信 パケット (Packet) ネットワークで転送される情報の単位 ヘッダとペイロードから構成 プロトコル層によって内容が変化 0.4 基本アーキテクチャの設計 // ネットワークエンティティの基本インターフェース type NetworkEntity interface { ID() string String() string } // パケット処理のインターフェース type PacketHandler interface { Send(packet Packet, destination string) error Receive() \u0026lt;-chan Packet } // アドレス管理のインターフェース type Addressable interface { Address() Address SetAddress(addr Address) } 0.5 学習計画 第1章: 基本要素の実装 (Node, Link, Packet) 第2章: 時間と並行性の導入 第3章: スイッチングとMACアドレス 第4章: MACアドレス学習とループ回避 第5章: IPパケットとルーティング 第6章: 動的ルーティングプロトコル 第7章: レイヤ化とカプセル化 第8章: アドレス解決プロトコル 第9章: 動的IPアドレス設定とNAT 第10章: TCP接続の確立 第11章: 確認応答と再送制御 第12章: 輻輳制御とウィンドウ制御 第13章: QoSと優先制御 第14章: アプリケーション層プロトコル 第15章: セキュリティと暗号化 0.6 評価ポイント 各章で以下の観点から実装を評価します：\nコードの品質: 読みやすく保守性の高いコード 正確性: プロトコル仕様の正しい実装 性能: 実用的な処理速度 拡張性: 将来的な機能追加への対応 実習環境について この教材では実際にコードを書きながら学習を進めます。各章で段階的に機能を追加し、最終的には本格的なネットワークシミュレーターを完成させることを目標とします。\n次のステップ 第1章では基本となるNodeとLinkの実装を行い、シンプルなパケット送受信を実現します。\n","permalink":"https://techblog.wasutech.dev/posts/go-network/","summary":"\u003ch2 id=\"参考インスピレーション元\"\u003e参考・インスピレーション元\u003c/h2\u003e\n\u003cp\u003eこの教材は以下のサイトの構成を参考に、Go言語での実装として新たに構築したものです：\u003c/p\u003e\n\u003cul\u003e\n\u003cli\u003e\u003cstrong\u003eCoNeCo｜コンピュータネットワーク with Colab\u003c/strong\u003e: \u003ca href=\"https://www.conecolab.com/\"\u003ehttps://www.conecolab.com/\u003c/a\u003e\n\u003cul\u003e\n\u003cli\u003e作者：中山悠（東京農工大学准教授）\u003c/li\u003e\n\u003cli\u003eライセンス：CC-BY-SA\u003c/li\u003e\n\u003cli\u003eGoogle Colabを使用したPython実装によるネットワーク学習教材\u003c/li\u003e\n\u003c/ul\u003e\n\u003c/li\u003e\n\u003c/ul\u003e\n\u003cp\u003e本教材は上記の教育アプローチにインスピレーションを受けつつ、Go言語での独自実装として作成しています。\u003c/p\u003e\n\u003ch1 id=\"go言語でネットワークプログラミングを学ぶ\"\u003eGo言語でネットワークプログラミングを学ぶ\u003c/h1\u003e\n\u003ch2 id=\"第0章環境構築とネットワーク基礎概念\"\u003e第0章：環境構築とネットワーク基礎概念\u003c/h2\u003e\n\u003ch3 id=\"01-環境構築\"\u003e0.1 環境構築\u003c/h3\u003e\n\u003cdiv class=\"highlight\"\u003e\u003cpre tabindex=\"0\" style=\"color:#f8f8f2;background-color:#272822;-moz-tab-size:4;-o-tab-size:4;tab-size:4;\"\u003e\u003ccode class=\"language-bash\" data-lang=\"bash\"\u003e\u003cspan style=\"display:flex;\"\u003e\u003cspan\u003e\u003cspan style=\"color:#75715e\"\u003e# Go 1.21以上をインストール\u003c/span\u003e\n\u003c/span\u003e\u003c/span\u003e\u003cspan style=\"display:flex;\"\u003e\u003cspan\u003ego version\n\u003c/span\u003e\u003c/span\u003e\u003cspan style=\"display:flex;\"\u003e\u003cspan\u003e\n\u003c/span\u003e\u003c/span\u003e\u003cspan style=\"display:flex;\"\u003e\u003cspan\u003e\u003cspan style=\"color:#75715e\"\u003e# プロジェクト初期化\u003c/span\u003e\n\u003c/span\u003e\u003c/span\u003e\u003cspan style=\"display:flex;\"\u003e\u003cspan\u003emkdir go-network-programming\n\u003c/span\u003e\u003c/span\u003e\u003cspan style=\"display:flex;\"\u003e\u003cspan\u003ecd go-network-programming\n\u003c/span\u003e\u003c/span\u003e\u003cspan style=\"display:flex;\"\u003e\u003cspan\u003ego mod init go-network-programming\n\u003c/span\u003e\u003c/span\u003e\u003cspan style=\"display:flex;\"\u003e\u003cspan\u003e\n\u003c/span\u003e\u003c/span\u003e\u003cspan style=\"display:flex;\"\u003e\u003cspan\u003e\u003cspan style=\"color:#75715e\"\u003e# 必要なパッケージ\u003c/span\u003e\n\u003c/span\u003e\u003c/span\u003e\u003cspan style=\"display:flex;\"\u003e\u003cspan\u003ego get github.com/google/uuid\n\u003c/span\u003e\u003c/span\u003e\u003cspan style=\"display:flex;\"\u003e\u003cspan\u003ego get gonum.org/v1/gonum/graph\n\u003c/span\u003e\u003c/span\u003e\u003c/code\u003e\u003c/pre\u003e\u003c/div\u003e\u003ch3 id=\"02-なぜgo言語なのか\"\u003e0.2 なぜGo言語なのか？\u003c/h3\u003e\n\u003cp\u003eネットワークプログラミングにおけるGo言語の利点：\u003c/p\u003e\n\u003col\u003e\n\u003cli\u003e\u003cstrong\u003e並行処理のサポート\u003c/strong\u003e：goroutineによる軽量な並行処理\u003c/li\u003e\n\u003cli\u003e\u003cstrong\u003e型安全性\u003c/strong\u003e：プロトコルの違いをコンパイル時に検証\u003c/li\u003e\n\u003cli\u003e\u003cstrong\u003eシンプルな文法\u003c/strong\u003e：複雑な仕様を直感的なコードで表現\u003c/li\u003e\n\u003cli\u003e\u003cstrong\u003e標準ライブラリ\u003c/strong\u003e：充実したネットワーク関連パッケージ\u003c/li\u003e\n\u003c/ol\u003e\n\u003ch3 id=\"03-学習対象の基本概念\"\u003e0.3 学習対象の基本概念\u003c/h3\u003e\n\u003ch4 id=\"ノード-node\"\u003eノード (Node)\u003c/h4\u003e\n\u003cul\u003e\n\u003cli\u003eネットワーク上のデバイス（PC、スマートフォン、ルーターなど）\u003c/li\u003e\n\u003cli\u003eパケットを送受信する機能\u003c/li\u003e\n\u003cli\u003e一意のアドレスを持つ\u003c/li\u003e\n\u003c/ul\u003e\n\u003ch4 id=\"リンク-link\"\u003eリンク (Link)\u003c/h4\u003e\n\u003cul\u003e\n\u003cli\u003eノード間の接続\u003c/li\u003e\n\u003cli\u003e帯域幅、遅延、エラー率などの特性を持つ\u003c/li\u003e\n\u003cli\u003e双方向または単方向の通信\u003c/li\u003e\n\u003c/ul\u003e\n\u003ch4 id=\"パケット-packet\"\u003eパケット (Packet)\u003c/h4\u003e\n\u003cul\u003e\n\u003cli\u003eネットワークで転送される情報の単位\u003c/li\u003e\n\u003cli\u003eヘッダとペイロードから構成\u003c/li\u003e\n\u003cli\u003eプロトコル層によって内容が変化\u003c/li\u003e\n\u003c/ul\u003e\n\u003ch3 id=\"04-基本アーキテクチャの設計\"\u003e0.4 基本アーキテクチャの設計\u003c/h3\u003e\n\u003cdiv class=\"highlight\"\u003e\u003cpre tabindex=\"0\" style=\"color:#f8f8f2;background-color:#272822;-moz-tab-size:4;-o-tab-size:4;tab-size:4;\"\u003e\u003ccode class=\"language-go\" data-lang=\"go\"\u003e\u003cspan style=\"display:flex;\"\u003e\u003cspan\u003e\u003cspan style=\"color:#75715e\"\u003e// ネットワークエンティティの基本インターフェース\u003c/span\u003e\n\u003c/span\u003e\u003c/span\u003e\u003cspan style=\"display:flex;\"\u003e\u003cspan\u003e\u003cspan style=\"color:#66d9ef\"\u003etype\u003c/span\u003e \u003cspan style=\"color:#a6e22e\"\u003eNetworkEntity\u003c/span\u003e \u003cspan style=\"color:#66d9ef\"\u003einterface\u003c/span\u003e {\n\u003c/span\u003e\u003c/span\u003e\u003cspan style=\"display:flex;\"\u003e\u003cspan\u003e    \u003cspan style=\"color:#a6e22e\"\u003eID\u003c/span\u003e() \u003cspan style=\"color:#66d9ef\"\u003estring\u003c/span\u003e\n\u003c/span\u003e\u003c/span\u003e\u003cspan style=\"display:flex;\"\u003e\u003cspan\u003e    \u003cspan style=\"color:#a6e22e\"\u003eString\u003c/span\u003e() \u003cspan style=\"color:#66d9ef\"\u003estring\u003c/span\u003e\n\u003c/span\u003e\u003c/span\u003e\u003cspan style=\"display:flex;\"\u003e\u003cspan\u003e}\n\u003c/span\u003e\u003c/span\u003e\u003cspan style=\"display:flex;\"\u003e\u003cspan\u003e\n\u003c/span\u003e\u003c/span\u003e\u003cspan style=\"display:flex;\"\u003e\u003cspan\u003e\u003cspan style=\"color:#75715e\"\u003e// パケット処理のインターフェース\u003c/span\u003e\n\u003c/span\u003e\u003c/span\u003e\u003cspan style=\"display:flex;\"\u003e\u003cspan\u003e\u003cspan style=\"color:#66d9ef\"\u003etype\u003c/span\u003e \u003cspan style=\"color:#a6e22e\"\u003ePacketHandler\u003c/span\u003e \u003cspan style=\"color:#66d9ef\"\u003einterface\u003c/span\u003e {\n\u003c/span\u003e\u003c/span\u003e\u003cspan style=\"display:flex;\"\u003e\u003cspan\u003e    \u003cspan style=\"color:#a6e22e\"\u003eSend\u003c/span\u003e(\u003cspan style=\"color:#a6e22e\"\u003epacket\u003c/span\u003e \u003cspan style=\"color:#a6e22e\"\u003ePacket\u003c/span\u003e, \u003cspan style=\"color:#a6e22e\"\u003edestination\u003c/span\u003e \u003cspan style=\"color:#66d9ef\"\u003estring\u003c/span\u003e) \u003cspan style=\"color:#66d9ef\"\u003eerror\u003c/span\u003e\n\u003c/span\u003e\u003c/span\u003e\u003cspan style=\"display:flex;\"\u003e\u003cspan\u003e    \u003cspan style=\"color:#a6e22e\"\u003eReceive\u003c/span\u003e() \u003cspan style=\"color:#f92672\"\u003e\u0026lt;-\u003c/span\u003e\u003cspan style=\"color:#66d9ef\"\u003echan\u003c/span\u003e \u003cspan style=\"color:#a6e22e\"\u003ePacket\u003c/span\u003e\n\u003c/span\u003e\u003c/span\u003e\u003cspan style=\"display:flex;\"\u003e\u003cspan\u003e}\n\u003c/span\u003e\u003c/span\u003e\u003cspan style=\"display:flex;\"\u003e\u003cspan\u003e\n\u003c/span\u003e\u003c/span\u003e\u003cspan style=\"display:flex;\"\u003e\u003cspan\u003e\u003cspan style=\"color:#75715e\"\u003e// アドレス管理のインターフェース\u003c/span\u003e\n\u003c/span\u003e\u003c/span\u003e\u003cspan style=\"display:flex;\"\u003e\u003cspan\u003e\u003cspan style=\"color:#66d9ef\"\u003etype\u003c/span\u003e \u003cspan style=\"color:#a6e22e\"\u003eAddressable\u003c/span\u003e \u003cspan style=\"color:#66d9ef\"\u003einterface\u003c/span\u003e {\n\u003c/span\u003e\u003c/span\u003e\u003cspan style=\"display:flex;\"\u003e\u003cspan\u003e    \u003cspan style=\"color:#a6e22e\"\u003eAddress\u003c/span\u003e() \u003cspan style=\"color:#a6e22e\"\u003eAddress\u003c/span\u003e\n\u003c/span\u003e\u003c/span\u003e\u003cspan style=\"display:flex;\"\u003e\u003cspan\u003e    \u003cspan style=\"color:#a6e22e\"\u003eSetAddress\u003c/span\u003e(\u003cspan style=\"color:#a6e22e\"\u003eaddr\u003c/span\u003e \u003cspan style=\"color:#a6e22e\"\u003eAddress\u003c/span\u003e)\n\u003c/span\u003e\u003c/span\u003e\u003cspan style=\"display:flex;\"\u003e\u003cspan\u003e}\n\u003c/span\u003e\u003c/span\u003e\u003c/code\u003e\u003c/pre\u003e\u003c/div\u003e\u003ch3 id=\"05-学習計画\"\u003e0.5 学習計画\u003c/h3\u003e\n\u003col\u003e\n\u003cli\u003e\u003cstrong\u003e第1章\u003c/strong\u003e: 基本要素の実装 (Node, Link, Packet)\u003c/li\u003e\n\u003cli\u003e\u003cstrong\u003e第2章\u003c/strong\u003e: 時間と並行性の導入\u003c/li\u003e\n\u003cli\u003e\u003cstrong\u003e第3章\u003c/strong\u003e: スイッチングとMACアドレス\u003c/li\u003e\n\u003cli\u003e\u003cstrong\u003e第4章\u003c/strong\u003e: MACアドレス学習とループ回避\u003c/li\u003e\n\u003cli\u003e\u003cstrong\u003e第5章\u003c/strong\u003e: IPパケットとルーティング\u003c/li\u003e\n\u003cli\u003e\u003cstrong\u003e第6章\u003c/strong\u003e: 動的ルーティングプロトコル\u003c/li\u003e\n\u003cli\u003e\u003cstrong\u003e第7章\u003c/strong\u003e: レイヤ化とカプセル化\u003c/li\u003e\n\u003cli\u003e\u003cstrong\u003e第8章\u003c/strong\u003e: アドレス解決プロトコル\u003c/li\u003e\n\u003cli\u003e\u003cstrong\u003e第9章\u003c/strong\u003e: 動的IPアドレス設定とNAT\u003c/li\u003e\n\u003cli\u003e\u003cstrong\u003e第10章\u003c/strong\u003e: TCP接続の確立\u003c/li\u003e\n\u003cli\u003e\u003cstrong\u003e第11章\u003c/strong\u003e: 確認応答と再送制御\u003c/li\u003e\n\u003cli\u003e\u003cstrong\u003e第12章\u003c/strong\u003e: 輻輳制御とウィンドウ制御\u003c/li\u003e\n\u003cli\u003e\u003cstrong\u003e第13章\u003c/strong\u003e: QoSと優先制御\u003c/li\u003e\n\u003cli\u003e\u003cstrong\u003e第14章\u003c/strong\u003e: アプリケーション層プロトコル\u003c/li\u003e\n\u003cli\u003e\u003cstrong\u003e第15章\u003c/strong\u003e: セキュリティと暗号化\u003c/li\u003e\n\u003c/ol\u003e\n\u003ch3 id=\"06-評価ポイント\"\u003e0.6 評価ポイント\u003c/h3\u003e\n\u003cp\u003e各章で以下の観点から実装を評価します：\u003c/p\u003e","title":"Go言語でネットワークプログラミングを学ぶ - 第0章"},{"content":"第1章：ネットワークの基本要素 - Node、Link、Packet 1.1 プロジェクト構造 go-network-programming/ ├── go.mod ├── go.sum ├── main.go ├── packet.go ├── node.go └── link.go この章では、ネットワークの基本的な構成要素であるノード、リンク、パケットをGo言語で実装します。実際のネットワーク機器と同じように、複数のプロセスが並行して動作し、channelを通じてパケットを送受信する仕組みを構築します。\n1.2 パケットの実装 パケットは、ネットワークで送信される情報の基本単位です。送信元、宛先、データ本体、タイムスタンプなどの情報を含みます。\nファイル名: ./packet.go\npackage main import ( \u0026#34;fmt\u0026#34; \u0026#34;time\u0026#34; \u0026#34;github.com/google/uuid\u0026#34; ) // Packet はネットワークで送信される基本単位を表現する // 実際のTCP/IPパケットのように、ヘッダ情報とペイロードを持つ type Packet struct { ID string // パケットの一意識別子 Source string // 送信元ノードの名前 Destination string // 宛先ノードの名前 Data []byte // 実際のデータ（ペイロード） Size int // データサイズ（バイト） Timestamp time.Time // パケット生成時刻 } // NewPacket は新しいパケットを生成する // 実際のネットワークスタックでパケットが生成される処理を模倣 func NewPacket(source, destination string, data []byte) *Packet { return \u0026amp;Packet{ ID: uuid.New().String(), Source: source, Destination: destination, Data: data, Size: len(data), Timestamp: time.Now(), } } // String はパケットの文字列表現を返す（デバッグ用） func (p *Packet) String() string { return fmt.Sprintf(\u0026#34;Packet{ID: %s, From: %s, To: %s, Size: %d bytes}\u0026#34;, p.ID[:8], p.Source, p.Destination, p.Size) } 1.3 ノードの実装 ノードは、ネットワーク上のデバイス（PC、スマートフォン、ルーターなど）を表現します。パケットの送受信機能を持ち、複数のリンクに接続できます。\nファイル名: ./node.go\npackage main import ( \u0026#34;fmt\u0026#34; \u0026#34;time\u0026#34; \u0026#34;github.com/google/uuid\u0026#34; ) // Node はネットワーク上のデバイスを表現する // 実際のコンピューターやルーターのように、パケットを処理する type Node struct { ID string // ノードの一意識別子 Name string // ノードの名前（人間が読める形式） inbox chan *Packet // 受信用パケットキュー outbox chan *Packet // 送信用パケットキュー links map[string]*Link // 接続されているリンクのリスト running bool // ノードが動作中かどうかのフラグ } // NewNode は新しいノードを生成する // メモリ上にネットワークノードのインスタンスを作成 func NewNode(name string) *Node { return \u0026amp;Node{ ID: uuid.New().String(), Name: name, inbox: make(chan *Packet, 100), // バッファサイズ100のキュー outbox: make(chan *Packet, 100), links: make(map[string]*Link), running: false, } } // Start はノードの動作を開始する // バックグラウンドでパケット処理を実行するgoroutineを起動 func (n *Node) Start() { if n.running { return } n.running = true // パケット処理用goroutineを開始 // 実際のネットワークカードのように、バックグラウンドで動作 go n.processPackets() fmt.Printf(\u0026#34;Node %s started\\n\u0026#34;, n.Name) } // Stop はノードの動作を停止する // 全てのチャネルを閉じてリソースを解放 func (n *Node) Stop() { if !n.running { return } n.running = false close(n.inbox) close(n.outbox) fmt.Printf(\u0026#34;Node %s stopped\\n\u0026#34;, n.Name) } // Send は指定された宛先にパケットを送信する // 実際のsocket送信のように、適切なルートを探してパケットを送出 func (n *Node) Send(destination string, data []byte) error { if !n.running { return fmt.Errorf(\u0026#34;node %s is not running\u0026#34;, n.Name) } packet := NewPacket(n.Name, destination, data) // 宛先に到達可能なリンクを探す（シンプルなルーティング） for _, link := range n.links { if link.CanReach(destination) { return link.Send(packet) } } return fmt.Errorf(\u0026#34;no route to destination %s\u0026#34;, destination) } // Receive は受信したパケットを返す // アプリケーションがソケットから読み取る動作を模倣 func (n *Node) Receive() *Packet { if !n.running { return nil } select { case packet := \u0026lt;-n.inbox: return packet case \u0026lt;-time.After(1 * time.Second): return nil // タイムアウト } } // AddLink はノードにリンクを追加する // ネットワークインターフェースを追加する動作に相当 func (n *Node) AddLink(link *Link) { n.links[link.ID] = link fmt.Printf(\u0026#34;Link added to node %s\\n\u0026#34;, n.Name) } // processPackets はパケット処理のメインループ // 実際のNIC（Network Interface Card）のパケット処理を模倣 func (n *Node) processPackets() { for n.running { select { case packet := \u0026lt;-n.outbox: if packet != nil { fmt.Printf(\u0026#34;Node %s processing outgoing: %s\\n\u0026#34;, n.Name, packet) } default: time.Sleep(10 * time.Millisecond) } } } // String はノードの文字列表現を返す（デバッグ用） func (n *Node) String() string { return fmt.Sprintf(\u0026#34;Node{Name: %s, ID: %s, Links: %d}\u0026#34;, n.Name, n.ID[:8], len(n.links)) } 1.4 リンクの実装 リンクは、ノード間の物理的または論理的な接続を表現します。帯域幅、遅延、パケット損失などのネットワーク特性を持ちます。\nファイル名: ./link.go\npackage main import ( \u0026#34;fmt\u0026#34; \u0026#34;time\u0026#34; \u0026#34;github.com/google/uuid\u0026#34; ) // Link はノード間の接続を表現する // イーサネットケーブルや無線接続のような物理メディアを模倣 type Link struct { ID string // リンクの一意識別子 NodeA *Node // 接続されているノードA NodeB *Node // 接続されているノードB Bandwidth int // 帯域幅（Mbps） Latency time.Duration // 遅延時間 PacketLoss float64 // パケット損失率（0.0-1.0） channel chan *Packet // パケット転送用チャネル running bool // リンクが稼働中かどうか } // NewLink は新しいリンクを生成する // 2つのノード間にネットワーク接続を確立 func NewLink(nodeA, nodeB *Node, bandwidth int, latency time.Duration) *Link { link := \u0026amp;Link{ ID: uuid.New().String(), NodeA: nodeA, NodeB: nodeB, Bandwidth: bandwidth, Latency: latency, PacketLoss: 0.0, // 初期状態では損失なし channel: make(chan *Packet, 50), // バッファ付きチャネル running: false, } // 両方のノードにこのリンクを登録 nodeA.AddLink(link) nodeB.AddLink(link) return link } // Start はリンクの動作を開始する // パケット転送処理を行うgoroutineを起動 func (l *Link) Start() { if l.running { return } l.running = true // パケット転送用goroutineを開始 // 実際のスイッチやハブのパケット転送機能を模倣 go l.forwardPackets() fmt.Printf(\u0026#34;Link between %s and %s started\\n\u0026#34;, l.NodeA.Name, l.NodeB.Name) } // Stop はリンクの動作を停止する func (l *Link) Stop() { if !l.running { return } l.running = false close(l.channel) fmt.Printf(\u0026#34;Link between %s and %s stopped\\n\u0026#34;, l.NodeA.Name, l.NodeB.Name) } // Send はリンクを通じてパケットを送信する // 実際のネットワークカードからの送信を模倣 func (l *Link) Send(packet *Packet) error { if !l.running { return fmt.Errorf(\u0026#34;link is not running\u0026#34;) } // チャネルにパケットを送信（非ブロッキング） select { case l.channel \u0026lt;- packet: return nil case \u0026lt;-time.After(100 * time.Millisecond): return fmt.Errorf(\u0026#34;link congested\u0026#34;) // 輻輳状態 } } // CanReach は指定された宛先に到達可能かチェックする // シンプルなルーティング判定（直接接続のみ） func (l *Link) CanReach(destination string) bool { return l.NodeA.Name == destination || l.NodeB.Name == destination } // forwardPackets はパケット転送のメインループ // スイッチやルーターのパケット転送処理を模倣 func (l *Link) forwardPackets() { for l.running { select { case packet := \u0026lt;-l.channel: if packet != nil { // ネットワーク遅延をシミュレート // 実際の光ファイバーや銅線の伝送遅延を模倣 time.Sleep(l.Latency) // 宛先ノードを決定 var targetNode *Node if packet.Destination == l.NodeA.Name { targetNode = l.NodeA } else if packet.Destination == l.NodeB.Name { targetNode = l.NodeB } else { // ブロードキャスト的な動作：送信元でないノードに転送 if packet.Source != l.NodeA.Name { targetNode = l.NodeA } else { targetNode = l.NodeB } } // パケットを宛先ノードの受信キューに配送 if targetNode != nil \u0026amp;\u0026amp; targetNode.running { select { case targetNode.inbox \u0026lt;- packet: fmt.Printf(\u0026#34;Packet forwarded to %s: %s\\n\u0026#34;, targetNode.Name, packet) case \u0026lt;-time.After(10 * time.Millisecond): fmt.Printf(\u0026#34;Failed to deliver packet to %s (queue full)\\n\u0026#34;, targetNode.Name) } } } default: time.Sleep(1 * time.Millisecond) } } } // String はリンクの文字列表現を返す（デバッグ用） func (l *Link) String() string { return fmt.Sprintf(\u0026#34;Link{%s \u0026lt;-\u0026gt; %s, %dMbps, %v latency}\u0026#34;, l.NodeA.Name, l.NodeB.Name, l.Bandwidth, l.Latency) } 1.5 メイン関数とテスト実行 実際にノード間でパケットを送受信するサンプルコードです。\nファイル名: ./main.go\npackage main import ( \u0026#34;fmt\u0026#34; \u0026#34;time\u0026#34; ) func main() { fmt.Println(\u0026#34;=== ネットワークシミュレーション開始 ===\u0026#34;) // 2つのノードを作成（AliceとBobという名前） alice := NewNode(\u0026#34;Alice\u0026#34;) bob := NewNode(\u0026#34;Bob\u0026#34;) // ノード間にリンクを作成（100Mbps、10ms遅延） // これは実際のイーサネット接続に相当 link := NewLink(alice, bob, 100, 10*time.Millisecond) // 全システムを起動 alice.Start() bob.Start() link.Start() // AliceからBobにメッセージを送信 message := []byte(\u0026#34;Hello, Bob! This is Alice.\u0026#34;) fmt.Printf(\u0026#34;Alice sends message: %s\\n\u0026#34;, string(message)) err := alice.Send(\u0026#34;Bob\u0026#34;, message) if err != nil { fmt.Printf(\u0026#34;Error sending message: %v\\n\u0026#34;, err) return } // ネットワーク遅延を考慮して待機 time.Sleep(50 * time.Millisecond) // Bobがメッセージを受信 received := bob.Receive() if received != nil { fmt.Printf(\u0026#34;Bob received: %s\\n\u0026#34;, string(received.Data)) fmt.Printf(\u0026#34;Packet details: %s\\n\u0026#34;, received) } else { fmt.Println(\u0026#34;No message received\u0026#34;) } // 逆方向の通信もテスト response := []byte(\u0026#34;Hi Alice! Nice to hear from you.\u0026#34;) fmt.Printf(\u0026#34;Bob sends response: %s\\n\u0026#34;, string(response)) err = bob.Send(\u0026#34;Alice\u0026#34;, response) if err != nil { fmt.Printf(\u0026#34;Error sending response: %v\\n\u0026#34;, err) } else { time.Sleep(50 * time.Millisecond) received = alice.Receive() if received != nil { fmt.Printf(\u0026#34;Alice received: %s\\n\u0026#34;, string(received.Data)) } } // システムを正常に終了 fmt.Println(\u0026#34;=== システム終了 ===\u0026#34;) alice.Stop() bob.Stop() link.Stop() } 1.6 実行方法 # プロジェクトディレクトリで実行 go run . 1.7 期待される出力例 === ネットワークシミュレーション開始 === Link added to node Alice Link added to node Bob Node Alice started Node Bob started Link between Alice and Bob started Alice sends message: Hello, Bob! This is Alice. Packet forwarded to Bob: Packet{ID: a1b2c3d4, From: Alice, To: Bob, Size: 27 bytes} Bob received: Hello, Bob! This is Alice. Packet details: Packet{ID: a1b2c3d4, From: Alice, To: Bob, Size: 27 bytes} Bob sends response: Hi Alice! Nice to hear from you. Packet forwarded to Alice: Packet{ID: e5f6g7h8, From: Bob, To: Alice, Size: 32 bytes} Alice received: Hi Alice! Nice to hear from you. === システム終了 === Node Alice stopped Node Bob stopped Link between Alice and Bob stopped 1.8 重要な概念の解説 1.8.1 並行処理 各ノードとリンクは独立したgoroutineで動作 実際のネットワーク機器のように、同時に複数の処理を実行 1.8.2 チャネル通信 パケットの送受信はGoのチャネルを使用 バッファ付きチャネルで輻輳制御を模倣 1.8.3 非ブロッキング送信 select文とタイムアウトを使用してデッドロックを回避 実際のネットワークスタックのような動作 1.9 練習問題 3ノード接続: Charlie というノードを追加し、Alice-Bob-Charlie の線形ネットワークを構築してください。\nパケット統計: ノードクラスに送受信パケット数をカウントする機能を追加してください。\nパケット損失: リンクにパケット損失機能を実装し、ランダムにパケットを破棄してください。\n1.10 次章への準備 第2章では、時間をより詳細に扱い、帯域幅制限やスループット測定を実装します。また、複数のパケットを同時に処理する機能を追加していきます。\n","permalink":"https://techblog.wasutech.dev/posts/go-network-1/","summary":"\u003ch1 id=\"第1章ネットワークの基本要素---nodelinkpacket\"\u003e第1章：ネットワークの基本要素 - Node、Link、Packet\u003c/h1\u003e\n\u003ch2 id=\"11-プロジェクト構造\"\u003e1.1 プロジェクト構造\u003c/h2\u003e\n\u003cpre tabindex=\"0\"\u003e\u003ccode\u003ego-network-programming/\n├── go.mod\n├── go.sum\n├── main.go\n├── packet.go\n├── node.go\n└── link.go\n\u003c/code\u003e\u003c/pre\u003e\u003cp\u003eこの章では、ネットワークの基本的な構成要素である\u003cstrong\u003eノード\u003c/strong\u003e、\u003cstrong\u003eリンク\u003c/strong\u003e、\u003cstrong\u003eパケット\u003c/strong\u003eをGo言語で実装します。実際のネットワーク機器と同じように、複数のプロセスが並行して動作し、channelを通じてパケットを送受信する仕組みを構築します。\u003c/p\u003e\n\u003ch2 id=\"12-パケットの実装\"\u003e1.2 パケットの実装\u003c/h2\u003e\n\u003cp\u003eパケットは、ネットワークで送信される情報の基本単位です。送信元、宛先、データ本体、タイムスタンプなどの情報を含みます。\u003c/p\u003e\n\u003cp\u003e\u003cstrong\u003eファイル名: \u003ccode\u003e./packet.go\u003c/code\u003e\u003c/strong\u003e\u003c/p\u003e\n\u003cdiv class=\"highlight\"\u003e\u003cpre tabindex=\"0\" style=\"color:#f8f8f2;background-color:#272822;-moz-tab-size:4;-o-tab-size:4;tab-size:4;\"\u003e\u003ccode class=\"language-go\" data-lang=\"go\"\u003e\u003cspan style=\"display:flex;\"\u003e\u003cspan\u003e\u003cspan style=\"color:#f92672\"\u003epackage\u003c/span\u003e \u003cspan style=\"color:#a6e22e\"\u003emain\u003c/span\u003e\n\u003c/span\u003e\u003c/span\u003e\u003cspan style=\"display:flex;\"\u003e\u003cspan\u003e\n\u003c/span\u003e\u003c/span\u003e\u003cspan style=\"display:flex;\"\u003e\u003cspan\u003e\u003cspan style=\"color:#f92672\"\u003eimport\u003c/span\u003e (\n\u003c/span\u003e\u003c/span\u003e\u003cspan style=\"display:flex;\"\u003e\u003cspan\u003e    \u003cspan style=\"color:#e6db74\"\u003e\u0026#34;fmt\u0026#34;\u003c/span\u003e\n\u003c/span\u003e\u003c/span\u003e\u003cspan style=\"display:flex;\"\u003e\u003cspan\u003e    \u003cspan style=\"color:#e6db74\"\u003e\u0026#34;time\u0026#34;\u003c/span\u003e\n\u003c/span\u003e\u003c/span\u003e\u003cspan style=\"display:flex;\"\u003e\u003cspan\u003e    \n\u003c/span\u003e\u003c/span\u003e\u003cspan style=\"display:flex;\"\u003e\u003cspan\u003e    \u003cspan style=\"color:#e6db74\"\u003e\u0026#34;github.com/google/uuid\u0026#34;\u003c/span\u003e\n\u003c/span\u003e\u003c/span\u003e\u003cspan style=\"display:flex;\"\u003e\u003cspan\u003e)\n\u003c/span\u003e\u003c/span\u003e\u003cspan style=\"display:flex;\"\u003e\u003cspan\u003e\n\u003c/span\u003e\u003c/span\u003e\u003cspan style=\"display:flex;\"\u003e\u003cspan\u003e\u003cspan style=\"color:#75715e\"\u003e// Packet はネットワークで送信される基本単位を表現する\u003c/span\u003e\n\u003c/span\u003e\u003c/span\u003e\u003cspan style=\"display:flex;\"\u003e\u003cspan\u003e\u003cspan style=\"color:#75715e\"\u003e// 実際のTCP/IPパケットのように、ヘッダ情報とペイロードを持つ\u003c/span\u003e\n\u003c/span\u003e\u003c/span\u003e\u003cspan style=\"display:flex;\"\u003e\u003cspan\u003e\u003cspan style=\"color:#66d9ef\"\u003etype\u003c/span\u003e \u003cspan style=\"color:#a6e22e\"\u003ePacket\u003c/span\u003e \u003cspan style=\"color:#66d9ef\"\u003estruct\u003c/span\u003e {\n\u003c/span\u003e\u003c/span\u003e\u003cspan style=\"display:flex;\"\u003e\u003cspan\u003e    \u003cspan style=\"color:#a6e22e\"\u003eID\u003c/span\u003e          \u003cspan style=\"color:#66d9ef\"\u003estring\u003c/span\u003e    \u003cspan style=\"color:#75715e\"\u003e// パケットの一意識別子\u003c/span\u003e\n\u003c/span\u003e\u003c/span\u003e\u003cspan style=\"display:flex;\"\u003e\u003cspan\u003e    \u003cspan style=\"color:#a6e22e\"\u003eSource\u003c/span\u003e      \u003cspan style=\"color:#66d9ef\"\u003estring\u003c/span\u003e    \u003cspan style=\"color:#75715e\"\u003e// 送信元ノードの名前\u003c/span\u003e\n\u003c/span\u003e\u003c/span\u003e\u003cspan style=\"display:flex;\"\u003e\u003cspan\u003e    \u003cspan style=\"color:#a6e22e\"\u003eDestination\u003c/span\u003e \u003cspan style=\"color:#66d9ef\"\u003estring\u003c/span\u003e    \u003cspan style=\"color:#75715e\"\u003e// 宛先ノードの名前\u003c/span\u003e\n\u003c/span\u003e\u003c/span\u003e\u003cspan style=\"display:flex;\"\u003e\u003cspan\u003e    \u003cspan style=\"color:#a6e22e\"\u003eData\u003c/span\u003e        []\u003cspan style=\"color:#66d9ef\"\u003ebyte\u003c/span\u003e    \u003cspan style=\"color:#75715e\"\u003e// 実際のデータ（ペイロード）\u003c/span\u003e\n\u003c/span\u003e\u003c/span\u003e\u003cspan style=\"display:flex;\"\u003e\u003cspan\u003e    \u003cspan style=\"color:#a6e22e\"\u003eSize\u003c/span\u003e        \u003cspan style=\"color:#66d9ef\"\u003eint\u003c/span\u003e       \u003cspan style=\"color:#75715e\"\u003e// データサイズ（バイト）\u003c/span\u003e\n\u003c/span\u003e\u003c/span\u003e\u003cspan style=\"display:flex;\"\u003e\u003cspan\u003e    \u003cspan style=\"color:#a6e22e\"\u003eTimestamp\u003c/span\u003e   \u003cspan style=\"color:#a6e22e\"\u003etime\u003c/span\u003e.\u003cspan style=\"color:#a6e22e\"\u003eTime\u003c/span\u003e \u003cspan style=\"color:#75715e\"\u003e// パケット生成時刻\u003c/span\u003e\n\u003c/span\u003e\u003c/span\u003e\u003cspan style=\"display:flex;\"\u003e\u003cspan\u003e}\n\u003c/span\u003e\u003c/span\u003e\u003cspan style=\"display:flex;\"\u003e\u003cspan\u003e\n\u003c/span\u003e\u003c/span\u003e\u003cspan style=\"display:flex;\"\u003e\u003cspan\u003e\u003cspan style=\"color:#75715e\"\u003e// NewPacket は新しいパケットを生成する\u003c/span\u003e\n\u003c/span\u003e\u003c/span\u003e\u003cspan style=\"display:flex;\"\u003e\u003cspan\u003e\u003cspan style=\"color:#75715e\"\u003e// 実際のネットワークスタックでパケットが生成される処理を模倣\u003c/span\u003e\n\u003c/span\u003e\u003c/span\u003e\u003cspan style=\"display:flex;\"\u003e\u003cspan\u003e\u003cspan style=\"color:#66d9ef\"\u003efunc\u003c/span\u003e \u003cspan style=\"color:#a6e22e\"\u003eNewPacket\u003c/span\u003e(\u003cspan style=\"color:#a6e22e\"\u003esource\u003c/span\u003e, \u003cspan style=\"color:#a6e22e\"\u003edestination\u003c/span\u003e \u003cspan style=\"color:#66d9ef\"\u003estring\u003c/span\u003e, \u003cspan style=\"color:#a6e22e\"\u003edata\u003c/span\u003e []\u003cspan style=\"color:#66d9ef\"\u003ebyte\u003c/span\u003e) \u003cspan style=\"color:#f92672\"\u003e*\u003c/span\u003e\u003cspan style=\"color:#a6e22e\"\u003ePacket\u003c/span\u003e {\n\u003c/span\u003e\u003c/span\u003e\u003cspan style=\"display:flex;\"\u003e\u003cspan\u003e    \u003cspan style=\"color:#66d9ef\"\u003ereturn\u003c/span\u003e \u003cspan style=\"color:#f92672\"\u003e\u0026amp;\u003c/span\u003e\u003cspan style=\"color:#a6e22e\"\u003ePacket\u003c/span\u003e{\n\u003c/span\u003e\u003c/span\u003e\u003cspan style=\"display:flex;\"\u003e\u003cspan\u003e        \u003cspan style=\"color:#a6e22e\"\u003eID\u003c/span\u003e:          \u003cspan style=\"color:#a6e22e\"\u003euuid\u003c/span\u003e.\u003cspan style=\"color:#a6e22e\"\u003eNew\u003c/span\u003e().\u003cspan style=\"color:#a6e22e\"\u003eString\u003c/span\u003e(),\n\u003c/span\u003e\u003c/span\u003e\u003cspan style=\"display:flex;\"\u003e\u003cspan\u003e        \u003cspan style=\"color:#a6e22e\"\u003eSource\u003c/span\u003e:      \u003cspan style=\"color:#a6e22e\"\u003esource\u003c/span\u003e,\n\u003c/span\u003e\u003c/span\u003e\u003cspan style=\"display:flex;\"\u003e\u003cspan\u003e        \u003cspan style=\"color:#a6e22e\"\u003eDestination\u003c/span\u003e: \u003cspan style=\"color:#a6e22e\"\u003edestination\u003c/span\u003e,\n\u003c/span\u003e\u003c/span\u003e\u003cspan style=\"display:flex;\"\u003e\u003cspan\u003e        \u003cspan style=\"color:#a6e22e\"\u003eData\u003c/span\u003e:        \u003cspan style=\"color:#a6e22e\"\u003edata\u003c/span\u003e,\n\u003c/span\u003e\u003c/span\u003e\u003cspan style=\"display:flex;\"\u003e\u003cspan\u003e        \u003cspan style=\"color:#a6e22e\"\u003eSize\u003c/span\u003e:        len(\u003cspan style=\"color:#a6e22e\"\u003edata\u003c/span\u003e),\n\u003c/span\u003e\u003c/span\u003e\u003cspan style=\"display:flex;\"\u003e\u003cspan\u003e        \u003cspan style=\"color:#a6e22e\"\u003eTimestamp\u003c/span\u003e:   \u003cspan style=\"color:#a6e22e\"\u003etime\u003c/span\u003e.\u003cspan style=\"color:#a6e22e\"\u003eNow\u003c/span\u003e(),\n\u003c/span\u003e\u003c/span\u003e\u003cspan style=\"display:flex;\"\u003e\u003cspan\u003e    }\n\u003c/span\u003e\u003c/span\u003e\u003cspan style=\"display:flex;\"\u003e\u003cspan\u003e}\n\u003c/span\u003e\u003c/span\u003e\u003cspan style=\"display:flex;\"\u003e\u003cspan\u003e\n\u003c/span\u003e\u003c/span\u003e\u003cspan style=\"display:flex;\"\u003e\u003cspan\u003e\u003cspan style=\"color:#75715e\"\u003e// String はパケットの文字列表現を返す（デバッグ用）\u003c/span\u003e\n\u003c/span\u003e\u003c/span\u003e\u003cspan style=\"display:flex;\"\u003e\u003cspan\u003e\u003cspan style=\"color:#66d9ef\"\u003efunc\u003c/span\u003e (\u003cspan style=\"color:#a6e22e\"\u003ep\u003c/span\u003e \u003cspan style=\"color:#f92672\"\u003e*\u003c/span\u003e\u003cspan style=\"color:#a6e22e\"\u003ePacket\u003c/span\u003e) \u003cspan style=\"color:#a6e22e\"\u003eString\u003c/span\u003e() \u003cspan style=\"color:#66d9ef\"\u003estring\u003c/span\u003e {\n\u003c/span\u003e\u003c/span\u003e\u003cspan style=\"display:flex;\"\u003e\u003cspan\u003e    \u003cspan style=\"color:#66d9ef\"\u003ereturn\u003c/span\u003e \u003cspan style=\"color:#a6e22e\"\u003efmt\u003c/span\u003e.\u003cspan style=\"color:#a6e22e\"\u003eSprintf\u003c/span\u003e(\u003cspan style=\"color:#e6db74\"\u003e\u0026#34;Packet{ID: %s, From: %s, To: %s, Size: %d bytes}\u0026#34;\u003c/span\u003e, \n\u003c/span\u003e\u003c/span\u003e\u003cspan style=\"display:flex;\"\u003e\u003cspan\u003e        \u003cspan style=\"color:#a6e22e\"\u003ep\u003c/span\u003e.\u003cspan style=\"color:#a6e22e\"\u003eID\u003c/span\u003e[:\u003cspan style=\"color:#ae81ff\"\u003e8\u003c/span\u003e], \u003cspan style=\"color:#a6e22e\"\u003ep\u003c/span\u003e.\u003cspan style=\"color:#a6e22e\"\u003eSource\u003c/span\u003e, \u003cspan style=\"color:#a6e22e\"\u003ep\u003c/span\u003e.\u003cspan style=\"color:#a6e22e\"\u003eDestination\u003c/span\u003e, \u003cspan style=\"color:#a6e22e\"\u003ep\u003c/span\u003e.\u003cspan style=\"color:#a6e22e\"\u003eSize\u003c/span\u003e)\n\u003c/span\u003e\u003c/span\u003e\u003cspan style=\"display:flex;\"\u003e\u003cspan\u003e}\n\u003c/span\u003e\u003c/span\u003e\u003c/code\u003e\u003c/pre\u003e\u003c/div\u003e\u003ch2 id=\"13-ノードの実装\"\u003e1.3 ノードの実装\u003c/h2\u003e\n\u003cp\u003eノードは、ネットワーク上のデバイス（PC、スマートフォン、ルーターなど）を表現します。パケットの送受信機能を持ち、複数のリンクに接続できます。\u003c/p\u003e","title":"Go言語でネットワークプログラミングを学ぶ - 第1章"},{"content":"DFS, BFSがわかりづらかったので、いくつかの記事を見て個人的に感じた疑問や 「こういうコード例が欲しい」という要望を踏まえて生成AIに生成してもらった。\n生成された内容を検証し、コードを実際に動かして確認したところ、 自分の理解が深まる良い記事になったので、このまま公開することにした。\nはじめに LeetCodeでMedium問題を解いていると、必ず遭遇するのがDFS（深さ優先探索）とBFS（幅優先探索）だ。\n「Dは深さ、Bは幅」というのは知っている。でも、なぜスタックとキューを使い分けるのか？その本質を理解している人は意外と少ない。\n今回は、入れ子リストの例を使って、DFS/BFSの動作原理とデータ構造の関係を視覚的に解説する。\n問題設定：入れ子リストをフラット化する 以下のような入れ子構造のリストがあるとする。\ndata = [1, [4, 5, [6, 7, 8], 2], 3] これをフラットな配列にしたい。このとき、「どの順番で要素を取り出すか」がDFS/BFSの違いだ。\nツリー構造として可視化する 入れ子リストは、実はツリー構造として表現できる。\nroot / | \\ 1 [] 3 | /|\\ \\ 4 5 [] 2 | /|\\ 6 7 8 この木をどう巡回するかで、DFSとBFSが決まる。\nDFS（深さ優先探索）：とにかく深く潜る 動作イメージ 「見つけた枝があれば、まずそこを最後まで探索する」\n訪問順序：\n1 → [中に入る] → 4 → 5 → [さらに中] → 6 → 7 → 8 → [戻る] → 2 → [戻る] → 3 結果：[1, 4, 5, 6, 7, 8, 2, 3]\n実装：スタックまたは再帰 再帰版 def dfs_recursive(data): result = [] def helper(item): if isinstance(item, list): for sub in item: helper(sub) # 再帰で潜る else: result.append(item) helper(data) return result スタック版 def dfs_stack(data): result = [] stack = [data] while stack: item = stack.pop() # 後入れ先出し（LIFO） if isinstance(item, list): # reversed()で逆順に追加 → pop()で元の順序を保つ # [4, 5]を処理する場合: 5→4の順でpush → 4→5の順でpop for sub in reversed(item): stack.append(sub) else: result.append(item) return result 重要：なぜreversed()が必要か？\nスタックは「後入れ先出し」なので、そのまま追加すると逆順になってしまう。\n# reversed()なしの場合 stack.append([4, 5, 6]) # → pop()で 6, 5, 4 の順に取り出される（逆順！） # reversed()ありの場合 stack.append([6, 5, 4]) # 逆順で追加 # → pop()で 4, 5, 6 の順に取り出される（正順！） なぜスタックなのか？ 「深く潜って、戻る」という動きがLIFO（後入れ先出し）だから。\nスタックは「最後に入れたものを最初に取り出す」データ構造。DFSの「深さ優先」の動きと完全に一致する。\nBFS（幅優先探索）：同じ階層を先に見る 動作イメージ 「同じ深さのノードを全部見てから、次の階層へ進む」\n訪問順序：\nレベル0: 1, [中身], 3 → 数値だけ取り出す: 1, 3 レベル1: 4, 5, [中身], 2 → 数値だけ取り出す: 4, 5, 2 レベル2: 6, 7, 8 → 数値だけ取り出す: 6, 7, 8 結果：[1, 3, 4, 5, 2, 6, 7, 8]\n実装：キュー from collections import deque def bfs(data): result = [] queue = deque([data]) while queue: item = queue.popleft() # 先入れ先出し（FIFO） if isinstance(item, list): for sub in item: queue.append(sub) else: result.append(item) return result なぜキューなのか？ 「同じ階層を順番に処理する」という動きがFIFO（先入れ先出し）だから。\nキューは「最初に入れたものを最初に取り出す」データ構造。BFSの「幅優先」の動きと完全に一致する。\n二重ループではダメな理由 初学者がやりがちなミス：\n# ❌ これは深さ2までしか対応できない for item in data: if isinstance(item, list): for sub in item: print(sub) 問題点：入れ子の深さが3以上になると対応不可能\ndata = [1, [2, [3, [4, [5]]]]] # 二重ループの場合 for item in data: if isinstance(item, list): for sub in item: print(sub) # 3までしか到達できない # 三重ループにしても... for item in data: if isinstance(item, list): for sub in item: if isinstance(sub, list): for subsub in sub: print(subsub) # 4までしか到達できない 深さが不定の場合、ループのネストを事前に決められない。\nこれが、再帰やスタック/キューといった動的なデータ構造が必要な理由だ。\n実際のツリー問題での違い 1 / \\ 2 3 / \\ 4 5 DFS（深さ優先）\n訪問順: 1 → 2 → 4 → 5 → 3 （左の枝を全部探索してから右へ） BFS（幅優先）\n訪問順: 1 → 2 → 3 → 4 → 5 （階層ごとに左から右へ） まとめ 項目 DFS BFS データ構造 スタック（再帰） キュー 動作原理 LIFO（後入れ先出し） FIFO（先入れ先出し） 探索方向 深さ優先（縦） 幅優先（横） 用途 経路探索、トポロジカルソート 最短経路、レベル順探索 核心：データ構造の選択が、探索の動きを決定する。\nスタックを使えば自動的に深さ優先になり、キューを使えば自動的に幅優先になる。これがDFS/BFSの本質だ。\n次にツリーやグラフ問題に出会ったとき、「スタックかキューか」を考えるだけで、解法の方向性が見えてくる。\n補足：「listでキューを実装してはいけない」理由 よくある疑問 「キューってlistのpop(0)でも実現できるよね？」\n答え：できるが、絶対にやるな。\n計算量の罠 # ✓ 動作はする queue = [] queue.append(1) # enqueue item = queue.pop(0) # dequeue # しかし... 時間計算量の比較\n操作 list deque append() O(1) O(1) pop(0) / popleft() O(n) O(1) なぜO(n)になるのか？ Pythonのlistは内部的に連続配列として実装されている。\nlist = [A, B, C, D, E] pop(0)を実行すると：\nBefore: [A, B, C, D, E] Step 1: Aを削除 Step 2: B, C, D, E を全て左にシフト ← O(n) Final: [B, C, D, E] 要素数nに比例して処理時間が増える。\n実際の速度差：キューサイズによる影響 小規模キュー（1,000要素）\nN = 1,000,000 QSIZE = 1,000 結果： list: 0.367秒 deque: 0.264秒 比率：約1.4倍 小規模なキューではPythonの最適化により、差は比較的小さい。\n中規模キュー（10,000要素）\nN = 1,000,000 QSIZE = 10,000 結果： list: 2.156秒 deque: 0.233秒 比率：約9.3倍 キューサイズが10倍になると、速度差も約10倍に拡大。\n理論的な説明\nlist.pop(0)の計算量：O(QSIZE) → キューサイズに比例して遅くなる deque.popleft()の計算量：O(1) → キューサイズに影響されない LeetCodeでの実害 Binary Tree Level Order Traversalのような問題では、ツリーのノード数が10,000を超えることは珍しくない。\nQSIZE = 1,000: 両方ともAC（ただしlistは遅い） QSIZE = 10,000: listでTLE（Time Limit Exceeded）の可能性 QSIZE = 100,000: listは確実にTLE 結論：LeetCodeでキューを使う場合、必ずcollections.dequeを使うこと。\n「動く」と「効率的」は別物。\nキューを実装するときは、必ずcollections.dequeを使うこと。これがアルゴリズム問題を解く上での鉄則だ。\n記事の正しい使い分け # スタック（LIFO）→ list stack = [] stack.append(1) # O(1) stack.pop() # O(1) ← 末尾から取るので速い # キュー（FIFO）→ deque from collections import deque queue = deque() queue.append(1) # O(1) queue.popleft() # O(1) ← 先頭から取るのも速い データ構造の選択ミスは、コードを遅くする最大の要因になる。\n","permalink":"https://techblog.wasutech.dev/posts/dfs-bfs/","summary":"\u003cp\u003eDFS, BFSがわかりづらかったので、いくつかの記事を見て個人的に感じた疑問や\n「こういうコード例が欲しい」という要望を踏まえて生成AIに生成してもらった。\u003c/p\u003e\n\u003cp\u003e生成された内容を検証し、コードを実際に動かして確認したところ、\n自分の理解が深まる良い記事になったので、このまま公開することにした。\u003c/p\u003e\n\u003ch2 id=\"はじめに\"\u003eはじめに\u003c/h2\u003e\n\u003cp\u003eLeetCodeでMedium問題を解いていると、必ず遭遇するのがDFS（深さ優先探索）とBFS（幅優先探索）だ。\u003c/p\u003e\n\u003cp\u003e「Dは深さ、Bは幅」というのは知っている。でも、\u003cstrong\u003eなぜスタックとキューを使い分けるのか\u003c/strong\u003e？その本質を理解している人は意外と少ない。\u003c/p\u003e\n\u003cp\u003e今回は、入れ子リストの例を使って、DFS/BFSの動作原理とデータ構造の関係を視覚的に解説する。\u003c/p\u003e\n\u003ch2 id=\"問題設定入れ子リストをフラット化する\"\u003e問題設定：入れ子リストをフラット化する\u003c/h2\u003e\n\u003cp\u003e以下のような入れ子構造のリストがあるとする。\u003c/p\u003e\n\u003cdiv class=\"highlight\"\u003e\u003cpre tabindex=\"0\" style=\"color:#f8f8f2;background-color:#272822;-moz-tab-size:4;-o-tab-size:4;tab-size:4;\"\u003e\u003ccode class=\"language-python\" data-lang=\"python\"\u003e\u003cspan style=\"display:flex;\"\u003e\u003cspan\u003edata \u003cspan style=\"color:#f92672\"\u003e=\u003c/span\u003e [\u003cspan style=\"color:#ae81ff\"\u003e1\u003c/span\u003e, [\u003cspan style=\"color:#ae81ff\"\u003e4\u003c/span\u003e, \u003cspan style=\"color:#ae81ff\"\u003e5\u003c/span\u003e, [\u003cspan style=\"color:#ae81ff\"\u003e6\u003c/span\u003e, \u003cspan style=\"color:#ae81ff\"\u003e7\u003c/span\u003e, \u003cspan style=\"color:#ae81ff\"\u003e8\u003c/span\u003e], \u003cspan style=\"color:#ae81ff\"\u003e2\u003c/span\u003e], \u003cspan style=\"color:#ae81ff\"\u003e3\u003c/span\u003e]\n\u003c/span\u003e\u003c/span\u003e\u003c/code\u003e\u003c/pre\u003e\u003c/div\u003e\u003cp\u003eこれをフラットな配列にしたい。このとき、「どの順番で要素を取り出すか」がDFS/BFSの違いだ。\u003c/p\u003e\n\u003ch2 id=\"ツリー構造として可視化する\"\u003eツリー構造として可視化する\u003c/h2\u003e\n\u003cp\u003e入れ子リストは、実はツリー構造として表現できる。\u003c/p\u003e\n\u003cpre tabindex=\"0\"\u003e\u003ccode\u003e        root\n       / | \\\n      1  []  3\n         |\n        /|\\ \\\n       4 5 [] 2\n           |\n          /|\\\n         6 7 8\n\u003c/code\u003e\u003c/pre\u003e\u003cp\u003eこの木をどう巡回するかで、DFSとBFSが決まる。\u003c/p\u003e\n\u003ch2 id=\"dfs深さ優先探索とにかく深く潜る\"\u003eDFS（深さ優先探索）：とにかく深く潜る\u003c/h2\u003e\n\u003ch3 id=\"動作イメージ\"\u003e動作イメージ\u003c/h3\u003e\n\u003cp\u003e\u003cstrong\u003e「見つけた枝があれば、まずそこを最後まで探索する」\u003c/strong\u003e\u003c/p\u003e\n\u003cp\u003e訪問順序：\u003c/p\u003e\n\u003cpre tabindex=\"0\"\u003e\u003ccode\u003e1 → [中に入る] → 4 → 5 → [さらに中] → 6 → 7 → 8 \n→ [戻る] → 2 → [戻る] → 3\n\u003c/code\u003e\u003c/pre\u003e\u003cp\u003e結果：\u003ccode\u003e[1, 4, 5, 6, 7, 8, 2, 3]\u003c/code\u003e\u003c/p\u003e","title":"DFS/BFSの本質：深さと幅を支配するデータ構造の選択"},{"content":"本の情報 タイトル: 本が読めなかったから、仕事をやめました\n著者: [著者名不明]\n読書期間: 2025年1月9日\n読了状況: 大正時代まで（未完）\nまえがき - 衝撃の一文 本が読めなかったから、仕事をやめました★\nロックすぎる。\n著者の状況 読書好き、本を買うために働く 週5勤務、21時まで残業 気づいたら1年間、本を読んでいない 時間があってもスマホを見てしまう 本を開いても目が閉じる、YouTubeに逃げる 3年半後、退職 退職後、ゆっくり読書できるようになった 著者の問題提起 会社で働きながら本を読むことは難しい\n本を読む余裕のない社会はおかしい\n→ SNSで多くの同意が集まる\n→ 趣味全般を続けづらい社会への問い\n→ 「あなたの文化は労働に搾取されている」\n所感: 共感と違和感 共感ポイント 仕事のために生きている人が多数派でビビる 仕事は微妙、人間関係は辛い これ、私のことだ 読書好きでもこうなるのか\u0026hellip;（本当なら） 余裕がない社会 働きながらX（Twitter）をやるのはマジで辛い 違和感 突然「搾取」という言葉が出てきて怖い 自称漫画家、バンドマンのようなゴミは働きながら続けるべき 辛いからこそ、続けるってことは熱量があるってこと 第1章: 労働と文化的生活の両立 著者の姿勢 文句だけ言っても仕方ない 歴史から学ぶアプローチ なぜ今、両立しなくなったのか どうしたら両立できるのか 『花束みたいな恋をした』分析 登場人物:\n麦: 地方の花火職人 → 会社員 絹: 金持ち、大企業 展開:\n就職後、麦は忙しくなる 漫画が続かない、頭に入らない パズドラしかやる気しない 絹からの本も無視 心が離れていく テーマ: 長時間労働と文化的生活は両立しないという前提の作品\n速読・自己啓発ブームの意味 Amazonで速読、情報処理スキル、読書術が人気 趣味ではなく、自己啓発メイン 効率優先 → 労働と読書の両立をみんななんとかしようとした結果 ファスト教養も同じ構造 第2章: 格差と読書 階級格差が読書意欲に影響 麦（労働者）vs 絹（富裕層）の対比 働けど働けど暮らしは楽にならず 本を読む余裕さえなくなる 暮らしの格差が余暇の時間も奪う 『独学大全』の指摘 格差は動機づけの段階から現れる 学ぶ動機づけがない者 → 学問は役に立たない、僻む 意欲から格差が生まれる 第3章: 明治時代 - 長時間労働と読書の始まり 労働環境 この頃から長時間労働 工場労働者: 農民時代より断然長時間 平均残業時間: 2時間 \u0026hellip;あれ？ 化学工場: 12時間労働 \u0026hellip;は？ 労働組合はゴミ、割増料金が魅力的 → 今もだいたい同じ 明治時代の感覚 「最近はみんな忙しそうにしてる」 余裕がなくなった感じ、せっかち 近代化 = せっかち 読書革命 句読点と黙読の発明:\n江戸時代: 読書 = 朗読 活版印刷 → 本が安くなる 一人一冊買えるようになる 黙読、個人で読みたい本を読めるように もっと目で読みやすくしたい → 句読点（くとうてん） 図書館で一気に広まった 所感: 歴史を感じる\n第4章: 自己啓発の起源 明治のミリオンセラー 『学問のすすめ』:\n明治初期のベストセラー しかし公的に流布されたから、作られたもの 『西国立志編』:\n元ネタ: サミュエル・スマイルズ『自助論（Self-Help）』 大正時代までベストセラー 100万部 - ありえんロッペン 成功者の伝記を教訓として紹介 身分関係なく、頑張れば成功するという内容 特徴:\n自助努力は男性オンリー 家庭ガン無視おじさんたちのみ登場 ホモソーシャル 富国強兵のコア 自己啓発書の走り 『成功』という雑誌 成功論を成功者にインタビューして回った 低所得者に人気 工場労働者への洗脳 重工業が本格化: 鉄道、鉄鋼 労働者: 13-18時間労働 工場の図書室に自己啓発本を配置 洗脳するかのような配置 所感: ブラックブラックアンドブラック\nインテリ層の反応 夏目漱石『門』: 『成功』という雑誌への皮肉 インテリ層からすれば、ひどく遠い感覚 階級格差 重要な気づき:\nやっぱり本当に賢い人たちは昔から自己啓発なんて読まないんやなって\u0026hellip;\n第5章: 大正時代 - 社会不安と救いの本 時代背景 社会主義、民主主義の波 社会不安が増大 ベストセラー 『出家とその弟子』: 苦しみの本 キリスト教系の本: 「祈ればいいよーん」 → アホ 『死線を越えて』: 社会主義者の書いた本 親鸞ブーム 所感:\n社会主義者の印象がゴミなのは、社会主義者の皮をかぶったテロリストが悪い。\n革マルのバカ共は死ね。\nサラリーマンの誕生 実家が太い？田舎から出てきた人たち 中間層が増えた 大正後期から「サラリーマン」という言葉が生まれた 文学 谷崎潤一郎『痴人の愛』 所感: えっちな本！？\n現時点での考察 日本の自己啓発の構造 明治時代に成立\n西国立志編 = 努力すれば成功する 工場労働者向けに配置 低所得者に人気 階級による分断\n労働者: 自己啓発を読む インテリ: 自己啓発を読まない（軽蔑） 150年間変わっていない\n明治: 身分関係なく頑張れば成功 令和: 努力すれば誰でも成功、自己責任 現代への示唆 ワイの気づき:\n社会がより複雑になったこと、SNSが出てきたこと、ゲームやアプリがより面白くなったことがでかい。正直ここらへんは麻薬。合法的な麻薬が多すぎるのが悪い。\n著者が指摘していない重要な点:\n明治時代: 娯楽の選択肢が少ない（読書、芝居、囲碁将棋） 令和時代: 娯楽が無限（YouTube、SNS、ゲーム、Netflix、TikTok\u0026hellip;） 読書が勝てるわけがない 冷笑について 冷笑はよくないけど、笑っちゃうよね\n150年間、同じパターンが繰り返されている 自己啓発に騙される人々 でも笑うだけではダメ、構造を理解する必要がある 続きを読む前の予想 残りの内容（予想） 昭和（戦前・戦後）の読書 高度成長期の働き方 バブル崩壊 平成〜令和の変化（スマホ、SNS） 解決策の提示 期待すること 著者が「合法的な麻薬」問題に気づいているか 単なる労働時間削減以外の解決策があるか 階級格差の問題をどう扱うか 個人的な学び 自己認識 自分はビジネス本をほぼ読まない イシューからはじめよ、くらい 技術書はオライリーを「ギリギリ」読む カロリー管理、タンパク質計算している プランク+プロテインで健康管理 労働で心の余裕がなくなる人は、そこが限界を超えているので、楽なところに行ったほうがいい 読書観 Audibleは新書に向いていない 頭に入ってこない 別のことを考えてしまう 水戸で読書環境実験を行う予定 新幹線環境の再現 ホテル引きこもり ","permalink":"https://techblog.wasutech.dev/posts/reading-working/","summary":"\u003ch2 id=\"本の情報\"\u003e本の情報\u003c/h2\u003e\n\u003cp\u003e\u003cstrong\u003eタイトル\u003c/strong\u003e: 本が読めなかったから、仕事をやめました\u003cbr\u003e\n\u003cstrong\u003e著者\u003c/strong\u003e: [著者名不明]\u003cbr\u003e\n\u003cstrong\u003e読書期間\u003c/strong\u003e: 2025年1月9日\u003cbr\u003e\n\u003cstrong\u003e読了状況\u003c/strong\u003e: 大正時代まで（未完）\u003c/p\u003e\n\u003chr\u003e\n\u003ch2 id=\"まえがき---衝撃の一文\"\u003eまえがき - 衝撃の一文\u003c/h2\u003e\n\u003cblockquote\u003e\n\u003cp\u003e本が読めなかったから、仕事をやめました★\u003c/p\u003e\u003c/blockquote\u003e\n\u003cp\u003e\u003cstrong\u003eロックすぎる。\u003c/strong\u003e\u003c/p\u003e\n\u003ch3 id=\"著者の状況\"\u003e著者の状況\u003c/h3\u003e\n\u003cul\u003e\n\u003cli\u003e読書好き、本を買うために働く\u003c/li\u003e\n\u003cli\u003e週5勤務、21時まで残業\u003c/li\u003e\n\u003cli\u003e気づいたら1年間、本を読んでいない\u003c/li\u003e\n\u003cli\u003e時間があってもスマホを見てしまう\u003c/li\u003e\n\u003cli\u003e本を開いても目が閉じる、YouTubeに逃げる\u003c/li\u003e\n\u003cli\u003e\u003cstrong\u003e3年半後、退職\u003c/strong\u003e\u003c/li\u003e\n\u003cli\u003e退職後、ゆっくり読書できるようになった\u003c/li\u003e\n\u003c/ul\u003e\n\u003ch3 id=\"著者の問題提起\"\u003e著者の問題提起\u003c/h3\u003e\n\u003cp\u003e\u003cstrong\u003e会社で働きながら本を読むことは難しい\u003c/strong\u003e\u003cbr\u003e\n\u003cstrong\u003e本を読む余裕のない社会はおかしい\u003c/strong\u003e\u003c/p\u003e\n\u003cp\u003e→ SNSで多くの同意が集まる\u003cbr\u003e\n→ 趣味全般を続けづらい社会への問い\u003cbr\u003e\n→ \u003cstrong\u003e「あなたの文化は労働に搾取されている」\u003c/strong\u003e\u003c/p\u003e\n\u003chr\u003e\n\u003ch2 id=\"所感-共感と違和感\"\u003e所感: 共感と違和感\u003c/h2\u003e\n\u003ch3 id=\"共感ポイント\"\u003e共感ポイント\u003c/h3\u003e\n\u003cul\u003e\n\u003cli\u003e仕事のために生きている人が多数派でビビる\u003c/li\u003e\n\u003cli\u003e仕事は微妙、人間関係は辛い\u003c/li\u003e\n\u003cli\u003e\u003cstrong\u003eこれ、私のことだ\u003c/strong\u003e\u003c/li\u003e\n\u003cli\u003e読書好きでもこうなるのか\u0026hellip;（本当なら）\u003c/li\u003e\n\u003cli\u003e余裕がない社会\u003c/li\u003e\n\u003cli\u003e働きながらX（Twitter）をやるのはマジで辛い\u003c/li\u003e\n\u003c/ul\u003e\n\u003ch3 id=\"違和感\"\u003e違和感\u003c/h3\u003e\n\u003cul\u003e\n\u003cli\u003e突然「搾取」という言葉が出てきて怖い\u003c/li\u003e\n\u003cli\u003e自称漫画家、バンドマンのようなゴミは働きながら続けるべき\n\u003cul\u003e\n\u003cli\u003e\u003cstrong\u003e辛いからこそ、続けるってことは熱量があるってこと\u003c/strong\u003e\u003c/li\u003e\n\u003c/ul\u003e\n\u003c/li\u003e\n\u003c/ul\u003e\n\u003chr\u003e\n\u003ch2 id=\"第1章-労働と文化的生活の両立\"\u003e第1章: 労働と文化的生活の両立\u003c/h2\u003e\n\u003ch3 id=\"著者の姿勢\"\u003e著者の姿勢\u003c/h3\u003e\n\u003cul\u003e\n\u003cli\u003e文句だけ言っても仕方ない\u003c/li\u003e\n\u003cli\u003e歴史から学ぶアプローチ\u003c/li\u003e\n\u003cli\u003e\u003cstrong\u003eなぜ今、両立しなくなったのか\u003c/strong\u003e\u003c/li\u003e\n\u003cli\u003e\u003cstrong\u003eどうしたら両立できるのか\u003c/strong\u003e\u003c/li\u003e\n\u003c/ul\u003e\n\u003ch3 id=\"花束みたいな恋をした分析\"\u003e『花束みたいな恋をした』分析\u003c/h3\u003e\n\u003cp\u003e\u003cstrong\u003e登場人物:\u003c/strong\u003e\u003c/p\u003e\n\u003cul\u003e\n\u003cli\u003e麦: 地方の花火職人 → 会社員\u003c/li\u003e\n\u003cli\u003e絹: 金持ち、大企業\u003c/li\u003e\n\u003c/ul\u003e\n\u003cp\u003e\u003cstrong\u003e展開:\u003c/strong\u003e\u003c/p\u003e\n\u003cul\u003e\n\u003cli\u003e就職後、麦は忙しくなる\u003c/li\u003e\n\u003cli\u003e漫画が続かない、頭に入らない\u003c/li\u003e\n\u003cli\u003e\u003cstrong\u003eパズドラしかやる気しない\u003c/strong\u003e\u003c/li\u003e\n\u003cli\u003e絹からの本も無視\u003c/li\u003e\n\u003cli\u003e心が離れていく\u003c/li\u003e\n\u003c/ul\u003e\n\u003cp\u003e\u003cstrong\u003eテーマ:\u003c/strong\u003e\n長時間労働と文化的生活は両立しないという前提の作品\u003c/p\u003e\n\u003ch3 id=\"速読自己啓発ブームの意味\"\u003e速読・自己啓発ブームの意味\u003c/h3\u003e\n\u003cul\u003e\n\u003cli\u003eAmazonで速読、情報処理スキル、読書術が人気\u003c/li\u003e\n\u003cli\u003e趣味ではなく、自己啓発メイン\u003c/li\u003e\n\u003cli\u003e効率優先\u003c/li\u003e\n\u003cli\u003e\u003cstrong\u003e→ 労働と読書の両立をみんななんとかしようとした結果\u003c/strong\u003e\u003c/li\u003e\n\u003cli\u003eファスト教養も同じ構造\u003c/li\u003e\n\u003c/ul\u003e\n\u003chr\u003e\n\u003ch2 id=\"第2章-格差と読書\"\u003e第2章: 格差と読書\u003c/h2\u003e\n\u003ch3 id=\"階級格差が読書意欲に影響\"\u003e階級格差が読書意欲に影響\u003c/h3\u003e\n\u003cul\u003e\n\u003cli\u003e麦（労働者）vs 絹（富裕層）の対比\u003c/li\u003e\n\u003cli\u003e働けど働けど暮らしは楽にならず\u003c/li\u003e\n\u003cli\u003e本を読む余裕さえなくなる\u003c/li\u003e\n\u003cli\u003e\u003cstrong\u003e暮らしの格差が余暇の時間も奪う\u003c/strong\u003e\u003c/li\u003e\n\u003c/ul\u003e\n\u003ch3 id=\"独学大全の指摘\"\u003e『独学大全』の指摘\u003c/h3\u003e\n\u003cul\u003e\n\u003cli\u003e格差は動機づけの段階から現れる\u003c/li\u003e\n\u003cli\u003e学ぶ動機づけがない者 → 学問は役に立たない、僻む\u003c/li\u003e\n\u003cli\u003e\u003cstrong\u003e意欲から格差が生まれる\u003c/strong\u003e\u003c/li\u003e\n\u003c/ul\u003e\n\u003chr\u003e\n\u003ch2 id=\"第3章-明治時代---長時間労働と読書の始まり\"\u003e第3章: 明治時代 - 長時間労働と読書の始まり\u003c/h2\u003e\n\u003ch3 id=\"労働環境\"\u003e労働環境\u003c/h3\u003e\n\u003cul\u003e\n\u003cli\u003eこの頃から長時間労働\u003c/li\u003e\n\u003cli\u003e工場労働者: 農民時代より断然長時間\u003c/li\u003e\n\u003cli\u003e\u003cstrong\u003e平均残業時間: 2時間\u003c/strong\u003e \u0026hellip;あれ？\u003c/li\u003e\n\u003cli\u003e\u003cstrong\u003e化学工場: 12時間労働\u003c/strong\u003e \u0026hellip;は？\u003c/li\u003e\n\u003cli\u003e労働組合はゴミ、割増料金が魅力的\u003c/li\u003e\n\u003cli\u003e\u003cstrong\u003e→ 今もだいたい同じ\u003c/strong\u003e\u003c/li\u003e\n\u003c/ul\u003e\n\u003ch3 id=\"明治時代の感覚\"\u003e明治時代の感覚\u003c/h3\u003e\n\u003cul\u003e\n\u003cli\u003e「最近はみんな忙しそうにしてる」\u003c/li\u003e\n\u003cli\u003e余裕がなくなった感じ、せっかち\u003c/li\u003e\n\u003cli\u003e\u003cstrong\u003e近代化 = せっかち\u003c/strong\u003e\u003c/li\u003e\n\u003c/ul\u003e\n\u003ch3 id=\"読書革命\"\u003e読書革命\u003c/h3\u003e\n\u003cp\u003e\u003cstrong\u003e句読点と黙読の発明:\u003c/strong\u003e\u003c/p\u003e","title":"本が読めなかったから、仕事をやめました - 読書メモ"},{"content":"はじめに Audibleでヨーロッパの歴史を聞きながら、メモを取っていたら思いのほか面白い内容になった。バシレイオス2世からカロリング朝の終焉まで、雑談形式で記録してみる。\nバシレイオス2世「ブルガリア人殺し」 基本情報 在位: 976-1025年 異名: Βουλγαροκτόνος（ブルガリア人殺し） 業績: ビザンツ帝国最盛期を築く 有名なエピソード：クレイディオンの戦い（1014年） ブルガリア軍15,000人を捕虜に 捕虜全員の目を潰す（100人に1人だけ片目を残して案内役に） ブルガリア皇帝サムイルがショック死 第一次ブルガリア帝国滅亡 個人的感想 「こいつカスだなー、こいつ大帝でいいの？」\n確かに現代の倫理観から見れば戦争犯罪者レベル。ただし中世の「大帝」基準では：\n領土拡張 ✓ 敵国完全屈服 ✓ 後世まで語り継がれるインパクト ✓ 帝国繁栄 ✓ オットー2世とマラリア 人物像 在位: 973-983年 比較的穏健な統治者 学問保護、教会制度整備 問題: 南イタリア遠征で無茶をした 死因：マラリア 南イタリア遠征中に感染 983年、28歳で死去 当時のマラリアはほぼ死刑宣告 北欧系には特に致命的 感想: 「やっちゃったねぇ」\n中世の皇帝は戦争で死ぬか病気で死ぬかの二択。現代医学があれば\u0026hellip;\nリウトプランド・オブ・クレモナ 外交官としての活動 オットー1世の外交使節 ビザンツ皇帝ニケフォロス2世フォカスとの交渉担当 結果: 大失敗 失敗の原因 ビザンツ側が西欧を「野蛮人」として完全に見下し オットー1世の「ローマ皇帝」称号をビザンツが拒否 リウトプランド本人のプライドの高さ 文学的価値 外交官としては無能だったが、『コンスタンティノープル使節記』は貴重な史料。ルポライターとしては一流。\n女帝イレーネ・アテネ女 母子の権力闘争 在位: 797-802年 息子コンスタンティノス6世の摂政として実権掌握 息子が独立を図る 797年: クーデターで息子の目を潰して廃位 史上初の女性単独皇帝 歴史的影響 西欧では「東に皇帝がいない」（女性は皇帝と認めない） 800年: カール大帝の「ローマ皇帝」戴冠の口実に 東西ローマ皇帝位問題の発端 最期 802年: ニケフォロスのクーデターで廃位 レスボス島に流刑 803年: 自然死（比較的穏やかな最期） サラセン人とアッシリア人の違い よくある混同 サラセン人: アラブ・イスラム勢力（中世ヨーロッパ人の呼称） アッシリア人: 古代メソポタミア系民族（主にキリスト教徒） 地理的分布（10-11世紀） サラセン人の拠点:\nシチリア島（イスラム支配） 南イタリア（海賊基地） 地中海全域 アッシリア系キリスト教徒:\nイラク北部 シリア東部 ビザンツ帝国との関係は複雑 カロリング朝の終焉 分裂と断絶 カール大帝の大帝国は三分割：\n西フランク王国（現フランス）: 987年ルイ5世で断絶 → カペー朝 東フランク王国（現ドイツ）: 911年断絶 → オットー朝 中部フランク王国（イタリア・ロレーヌ）: 神聖ローマ帝国に吸収 感想 「途絶えちゃった\u0026hellip;」\n巨大帝国も3代で分裂、数世紀で断絶。政治的統一の困難さを物語る。\n第二次世界大戦時のイタリア評価 「無能な味方」説の根拠 ギリシャ侵攻で大苦戦 → ドイツが救援 北アフリカで連戦連敗 1943年早々と降伏・寝返り より複雑な実情 構造的問題:\n工業力がドイツの1/10以下 資源の圧倒的不足 国民の戦争への消極姿勢 指導層の戦略的無謀さ 結論: 「無能」というより「そもそも大国と戦争する国力がなかった」\n歴史的皮肉 中世イタリア: ヴェネツィア、ジェノヴァ、フィレンツェなど最先端都市 WW2イタリア: 工業力不足で苦戦 現代イタリア: ファッション・食文化で世界制覇 日本の組織問題：歴史的継続性 WW2時代の問題点 海軍と陸軍の完全な縦割り 情報共有・作戦調整の欠如 真珠湾攻撃の宣戦布告ミス（外務省の事務処理遅れ） 現代への継続 部署間連携の下手さ 硬直的な報告システム 「空気を読む」文化による本質的議論の回避 希望的観測 技術分野では比較的革新的：\nソフトウェア開発現場のフラット化 オープンソースコミュニティの活発さ スタートアップの増加 まとめ 歴史を学ぶ面白さは、現代の視点で過去を見ることで見えてくる人間の普遍的な問題や組織の構造的課題。\n今回の学び:\n権力者の評価は時代によって変わる（バシレイオス2世の例） 組織の縦割り問題は古今東西共通（日本の例） 国家の能力は時代と状況に大きく左右される（イタリアの例） 個人のミスが歴史を左右することがある（外務省のミス） 歴史は人間の愚かさと賢明さの両方を教えてくれる。現代の問題を考える上でも、過去の事例は貴重な参考資料になる。\nこのメモは2026年1月3日夜、Audibleでヨーロッパ史を聞きながら雑談形式で記録したもの。\n","permalink":"https://techblog.wasutech.dev/posts/history_europe_1/","summary":"\u003ch2 id=\"はじめに\"\u003eはじめに\u003c/h2\u003e\n\u003cp\u003eAudibleでヨーロッパの歴史を聞きながら、メモを取っていたら思いのほか面白い内容になった。バシレイオス2世からカロリング朝の終焉まで、雑談形式で記録してみる。\u003c/p\u003e\n\u003ch2 id=\"バシレイオス2世ブルガリア人殺し\"\u003eバシレイオス2世「ブルガリア人殺し」\u003c/h2\u003e\n\u003ch3 id=\"基本情報\"\u003e基本情報\u003c/h3\u003e\n\u003cul\u003e\n\u003cli\u003e\u003cstrong\u003e在位\u003c/strong\u003e: 976-1025年\u003c/li\u003e\n\u003cli\u003e\u003cstrong\u003e異名\u003c/strong\u003e: Βουλγαροκτόνος（ブルガリア人殺し）\u003c/li\u003e\n\u003cli\u003e\u003cstrong\u003e業績\u003c/strong\u003e: ビザンツ帝国最盛期を築く\u003c/li\u003e\n\u003c/ul\u003e\n\u003ch3 id=\"有名なエピソードクレイディオンの戦い1014年\"\u003e有名なエピソード：クレイディオンの戦い（1014年）\u003c/h3\u003e\n\u003cul\u003e\n\u003cli\u003eブルガリア軍15,000人を捕虜に\u003c/li\u003e\n\u003cli\u003e捕虜全員の目を潰す（100人に1人だけ片目を残して案内役に）\u003c/li\u003e\n\u003cli\u003eブルガリア皇帝サムイルがショック死\u003c/li\u003e\n\u003cli\u003e\u003cstrong\u003e第一次ブルガリア帝国\u003c/strong\u003e滅亡\u003c/li\u003e\n\u003c/ul\u003e\n\u003ch3 id=\"個人的感想\"\u003e個人的感想\u003c/h3\u003e\n\u003cp\u003e「こいつカスだなー、こいつ大帝でいいの？」\u003c/p\u003e\n\u003cp\u003e確かに現代の倫理観から見れば戦争犯罪者レベル。ただし中世の「大帝」基準では：\u003c/p\u003e\n\u003cul\u003e\n\u003cli\u003e領土拡張 ✓\u003c/li\u003e\n\u003cli\u003e敵国完全屈服 ✓\u003c/li\u003e\n\u003cli\u003e後世まで語り継がれるインパクト ✓\u003c/li\u003e\n\u003cli\u003e帝国繁栄 ✓\u003c/li\u003e\n\u003c/ul\u003e\n\u003ch2 id=\"オットー2世とマラリア\"\u003eオットー2世とマラリア\u003c/h2\u003e\n\u003ch3 id=\"人物像\"\u003e人物像\u003c/h3\u003e\n\u003cul\u003e\n\u003cli\u003e\u003cstrong\u003e在位\u003c/strong\u003e: 973-983年\u003c/li\u003e\n\u003cli\u003e比較的穏健な統治者\u003c/li\u003e\n\u003cli\u003e学問保護、教会制度整備\u003c/li\u003e\n\u003cli\u003e\u003cstrong\u003e問題\u003c/strong\u003e: 南イタリア遠征で無茶をした\u003c/li\u003e\n\u003c/ul\u003e\n\u003ch3 id=\"死因マラリア\"\u003e死因：マラリア\u003c/h3\u003e\n\u003cul\u003e\n\u003cli\u003e南イタリア遠征中に感染\u003c/li\u003e\n\u003cli\u003e983年、28歳で死去\u003c/li\u003e\n\u003cli\u003e当時のマラリアは\u003cstrong\u003eほぼ死刑宣告\u003c/strong\u003e\u003c/li\u003e\n\u003cli\u003e北欧系には特に致命的\u003c/li\u003e\n\u003c/ul\u003e\n\u003cp\u003e\u003cstrong\u003e感想\u003c/strong\u003e: 「やっちゃったねぇ」\u003c/p\u003e\n\u003cp\u003e中世の皇帝は戦争で死ぬか病気で死ぬかの二択。現代医学があれば\u0026hellip;\u003c/p\u003e\n\u003ch2 id=\"リウトプランドオブクレモナ\"\u003eリウトプランド・オブ・クレモナ\u003c/h2\u003e\n\u003ch3 id=\"外交官としての活動\"\u003e外交官としての活動\u003c/h3\u003e\n\u003cul\u003e\n\u003cli\u003eオットー1世の外交使節\u003c/li\u003e\n\u003cli\u003eビザンツ皇帝ニケフォロス2世フォカスとの交渉担当\u003c/li\u003e\n\u003cli\u003e\u003cstrong\u003e結果\u003c/strong\u003e: 大失敗\u003c/li\u003e\n\u003c/ul\u003e\n\u003ch3 id=\"失敗の原因\"\u003e失敗の原因\u003c/h3\u003e\n\u003cul\u003e\n\u003cli\u003eビザンツ側が西欧を「野蛮人」として完全に見下し\u003c/li\u003e\n\u003cli\u003eオットー1世の「ローマ皇帝」称号をビザンツが拒否\u003c/li\u003e\n\u003cli\u003eリウトプランド本人のプライドの高さ\u003c/li\u003e\n\u003c/ul\u003e\n\u003ch3 id=\"文学的価値\"\u003e文学的価値\u003c/h3\u003e\n\u003cp\u003e外交官としては無能だったが、『コンスタンティノープル使節記』は貴重な史料。\u003cstrong\u003eルポライター\u003c/strong\u003eとしては一流。\u003c/p\u003e\n\u003ch2 id=\"女帝イレーネアテネ女\"\u003e女帝イレーネ・アテネ女\u003c/h2\u003e\n\u003ch3 id=\"母子の権力闘争\"\u003e母子の権力闘争\u003c/h3\u003e\n\u003cul\u003e\n\u003cli\u003e\u003cstrong\u003e在位\u003c/strong\u003e: 797-802年\u003c/li\u003e\n\u003cli\u003e息子コンスタンティノス6世の摂政として実権掌握\u003c/li\u003e\n\u003cli\u003e息子が独立を図る\u003c/li\u003e\n\u003cli\u003e\u003cstrong\u003e797年\u003c/strong\u003e: クーデターで息子の目を潰して廃位\u003c/li\u003e\n\u003cli\u003e\u003cstrong\u003e史上初の女性単独皇帝\u003c/strong\u003e\u003c/li\u003e\n\u003c/ul\u003e\n\u003ch3 id=\"歴史的影響\"\u003e歴史的影響\u003c/h3\u003e\n\u003cul\u003e\n\u003cli\u003e西欧では「東に皇帝がいない」（女性は皇帝と認めない）\u003c/li\u003e\n\u003cli\u003e\u003cstrong\u003e800年\u003c/strong\u003e: カール大帝の「ローマ皇帝」戴冠の口実に\u003c/li\u003e\n\u003cli\u003e\u003cstrong\u003e東西ローマ皇帝位問題\u003c/strong\u003eの発端\u003c/li\u003e\n\u003c/ul\u003e\n\u003ch3 id=\"最期\"\u003e最期\u003c/h3\u003e\n\u003cul\u003e\n\u003cli\u003e\u003cstrong\u003e802年\u003c/strong\u003e: ニケフォロスのクーデターで廃位\u003c/li\u003e\n\u003cli\u003eレスボス島に流刑\u003c/li\u003e\n\u003cli\u003e\u003cstrong\u003e803年\u003c/strong\u003e: 自然死（比較的穏やかな最期）\u003c/li\u003e\n\u003c/ul\u003e\n\u003ch2 id=\"サラセン人とアッシリア人の違い\"\u003eサラセン人とアッシリア人の違い\u003c/h2\u003e\n\u003ch3 id=\"よくある混同\"\u003eよくある混同\u003c/h3\u003e\n\u003cul\u003e\n\u003cli\u003e\u003cstrong\u003eサラセン人\u003c/strong\u003e: アラブ・イスラム勢力（中世ヨーロッパ人の呼称）\u003c/li\u003e\n\u003cli\u003e\u003cstrong\u003eアッシリア人\u003c/strong\u003e: 古代メソポタミア系民族（主にキリスト教徒）\u003c/li\u003e\n\u003c/ul\u003e\n\u003ch3 id=\"地理的分布10-11世紀\"\u003e地理的分布（10-11世紀）\u003c/h3\u003e\n\u003cp\u003e\u003cstrong\u003eサラセン人の拠点\u003c/strong\u003e:\u003c/p\u003e","title":"Audibleで学ぶヨーロッパ中世史：雑談メモ"},{"content":"前回のハマり話の続編。\n今回は実際にCI/CDパイプラインを動かすところまで進めた。結論から言うと、自動化は99%完成したが、最後の1%（Webhook）で詰んだ。\n目標設定 理想は当然これ：\nGitHub push → Drone検知 → Hugo自動ビルド → Dockerイメージ作成 → k3sデプロイ更新 ただし、私の環境には致命的な制約がある。\n制約：外部IP持ってない\n自宅サーバーはTailscaleでVPN経由でのみアクセス可能。つまりGitHubからのWebhookが届かない。まあ、DuckDNSでドメインは取ってるけど、それでもTailscale依存の構成。\nそれでも「やれるとこまでやってみよう」精神で進めた。\n.drone.yml 設定 最終的にはこんな感じになった：\nkind: pipeline type: kubernetes name: hugo-pipeline steps: - name: build-hugo image: klakegg/hugo:latest commands: - cd posts - hugo --minify - ls -la public/ - name: create-docker-context image: alpine:latest commands: - cp -r posts/public ./public - ls -la public/ - name: docker-build image: plugins/docker settings: registry: ghcr.io repo: ghcr.io/wasuken/tech_blog username: from_secret: github_username password: from_secret: github_token tags: - latest - \u0026#34;${DRONE_COMMIT_SHA:0:8}\u0026#34; - name: deploy-to-k3s image: bitnami/kubectl environment: KUBECONFIG: from_secret: kubeconfig commands: - kubectl set image deployment/hugo-site hugo=ghcr.io/wasuken/tech_blog:latest - kubectl rollout status deployment/hugo-site - name: deploy-complete image: alpine:latest commands: - echo \u0026#34;Hugo build complete!\u0026#34; - echo \u0026#34;Image pushed successfully\u0026#34; ポイントは、HugoビルドからDockerイメージ作成、GHCR（GitHub Container Registry）へのプッシュ、最終的なk3sデプロイまで全部自動化したこと。\nhugo \u0026ndash;minifyで謎のエラー 最初、Hugo buildで謎のエラーが出た：\nERROR error building site: render: failed to process \u0026#34;/posts/xxx/index.html\u0026#34;: expected comma character or an array or object ending on line 225 and column 40 原因調査：\nローカルPC（Ubuntu）: 成功 k3s環境（LXCコンテナ）: 失敗 Hugoバージョン: 0.153 vs 0.154（大きな差はない） 結局、minifyオプションを外したら解決。\n推測だが、minifyライブラリが記事内のコードブロック（YAMLやTOMLの部分）をJSONと誤認識して構文エラーを起こしていた模様。ローカルとコンテナでのライブラリのバージョンや環境の微細な差が影響していると思われる。\nまあ、ローカルブログで多少ファイルサイズがでかくても問題ないので、minifyは諦めた。\nRBAC権限でハマる 当然のように権限エラーで弾かれた：\nError from server (Forbidden): deployments.apps \u0026#34;hugo-site\u0026#34; is forbidden: User \u0026#34;system:serviceaccount:default:default\u0026#34; cannot get resource \u0026#34;deployments\u0026#34; まあ、これは予想通り。Droneのdefault ServiceAccountにはdeploymentを操作する権限がない。\n解決方法：\nkubectl create clusterrolebinding default-admin \\ --clusterrole=cluster-admin \\ --serviceaccount=default:default はい、ガバガバ権限付与。\n本当はServiceAccount分けて最小権限で運用すべきだけど、自宅ラボの遊び環境だし、まあいいかということで。学習目的なら動かすことが優先。\n実際のCI/CDフロー 手動ビルドボタンを押すと、以下の流れで処理される：\nHugo Build: Markdownファイル群をstaticなHTMLに変換 Docker Context準備: publicディレクトリをDockerビルド用にコピー Docker Build \u0026amp; Push: GHCR（ghcr.io/wasuken/tech_blog）にコンテナイメージをpush k3s Deploy: kubectl set imageでdeploymentのイメージを更新 Rollout確認: 新しいPodが正常に起動するまで待機 全体で3-4分程度。まあまあの速度。\n成果と課題 成果：\nCI/CDパイプライン完全構築 GitHub Container Registry連携 k3s自動デプロイ 記事更新→手動ビルド→自動反映のワークフロー確立 課題：\nWebhookが動かない（Tailscale環境の制約） 手動トリガーが必要 RBAC権限が雑 今後の改善案 外部IP取得してWebhook有効化\nルーター設定変更してポート開放 DuckDNSを外部公開用に設定変更 セキュリティリスクとのトレードオフ RBAC権限の細分化\n# Drone専用ServiceAccount作成 kubectl create serviceaccount drone-deployer # 最小権限のClusterRole作成 # RoleBinding設定 ArgoCD導入でGitOps化\nDroneでイメージ作成まで ArgoCDでk3sデプロイ自動化 でも正直、現状でも十分実用的。記事を書いて、Drone UIで手動ビルドボタンを押すだけで自動的にブログが更新される。\nまとめ 自動化の最後の1%（Webhook）で詰んだが、99%は完全自動化できた。\nk3s環境でのCI/CD構築、思ったより簡単だった。特にDroneは設定がシンプルでYAMLも分かりやすい。GitLab CIとかJenkinsとかより全然楽。\n手動トリガーでも実用上は問題ないし、これで技術ブログの更新が格段に楽になった。記事を書くことに集中できる。\nそして何より、自分でCI/CDパイプラインを組んでデプロイできているという達成感がある。インフラエンジニアになった気分。\n次は監視とかログ収集とかやってみたいな。Prometeus + Grafanaとか。\n","permalink":"https://techblog.wasutech.dev/posts/hugo-proxmox-drone-2/","summary":"\u003cp\u003e\u003ca href=\"https://mintblog.hatenablog.com/entry/2026/01/01/112426\"\u003e前回のハマり話\u003c/a\u003eの続編。\u003c/p\u003e\n\u003cp\u003e今回は実際にCI/CDパイプラインを動かすところまで進めた。結論から言うと、自動化は99%完成したが、最後の1%（Webhook）で詰んだ。\u003c/p\u003e\n\u003ch2 id=\"目標設定\"\u003e目標設定\u003c/h2\u003e\n\u003cp\u003e理想は当然これ：\u003c/p\u003e\n\u003cul\u003e\n\u003cli\u003eGitHub push → Drone検知 → Hugo自動ビルド → Dockerイメージ作成 → k3sデプロイ更新\u003c/li\u003e\n\u003c/ul\u003e\n\u003cp\u003eただし、私の環境には致命的な制約がある。\u003c/p\u003e\n\u003cp\u003e\u003cstrong\u003e制約：外部IP持ってない\u003c/strong\u003e\u003c/p\u003e\n\u003cp\u003e自宅サーバーはTailscaleでVPN経由でのみアクセス可能。つまりGitHubからのWebhookが届かない。まあ、DuckDNSでドメインは取ってるけど、それでもTailscale依存の構成。\u003c/p\u003e\n\u003cp\u003eそれでも「やれるとこまでやってみよう」精神で進めた。\u003c/p\u003e\n\u003ch2 id=\"droneyml-設定\"\u003e.drone.yml 設定\u003c/h2\u003e\n\u003cp\u003e最終的にはこんな感じになった：\u003c/p\u003e\n\u003cdiv class=\"highlight\"\u003e\u003cpre tabindex=\"0\" style=\"color:#f8f8f2;background-color:#272822;-moz-tab-size:4;-o-tab-size:4;tab-size:4;\"\u003e\u003ccode class=\"language-yaml\" data-lang=\"yaml\"\u003e\u003cspan style=\"display:flex;\"\u003e\u003cspan\u003e\u003cspan style=\"color:#f92672\"\u003ekind\u003c/span\u003e: \u003cspan style=\"color:#ae81ff\"\u003epipeline\u003c/span\u003e\n\u003c/span\u003e\u003c/span\u003e\u003cspan style=\"display:flex;\"\u003e\u003cspan\u003e\u003cspan style=\"color:#f92672\"\u003etype\u003c/span\u003e: \u003cspan style=\"color:#ae81ff\"\u003ekubernetes\u003c/span\u003e\n\u003c/span\u003e\u003c/span\u003e\u003cspan style=\"display:flex;\"\u003e\u003cspan\u003e\u003cspan style=\"color:#f92672\"\u003ename\u003c/span\u003e: \u003cspan style=\"color:#ae81ff\"\u003ehugo-pipeline\u003c/span\u003e\n\u003c/span\u003e\u003c/span\u003e\u003cspan style=\"display:flex;\"\u003e\u003cspan\u003e\n\u003c/span\u003e\u003c/span\u003e\u003cspan style=\"display:flex;\"\u003e\u003cspan\u003e\u003cspan style=\"color:#f92672\"\u003esteps\u003c/span\u003e:\n\u003c/span\u003e\u003c/span\u003e\u003cspan style=\"display:flex;\"\u003e\u003cspan\u003e- \u003cspan style=\"color:#f92672\"\u003ename\u003c/span\u003e: \u003cspan style=\"color:#ae81ff\"\u003ebuild-hugo\u003c/span\u003e\n\u003c/span\u003e\u003c/span\u003e\u003cspan style=\"display:flex;\"\u003e\u003cspan\u003e  \u003cspan style=\"color:#f92672\"\u003eimage\u003c/span\u003e: \u003cspan style=\"color:#ae81ff\"\u003eklakegg/hugo:latest\u003c/span\u003e\n\u003c/span\u003e\u003c/span\u003e\u003cspan style=\"display:flex;\"\u003e\u003cspan\u003e  \u003cspan style=\"color:#f92672\"\u003ecommands\u003c/span\u003e:\n\u003c/span\u003e\u003c/span\u003e\u003cspan style=\"display:flex;\"\u003e\u003cspan\u003e  - \u003cspan style=\"color:#ae81ff\"\u003ecd posts\u003c/span\u003e\n\u003c/span\u003e\u003c/span\u003e\u003cspan style=\"display:flex;\"\u003e\u003cspan\u003e  - \u003cspan style=\"color:#ae81ff\"\u003ehugo --minify\u003c/span\u003e\n\u003c/span\u003e\u003c/span\u003e\u003cspan style=\"display:flex;\"\u003e\u003cspan\u003e  - \u003cspan style=\"color:#ae81ff\"\u003els -la public/\u003c/span\u003e\n\u003c/span\u003e\u003c/span\u003e\u003cspan style=\"display:flex;\"\u003e\u003cspan\u003e\n\u003c/span\u003e\u003c/span\u003e\u003cspan style=\"display:flex;\"\u003e\u003cspan\u003e- \u003cspan style=\"color:#f92672\"\u003ename\u003c/span\u003e: \u003cspan style=\"color:#ae81ff\"\u003ecreate-docker-context\u003c/span\u003e\n\u003c/span\u003e\u003c/span\u003e\u003cspan style=\"display:flex;\"\u003e\u003cspan\u003e  \u003cspan style=\"color:#f92672\"\u003eimage\u003c/span\u003e: \u003cspan style=\"color:#ae81ff\"\u003ealpine:latest\u003c/span\u003e\n\u003c/span\u003e\u003c/span\u003e\u003cspan style=\"display:flex;\"\u003e\u003cspan\u003e  \u003cspan style=\"color:#f92672\"\u003ecommands\u003c/span\u003e:\n\u003c/span\u003e\u003c/span\u003e\u003cspan style=\"display:flex;\"\u003e\u003cspan\u003e  - \u003cspan style=\"color:#ae81ff\"\u003ecp -r posts/public ./public\u003c/span\u003e\n\u003c/span\u003e\u003c/span\u003e\u003cspan style=\"display:flex;\"\u003e\u003cspan\u003e  - \u003cspan style=\"color:#ae81ff\"\u003els -la public/\u003c/span\u003e\n\u003c/span\u003e\u003c/span\u003e\u003cspan style=\"display:flex;\"\u003e\u003cspan\u003e\n\u003c/span\u003e\u003c/span\u003e\u003cspan style=\"display:flex;\"\u003e\u003cspan\u003e- \u003cspan style=\"color:#f92672\"\u003ename\u003c/span\u003e: \u003cspan style=\"color:#ae81ff\"\u003edocker-build\u003c/span\u003e\n\u003c/span\u003e\u003c/span\u003e\u003cspan style=\"display:flex;\"\u003e\u003cspan\u003e  \u003cspan style=\"color:#f92672\"\u003eimage\u003c/span\u003e: \u003cspan style=\"color:#ae81ff\"\u003eplugins/docker\u003c/span\u003e\n\u003c/span\u003e\u003c/span\u003e\u003cspan style=\"display:flex;\"\u003e\u003cspan\u003e  \u003cspan style=\"color:#f92672\"\u003esettings\u003c/span\u003e:\n\u003c/span\u003e\u003c/span\u003e\u003cspan style=\"display:flex;\"\u003e\u003cspan\u003e    \u003cspan style=\"color:#f92672\"\u003eregistry\u003c/span\u003e: \u003cspan style=\"color:#ae81ff\"\u003eghcr.io\u003c/span\u003e\n\u003c/span\u003e\u003c/span\u003e\u003cspan style=\"display:flex;\"\u003e\u003cspan\u003e    \u003cspan style=\"color:#f92672\"\u003erepo\u003c/span\u003e: \u003cspan style=\"color:#ae81ff\"\u003eghcr.io/wasuken/tech_blog\u003c/span\u003e\n\u003c/span\u003e\u003c/span\u003e\u003cspan style=\"display:flex;\"\u003e\u003cspan\u003e    \u003cspan style=\"color:#f92672\"\u003eusername\u003c/span\u003e:\n\u003c/span\u003e\u003c/span\u003e\u003cspan style=\"display:flex;\"\u003e\u003cspan\u003e      \u003cspan style=\"color:#f92672\"\u003efrom_secret\u003c/span\u003e: \u003cspan style=\"color:#ae81ff\"\u003egithub_username\u003c/span\u003e\n\u003c/span\u003e\u003c/span\u003e\u003cspan style=\"display:flex;\"\u003e\u003cspan\u003e    \u003cspan style=\"color:#f92672\"\u003epassword\u003c/span\u003e:\n\u003c/span\u003e\u003c/span\u003e\u003cspan style=\"display:flex;\"\u003e\u003cspan\u003e      \u003cspan style=\"color:#f92672\"\u003efrom_secret\u003c/span\u003e: \u003cspan style=\"color:#ae81ff\"\u003egithub_token\u003c/span\u003e\n\u003c/span\u003e\u003c/span\u003e\u003cspan style=\"display:flex;\"\u003e\u003cspan\u003e    \u003cspan style=\"color:#f92672\"\u003etags\u003c/span\u003e:\n\u003c/span\u003e\u003c/span\u003e\u003cspan style=\"display:flex;\"\u003e\u003cspan\u003e    - \u003cspan style=\"color:#ae81ff\"\u003elatest\u003c/span\u003e\n\u003c/span\u003e\u003c/span\u003e\u003cspan style=\"display:flex;\"\u003e\u003cspan\u003e    - \u003cspan style=\"color:#e6db74\"\u003e\u0026#34;${DRONE_COMMIT_SHA:0:8}\u0026#34;\u003c/span\u003e\n\u003c/span\u003e\u003c/span\u003e\u003cspan style=\"display:flex;\"\u003e\u003cspan\u003e\n\u003c/span\u003e\u003c/span\u003e\u003cspan style=\"display:flex;\"\u003e\u003cspan\u003e- \u003cspan style=\"color:#f92672\"\u003ename\u003c/span\u003e: \u003cspan style=\"color:#ae81ff\"\u003edeploy-to-k3s\u003c/span\u003e\n\u003c/span\u003e\u003c/span\u003e\u003cspan style=\"display:flex;\"\u003e\u003cspan\u003e  \u003cspan style=\"color:#f92672\"\u003eimage\u003c/span\u003e: \u003cspan style=\"color:#ae81ff\"\u003ebitnami/kubectl\u003c/span\u003e\n\u003c/span\u003e\u003c/span\u003e\u003cspan style=\"display:flex;\"\u003e\u003cspan\u003e  \u003cspan style=\"color:#f92672\"\u003eenvironment\u003c/span\u003e:\n\u003c/span\u003e\u003c/span\u003e\u003cspan style=\"display:flex;\"\u003e\u003cspan\u003e    \u003cspan style=\"color:#f92672\"\u003eKUBECONFIG\u003c/span\u003e:\n\u003c/span\u003e\u003c/span\u003e\u003cspan style=\"display:flex;\"\u003e\u003cspan\u003e      \u003cspan style=\"color:#f92672\"\u003efrom_secret\u003c/span\u003e: \u003cspan style=\"color:#ae81ff\"\u003ekubeconfig\u003c/span\u003e\n\u003c/span\u003e\u003c/span\u003e\u003cspan style=\"display:flex;\"\u003e\u003cspan\u003e  \u003cspan style=\"color:#f92672\"\u003ecommands\u003c/span\u003e:\n\u003c/span\u003e\u003c/span\u003e\u003cspan style=\"display:flex;\"\u003e\u003cspan\u003e    - \u003cspan style=\"color:#ae81ff\"\u003ekubectl set image deployment/hugo-site hugo=ghcr.io/wasuken/tech_blog:latest\u003c/span\u003e\n\u003c/span\u003e\u003c/span\u003e\u003cspan style=\"display:flex;\"\u003e\u003cspan\u003e    - \u003cspan style=\"color:#ae81ff\"\u003ekubectl rollout status deployment/hugo-site\u003c/span\u003e\n\u003c/span\u003e\u003c/span\u003e\u003cspan style=\"display:flex;\"\u003e\u003cspan\u003e\n\u003c/span\u003e\u003c/span\u003e\u003cspan style=\"display:flex;\"\u003e\u003cspan\u003e- \u003cspan style=\"color:#f92672\"\u003ename\u003c/span\u003e: \u003cspan style=\"color:#ae81ff\"\u003edeploy-complete\u003c/span\u003e\n\u003c/span\u003e\u003c/span\u003e\u003cspan style=\"display:flex;\"\u003e\u003cspan\u003e  \u003cspan style=\"color:#f92672\"\u003eimage\u003c/span\u003e: \u003cspan style=\"color:#ae81ff\"\u003ealpine:latest\u003c/span\u003e\n\u003c/span\u003e\u003c/span\u003e\u003cspan style=\"display:flex;\"\u003e\u003cspan\u003e  \u003cspan style=\"color:#f92672\"\u003ecommands\u003c/span\u003e:\n\u003c/span\u003e\u003c/span\u003e\u003cspan style=\"display:flex;\"\u003e\u003cspan\u003e  - \u003cspan style=\"color:#ae81ff\"\u003eecho \u0026#34;Hugo build complete!\u0026#34;\u003c/span\u003e\n\u003c/span\u003e\u003c/span\u003e\u003cspan style=\"display:flex;\"\u003e\u003cspan\u003e  - \u003cspan style=\"color:#ae81ff\"\u003eecho \u0026#34;Image pushed successfully\u0026#34;\u003c/span\u003e\n\u003c/span\u003e\u003c/span\u003e\u003c/code\u003e\u003c/pre\u003e\u003c/div\u003e\u003cp\u003eポイントは、HugoビルドからDockerイメージ作成、GHCR（GitHub Container Registry）へのプッシュ、最終的なk3sデプロイまで全部自動化したこと。\u003c/p\u003e","title":"k3s + Drone CI/CD構築体験記② 手動ビルドでなんとか動いた"},{"content":"参考 この記事は、以下の記事を読んで疑問に思ったことを調べた学習記録である。\nZennの検索スピードを5倍に高速化した話\n記事では、Zennのサイト内検索をpg_trgm拡張を使って平均6倍、95パーセンタイルで4.25倍高速化した事例が紹介されている。\nなぜ中間一致検索は遅いのか 通常、PostgreSQLでLIKE '%keyword%'のような中間一致検索を実行すると、BTreeインデックスが使えずフルスキャンが発生する。BTreeインデックスは文字列の前方一致には有効だが、中間一致では活用できない構造になっているためである。\nデータ量が増えると、このフルスキャンが深刻なパフォーマンスボトルネックになる。参考記事では、検索に1秒〜数秒かかる状態だったとのことだ。\nn-gramインデックスの仕組み n-gramインデックスは、文字列をn文字ずつに分割してインデックス化することで、中間一致検索でもインデックスを効かせる仕組みである。\n3-gramの例 「PostgreSQL」という文字列を3-gram（トライグラム）で分割すると以下のようになる。\n__P, _Po, Pos, ost, stg, tgr, gre, reS, eSQL, QL_, L__ 先頭と末尾にはパディング文字（_）が付与される。\n検索時の動作 「stgre」というキーワードで検索する場合：\n検索キーワードを3-gramで分割: stg, tgr, gre インデックスからこれらすべてのトライグラムを含む文書を抽出 抽出された候補に対してRecheck処理を実行 重要なのは「いずれか」ではなく「すべて」のトライグラムが存在する文書が候補になる点である。もし「いずれか」だと、無関係な文書が大量に候補に含まれてしまう。\nRecheck処理が必要な理由 n-gramインデックスでは、インデックスレベルでの検索後に必ずRecheck処理が必要になる。\n具体例 以下のような状況を考える。\n本文: 「小学校校長」 クエリ: 「小学校長」 3-gramで分割すると：\n「小学校校長」→ 小学校, 学校校, 校校長 「小学校長」→ 小学校, 学校長 「小学校」が共通しているため、n-gramレベルでは「小学校校長」が候補として抽出される。しかし実際には「小学校長」という文字列は含まれていない。\nこのようなfalse positive（誤検出）を除外するため、インデックスで絞り込んだ候補に対して、実際に検索キーワードが含まれているかを厳密にチェックする必要がある。これがRecheck処理である。\npg_trgmとpg_bigmの選択 PostgreSQLには2つの主要なn-gram拡張がある。\npg_trgm: 3-gram方式、PostgreSQL本体にcontribとして付属 pg_bigm: 2-gram方式、サードパーティ製（NECが開発） 比較表 機能 pg_trgm pg_bigm エコシステム PostgreSQLコミュニティ サードパーティ ILIKE対応 ○ × 2文字以下の検索 × ○ Recheck無効化 × ○ インデックスサイズ 小 大（約2倍） なぜpg_trgmが選ばれたか 参考記事では、以下の理由でpg_trgmのみを採用している。\nILIKE対応が必須: 英字の大小文字を区別しない検索を実現 エコシステムの安定性: PostgreSQLコミュニティによる長期的なメンテナンス 2文字以下の対応: トピック検索へのフォールバックで代替可能 pg_bigmでLOWER()を使う方法もあるが、これはカラム全体を小文字化した上でインデックスを作成する必要があり、インデックスサイズがさらに増大する。\nGINとGiSTの使い分け n-gramインデックスの作成時には、インデックスメソッドとしてGIN（Generalized Inverted Index）またはGiST（Generalized Search Tree）を選択できる。\n特徴の違い GIN:\n検索速度が速い 構築・更新が遅い インデックスサイズが大きい 全文検索、JSONB、配列型に適している GiST:\n検索速度が遅い 構築・更新が速い インデックスサイズが小さい 更新頻度が高いテーブル、幾何データ（地理情報）に適している GINの内部構造 GINインデックスは以下の要素で構成される。\nエントリツリー（BTree）: 各トライグラムをキーとして保持 ポスティングツリー/リスト: 各トライグラムがどの行に存在するかを記録 ペンディングリスト: 最近の更新を一時的に保持 ペンディングリストの役割 GINインデックスは更新が遅いという特性があるため、fastupdate機能（デフォルトで有効）でペンディングリストを使った遅延更新を行う。\nINSERT/UPDATEでペンディングリストに即座に追加 検索時はメインインデックス + ペンディングリストの両方をスキャン 以下のいずれかでメインインデックスにマージ: gin_pending_list_limit（デフォルト4MB）に達した時 VACUUM/ANALYZE実行時 gin_clean_pending_list関数の明示的呼び出し ペンディングリストが大きくなると検索が遅くなるため、適切なVACUUM設定が重要である。\n本文検索での課題 参考記事では、タイトル検索は成功したが本文検索は見送られている。\n本文検索でRecheck処理が遅い理由 本文の文字数が多い: 数千〜数万文字 インデックススキャンで抽出される候補が膨大: 本文が長いため、多くのトライグラムが一致する Recheckの処理対象が多い: 候補すべてに対して中間一致検索相当の処理を実行 結果として、Recheck処理に数秒〜十数秒かかってしまう。\nRecheck無効化の試み pg_bigmでRecheck処理をOFFにする実験も行われたが、以下の問題があった。\nclineでclientが引っかかる（3-gram: cliが共通） ユーザーの意図と異なる結果が多数含まれる タイトルやトピックの短いテキストでは許容できても、本文検索ではユーザビリティが損なわれると判断された。\nCONCURRENTLYオプションの注意点 参考記事では、インデックス作成時にCONCURRENTLYオプションを使用している。\nCREATE INDEX CONCURRENTLY idx_my_column_on_my_table_using_trgm ON my_table USING gin (my_column gin_trgm_ops); メリット テーブルへの書き込みロックなしでインデックス作成 サービスを停止せずにマイグレーション可能 注意点 2回のテーブルスキャンが必要（通常は1回） 時間がかかる: 通常のインデックス作成より時間が長い 失敗時の扱い: 途中で失敗すると「INVALID」状態のインデックスが残る（手動削除が必要） トランザクション制約: トランザクションブロック内では使えない ディスク容量: 完成までは新旧インデックスが共存するため一時的に容量増加 CDNキャッシュとインデックス改善の役割分担 参考記事では、最初にCDNキャッシュで対応し、その後pg_trgmインデックスを追加している。\nCDNキャッシュの効果 人気キーワード（「PostgreSQL」「React」など）: キャッシュヒット率高い マイナーなキーワード、初回検索: キャッシュミス なぜCDNだけでは不十分か キャッシュミス時は依然として遅い（1秒〜数秒） DB負荷の根本的な解決にならない ロングテールの検索クエリ（多様なキーワード）に対応できない pg_trgmインデックスの役割 キャッシュミス時でも高速化: フルスキャンを回避 DB負荷を根本的に軽減: すべての検索クエリに効果 CDNとの組み合わせで、全体的なパフォーマンス向上を実現 学んだこと n-gramの動作原理: 文字列を分割してインデックス化し、すべてのトライグラムが一致する文書を候補とする Recheck処理の必要性: false positiveを除外するため、インデックス検索後の厳密チェックが不可欠 pg_trgmとpg_bigmの選択基準: ILIKE対応、エコシステムの安定性、インデックスサイズのトレードオフを考慮 GINインデックスの仕組み: ペンディングリストによる遅延更新で書き込み性能を改善 本文検索の難しさ: Recheck処理の負荷が大きく、pg_trgmだけでは実用的でない PostgreSQLの全文検索インデックスは奥が深く、用途に応じた適切な選択が重要であると改めて認識した。\n","permalink":"https://techblog.wasutech.dev/posts/pgsql-pg-trigm/","summary":"\u003ch2 id=\"参考\"\u003e参考\u003c/h2\u003e\n\u003cp\u003eこの記事は、以下の記事を読んで疑問に思ったことを調べた学習記録である。\u003c/p\u003e\n\u003cp\u003e\u003ca href=\"https://zenn.dev/team_zenn/articles/zenn-search-tuning-story\"\u003eZennの検索スピードを5倍に高速化した話\u003c/a\u003e\u003c/p\u003e\n\u003cp\u003e記事では、Zennのサイト内検索をpg_trgm拡張を使って平均6倍、95パーセンタイルで4.25倍高速化した事例が紹介されている。\u003c/p\u003e\n\u003ch2 id=\"なぜ中間一致検索は遅いのか\"\u003eなぜ中間一致検索は遅いのか\u003c/h2\u003e\n\u003cp\u003e通常、PostgreSQLで\u003ccode\u003eLIKE '%keyword%'\u003c/code\u003eのような中間一致検索を実行すると、BTreeインデックスが使えずフルスキャンが発生する。BTreeインデックスは文字列の前方一致には有効だが、中間一致では活用できない構造になっているためである。\u003c/p\u003e\n\u003cp\u003eデータ量が増えると、このフルスキャンが深刻なパフォーマンスボトルネックになる。参考記事では、検索に1秒〜数秒かかる状態だったとのことだ。\u003c/p\u003e\n\u003ch2 id=\"n-gramインデックスの仕組み\"\u003en-gramインデックスの仕組み\u003c/h2\u003e\n\u003cp\u003en-gramインデックスは、文字列をn文字ずつに分割してインデックス化することで、中間一致検索でもインデックスを効かせる仕組みである。\u003c/p\u003e\n\u003ch3 id=\"3-gramの例\"\u003e3-gramの例\u003c/h3\u003e\n\u003cp\u003e「PostgreSQL」という文字列を3-gram（トライグラム）で分割すると以下のようになる。\u003c/p\u003e\n\u003cpre tabindex=\"0\"\u003e\u003ccode\u003e__P, _Po, Pos, ost, stg, tgr, gre, reS, eSQL, QL_, L__\n\u003c/code\u003e\u003c/pre\u003e\u003cp\u003e先頭と末尾にはパディング文字（\u003ccode\u003e_\u003c/code\u003e）が付与される。\u003c/p\u003e\n\u003ch3 id=\"検索時の動作\"\u003e検索時の動作\u003c/h3\u003e\n\u003cp\u003e「stgre」というキーワードで検索する場合：\u003c/p\u003e\n\u003col\u003e\n\u003cli\u003e検索キーワードを3-gramで分割: \u003ccode\u003estg\u003c/code\u003e, \u003ccode\u003etgr\u003c/code\u003e, \u003ccode\u003egre\u003c/code\u003e\u003c/li\u003e\n\u003cli\u003eインデックスから\u003cstrong\u003eこれらすべてのトライグラムを含む\u003c/strong\u003e文書を抽出\u003c/li\u003e\n\u003cli\u003e抽出された候補に対してRecheck処理を実行\u003c/li\u003e\n\u003c/ol\u003e\n\u003cp\u003e重要なのは「いずれか」ではなく「\u003cstrong\u003eすべて\u003c/strong\u003e」のトライグラムが存在する文書が候補になる点である。もし「いずれか」だと、無関係な文書が大量に候補に含まれてしまう。\u003c/p\u003e\n\u003ch2 id=\"recheck処理が必要な理由\"\u003eRecheck処理が必要な理由\u003c/h2\u003e\n\u003cp\u003en-gramインデックスでは、インデックスレベルでの検索後に必ずRecheck処理が必要になる。\u003c/p\u003e\n\u003ch3 id=\"具体例\"\u003e具体例\u003c/h3\u003e\n\u003cp\u003e以下のような状況を考える。\u003c/p\u003e\n\u003cul\u003e\n\u003cli\u003e本文: 「小学校校長」\u003c/li\u003e\n\u003cli\u003eクエリ: 「小学校長」\u003c/li\u003e\n\u003c/ul\u003e\n\u003cp\u003e3-gramで分割すると：\u003c/p\u003e\n\u003cul\u003e\n\u003cli\u003e「小学校校長」→ \u003ccode\u003e小学校\u003c/code\u003e, \u003ccode\u003e学校校\u003c/code\u003e, \u003ccode\u003e校校長\u003c/code\u003e\u003c/li\u003e\n\u003cli\u003e「小学校長」→ \u003ccode\u003e小学校\u003c/code\u003e, \u003ccode\u003e学校長\u003c/code\u003e\u003c/li\u003e\n\u003c/ul\u003e\n\u003cp\u003e「小学校」が共通しているため、n-gramレベルでは「小学校校長」が候補として抽出される。しかし実際には「小学校長」という文字列は含まれていない。\u003c/p\u003e\n\u003cp\u003eこのような\u003cstrong\u003efalse positive（誤検出）を除外するため\u003c/strong\u003e、インデックスで絞り込んだ候補に対して、実際に検索キーワードが含まれているかを厳密にチェックする必要がある。これがRecheck処理である。\u003c/p\u003e\n\u003ch2 id=\"pg_trgmとpg_bigmの選択\"\u003epg_trgmとpg_bigmの選択\u003c/h2\u003e\n\u003cp\u003ePostgreSQLには2つの主要なn-gram拡張がある。\u003c/p\u003e\n\u003cul\u003e\n\u003cli\u003e\u003cstrong\u003epg_trgm\u003c/strong\u003e: 3-gram方式、PostgreSQL本体にcontribとして付属\u003c/li\u003e\n\u003cli\u003e\u003cstrong\u003epg_bigm\u003c/strong\u003e: 2-gram方式、サードパーティ製（NECが開発）\u003c/li\u003e\n\u003c/ul\u003e\n\u003ch3 id=\"比較表\"\u003e比較表\u003c/h3\u003e\n\u003ctable\u003e\n  \u003cthead\u003e\n      \u003ctr\u003e\n          \u003cth\u003e機能\u003c/th\u003e\n          \u003cth\u003epg_trgm\u003c/th\u003e\n          \u003cth\u003epg_bigm\u003c/th\u003e\n      \u003c/tr\u003e\n  \u003c/thead\u003e\n  \u003ctbody\u003e\n      \u003ctr\u003e\n          \u003ctd\u003eエコシステム\u003c/td\u003e\n          \u003ctd\u003ePostgreSQLコミュニティ\u003c/td\u003e\n          \u003ctd\u003eサードパーティ\u003c/td\u003e\n      \u003c/tr\u003e\n      \u003ctr\u003e\n          \u003ctd\u003eILIKE対応\u003c/td\u003e\n          \u003ctd\u003e○\u003c/td\u003e\n          \u003ctd\u003e×\u003c/td\u003e\n      \u003c/tr\u003e\n      \u003ctr\u003e\n          \u003ctd\u003e2文字以下の検索\u003c/td\u003e\n          \u003ctd\u003e×\u003c/td\u003e\n          \u003ctd\u003e○\u003c/td\u003e\n      \u003c/tr\u003e\n      \u003ctr\u003e\n          \u003ctd\u003eRecheck無効化\u003c/td\u003e\n          \u003ctd\u003e×\u003c/td\u003e\n          \u003ctd\u003e○\u003c/td\u003e\n      \u003c/tr\u003e\n      \u003ctr\u003e\n          \u003ctd\u003eインデックスサイズ\u003c/td\u003e\n          \u003ctd\u003e小\u003c/td\u003e\n          \u003ctd\u003e大（約2倍）\u003c/td\u003e\n      \u003c/tr\u003e\n  \u003c/tbody\u003e\n\u003c/table\u003e\n\u003ch3 id=\"なぜpg_trgmが選ばれたか\"\u003eなぜpg_trgmが選ばれたか\u003c/h3\u003e\n\u003cp\u003e参考記事では、以下の理由でpg_trgmのみを採用している。\u003c/p\u003e","title":"PostgreSQLのpg_trgmで中間一致検索を高速化する仕組みを学ぶ"},{"content":"背景 日課でできる範囲の活動として、軽い記事から、疑問を生成AIに出してもらって、それに答えてもらって、深堀や補足、添削をしてもらった内容までを記事にするという習慣を続けていたが、公開するのはどうなのかなと思った。\nしかし、後ほど止めるのはもったいないということで妥協案として、ローカルで動くブログには投稿することにした。\nなので、ローカルブログを立ち上げることにした。\n最初はGitHub Pagesでよく使われているJekyllを試した。しかし、ローカル環境とDocker環境でRubyのバージョン不一致が発生し、プロジェクト初期化の段階で躓いた。\nローカルのRuby 3.4に対してDockerの最新イメージがRuby 3.1で、この差分が原因でSCSS変換周りでエラーが頻発。Jekyllはプロジェクト作成をローカルで行う必要があるため、「Docker使えば環境差を吸収できる」という謳い文句が実質的に機能しなかった。\nもっとうまくやればよかっただろうが、そのときは血が登っていて、Hugoにしてしまった。\n要件整理 改めて自分の要件を整理した：\nMarkdownファイルのマウントだけで完結 ローカル環境に一切依存しない プロジェクト初期化もDocker内で実行可能 検索機能とファイル一覧が欲しい これを満たすツールを探した結果、Hugoに行き着いた。\nなぜHugoなのか Hugoを選んだ理由は明確：\n1. バイナリ単体で動作 Go言語で書かれたHugoは単一バイナリで動作する。RubyやNode.js、Pythonのようなランタイム環境が不要。これにより依存関係地獄から解放される。\n2. プロジェクト初期化もDocker内で完結 当初は生成AIの言うとおりに以下のコマンドでプロジェクトを作成した。\ndocker run --rm -v $(pwd)/posts:/src klakegg/hugo:alpine new site . この1コマンドでプロジェクト作成が完了する。ローカルに何もインストールする必要がない。\nのだが、後ほどこれがトラブルを産んだ。\n3. 高速なビルド Goの並列処理能力により、数千ページ規模のサイトでも秒単位でビルドが完了する。開発時のホットリロードも快適。\n構築手順 1. docker-compose.yml作成 services: hugo: image: hugomods/hugo:base container_name: hugo-blog ports: - \u0026#34;7000:7000\u0026#34; volumes: - ./posts:/src command: server --bind 0.0.0.0 --port 7000 --buildDrafts --buildFuture restart: unless-stopped ポイント：\nhugomods/hugo:base を使用 ポートは7000にマッピング（後述のブラウザ制限回避） --buildDrafts --buildFuture で下書きと未来日付の記事も表示 2. プロジェクト初期化 docker run --rm -v $(pwd)/posts:/src klakegg/hugo:alpine new site . これで posts/ ディレクトリに必要なファイル群が生成される。\nのだが、ここは本来は\ndocker run --rm -v $(pwd)/posts:/src hugomods/hugo:base new site . が正しいはず。私は一度間違えて、バージョン差異で一瞬止まったので注意。\n3. テーマのインストール 検索機能と一覧表示が充実しているPaperModテーマを採用：\n最初はanakeを試したが、シンプルすぎたのでPaperModへと変更。\ncd posts git clone https://github.com/adityatelange/hugo-PaperMod themes/PaperMod --depth=1 4. config.toml設定 baseURL = \u0026#39;http://localhost:8080/\u0026#39; languageCode = \u0026#39;ja\u0026#39; title = \u0026#39;My Blog\u0026#39; theme = \u0026#39;PaperMod\u0026#39; [params] ShowShareButtons = false ShowReadingTime = true ShowBreadCrumbs = true ShowPostNavLinks = true [params.homeInfoParams] Title = \u0026#34;ブログ\u0026#34; Content = \u0026#34;技術メモ\u0026#34; [[menu.main]] name = \u0026#34;アーカイブ\u0026#34; url = \u0026#34;/archives/\u0026#34; weight = 10 [[menu.main]] name = \u0026#34;検索\u0026#34; url = \u0026#34;/search/\u0026#34; weight = 20 [[menu.main]] name = \u0026#34;タグ\u0026#34; url = \u0026#34;/tags/\u0026#34; weight = 30 [outputs] home = [\u0026#34;HTML\u0026#34;, \u0026#34;RSS\u0026#34;, \u0026#34;JSON\u0026#34;] 5. 検索・アーカイブページ作成 mkdir -p posts/content cat \u0026gt; posts/content/search.md \u0026lt;\u0026lt; \u0026#39;EOF\u0026#39; --- title: \u0026#34;検索\u0026#34; layout: \u0026#34;search\u0026#34; --- EOF cat \u0026gt; posts/content/archives.md \u0026lt;\u0026lt; \u0026#39;EOF\u0026#39; --- title: \u0026#34;アーカイブ\u0026#34; layout: \u0026#34;archives\u0026#34; --- EOF これ忘れてて404でて焦った。\n6. 起動 docker compose up -d http://localhost:7000 でアクセス可能。\nハマったポイント ポート6000がブロックされる 最初ポート6000を指定したところ、Chrome/Edgeで ERR_UNSAFE_PORT エラーが発生。\n原因: ポート6000はX11関連で予約されており、Chromiumベースのブラウザがセキュリティ上ブロックするみたいだ。\n解決: ポートを6000以外(ここでは7000)に変更して解決。\n参考: Chromium Blocked Ports\nテーマなしでは何も表示されない Hugoはテーマが必須。テーマを入れないと page not found になる。\n最初 klakegg/hugo:alpine イメージを使用したが、バージョンが古く（v0.111.3）、最新のテーマと互換性がなかった。hugomods/hugo:base に変更することで解決。\n記事の配置 Markdownファイルは posts/content/posts/ に配置：\nposts/ ├── content/ │ ├── posts/ │ │ ├── 2025-12-21-first-post.md │ │ └── 2025-12-22-second-post.md │ ├── search.md │ └── archives.md ├── themes/ │ └── PaperMod/ └── config.toml 記事のフォーマット例：\n--- title: \u0026#34;記事タイトル\u0026#34; date: 2025-12-21T10:00:00+09:00 draft: false tags: [\u0026#34;タグ1\u0026#34;, \u0026#34;タグ2\u0026#34;] --- 本文をここに書く PaperModの検索機能 PaperModテーマはFuse.jsを使った全文検索を内蔵している。config.toml で [outputs] に JSON を追加することで、検索用のインデックスが自動生成される。\n検索ページ（/search/）にアクセスすると、リアルタイムで記事をフィルタリングできる。完全にクライアントサイドで動作するため、サーバーサイドの実装は不要。\nまとめ 完全にDocker内で完結する静的サイト構築環境をHugoで実現できた。\n利点:\nローカル環境を一切汚さない プロジェクト作成から起動まで全てDocker内で完結 高速なビルドと快適な開発体験 検索・一覧機能も標準的なテーマで実現可能 注意点:\nテーマは必須（完全ゼロからの構築は手間） Dockerイメージのバージョン選定が重要 ブラウザの安全でないポート制限に注意 静的サイトジェネレータは他にもZola（Rust製）やAstro（Node.js）など選択肢があるが、バイナリ単体で動作し、Dockerとの親和性が高いHugoは「環境を汚したくない」要件に最適だった。\n参考 Hugo公式ドキュメント HugoMods Docker Image PaperMod テーマ ","permalink":"https://techblog.wasutech.dev/posts/hugo-blog-setup/","summary":"\u003ch2 id=\"背景\"\u003e背景\u003c/h2\u003e\n\u003cp\u003e日課でできる範囲の活動として、軽い記事から、疑問を生成AIに出してもらって、それに答えてもらって、深堀や補足、添削をしてもらった内容までを記事にするという習慣を続けていたが、公開するのはどうなのかなと思った。\u003c/p\u003e\n\u003cp\u003eしかし、後ほど止めるのはもったいないということで妥協案として、ローカルで動くブログには投稿することにした。\u003c/p\u003e\n\u003cp\u003eなので、ローカルブログを立ち上げることにした。\u003c/p\u003e\n\u003cp\u003e最初はGitHub Pagesでよく使われているJekyllを試した。しかし、ローカル環境とDocker環境でRubyのバージョン不一致が発生し、プロジェクト初期化の段階で躓いた。\u003c/p\u003e\n\u003cp\u003eローカルのRuby 3.4に対してDockerの最新イメージがRuby 3.1で、この差分が原因でSCSS変換周りでエラーが頻発。Jekyllはプロジェクト作成をローカルで行う必要があるため、「Docker使えば環境差を吸収できる」という謳い文句が実質的に機能しなかった。\u003c/p\u003e\n\u003cp\u003eもっとうまくやればよかっただろうが、そのときは血が登っていて、Hugoにしてしまった。\u003c/p\u003e\n\u003ch2 id=\"要件整理\"\u003e要件整理\u003c/h2\u003e\n\u003cp\u003e改めて自分の要件を整理した：\u003c/p\u003e\n\u003cul\u003e\n\u003cli\u003e\u003cstrong\u003eMarkdownファイルのマウントだけで完結\u003c/strong\u003e\u003c/li\u003e\n\u003cli\u003e\u003cstrong\u003eローカル環境に一切依存しない\u003c/strong\u003e\u003c/li\u003e\n\u003cli\u003e\u003cstrong\u003eプロジェクト初期化もDocker内で実行可能\u003c/strong\u003e\u003c/li\u003e\n\u003cli\u003e\u003cstrong\u003e検索機能とファイル一覧が欲しい\u003c/strong\u003e\u003c/li\u003e\n\u003c/ul\u003e\n\u003cp\u003eこれを満たすツールを探した結果、Hugoに行き着いた。\u003c/p\u003e\n\u003ch2 id=\"なぜhugoなのか\"\u003eなぜHugoなのか\u003c/h2\u003e\n\u003cp\u003eHugoを選んだ理由は明確：\u003c/p\u003e\n\u003ch3 id=\"1-バイナリ単体で動作\"\u003e1. バイナリ単体で動作\u003c/h3\u003e\n\u003cp\u003eGo言語で書かれたHugoは単一バイナリで動作する。RubyやNode.js、Pythonのようなランタイム環境が不要。これにより依存関係地獄から解放される。\u003c/p\u003e\n\u003ch3 id=\"2-プロジェクト初期化もdocker内で完結\"\u003e2. プロジェクト初期化もDocker内で完結\u003c/h3\u003e\n\u003cp\u003e当初は生成AIの言うとおりに以下のコマンドでプロジェクトを作成した。\u003c/p\u003e\n\u003cdiv class=\"highlight\"\u003e\u003cpre tabindex=\"0\" style=\"color:#f8f8f2;background-color:#272822;-moz-tab-size:4;-o-tab-size:4;tab-size:4;\"\u003e\u003ccode class=\"language-bash\" data-lang=\"bash\"\u003e\u003cspan style=\"display:flex;\"\u003e\u003cspan\u003edocker run --rm -v \u003cspan style=\"color:#66d9ef\"\u003e$(\u003c/span\u003epwd\u003cspan style=\"color:#66d9ef\"\u003e)\u003c/span\u003e/posts:/src klakegg/hugo:alpine new site .\n\u003c/span\u003e\u003c/span\u003e\u003c/code\u003e\u003c/pre\u003e\u003c/div\u003e\u003cp\u003eこの1コマンドでプロジェクト作成が完了する。ローカルに何もインストールする必要がない。\u003c/p\u003e\n\u003cp\u003eのだが、後ほどこれがトラブルを産んだ。\u003c/p\u003e\n\u003ch3 id=\"3-高速なビルド\"\u003e3. 高速なビルド\u003c/h3\u003e\n\u003cp\u003eGoの並列処理能力により、数千ページ規模のサイトでも秒単位でビルドが完了する。開発時のホットリロードも快適。\u003c/p\u003e\n\u003ch2 id=\"構築手順\"\u003e構築手順\u003c/h2\u003e\n\u003ch3 id=\"1-docker-composeyml作成\"\u003e1. docker-compose.yml作成\u003c/h3\u003e\n\u003cdiv class=\"highlight\"\u003e\u003cpre tabindex=\"0\" style=\"color:#f8f8f2;background-color:#272822;-moz-tab-size:4;-o-tab-size:4;tab-size:4;\"\u003e\u003ccode class=\"language-yaml\" data-lang=\"yaml\"\u003e\u003cspan style=\"display:flex;\"\u003e\u003cspan\u003e\u003cspan style=\"color:#f92672\"\u003eservices\u003c/span\u003e:\n\u003c/span\u003e\u003c/span\u003e\u003cspan style=\"display:flex;\"\u003e\u003cspan\u003e  \u003cspan style=\"color:#f92672\"\u003ehugo\u003c/span\u003e:\n\u003c/span\u003e\u003c/span\u003e\u003cspan style=\"display:flex;\"\u003e\u003cspan\u003e    \u003cspan style=\"color:#f92672\"\u003eimage\u003c/span\u003e: \u003cspan style=\"color:#ae81ff\"\u003ehugomods/hugo:base\u003c/span\u003e\n\u003c/span\u003e\u003c/span\u003e\u003cspan style=\"display:flex;\"\u003e\u003cspan\u003e    \u003cspan style=\"color:#f92672\"\u003econtainer_name\u003c/span\u003e: \u003cspan style=\"color:#ae81ff\"\u003ehugo-blog\u003c/span\u003e\n\u003c/span\u003e\u003c/span\u003e\u003cspan style=\"display:flex;\"\u003e\u003cspan\u003e    \u003cspan style=\"color:#f92672\"\u003eports\u003c/span\u003e:\n\u003c/span\u003e\u003c/span\u003e\u003cspan style=\"display:flex;\"\u003e\u003cspan\u003e      - \u003cspan style=\"color:#e6db74\"\u003e\u0026#34;7000:7000\u0026#34;\u003c/span\u003e\n\u003c/span\u003e\u003c/span\u003e\u003cspan style=\"display:flex;\"\u003e\u003cspan\u003e    \u003cspan style=\"color:#f92672\"\u003evolumes\u003c/span\u003e:\n\u003c/span\u003e\u003c/span\u003e\u003cspan style=\"display:flex;\"\u003e\u003cspan\u003e      - \u003cspan style=\"color:#ae81ff\"\u003e./posts:/src\u003c/span\u003e\n\u003c/span\u003e\u003c/span\u003e\u003cspan style=\"display:flex;\"\u003e\u003cspan\u003e    \u003cspan style=\"color:#f92672\"\u003ecommand\u003c/span\u003e: \u003cspan style=\"color:#ae81ff\"\u003eserver --bind 0.0.0.0 --port 7000 --buildDrafts --buildFuture\u003c/span\u003e\n\u003c/span\u003e\u003c/span\u003e\u003cspan style=\"display:flex;\"\u003e\u003cspan\u003e    \u003cspan style=\"color:#f92672\"\u003erestart\u003c/span\u003e: \u003cspan style=\"color:#ae81ff\"\u003eunless-stopped\u003c/span\u003e\n\u003c/span\u003e\u003c/span\u003e\u003c/code\u003e\u003c/pre\u003e\u003c/div\u003e\u003cp\u003eポイント：\u003c/p\u003e\n\u003cul\u003e\n\u003cli\u003e\u003ccode\u003ehugomods/hugo:base\u003c/code\u003e を使用\u003c/li\u003e\n\u003cli\u003eポートは7000にマッピング（後述のブラウザ制限回避）\u003c/li\u003e\n\u003cli\u003e\u003ccode\u003e--buildDrafts --buildFuture\u003c/code\u003e で下書きと未来日付の記事も表示\u003c/li\u003e\n\u003c/ul\u003e\n\u003ch3 id=\"2-プロジェクト初期化\"\u003e2. プロジェクト初期化\u003c/h3\u003e\n\u003cdiv class=\"highlight\"\u003e\u003cpre tabindex=\"0\" style=\"color:#f8f8f2;background-color:#272822;-moz-tab-size:4;-o-tab-size:4;tab-size:4;\"\u003e\u003ccode class=\"language-bash\" data-lang=\"bash\"\u003e\u003cspan style=\"display:flex;\"\u003e\u003cspan\u003edocker run --rm -v \u003cspan style=\"color:#66d9ef\"\u003e$(\u003c/span\u003epwd\u003cspan style=\"color:#66d9ef\"\u003e)\u003c/span\u003e/posts:/src klakegg/hugo:alpine new site .\n\u003c/span\u003e\u003c/span\u003e\u003c/code\u003e\u003c/pre\u003e\u003c/div\u003e\u003cp\u003eこれで \u003ccode\u003eposts/\u003c/code\u003e ディレクトリに必要なファイル群が生成される。\u003c/p\u003e","title":"ローカル環境を汚さない静的サイト構築 - Hugo Docker Compose環境構築記録"}]