たろログ2

実験的運用により、記事品質が乱高下することがあります。予めご了承ください。

2026-06-18 対米投資枠ってなんだ

疑問

この記事を読んでいてふと疑問に思った。

www.nikkei.com

対米投資枠ってなんだ。本当に投資なのか?単なる送金ではないのか?

5500億ドルの対米投資を約束とか書いてた

調査結果

対米投資枠とは、

日本企業が米国事業に投資するにおいて、日本国がその日本企業の事業費用を援助するもの。

そのため、投資である。見返りのない送金ではない。

投資の利益や損益は、日本企業に所属することになる。

今回の、米GEベルの場と日立製作所による SMR (小型原子炉)事業への投資においては、日本からの資金は日立に流れる。日立が米GEベルノバとの共同事業に投資し、その利益・損益は日立に所属する。

Claude Code と協力しながら、 esa に過去のドキュメント (Google Document, Confluence) を集約した

これは何?

日記とか、作業記録とか、メモとか、各所に散らばっていた私のドキュメント類を esa に集約したよという話です。

その際、 Claude Code を利用しました。

こういうことできたよという事例になればと思い共有します。

同様に Claude Code に指示したり、なんならこの記事のリンク貼って「おんなじようにやって」と指示すればやってくれると思うので、細かな部分やエビデンス等は省きます。

esa のコンテンツをリストア

esa は以前使っていましたが、その後解約し利用していませんでした。

esa は解約するとコンテンツが消えます。

エクスポートファイルをとってあったので、これを Claude Code でリストアします。

なお、画像ファイルはエクスポートに含まれません。

過去の自分が解約前に、以下のコマンドを使ってバックアップしていたため、復旧できました。

バックアップしていなかった場合は、画像はあきらめましょう。

find . -name '*.md' -exec cat {} \; > all.md
grep 'https://img.esa.io/uploads/production/' all.md | awk '{print $6}' | awk -F "\"" '{print $2}' | xargs -I {} wget -x {}

バックアップした画像は img.esa.io にホストしてもらうことができないため、別途 AWS の s3 にアップロードして保持します。

AWS の操作は、 aws コマンドを OS 上でセットアップし、 aws login でログインしていればバケット作成からアクセス権の調整までやってくれます (ちょいちょい設定不足してて見れなかったりしましたが、指摘したら直してくれました)。

この s3 バケットは、今後 esa で画像を新規にアップロードする時にも使うように設定します。

esa の設定項目として画像のアップロード先を s3 に設定できるため、これを利用します。なお、ここは手動で。

指示したら、諸々 Claude Code がスクリプトを作って処理してくれました。

  • markdown 形式の記事をアップロードする
  • 画像ファイルを s3 にアップロードする
  • img.esa.io になっている URL を s3 バケットのものに書き換える

というステップで実現してくれました。

なお、記事のアップロードには esa の API を利用。 API キーを発行して渡し、あとはやってもらいました。

Google Document の記事も esa.io に移動する

esa の後、 Google Document でも日記などを書いており、こちらにも日記のドキュメントが存在しました。

これを、Google Drive API を使って取得し、 esa の形式にコンバートして移行しました。

例えば日記で言うと、esa は 1記事 1日ですが、 Google Document 側は 1記事に複数日含まれていたりします。

そのあたり、 Claude Code の方でよしなに調整してもらいました。

前提として、 gcloud コマンドでの認証が必要でした。 aws コマンド同様、 gcloud コマンドを OS にインストールし、既存の Google Cloud のアカウントでログイン、あとは Claude Code が gcloud コマンド経由でよしなにやってくれました。

最初は画像が取れなかったり、文書構造 (見出し、改行、リストなど) が反映されなかったりしていましたが、適宜指摘したら直りました。

Confluence も同様に移行

Confluence も同様に移行しました。

esa と似た感じです。 Confluence の API キーを発行し、 Claude Code に同様にお願いと頼みました。

どうも「onfluence-markdown-exporter」というツールを使って Confluence の記事をエクスポート、そこから読み込むという形で実現してくれたようでした。

同様、画像が反映されなかったので明示的に追加で指示しました。

esa の MCP を利用し、記事の内容を読み込めるようにする

さて、一気に 1000件近い記事が作成されました。

この内容を、 Claude 側で把握してもらうようにします。

私の過去や背景情報などを、この esa から把握してもらうことが理想です。

しかし、学習のような手法では難しそうだなと言うことで、一旦 MCP で実現しました。

docs.esa.io

esa には、公式で MCP サーバのコードが公開されているため、これを利用します。

これにより、適宜 esa の内容を会話の中で読み込んでもらうことが可能になりました。

単純なところでは、「去年の今頃何してた?」というと、記事を読み込んで回答してくれます。

ほか、もっと役立っているところでは、

「ClickUp に記載したタスクを実行 (こちらも MCP で実現している) するにおいて、一般的でない用語が出てきた際、 esa で検索してもらう」というのがあります。

内輪で使っている用語や、自分個人のプロジェクトやプロダクトの名称について、ハレーションのようなことを起こさず、 esa をまず参照してくれます。

これによって、毎度用語について解説したり、間違いを修正したりする手間が省けます。見つからなかった単語についても、説明の上 esa に反映しておくよう教えればバッチリです。

(余談) Claude Code すごい

Claude Code によって「調べ物」というプロセスが凄まじく圧縮されていて、机に座る時間が短くなっているまであります。

例えば、今「AT 限定解除したい」というタスクがあるのですが、これも「まず自動車学校ベストなところを探す」という事前の調査を AI が片付けてしまうので、あとは私が身体を動かすだけになり、なんか自分が外に出てやらないといけないタスクばっかりが積み上がってるなぁというような形になりました。

でも、「まとまった時間が取れなくてできないなぁ」と思っていたやりたいことがどんどん「あとは動くだけ」となり、消化しやすくなり、実際消化されていくので、かなり助かるなぁと思います。同時にすごいなと。スピードだけでなく、「こういうアイデアあるけど実装どうやったらいいかわからんなぁ」というものに対しても効くので。

さらに余談

今この記事は完全手書きですが、最近は Qiita とかの技術記事執筆も Claude Code に手伝ってもらっています。

だいたいフォーマットって似てるので、 Qiita の過去の記事を投げて、「このフォーマットで記載して」みたいな。

  • Claude Code と一緒に、技術検証行う
  • 行った手順をもとに、記事に起こしてもらう。
    • スクリーンショットの必要な箇所は [ここにスクリーンショットを挿入] とか書いといてもらう
  • 起こした記事を確認しながら、再度手動で手順を実施、動作確認も行なって、適宜スクリーンショットでエビデンスを取る
  • 投稿する

というような感じ。

この記事とかそうですね。

qiita.com

技術記事はどっちしだいたい決まったフォーマットで書くので、この形が楽だし、品質も変動があまりないです。

一方で、日記やこういうブログ記事はやっぱり手書きじゃないと、自分の文章の味がでなくなるから手書きじゃないとなって思ってます。

読み返した時、自分が書いたように思えないっていうのもありますしね。

任せられるところは任せつつ、自分がやるべきところは守りつつ、とはいえ色々試しながら変えつつ。

引き続きやっていきたいと思います。

以上、眠れなかったので気まぐれに書いてみた記事 2026 でした。最後まで読んでいただきましてありがとうございました。

P.S.

Confluence からの移行は ClickUp のタスクベースで実施しましたが、こんな感じ。

URL 渡したら勝手にタイトルと内容を読み込んで、ステータス更新して、コメントもしてくれます (写ってるコメントは Claude Code が私のアカウントの API 利用して送信したものです)。すごいでしょ。すごいよね。

2025年11月

振り返り

月ごとの振り返り記事を出していこうかと。今月で終わるかもしれないけど。

30歳

11月9日で 30歳になりました。パチパチ。

あまり年齢を重ねることをうれしいと思えなかったり、なんだり。

11月イベント

11/1 (土) 麻雀

友人と貸卓の雀荘で麻雀しました。春夏秋冬、1年に4回対戦しています。遅めながら、一応秋の陣。

リアル卓では初の役満国士無双出しました ㊗️

6局くらいやってトータル 2位でした。

11/2 (日) ボクシングジム

ここ一年ほど、キックボクシングジムに通っています。2週に1回ほどのペースでゆるゆると。

今月は友人がキックボクシングの大会に出る予定があり、自分がセコンドにつくので、11/16 (日)、11/23 (日) と、少し多めに通いました。

11/3 (祝) 車の運転

実家に冬物を取りに運転していきました。タイムズカーシェア利用。

車の運転はまだ慣れていないので、毎回イベントごとです。

11/7 (金) カラオケ

前の会社の先輩後輩とカラオケに行きました。いつまでも遊んでくれて感謝です。

11/15 (日) 家探し

住之江区周辺で新居探しを始めました。

結局やめましたが、今月は不動産屋さんに足繁く通ってました。

担当者さん、ありがとうございました。

家探しの後は、毎度住之江競艇やスパ住之江寄ってました。あの辺好きです。

11/19 (水) カラオケ

11/7 と同じメンツでカラオケへ。の予定でしたが一人来られなくなるなど。

11/20 (木) 納品🎉

一か月ほど対応してた案件が無事納品になりました。

忙しかった...。

ストレスのおかげで毎日お酒がおいしく飲めましたが、のちのち体調不要になりました。

残業はほどほどにしようと思います。

11/21 (金) お習字

地元に帰って、子どものころから通っているお習字の教室にお邪魔しました。

11/24 (日) ボクシングの大会

友人のボクシングの大会にセコンドとして参加しました。

残念ながら、結果はあまりよくなく...。でした。

一日いましたが、やっぱ目の前で試合見ると迫力があるなと思いました。

マチュアの試合で KO ってほとんどないのですが、膝蹴りでの TKO が一試合ありました。

11/26 (水)

仕事終わり、前の会社の同期と "エルデンリング ナイトレイン" をプレイ。

一度も夜の王を倒せず終了。

11/28 (金) ~ 11/30 (日) 大分旅行

友人と四人で大分、別府へ旅行に行きました。

金曜夜からさんふらわあに乗り、一泊しつつ別府へ移動。

着いてから地獄湯巡り。温泉からのホテルで一泊。

福岡でラーメン食べて帰っての 2泊3日の旅でした。

一番楽しかったのはさんふらわあでした。デッキに出て、海風に当たってるのが非常に楽しかったです。

前に淡路島行った時もそうなんですけど、船や海、海風が好きだなと思います。

そんな感じで

ざっくばらんに書きましたが、書いてみて思ったのは、もうちょっと書くこと絞ってもよかったかも?ということ。

実験的ブログなので、繰り返し書いていく中でちょっとずつ改善していけたらと思います。

ではでは。

Drupal認定 アクイア認定サイトビルダー試験対策講座の動画を視聴

試験対策において下記動画を視聴したので、知識の補完になったところをピックアップして記す。

youtube.com

クイックエディット (quick edit)

  • コアで提供される
  • ブロック等をその場で修正可能
  • コンテキストリンクから遷移できる

パスモジュール

ブロック関連

  • 検索ブロックは、 Search モジュールによる提供
  • ブロックごとに閲覧制限などの設定を行うことができる

テーマ

  • テーマの設定から、カラーを自由に変更することができる (あまり使ってる人はみたことない)

ユーザID1

  • スーパーユーザ、 root ユーザとも。すべてのアクションを実行する権限。
  • このアカウントを共有して使用するのは、バッドプラクティスとなる
  • ユーザID のユーザID を admin 以外に変更したり、ブロックしたりすることもベストプラクティスとなる

レポート > 最近のログメッセージ

Database Logging モジュールで提供される機能

コンテンツエンティティ

-> コアソフトウェア、またはモジュールによって定義、提供される -> エンティティタイプと、エンティティサブタイプを持つ

コンテンツエンティティがすべての祖

これをタイプ分けしたものとして、「エンティティタイプ」がある。

エンティティタイプの例としては、「コンテンツアイテム」、「コメント」、「ユーザプロファイル」、「カスタムブロック」、「タクソノミーターム」など。

これらのエンティティタイプのサブタイプとして、「エンティティサブタイプ」がある。

「コンテンツアイテム」に対する「コンテンツタイプ」

「カスタムブロック」に対する「ブロックタイプ (基本ブロック)」

「コメント」に対する「コメントタイプ」

「タクソノミーターム」に対する「ボキャブラリ」

(ややこしい)

メディア管理

  • 画像、ビデオ、 PDF などのメディアセットを一元管理する

  • メディア参照フィールドとしてフィールドを関連づけることで利用できる

-> ファイルフィールド、画像フィールドとは異なる (メディア機能がコアに追加される前に利用されていた機能)

-> 比べた利点として、同じフィールド内でファイルや画像など、複数のタイプのメディアを参照できる。

また、他のコンテンツタイプでの使いまわしが可能となる (ここが強い)

翻訳

  • 構成の翻訳 -> コンテンツの翻訳
  • インターフェースの翻訳 -> コアによる翻訳
  • コンテンツの翻訳 -> コンテンツの翻訳

ビューモード

表示するフィールドの順序などを変更。また、表示するフォーマットの変更も可能 (詳細フィールドの表示を全部表示するか、切り詰めて表示するか等)

カスタムビューモード

エンティティサブタイプごとに、コンテンツの見せ方を複数用意できる。

例) タグの表示方法を、デフォルトから指定のビューモードに変更することなどが可能 (ティーザーなど)

-> タグを「渋谷本店」として、その店舗の情報を単なるタグとしてでなく、その店舗の画像や住所などと合わせてカードのように表示する

Views

  • コンテンツのリストを作成する機能
  • クエリビルダとも呼称される
    • 店舗一覧とか作れる
    • グリッド、テーブル、 HTML リスト、ブロック、 JSONXML

Views > コンテキスチュアルフィルター

関連コンテンツみたいな感じで、今見てるページのフィールド等の値を引数に、表示をカスタマイズできる

-> タクソノミーのページを表示したときに、そのタクソノミーが関連づけられている記事の一覧を得るなど

リレーションシップ

-> SQL でいう JOIN。

表示されているコンテンツを、他のコンテンツエンティティに関連付けることができる。

例.) この人の書いた他の記事を表示する、など

関連商品とかもこの類となる。

レイアウトビルダー

  • ブロックレイアウトの編集を、ドラッグアンドドロップで行うことができる
  • Drupal8 あたりから新機能として導入
  • テーマ共通でレイアウトを設定するだけでなく、コンテンツタイプごとに個別にレイアウトを変更することもできる (override)。

Symfony のサービスコンテナ

これを用いて、 Drupal は DB、翻訳、日時フォーマッタ等のサービスオブジェクトを一元管理する。

作成と依存性の注入をコンテナが担当する。

各コントローラは、 DI コンテナを引数にとり、必要なサービスオブジェクトを DI コンテナから取得して自身のインスタンスに登録する。

プロジェクトマネジメント 組織論 読書メモ

概要

組織論の観点から、プロジェクトマネジメントについて語る本を読んだ時のメモ。

本のタイトルは忘れてしまった。

メモ

大きな流れ

ハードウェアからソフトウェア。

元々、ソフトウェアは、ハードウェアが高価だった時代、ハードウェアのおまけの位置づけだった。

ソフトウェアが目的ではなく、ハードウェア自体が投資の目的だったりした。

それが、ソフトウェアの価格の方が高くなるようになり、ソフトウェアがハードウェアよりも重視されるものとなった (コストも大きくなったため、その管理手法も検討されるようになった)

1970年代

「規模対応」の時代。

ソフトウェアの規模や、それにかかわる工数が加速度的に増え、それらに対する危機感が感じられるようになった。

産業としては拡大期であり、作れば売れる、人手足りないという問題から、ソフトウェア開発手法の目的としては生産性向上が主眼に据えられた。また、規模が大きくなったことにより、複雑性への対処が主眼に据えられた。

この時代に作られるシステムとしては、業務効率向上の余地が多分にある時代で、人手の作業を置き換える発想のシステムが主眼だった。

1980年代

方法論や開発プロセスに関心が集まった時代。定量化アプローチが試みられた時代。

ウォーターフォール型開発の致命的な欠点 (手戻りが発生した際の工数が大きい) の発見と、それに対する対応を背景として、様々な手法が検討された。

ソフトウェアの内容としては、業務分析し、業務そのものを IT に合わせて作り変えていくのがトレンドになった。

1990年代

可視化、工業化の時代。

開発プロセス、システム監査、プロジェクトマネジメント、知識体系など、様々なものを標準化していこうという動きが出た時代。

投資対象として、ソフトウェア開発に、経営からみてわかりやすく、判断しやすくということが求められた。

そのため、透明性確保のための標準化が試みられた。

額や規模もそれなりに大きくなり、ソフトウェアの価値も単純な人手の作業の置き換えだけではなくなった。

投資に見合うリターンがあることや、必要以上にコストをかけていないことなどの担保を目的に、コストの根拠などが求められるようになったのだろうか。

2000年代

変化対応の時代。可変性が求められるようになった。

作って終わりではない。

インターネットの普及、 ITバブル崩壊アジャイル、サービス志向、クラウドコンピューティングなど、開発自体も変わっていった。

ビジネス環境自体も激変していったため、可視性が求められた。

プロジェクトマネジメントは段取り工学

プロジェクトマネジメントは段取り工学である。

段取りを洗い出して、進行度を出すのには向く (工程管理)

しかし、段取りだけではうまくいかない。

  • 業務分析をして初めて設計の作業が見えてくる
  • 設計してから初めては開発の部分も作業定義することができる。
  • 設計してから初めて要件定義部分が見えることもある

どの段階でも、作業 (段取りの中身) を明確にすることが難しい。 また、トップダウン型で作業を詳細化していく手法 (段階的詳細化法) に、大本が間違っている場合破綻するという弱点がある。

ウォーターフォールにおける V字モデルでも同様で、最終工程にならないと、最初の意思決定の妥当性がわからない。

-> これを緩和するために考案されたものとして、プロトタイピングや、スパイラルモデルなどがある。

複雑性への対処

複雑性への対処は、 WBSトップダウン型では難しい。

不明瞭であいまいなものを分解していく必要があり、かつ、大本が間違っていた場合に破綻する。

同調性、可変性

リスクとして扱う程度しか対処できない。

実際動くソフトウェアを目の前にした顧客が、「この方がよかった (今あるソフトウェアよりも別の形であった方がよかった)」と述べることに対する問題

不可視性

プロジェクトマネジメントは、不可視性には何ら寄与しない。

PV, EV, AC, BAC, SV, CV, SPI, CPI

プロジェクトの進捗管理 (プロジェクトマネジメント) に使われる用語について調べた。

BAC (Budget at Completetion)

(プロジェクト) 完了時点の予算。

つまり、プロジェクト開始当初に計画された全体予算。

PV (Planned Value)

プロジェクトが計画通りに進行していた場合に、現時点で発生しているはずの作業価値。

別名、 BCWS (Budgeted Cost of Work Scheduled)

EV (Earned Value)

実際に完了した作業が生み出した価値。

別名、 BCWP (Budgeted Cost of Work Performed)

AC (Actual Cost)

実際に支払った (使った) コスト。費用。

別名、 ACWP (Actual Cost of Work Performed)

SV (Schedule Variance)

EV (Earned Value) - PV (Planned Value) で求められる。

実際に発生した価値 - 計画通りの場合に発生している見込みの価値。

プラスの値の場合は、現時点において、予定よりも価値が多く発生している -> プロジェクトが前倒しで進んでいる。

マイナスの場合は、現時点で、予定よりも発生している価値が少ない -> プロジェクトが遅延している

ということを示す。

CV (Cost Variance)

EV (Earned Value) - AC (Actual Cost)

現時点で発生した価値から、現時点の実費用を引く。

プラスの場合は、発生した価値の方が大きい。コストパフォーマンスがよい。

まあ、発生したコストが生み出す価値を下回るということは赤字ということなので、これは通常プラスになることが予見されるものだろう (でなければ、最初から赤字のプロジェクトを計画していることになるので)

反対に、この値がマイナスの場合は、赤字プロジェクトとなる。

SPI (Schedule Performance Index)

EV (Earned Value) / PV (Planned Value)

SV と同じものを示す指標。

引き算するところを割り算しているので、今回は 1 を上回ると進捗前倒し、 1 を下回ると進捗後ろ倒しとなる。

CPI (Cost Performance Index)

EV (Earned Value) / AC (Actual Cost)

SPI が SV の計算式の割り算バージョンだったのと同様に、 CPI は CV の計算式の割り算バージョンである。

ここでいう「価値」とは?

コストはよくわかるが (システム開発のプロジェクトであれば人月等)、ここでいう「価値」とは何だろうか。

ここでいう「価値」は、コストと同じく、金額で表せるものであるらしい。

例えば見積り上、10万円の作業を完了させたら、EVが10万円計上されるということになります。

ssaits.jp

EVM (Earned Value Management)

今回の用語は、 EVM (Earned Value Management) というプロジェクト管理の手法において出てくるもののようだった。

PMBOKの定義によると「プロジェクトのパフォーマンスと進捗を評価するために、スコープ、スケジュール、および資源の測定値を結びつける方法論のことを指します。

EVM を導入することで、単に「順調です」、「遅れてます」と報告するよりも定量的に、数値を使って誰でもわかる形で、どの程度順調なのか、どの程度遅れているのかがわかる。

とはいえ

SV が -100000 (円) ですと言われても、結局どの程度遅れているのかは中身を見ないとわからないし、 SPI が 1.5 ですと言われても、慣れている人でないと具体的にどの程度遅れているのかをぱっと把握することは難しい。

結局、これらの数値をトリガにして、中身を確認していきましょうかという話になるだろう。

重要なこと

一番重要なのは、 BAC, AC, PV, EV の算出なのだろうなと思った。

これを算出することで、特にスケジュールの遅れを金額に反映して、数値的に算出できる。

何円分の遅れが出ていますよ、または、予定の二倍のコストがかかっていますよ、みたいな。

逆にこの、算出が適当、または進捗を示すためのタスクの粒度が大きすぎたりすると、逆に導入することで混乱を生むことになるだろう。

そんなことを思ったりした。

Acquia Certified Developer の受験

試験範囲の把握

Certified Developer のブループリント

www.acquia.com

合格ライン

全60問。合格ライン 65%。 60 * 0.65 = 39 なので、 21問まで間違えることができる。

試験時間

90分。

学習リソース

今回はあまり見当たらないため、 AI にブループリントを読み込ませ、理解を助けるチュートリアルを作ってもらう。

Todo アプリを作ってくれたので、これの解説をしつつ進める。

todo.info.yml

name: Todo -> モジュール名 (id)
type: module -> モジュール。このほかに library, project など
description: Simple todo list for Drupal 11 hands-on  -> モジュールの説明文。「Extend」から表示されるモジュールの一覧表示などに表示される
package: Training -> モジュールをカテゴリ分けするもの。「Extend」から表示されるモジュールの一覧表示などに利用される
core_version_requirement: ^11 -> drupal core バージョンの依存

todo.permissions.yml

add todo items:
  title: 'Add todo items'
  description: 'Create new todo items'
administer todo:
  title: 'Administer todo'
  description: 'Manage todo items'

権限を定義するもの。画面上での表示は上の通り。

todo.routing.yml

todo.list:
  path: '/todo'
  defaults:
    _controller: '\Drupal\todo\Controller\TodoController::list'
    _title: 'Todo list'
  requirements:
    _permission: 'access content'

todo.add:
  path: '/todo/add'
  defaults:
    _form: '\Drupal\todo\Form\TodoAddForm'
    _title: 'Add todo item'
  requirements:
    _permission: 'add todo items'

todo.delete:
  path: '/todo/{id}/delete'
  defaults:
    _form: '\Drupal\todo\Form\TodoDeleteForm'
    _title: 'Delete todo item'
  requirements:
    _permission: 'administer todo'
  options:
    parameters:
      id:
        type: 'integer'

ルート情報を定義するもの。主に GET だが、 path にアクセスしたときに表示するページ、フォームを指定する。

ページについては Drupal\Core\Controller\ControllerBase を継承した Controller のメソッドを指定する。

フォームについては Drupal\Core\Form\FormBase を継承した Form のクラスを定義する形で指定する。

todo.services.yml

services:
  logger.channel.todo:
    parent: 'logger.channel_base'
    arguments: ['todo']

  todo.storage:
    class: 'Drupal\todo\Service\TodoStorage'
    arguments: ['@database', '@cache_tags.invalidator', '@logger.channel.todo', '@datetime.time']

サービスの定義を行う層。ここでいうサービスは、サービスコンテナで DI されるサービスを指す。

サービスコンテナは下記のように Controller に引数として受け渡され、 Controller にサービスを Injection する。

  public function __construct(
    private readonly TodoStorage $storage,
    private readonly DateFormatterInterface $dateFormatter
  ) {}

core.services.yml で定義されたサービスと、上記のように各モジュール以下 *.services.yml で定義されたサービスは、自動的にサービスコンテナに注入される。

今回は、以下のサービスを定義している。

  • logger.channel.todo -> その名の通り Logger。 LoggerInterface のようなもの
  • todo.storage -> Todo のエンティティを保存する Storage。 TodoRepositoryInterface のようなもの

todo.install

<?php

/**
 * Implements hook_schema().
 */
function todo_schema() {
  $schema['todo_item'] = [
    'description' => 'Stores todo items.',
    'fields' => [
      'id' => ['type' => 'serial','unsigned' => TRUE,'not null' => TRUE],
      'label' => ['type' => 'varchar','length' => 255,'not null' => TRUE],
      'done' => ['type' => 'int','size' => 'tiny','not null' => TRUE,'default' => 0],
      'created' => ['type' => 'int','unsigned' => TRUE,'not null' => TRUE],
    ],
    'primary key' => ['id'],
    'indexes' => ['done' => ['done']],
  ];
  return $schema;
}

*.install ファイル。マイグレーションのようなもの。

hook_schema() で定義されたテーブルは、アンインストール時に自動で削除されるとのこと。

追加で後片付けを行う場合は、 hook_uninstall() で実装。

TodoStorage.php

<?php

namespace Drupal\todo\Service;

use Drupal\Core\Database\Connection;
use Drupal\Core\Cache\CacheTagsInvalidatorInterface;
use Drupal\Core\Logger\LoggerChannelInterface;
use Drupal\Component\Datetime\TimeInterface;
use Drupal\Core\Database\Statement\FetchAs;

final class TodoStorage {
  public const CACHE_TAG = 'todo_list';

  public function __construct(
    private readonly Connection $db,
    private readonly CacheTagsInvalidatorInterface $invalidator,
    private readonly LoggerChannelInterface $logger,
    private readonly TimeInterface $time
  ) {}

  public function add(string $label): int {
    $this->db->insert('todo_item')->fields([
      'label' => $label,
      'done' => 0,
      'created' => $this->time->getRequestTime(),
    ])->execute();
    $this->invalidator->invalidateTags([self::CACHE_TAG]);
    $this->logger->notice('Added todo: @label', ['@label' => $label]);
    return (int) $this->db->lastInsertId();
  }

  public function all(): array {
    $q = $this->db->select('todo_item', 't')
      ->fields('t', ['id','label','done','created'])
      ->orderBy('id', 'DESC');
    return $q->execute()->fetchAllAssoc('id', FetchAs::Associative);
  }

  public function delete(int $id): void {
    $this->db->delete('todo_item')->condition('id', $id)->execute();
    $this->invalidator->invalidateTags([self::CACHE_TAG]);
    $this->logger->warning('Deleted todo @id', ['@id' => $id]);
  }

  public function countOpen(): int {
    $count = $this->db->select('todo_item', 't')->condition('done', 0)->countQuery()->execute()->fetchField();
    return (int) $count;
  }
}

todo.services.yml で定義された todo.storage のサービスクラス。

Todo のエンティティを保存する Storage。 TodoRepositoryInterface のようなもの。

試験のブループリントに「Object-Oriented な PHP を用いた~」というのがあったので、それを意識してくれた様子。

ここでは Interface は使われていないが、もし Todo のデータをメモリ上に格納したければ同様のメソッドを生やして InMemoryStorage など作り、 services.yml で差し替えればいいし、 API で接続された先にある保存先に格納したければ、同様のメソッドを生やして HogeApiService など作り、差し替えればよい。

TodoController.php

<?php

namespace Drupal\todo\Controller;

use Drupal\Core\Cache\CacheableMetadata;
use Drupal\Core\Controller\ControllerBase;
use Drupal\Core\Datetime\DateFormatterInterface;
use Drupal\Core\Link;
use Drupal\Core\Url;
use Drupal\todo\Service\TodoStorage;
use Symfony\Component\DependencyInjection\ContainerInterface;

final class TodoController extends ControllerBase {

  public function __construct(
    private readonly TodoStorage $storage,
    private readonly DateFormatterInterface $dateFormatter
  ) {}

  public static function create(ContainerInterface $container): static {
    return new static(
      $container->get('todo.storage'),
      $container->get('date.formatter')
    );
  }

  public function list(): array {
    $rows = [];

    foreach ($this->storage->all() as $item) {
      // Delete リンク(文字列化 + class 付与)
      $delete_url = Url::fromRoute('todo.delete', ['id' => (int) $item['id']]);
      $delete_url = $delete_url->setOptions([
        'attributes' => ['class' => ['button', 'button--small']],
      ]);
      $delete_link = Link::fromTextAndUrl($this->t('Delete'), $delete_url)->toString();

      $rows[] = [
        'data' => [
          $item['id'],
          $item['label'],
          $item['done'] ? $this->t('Done') : $this->t('Open'),
          $this->dateFormatter->format((int) $item['created']),
          // 文字列(MarkupInterface)として渡す
          $delete_link,
        ],
      ];
    }

    // Add リンク(同様に toString)
    $add_url = Url::fromRoute('todo.add')->setOptions([
      'attributes' => ['class' => ['button', 'button--primary']],
    ]);
    $add_link = Link::fromTextAndUrl($this->t('Add item'), $add_url)->toString();

    $build = [
      '#type' => 'table',
      '#header' => [
        $this->t('ID'),
        $this->t('Label'),
        $this->t('Status'),
        $this->t('Created'),
        $this->t('Operations'),
      ],
      '#rows' => $rows,
      '#empty' => $this->t('No items yet.'),
      // 下にボタンを表示(単純に #markup に流す)
      'actions' => [
        '#type' => 'container',
        'add_link' => ['#markup' => $add_link],
      ],
      '#cache' => [
        'tags' => [TodoStorage::CACHE_TAG],
        'contexts' => ['user.permissions'],
      ],
    ];

    (new CacheableMetadata())
      ->setCacheTags([TodoStorage::CACHE_TAG])
      ->setCacheContexts(['user.permissions'])
      ->applyTo($build);

    return $build;
  }
}

/todo にアクセスされたときに呼び出されるルート、 todo.list

その際に呼び出されるメソッドである TodoController::list() を定義する。

ポイントとしては、

  • create() メソッドの通り、サービスが DI コンテナから注入されている。
  • 'tags' => [TodoStorage::CACHE_TAG] の通り、作成するキャッシュにタグ付けを行っている
  • 'contexts' => ['user.permissions'] の通り、ユーザの権限によって作成されるキャッシュを変更している

点がある。

TodoAddForm.php

<?php

namespace Drupal\todo\Form;

use Drupal\Core\Form\FormBase;
use Drupal\Core\Form\FormStateInterface;
use Drupal\todo\Service\TodoStorage;
use Symfony\Component\DependencyInjection\ContainerInterface;

final class TodoAddForm extends FormBase {
  public function __construct(private readonly TodoStorage $storage) {}
  public static function create(ContainerInterface $c): static { return new static($c->get('todo.storage')); }
  public function getFormId(): string { return 'todo_add_form'; }

  public function buildForm(array $form, FormStateInterface $form_state): array {
    $form['label'] = ['#type' => 'textfield', '#title' => $this->t('What to do'), '#required' => TRUE, '#maxlength' => 255];
    $form['actions']['submit'] = ['#type' => 'submit', '#value' => $this->t('Add'), '#button_type' => 'primary'];
    return $form;
  }

  public function submitForm(array &$form, FormStateInterface $form_state): void {
    $label = (string) $form_state->getValue('label');
    $this->storage->add($label);
    $this->messenger()->addStatus($this->t('Added "@label".', ['@label' => $label]));
    $form_state->setRedirect('todo.list');
  }
}

/todo/add にアクセスされたときに呼び出されるルート情報 todo.add

これに紐づき、呼び出される TodoAddForm を定義する。

buildForm() がフォーム表示の際の処理。

submitForm() がフォームの入力内容を POST で送信し、サーバが受け付けた際の処理。

この二つのメソッドが必須っぽい感じがする。

なお、このような Form API 経由で呼び出したフォームは、 CSRF トークンや XSS 対策が標準で効くとのこと。

TodoDeleteForm.php

<?php

namespace Drupal\todo\Form;

use Drupal\Core\Form\ConfirmFormBase;
use Drupal\Core\Form\FormStateInterface;
use Symfony\Component\DependencyInjection\ContainerInterface;
use Drupal\todo\Service\TodoStorage;
use Drupal\Core\Url;

final class TodoDeleteForm extends ConfirmFormBase {
  private int $id;
  public function __construct(private readonly TodoStorage $storage) {}  public static function create(ContainerInterface $c): static { return new static($c->get('todo.storage')); }
  public function getFormId(): string { return 'todo_delete_form'; }
  public function getQuestion(): string { return $this->t('Are you sure you want to delete item @id?', ['@id' => $this->id]); }
  public function getCancelUrl(): Url { return Url::fromRoute('todo.list'); }
  public function getConfirmText(): string { return $this->t('Delete'); }
  public function buildForm(array $form, FormStateInterface $form_state, int $id = NULL): array { $this->id = (int) $id; return parent::buildForm($form, $form_state); }
  public function submitForm(array &$form, FormStateInterface $form_state): void {
    $this->storage->delete($this->id);
    $this->messenger()->addStatus($this->t('Deleted item @id.', ['@id' => $this->id]));
    $form_state->setRedirect('todo.list');
  }
}

getQuestion(), getConfirmText() , getCancelUrl() などを定義することによって、削除実行前に削除確認のページを挟むことができる (ConfirmFormBase)。

  • getQuestion() … 確認ページの見出し文
  • getConfirmText() … 送信ボタンのラベル
  • getCancelUrl() … キャンセル遷移先

https://www.drupal.org/docs/drupal-apis/form-api/confirmformbase-to-confirm-an-action?utm_source=chatgpt.com

TodoBlock.php

<?php

namespace Drupal\todo\Plugin\Block;

use Drupal\Core\Block\BlockBase;
use Drupal\todo\Service\TodoStorage;
use Symfony\Component\DependencyInjection\ContainerInterface;
use Drupal\Core\Plugin\ContainerFactoryPluginInterface;

/**
 * @Block(
 *   id = "todo_count_block",
 *   admin_label = @Translation("Todo: open count")
 * )
 */
final class TodoBlock extends BlockBase implements ContainerFactoryPluginInterface {
  public function __construct(array $configuration, $plugin_id, $plugin_definition, private readonly TodoStorage $storage) {
    parent::__construct($configuration, $plugin_id, $plugin_definition);
  }
  public static function create(ContainerInterface $c, array $conf, $id, $def): static {
    return new static($conf, $id, $def, $c->get('todo.storage'));
  }
  public function build(): array {
    $count = $this->storage->countOpen();
    return ['#markup' => $this->t('@count open items', ['@count' => $count]), '#cache' => ['tags' => [TodoStorage::CACHE_TAG]]];
  }
}

Drupal\Core\Block\BlockBase を拡張したクラスを定義し、 Plugin/Block 以下に設置することで、モジュールからカスタムブロックを作成することができる。

モジュールの有効化後、任意のリージョンに設置することができる。

モジュール削除すると、作成したカスタムブロックは削除されるので、リージョンに設置したブロックも削除されるらしい。

その他

コード品質

コーディング規約のチェックを、 phpcs により行うことが可能。

tarohida@drupal11-web:/var/www/html$ ./vendor/bin/phpcs -p --standard=Drupal,DrupalPractice web/modules/custom/todo
Xdebug: [Step Debug] Could not connect to debugging client. Tried: 10.255.255.254:9003 (fallback through xdebug.client_host/xdebug.client_port).
EEEEEE 6 / 6 (100%)



FILE: /var/www/html/web/modules/custom/todo/tests/Kernel/TodoStorageKernelTest.php
--------------------------------------------------------------------------------------
FOUND 9 ERRORS AND 1 WARNING AFFECTING 7 LINES
--------------------------------------------------------------------------------------
  7 | ERROR   | [ ] Missing short description in doc comment
  8 | WARNING | [x] 'todo' should match the format '@todo Fix problem X here.'
 11 | ERROR   | [ ] Missing member variable doc comment
 11 | ERROR   | [x] Expected one space after the comma, 0 found
 11 | ERROR   | [x] Expected one space after the comma, 0 found

また、コード品質のチェックを、 phpstan によって行うことも可能。

tarohida@drupal11-web:/var/www/html$ php -d xdebug.mode=off ./vendor/bin/phpstan analyse
Note: Using configuration file /var/www/html/phpstan.neon.dist.
 7/7 [▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓] 100%

 ------ ---------------------------------------------------------------------------------------------------------------
  Line   src/Controller/TodoController.php
 ------ ---------------------------------------------------------------------------------------------------------------
  16     Method Drupal\todo\Controller\TodoController::list() return type has no value type specified in iterable type
         array.
         🪪  missingType.iterableValue
         💡  See: https://phpstan.org/blog/solving-phpstan-no-value-type-specified-in-iterable-type
  24     Call to an undefined method Drupal\todo\Controller\TodoController::dateFormatter().
         🪪  method.notFound
 ------ ---------------------------------------------------------------------------------------------------------------

性能ボトルネックの把握

使用できるモジュール:Webprofiler

パフォーマンス改善案例:

  • 一覧やブロックにキャッシュタグを利用する。

Cache Tag -> キャッシュのタグ付けを行うもの。データの更新があった際に、一括でキャッシュを削除できる。

https://www.drupal.org/docs/drupal-apis/cache-api/cache-tags

例:'#cache' => ['tags' => ['todo_list']] を付け、更新時に invalidateTags(['todo_list'])。

todo.list の処理側で下記をつけておき、

// Render配列側
$build['#cache'] = [
  'tags' => ['todo_list'],
  'contexts' => ['user.permissions'],
];

todo.add, todo.delete の処理側で下記をつけておけば、

$this->invalidator->invalidateTags(['todo_list']);

タスクの追加、削除があった際、自動的に "todo_list" のタグ付けがされたキャッシュが削除され、最新のコンテンツが表示されるようになる。

基本の文法は thing:identifiernode:5 のように指定する。

他、以下のような記法がある。

  • node:5:Node エンティティの id=5 を対象とする
  • user:3: User エンティティの id=3 を対象とする
  • node_list:Node エンティティのリスト表示 (View など) を対象とする
  • {entity_type}_list:今回のパターン。該当のエンティティのリスト表示を対象とする
  • node_list:article: Node エンティティの Article バンドルのリスト表示を対象とする
  • {entity_type}_list:{bundle}:指定のエンティティの、指定のバンドルのリスト表示を対象とする

  • 認可で分岐する要素は cache contexts を利用する

Cache Contexts -> ユーザや言語など、条件 (Context) ごとに別でキャッシュを持たせることができる。

例:権限差で出し分け→ '#cache' => ['contexts' => ['user.permissions']]

https://www.drupal.org/docs/drupal-apis/cache-api/cache-contexts

他にも、色々な条件が設定できるらしい

  • cookies
  • headers
  • ip
  • languages
  • protocol_version
  • request_format
  • route
  • session
  • theme
  • timezone
  • url
  • user

  • 重い処理は Queue API / Cron -> Queue API わからん、誰か教えて。 Cron はわかる、バッチ処理にしようねってことね。

  • N+1 を避け、 DB のインデックスを作成する -> これは DB 面のパフォーマンスチューニングの話ね。 N+1 の話あんま知らないや、誰か教えて、指数関数的に負荷が高まっていくのは知ってるが

使用できる拡張: Xdebug などの PHP プロファイラ

-> 具体的に、プロファイラとして Xdebug を利用する方法知らないや。どうすればいいか誰か教えて

セキュリティ対策

権限: todo.routing.yml_permission を指定

これによって、適切な権限を持つユーザにのみ、該当の操作を指せるようにすることができる。

DB: Drupal DB API を利用することで、 SQL を手書きせず、自動的な SQL エスケープ、プレースホルダを利用

ログ: logger.channel.todo で監査可能

-> サービスコンテナとして定義したロガーによるもの。

  logger.channel.todo:
    parent: 'logger.channel_base'
    arguments: ['todo']

下記の通り、 type が "todo" となる。これは channel の値が "todo" であることによる。

Drupal コアのテスト実行

phpunit によって行うことができます。

tarohida@drupal11-web:/var/www/html$ SIMPLETEST_DB='mysql://db:db@db:3306/db?charset=utf8mb4' \
BROWSERTEST_OUTPUT_DIRECTORY=/var/www/html/web/sites/simpletest/browser_output \
php -d xdebug.mode=off ./vendor/bin/phpunit \
  -c web/core/phpunit.xml.dist \
  --testsuite kernel \
  web/modules/custom/todo/tests
PHPUnit 11.5.39 by Sebastian Bergmann and contributors.

Runtime:       PHP 8.3.16
Configuration: /var/www/html/web/core/phpunit.xml.dist

D                                                                   1 / 1 (100%)

Time: 00:03.720, Memory: 6.00 MB

1 test triggered 1 deprecation:

1) /var/www/html/web/core/lib/Drupal/Core/Database/Statement/StatementBase.php:328
Passing the $fetch argument as an integer to fetchAllAssoc() is deprecated in drupal:11.2.0 and is removed from drupal:12.0.0. Use a case of \Drupal\Core\Database\Statement\FetchAs enum instead. See https://www.drupal.org/node/3488338

Triggered by:

* Drupal\Tests\todo\Kernel\TodoStorageKernelTest::testAddAndCount
  /var/www/html/web/modules/custom/todo/tests/Kernel/TodoStorageKernelTest.php:18

OK, but there were issues!
Tests: 1, Assertions: 2, Deprecations: 1, PHPUnit Deprecations: 2.

学習リソースその2

テーマ周りについても学習リソースを作成してもらったので、これを進める。

### development.services.yml
parameters:
  twig.config:
    debug: true
    auto_reload: true
    cache: false

これによって、 Twig のデバッグを有効化する。

taro_theme.info.yml

{theme_id}.info.yml は、テーマの情報を記述したファイル。必須。

name: 'Taro Theme'
type: theme
description: 'Minimal Olivero sub-theme for hands-on learning'
package: Custom
base theme: olivero
core_version_requirement: ^11

libraries:
  - taro_theme/global

-> 紐づくライブラリの定義 -> 何に使うんやろ

regions:
  header: Header
  hero: Hero
  content: Content
  sidebar: Sidebar
  footer: Footer

-> 各種リージョンの定義

taro_theme.libraries.yml

テーマが読み込む CSS, JS などのアセットファイルのパスを指定

global:
  css:
    theme:
      assets/css/global.css: {}
  js:
    assets/js/global.js: {}
  dependencies:
    - core/drupal
    - core/drupalSettings

/assets/css/global.css

読み込まれる css アセットファイル。

:root { --brand: #3b82f6; }
.taro-hero { padding: 2rem; background: var(--brand); color: #fff; border-radius: .5rem; }
.taro-badge { display:inline-block; padding:.2rem .5rem; border:1px solid currentColor; border-radius:.25rem; font-size:.8rem; }

/assets/js/global.js

読み込まれる js アセットファイル。

((Drupal, drupalSettings) => {
  Drupal.behaviors.taroHello = {
    attach(context) {
      if (!context.querySelector('[data-taro-hello]')) return;
      // Just a demo hook point
      // console.log('taro_theme attached', drupalSettings.path);
    }
  };
})(Drupal, drupalSettings);

/templates/page.html.twig

全ページ共通のテンプレートファイルを変更。基底クラス的な。

{# 最小のページテンプレート。Twigの if/for, 変数出力などを体験 #}
{% set classes = [
  'layout-container',
  logged_in ? 'is-logged-in' : 'is-anon'
] %}

<div{{ attributes.addClass(classes) }} data-taro-hello>
  <header class="site-header">
    <div class="branding">
      <a href="{{ path('<front>') }}" class="site-name">{{ site_name|escape }}</a>
    </div>
    {% if page.header %}
      <div class="region-header">{{ page.header }}</div>
    {% endif %}
  </header>

  {% if page.hero %}
    <section class="taro-hero">
      <h2>{{ hero_title|default('Welcome') }}</h2>
      {{ page.hero }}
    </section>
  {% endif %}

  <main role="main" class="site-main">
    <a id="main-content" tabindex="-1"></a>
    {{ page.content }}
    {% if page.sidebar %}
      <aside class="sidebar">{{ page.sidebar }}</aside>
    {% endif %}
  </main>

  <footer class="site-footer">
    {{ page.footer }}
  </footer>
</div>

/templates/node--article.html.twig

Node エンティティのサブタイプ、 Article コンテンツタイプのテンプレートファイルを作成。

<article{{ attributes.addClass('node--article') }}>
  <header>
    <h1>{{ label }}</h1>
    <div class="meta">
      <span class="taro-badge">
        {{ published ? 'Published' : 'Draft' }}
      </span>
      {% if reading_time %}
        <span class="taro-badge">~{{ reading_time }} min read</span>
      {% endif %}
      <time datetime="{{ node.created.value|date('c') }}">
        {{ node.created.value|date('Y-m-d') }}
      </time>
    </div>
  </header>

  <div class="content">
    {{ content|without('links') }}
  </div>

  {% if content.links %}
    <nav class="node-links">{{ content.links }}</nav>
  {% endif %}
</article>
  • without('links') などで、特定のフィールドを除外

block--system-menu-block.html.twig

メニューブロックのテンプレートを変更

いずれも、 ./themes/custom/theme_name/templates/ 以下にある。

そして、 {node|block}--sub1-sub2-sub3.html.twig

{% set classes = ['block', 'block--menu', configuration.provider ~ '-' ~ plugin_id] %}
<nav{{ attributes.addClass(classes) }} aria-label="{{ configuration.label }}">
  {% if label %}
    <h2 class="menu-title">{{ label }}</h2>
  {% endif %}
  {{ content }}
</nav>

プリプロセス

エンティティからデータを受け取ってテンプレートにぶち込む前に、テンプレートに値を当てはめたりする。

<?php

/**
 * @file
 * Theme hooks and preprocess functions for Taro Theme.
 */

/**
 * Implements hook_preprocess_html().
 */
function taro_theme_preprocess_html(array &$variables) {
  // 週末なら body にクラスを追加
  $is_weekend = in_array((int) date('w'), [0,6], true);
  if ($is_weekend) {
    $variables['attributes']['class'][] = 'is-weekend';
  }
}

/**
 * Implements hook_preprocess_page().
 */
function taro_theme_preprocess_page(array &$variables) {
  // ヒーローの見出し(サイト名を拝借)
  $config = \Drupal::config('system.site');
  $variables['hero_title'] = $config->get('name') ?: 'Welcome';
  // 必要ならここでライブラリ追加も可能
  // $variables['#attached']['library'][] = 'taro_theme/global';
}

/**
 * Implements hook_preprocess_node().
 */
function taro_theme_preprocess_node(array &$variables) {
  /** @var \Drupal\node\NodeInterface $node */
  $node = $variables['node'] ?? null;
  if (!$node) {
    return;
  }
  // 公開/非公開を簡便に
  $variables['published'] = (bool) $node->isPublished();

  // 記事の推定読了時間(ざっくり)
  if ($node->bundle() === 'article' && $node->hasField('body')) {
    $text = $node->get('body')->summary ?: $node->get('body')->value;
    $words = str_word_count(strip_tags((string) $text));
    $variables['reading_time'] = max(1, (int) ceil($words / 400)); // 400wpm を仮定
  }
}

結果

結果は 58%で NG でした 😿