baserCMS 5系 プラグイン開発の正本パターン集
baserCMS 5系(CakePHP 5ベース)でプラグインを新規開発・改修するときのコーディングパターンを機能別にまとめたもの。環境・ディレクトリ構成・命名規則・コアハック禁止(vendor/baserproject・vendor/cakephp は読み取り専用)などの共通ルールは basercms5-development、テーマ(templates 中心)の開発は basercms5-theme-development、4系からの移行作業は basercms-plugin-4-to-5-upgrade、テストの実行・基盤導入は basercms-unittest、bc-custom-content(カスタムコンテンツ)を使った開発は basercms5-custom-content-development を参照する。
1. 新規開発の始め方
- 雛形は bake で生成する:
bin/cake bake bc_plugin {PluginName}でプラグイン雛形、bin/cake bake bc_all {ModelName} --plugin {PluginName}で Table/Entity/Controller/テンプレート一式を生成できる。既存プラグインへのテスト基盤の後付け手順は basercms-unittest を参照。 - 標準ディレクトリ構成:
src/{PluginName}Plugin.php— プラグインクラスsrc/Controller/(フロント)・src/Controller/Admin/(管理画面)src/Model/Table/・src/Model/Entity/src/View/Helper/・src/Event/(イベントリスナー。置くだけで自動 attach される)config/(config.php・setting.php・routes.php・Migrations/)templates/(フロント)・templates/Admin/(管理画面。検索フォームはtemplates/Admin/element/search/)webroot/(js/css/img。Vue ソースはwebroot/js/src)
- プラグインクラスは
class SamplePlugin extends \BaserCore\BcPluginと書く。install(array $options = []): boolはreturn parent::install($options);を基本とし、bootstrap()は**動的な設定(実行時に決まる値の Configure 追加、ログ設定の登録など)**にのみ使う。 - setting.php / config.php は「静的な return 配列」にする(実行型コードを書かない)。動的に決まる部分は
bootstrap()に分離する。// config/setting.php return [ 'BcApp' => [...], 'BcShortCode' => ['Sample' => ['Sample.showList']], ]; - adminNavigation(管理メニュー)は setting.php に登録する。
'plugin'・'controller'はいずれも CamelCase で書く('controller' => 'SampleArticles')。snake_case で書くと全管理画面が404になる(メニュー描画時のURL解決に失敗するため対象画面以外も巻き込む)。 - 管理画面URLの
{controller}セグメントは実際にはプラグイン名をダッシュ区切り(kebab-case)にしたもの(例:BlogTagGroup→/baser/admin/blog-tag-group/{controller}/...)。他プラグインのURLパターンから類推せず、bin/cake routes | grep <PluginName>(大文字小文字を落として検索)で実際のルートを確認してから使う。 - BcBlog(
bc-blog)の管理画面「記事編集」URLはedit/{blogContentId}/{id}の2引数が必須(Admin\BlogPostsController::beforeFilter()が$this->request->getParam('pass.0')をブログコンテンツIDとして要求し、無いとBcException: コンテンツデータが見つかりません。になる)。記事IDだけのedit/{id}ではアクセスできない。他プラグインが特定ブログ記事の編集画面へリンクを張る場合(例: 追加フィールドプラグインの編集導線)は、edit($blogContentId, $id)のシグネチャ通り両方を渡す。 - 有効化は管理画面のプラグイン管理から行うか、
pluginsテーブルへ直接 INSERT(status=1)する。DBで有効化されたプラグインは自動で読み込まれるため、composer への登録やcomposer dump-autoloadは不要。 - サードパーティ製ライブラリ(例
phpoffice/phpspreadsheet)はcomposer requireで導入する。雛形アセット(Excel テンプレート等)はtemplates/Admin/Excel/...に置き、\Cake\Core\Plugin::templatePath('Sample') . 'Admin' . DS . 'Excel' . DS . ...で参照する(templatePath()は末尾スラッシュ付き)。
2. ORM / Table / Entity
責務分離: バリデーション・ビジネスロジックは Table(src/Model/Table)、行データの振る舞いは Entity(src/Model/Entity)に書く。DB変更は Migration (phinx) を使う。
- アソシエーションは
initialize()で宣言し、エイリアスは複数形にする。classNameはプラグイン接頭辞付き・複数形で書く。エイリアスはcontain()・条件キー・エンティティのプロパティ名すべてに波及する。public function initialize(array $config): void { parent::initialize($config); $this->setTable('sample_articles'); $this->addBehavior('BaserCore.BcUpload', [...]); $this->belongsTo('Users', ['className' => 'BaserCore.Users', 'foreignKey' => 'user_id']); $this->hasMany('SampleComments', ['className' => 'Sample.SampleComments', 'foreignKey' => 'article_id', 'dependent' => true]); $this->belongsToMany('SampleTags', [ 'className' => 'Sample.SampleTags', 'joinTable' => 'sample_articles_sample_tags', 'foreignKey' => 'article_id', 'targetForeignKey' => 'tag_id', ]); }dependentはhasMany/hasOneのみ有効。 - バリデーションは
validationDefault()に書く。カスタムルールはシグネチャ($value, $context)のメソッドをadd()で登録する。public function validationDefault(\Cake\Validation\Validator $validator): \Cake\Validation\Validator { $validator->notEmptyString('title', 'タイトルを入力してください'); $validator->add('title', 'custom', ['rule' => [$this, 'customMethod'], 'message' => '...']); return $validator; } - 検索はクエリビルダで書く:
$this->find()->where([...])->contain([...])->orderBy([...])->first()/all()。list はfind('list', keyField: 'id', valueField: 'name')。- 配列値に IN は自動付与されない。複数値は必ず
'field IN' => (array)$valuesと明示する(書かないとCannot convert value Array例外)。空配列のIN ()は例外になるので、空になりうる場合は先頭でガードして早期 return する。 - null 一致は
IS演算子:['field IS' => null]/否定は['field IS NOT' => null]。 - 関連テーブルの列で絞り込むときは
contain()ではなく->innerJoinWith('Assocs')かmatching()を使う(containは WHERE に使えない)。条件キーは複数形エイリアス(SampleComments.user_id)。常時 join すると関連なし行が除外される点に注意。
- 配列値に IN は自動付与されない。複数値は必ず
- 保存はエンティティ経由:
$entity = $table->newEntity($data);(新規)または$entity = $table->patchEntity($table->get($id), $data);(更新)→$table->save($entity);。 - belongsToMany の保存は
_idsで書く:patchEntity($e, $data + ['sample_tags' => ['_ids' => [$id, ...]]], ['associated' => ['SampleTags']])。表示側はcontain(['SampleTags'])。 - name/value の KVS テーブルは
initialize()に$this->addBehavior('BaserCore.BcKeyValue')を足すとsaveKeyValue([$key => $value])が使える。読み出しはfind()->where(['name' => $key])->first()->value。 - 集計は
func()->sum()+->first()->alias ?? 0で書く:$total = $this->find() ->where(['user_id' => $userId]) ->select(['total' => $this->find()->func()->sum('amount')]) ->first()->total ?? 0;- datetime カラムの MIN/MAX に集計関数を使わない。
func()->min()/max()は結果が数値型にキャストされて壊れるため、->orderBy(['col' => 'ASC'])->first()で最古/最新行を取り、そのエンティティの日時プロパティを使う。
- datetime カラムの MIN/MAX に集計関数を使わない。
- afterSave で他テーブルを save する(相互に save し合う)場合は再入ガードフラグを置く。
save()にcallbacksオプションは存在しない(渡しても黙って無視され afterSave は常に発火する)ため、抑止はフラグで行う。private bool $inAfterSave = false; public function afterSave(EventInterface $e, EntityInterface $entity, \ArrayObject $o): void { if ($this->inAfterSave) return; $this->inAfterSave = true; try { /* 他テーブルの save 等 */ } finally { $this->inAfterSave = false; } } - 削除は
get($id)→delete($entity)で書く(関連のdependent => trueが cascade する)。deleteAll()は ORM の cascade を通さず子が孤児として残るので、子を連れて消す削除には使わない。 fetchTable()は Controller / Command 専用。Table・Lib・Helper・イベントリスナー内では\Cake\ORM\TableRegistry::getTableLocator()->get('Sample.SampleArticles')を使う(fetchTableを呼ぶとBadMethodCallException)。エイリアスは常に複数形。- **
getControlSource()(フォーム選択肢の供給元)**は Table に定義し、テンプレートからは$this->BcAdminForm->getControlSource('Sample.SampleArticles.user_id')のようにPlugin.複数形モデル.fieldで呼ぶ(単数形だとMissingTableClassException)。 disableHydration()を使うと結果はフラット連想配列になる。select には明示エイリアスを付けて(->select(['id' => 'SampleArticles.id', 'tag_name' => 'SampleTags.name']))$row['id']でフラット参照する。- 生SQLが必要なときは
$this->getConnection()->execute($sql)->fetchAll('assoc')(戻りはフラット連想配列)。トランザクションは$this->getConnection()->begin()/commit()/rollback()。 - プラグイン共通のメソッドは共通基底 Table(
\BaserCore\Model\Table\AppTableを継承したSampleAppTable等)に置き、各 Table はそれを継承する。
3. コントローラ
管理画面コントローラは src/Controller/Admin/ に置き、プラグインの Admin 基底(SampleAdminAppController など、BcAdminAppController を継承)を経由して継承する。
- テーブル取得は
$this->fetchTable('Sample.SampleArticles')(複数形エイリアス)。 - ページネーションはクエリビルダを組んで
$this->paginate($query)に渡す(PaginatorComponent は存在しない。loadComponent('Paginator')はMissingComponentException)。必要な関連はcontain()、手書き結合はleftJoinWith()/innerJoinWith()/matching()、->groupBy()/->having()も使える。並べ替え・ページはクエリ文字列?sort=&direction=&page=で自動処理される。 - サイト設定値は
\BaserCore\Utility\BcSiteConfig::get('admin_list_num')で取得する。ログインユーザーは\BaserCore\Utility\BcUtil::loginUser()(未ログイン時はfalseを返すので?->は効かない。(BcUtil::loginUser() ?: null)?->idか明示分岐で書く)。 - フラッシュメッセージは
$this->BcMessage->setSuccess('...')/setError('...')。 redirect()は必ずreturn $this->redirect([...]);と書く。redirect は Response を返すだけで後続コードが実行され続けるため、ガード節(引数チェック→リダイレクト)では return しないと直後の処理まで走って TypeError 等になる。例外:initialize()/beforeFilter()など: void宣言のメソッド内では return を付けない(付けると TypeError)。- request は immutable。getter への代入は書けない。値を足すときは
withQueryParams()/withData()で新しい request を作ってsetRequest()する。
読み取りは$this->setRequest($this->getRequest()->withQueryParams( $this->getRequest()->getQueryParams() + ['begin' => $begin] ));getData('key')/getQuery('key')/getParam('key')/getQueryParams()。ajax 判定は$this->getRequest()->is('ajax')。getParam('controller')は CamelCase('SampleArticles')で返る点に注意(分岐で snake_case と比較すると常に false)。 - カスタム Component は
public function initialize(array $config): void { parent::initialize($config); }のシグネチャで書き、コントローラ参照は$this->getController()で取得する。使用するコントローラのinitialize()で$this->loadComponent('Sample.Excel')のように明示ロードする(自動ロードはされない)。 - delete アクションの定石:
$this->getRequest()->allowMethod(['post', 'delete'])→$entity = $table->get($id)(RecordNotFoundExceptionを try/catch して「無効な処理です」→ index へ)→$table->delete($entity)。一覧の bca-submit-token 経由の POST を受けるアクションは FormProtection のunlockedActionsに登録する(§6)。 - テンプレートが参照する計算値・設定値はコントローラで明示的に
set()する。旧来のコールバックによる自動注入は無いので、行ごとの計算値は Table の計算メソッド(calcBalance($entity)等)を paginate 後にループ適用する。プラグイン設定(Configure::read('Sample'))を多数のテンプレートが参照する場合は、プラグインの Admin 基底コントローラのbeforeRender()で一括 set する。
各 Admin コントローラはpublic function beforeRender(\Cake\Event\EventInterface $event): void { parent::beforeRender($event); $this->set(\Cake\Core\Configure::read('Sample')); }BcAdminAppController直継承ではなくこの基底を継承する(直継承だと設定ビュー変数が全画面で欠落する)。 - エラー応答は例外で書く:
throw new \Cake\Http\Exception\BadRequestException(...)/NotFoundException/InternalErrorException。レスポンス操作は$this->setResponse($this->getResponse()->withStatus(400))。 - 検索インデックス・ヘルプは
$this->setSearch('sample_articles_index')/$this->setHelp('...')で設定する。
4. 管理画面フォーム・一覧・検索
- 管理画面のフォームは
$this->BcAdminFormを使い、入力は必ずcontrol()で出す(input()は無い)。フロント用$this->BcForm/$this->Formや素のウィジェット(text()/select()/multiCheckbox())を直接呼ぶと bca-* の管理スタイルが当たらず崩れる。一覧の一括処理(ListTool.batch)・一括選択(ListTool.checkall)・submit()/button()もBcAdminFormで出す。 - フォーム開始: エンティティがあるなら
create($entity)、コンテキストレスならcreate(null, ['valueSources' => ['data', 'context']])と書く(GET検索フォームは['query', 'context'])。文字列モデル名を渡すとNo context provider found for value of type string。 - 複数チェックボックスは options をループして個別 control で書く(
['multiple' => 'checkbox']やmultiCheckbox()は崩れる):
受け側の条件は<?php $selected = array_map('strval', (array)$this->getRequest()->getData('SampleArticle.tags')) ?> <?php foreach($options as $v => $label): ?> <?php echo $this->BcAdminForm->control('SampleArticle.tags[]', [ 'type' => 'checkbox', 'label' => $label, 'value' => $v, 'checked' => in_array((string)$v, $selected, true), 'hiddenField' => false]) ?> <?php endforeach ?>'field IN' => (array)$values(§2)。 control()のlabelに HTML を渡すときは配列形式['text' => '<span...>', 'escape' => false]と書く(文字列だとエスケープされ生表示)。- 検索フォームは
templates/Admin/element/search/{name}.php(単数search/) に置く。コントローラのsetSearch('sample_articles_index')がこのパスを解決する。検索は GET 送信が基本(条件がURLに残りページャ遷移でも保持)で、コントローラはgetQueryParams()から条件を読む。入力値の再表示にはvalueSourcesにqueryを含める。 control()の DOM 生成規則を把握して JS と整合させる(描画は通るのに JS 連携が無言で死ぬ最頻出ポイント):- id は小文字ハイフン:
control('SampleArticle.publish_date')→id="samplearticle-publish-date"(Text::slugによる)。 - name は
Model[field]形式:name="SampleArticle[publish_date]"。 - バンドルJSが CamelCase id(
#SampleArticlePublishDate)や別形式の name を掴む設計なら、JS 側を実生成の id/name に合わせるか、control(..., ['id' => 'SampleArticlePublishDate'])で明示 id を振る(その場合 label のforも合わせる。name は POST キーなので変えない)。フォーム全体への委譲$("#FormId").on('change', 'select, input', ...)にすると id 依存自体をなくせる。 - 統合テストで「JS が期待する id/name が HTML に実在する」ことを assert する番人パターンを置くと、この不整合の回帰を検出できる(描画200のテストでは検出できない。basercms-unittest 参照)。
- id は小文字ハイフン:
- 年月セレクトは
control('Model.field.year', ['type' => 'select', 'options' => $years, 'label' => false])のように自前 options で書く(year()/month()というメソッドは無い)。getData('Model.field')は['year' =>, 'month' =>]で受かる。 - 一覧 element とフォームの同居に注意:
Paginator->sort()やelement('pagination')を含む一覧 element を、ページネーションしない画面(編集フォームへの埋め込み等)で描画するとYou must set a pagination instance using setPaginated() firstで落ちる。ページャ系の描画は「専用一覧コントローラのときだけ」にgetParam('controller') === 'SampleArticles'(CamelCase)でガードする。 - 生SQL等のカスタムページネーションは、総件数+当ページ行を自前で取得し
new \Cake\Datasource\Paging\PaginatedResultSet(new \ArrayIterator($rows), $params)をビュー変数に set する(PaginatorHelper がPaginatedInterfaceを自動検出する)。sort カラムはホワイトリストで ORDER BY を組み立てる。 - フォーム拡張フックは
$this->BcFormTable->dispatchBefore()/dispatchAfter()、$this->BcAdminForm->dispatchAfterForm()を使う。 BcBaser->link()に画像/HTML を渡すときは['escape' => false]。アイコンボタンは['class' => 'bca-btn-icon', 'data-bca-btn-type' => 'add', 'data-bca-btn-size' => 'lg']の作法で書く。
5. イベント
リスナーは src/Event/ に置く。プラグインの BcPlugin が自動 attach するため、リスナーが壊れているとイベント発火で即 Fatal になる点に注意して書く。
- 自動 attach の実体・ファイル名の厳密な規則:
BaserCore\Utility\BcEvent::registerPluginEvent()(BaserCorePluginから有効化済み全プラグインに対して呼ばれる)が、src/Event/{Plugin名}{Controller|Model|View|Helper|Mailer}EventListener.phpというファイル名と完全一致するクラスだけを自動生成・EventManager::instance()->on()で登録する。例えば<Plugin>プラグインでヘッダーを差し替えるビューイベントリスナーなら、ファイル名は必ずsrc/Event/<Plugin>ViewEventListener.php、クラス名も<Plugin>ViewEventListenerにする(名前が1文字でもズレると静かに登録されず、イベントが一切発火しない)。 - 基底クラス: モデル系は
BcModelEventListener、コントローラ系はBcControllerEventListener、ビュー系はBcViewEventListener、ヘルパ系はBcHelperEventListenerを継承する。 - イベント名の命名規則:
public $events = [...]に登録する。- 自プラグインのモデルイベント:
'SampleArticles.afterSave'(Table 名の複数形)。 - 他プラグインを修飾する場合:
'BcBlog.BlogPosts.afterSave'(プラグイン名+Table複数形)。名前が実在イベントと一致しないとエラーも出ずに無発火になるため、発火側をgrep -rn "dispatchLayerEvent\|createEvent" vendor/baserproject/...して実在するイベント名に合わせる。 - View 系は
BcViewEventDispatcherが標準イベントを{Plugin}.{ViewName}.afterRenderとして再ディスパッチする方式。'BcBlog.Blog.afterRender'のように View のgetPlugin()/getName()に一致させる。
- 自プラグインのモデルイベント:
- ハンドラ名はイベント名の CamelCase:
'BcBlog.BlogPosts.afterSave'→public function bcBlogBlogPostsAfterSave(EventInterface $event)。型ヒントは\Cake\Event\EventInterface。 - イベントデータ: 読み取りは
$event->getData('key')、書き込みは$event->setData('key', $v)、サブジェクト(Table/Controller/View)は$event->getSubject()。保存系イベントのエンティティは$event->getData('entity')で取る。 - コンストラクタで Table を即時取得しない。リスナーは bootstrap 段階で生成されるため、コンストラクタ内の
TableRegistry::...->get()はMissingTableClassExceptionになる。ハンドラ内で必要になったときに遅延取得する。 Model.afterFindというイベントは存在しない。find 結果の加工はformatResults()かカスタム finder に書く。- View 系ハンドラでビュー変数を読むときは
$view->get('varName')を使う($view->viewVarsプロパティは無い)。$view->requestプロパティも無い(未知プロパティ参照はヘルパの自動ロード扱いとなりxxxHelper could not be foundで全画面を巻き込む)ので$view->getRequest()->getParam(...)で書く。 - ハンドラからビューへ値を渡すときは
$controller->set('x', $v)(request への代入は immutable のため無効)。
6. Vue / jQuery 連携
- 管理画面の ajax / リンク URL をハードコードしない。配置(サブディレクトリ・prefix 変更)で壊れるため、baser が用意するグローバル
$.bcUtil.adminBaseUrl(baseUrl + '/' + baserCorePrefix + '/' + adminPrefix + '/'、末尾スラッシュ付き)に連結して書く:$.bcUtil.adminBaseUrl + 'sample/sample_articles/edit/' + id。- Vue2 のテンプレート式からはグローバル
$を参照できないので、computedadminBaseUrl() { return $.bcUtil.adminBaseUrl; }を公開し:href="adminBaseUrl + 'sample/...'"と書く。
- Vue2 のテンプレート式からはグローバル
- Vue バンドルはソース(
webroot/js/src)を直して webpack で再ビルドする(コンパイル済み.bundle.js/.bundle.cssを手で編集しない)。webpack.config.jsがあればcd plugins/Sample && npm install && npx webpack。出力はwebroot/js/[name].bundle.js、CSS は../css/[name].bundle.css。再ビルド後に bundle へ反映されたか grep し、ブラウザは Cmd+Shift+R で確認する。 - ajax アクションは Response を返す:
return $this->getResponse()->withType('application/json')->withStringBody(json_encode($rows ?: []));。文字列を return しても出力されない。Vue が参照するフィールドはフラット配列に整形して返す。 - Vue が動的生成するフォーム行(明細等)は FormProtection に弾かれる(
Unexpected field '...' in POST data)。描画時に DOM に無いフィールドは送信トークンに含まれないため、動的フィールドを POST するアクションはbeforeFilter()で$this->FormProtection->setConfig('unlockedActions', ['add', 'edit', ...])に登録する(CSRF は別ミドルウェアなので維持される)。Vue 側の:nameは'Model[' + i + '][field]'形式で書く(コントローラのgetData('Model')で受かる形)。 - prop に複数型が来るなら
type: [String, Number]のように許容する。ajax レスポンスが JSON でなく文字列になるとv-forが文字単位で反復する(Expected Array, got Stringが1文字ずつ大量に出たらサーバ側エラーを疑う)。
7. 日付・数値・文字列
$this->Time->format($d, 'yyyy-MM-dd')は ICU パターンで書く(PHP のdate()形式ではない。Y=週年・m=分・M=月なので'Y-m-d'と書くと月が0になる)。スラッシュ区切りは'yyyy/MM/dd'、時刻込みは'yyyy-MM-dd HH:mm'。第2引数を省略すると日時表示になるため、日付のみは必ず指定する。BcTime->formatも同様。- 一方、エンティティの日時プロパティの
$v->format('Y/m/d')は PHP の date() 形式(DateTimeInterface::format)。TimeHelper 経由(ICU)と使い分ける。 - null になりうる日付は
$this->Time->format($e->x, 'yyyy-MM-dd')(helper は null で空文字)か$e->x?->format('Y-m-d')で書く。
- 一方、エンティティの日時プロパティの
Number::format()/NumberHelper::format()/currency()は第1引数 null 不可(TypeError)。nullable な値はformat($x ?? 0, [...])/currency($e->budget ?? 0, ...)とガードする。Text::truncate()/BcText::truncate()も第1引数 string 必須。nullable カラムはtruncate((string)$e->notes, 46)と(string)キャストする。- 独自の数値フォーマット登録(addFormat 相当)は無い。記号なし3桁区切りは
$this->Number->format($v, ['places' => 0])で書く(currency()の第2引数は ICU の ISO 通貨コード前提で、未知コードは接頭辞としてそのまま出力される)。
8. ルーティング・ショートコード・管理画面CSS
config/routes.phpは RouteBuilder のクロージャで書く:
コントローラ名は CamelCase。<?php use Cake\Routing\RouteBuilder; return function (RouteBuilder $routes) { $routes->connect('/sample/*', ['plugin' => 'Sample', 'controller' => 'SampleArticles', 'action' => 'view']); };Router::getRequest()を routes.php で使う場合は CLI 実行時に null になるためif (!$request) return;のガードを必ず入れる(無いとbin/cakeが全コマンド停止する)。- ショートコードは setting.php の
'BcShortCode'キーに登録する(§1のコード例参照)。登録元プラグインが無効だと未登録扱いとなり、コンテンツ中の[Sample.showList]がエラーなく生テキストのまま露出する(プラグイン有効化が前提)。 - 横長テーブル(多数列)の管理画面では、管理テーマの
.bca-container(flex) 子要素.bca-mainにmin-width:0が無いためページ全体が横スクロールする。その画面の CSS に.bca-main { min-width: 0; }を足し、テーブルはoverflow:autoのスクロールラッパーで囲う(position:stickyの列固定もこれで効く)。CSSがバンドル内なら webpack 再ビルド(§6)。
9. テスト
テストの実行手順・プラグイン単体(スタンドアロン)テスト基盤の導入・Fixture Factory の書き方は basercms-unittest スキルを正本として参照する。開発時の要点だけ挙げると: 管理画面はログイン付きコントローラ統合テストで GET 描画200+POST フロー(DB 変化の assert)を固定する、描画200は warning を握り潰すのでテスト後に error.log の pristine を確認する、JS 連携がある画面は「JS が期待する DOM id/name が HTML に実在する」assert(§4の番人パターン)を置く、日付保存は値まで assert する(silently NULL を見逃さない)。