[{"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":"/posts/2026-04-11-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":"/posts/2026-04-06-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":"/posts/2026-04-04-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":"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":"/posts/2026-03-28-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":"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":"/posts/2026-03-28-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":"/posts/2026-03-28-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":"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":"/posts/2026-03-28-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":"/posts/2026-03-28-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":"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":"/posts/2026-03-28-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":"乱数を 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":"/posts/2026-03-28-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":"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":"/posts/2026-03-28-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":"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":"/posts/2026-03-28-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":"症状 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":"/posts/2026-03-21-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":"/posts/2026-03-05-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":"はじめに 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":"/posts/2026-03-04-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":"はじめに 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":"/posts/2026-03-04-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":"はじめに 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":"/posts/2026-03-04-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":"前回のおさらい その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":"/posts/2026-03-04-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":"なぜこの記事を書いたか 動くものをまず作って、そこにテストを後付けしようとした。しかし、できなかった。\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":"/posts/2026-03-04-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":"はじめに 動かしながらゼロから学ぶ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":"/posts/2026-03-01-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":"/posts/2026-03-01-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":"/posts/2026-02-28-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":"/posts/2026-02-25-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":"/posts/2026-02-23-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":"/posts/2026-02-17-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":"/posts/2026-02-15-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":"/posts/2026-02-12-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":"/posts/2026-02-08-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":"/posts/2026-02-08-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":"/posts/2026-02-06-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":"/posts/2026-02-06-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":"問題：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":"/posts/2026-01-31-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":"やりたかったこと 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":"/posts/2026-01-31-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":"最近趣味の方でモバイル開発を始めた。 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":"/posts/2026-01-31-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":"/posts/2026-01-26-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":"/posts/2026-01-26-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/2026-01-26-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":"/posts/2026-01-24-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":"/posts/2026-01-23-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/2026-01-23-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":"/posts/2026-01-19-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":"/posts/2026-01-13-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":"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":"/posts/2026-01-11-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":"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":"/posts/2026-01-11-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":"第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":"/posts/2026-01-10-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":"参考・インスピレーション元 この教材は以下のサイトの構成を参考に、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":"/posts/2026-01-10-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":"本の情報 タイトル: 本が読めなかったから、仕事をやめました\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":"/posts/2026-01-09-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":"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":"/posts/2026-01-09-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":"はじめに 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":"/posts/2026-01-03-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":"/posts/2026-01-01-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":"背景 日課でできる範囲の活動として、軽い記事から、疑問を生成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":"/posts/2025-12-21-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環境構築記録"},{"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":"/posts/2025-12-21-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で中間一致検索を高速化する仕組みを学ぶ"}]