動的スキーマを持つ製品のための Python SDK を設計して、実装してみる

記事タイトルとURLをコピーする

こんにちは。自称ソフトウェアエンジニアの橋本 (@hassaku_63)です。

この記事では、CMDBuild というアセット管理DBの製品の API をラップした Python 製の簡易 SDK を作ってみたよ、という話をします。

(実態は単純な API ラッパーに毛が生えた程度のもので SDK と呼ぶにはお粗末な気もしておりますが、いったん本記事では "SDK" と表現させていただきます)

ユーザーが独自拡張したスキーマにも対応し、適切に型付けされ、IDE 補完の効く形で扱える体験の提供を目指しました。

どういう設計の検討・判断をしたのか、という考え方の部分をご紹介してみようと思います。

前提知識をざっとご説明して、次に設計判断の結果、そしてその判断に至るまでの過程を述べます。ものづくりするときには色々な側面や抽象度から「設計」を考えますよね?というのを明るみに出してみたくなって、私なりの実例を書いてみた次第です。私個人は思考のプロセスへの意識と言語化が自己成長にとって重要と考えているので、過程を垂れ流す記事にも多少なり意義があると考えています。

今回作ったソフトウェアは行数で言えば小規模です。直近の仕事で「こういうのがあったらいいな」と思ったのでついでにコンセプト検証のための実装をやってみたというのが実際のところで、どう作るのかさえ決まってしまえば時間を掛けずに作れる程度のものです。

はじめにお断りしておきますが、めちゃくちゃ長いです。お時間があればお付き合いください。

この記事を読んで得られる(であろう)モノ

この記事を読んでいただく主な理由は「ソフトウェアの設計判断に関する思考プロセスの言語化」を行った点にあると考えています。具体的には次のようなことです。

テーマ: 「ユーザーが自由にスキーマ拡張できる製品を扱う API ラッパー (SDK) において、SDK としての汎用性を失わずに、どうやって型ヒント付きの開発体験を提供するか」

  • CMDBuild はユーザーが任意にデータ構造(Class)を定義可能
  • SDK 自体は汎用的であるべきだが、ユーザー固有のスキーマも型ヒント付きで扱いたい
  • 動的型付け言語である Python でこれをどう実現するか

また、AI (Kiro) に本記事の価値を400字要約してもらいました。大筋は合ってると思ったので、全文ほぼそのまま提示しておきます。

本記事の読者にとっての価値は、大きく3点に集約されます。

第一に、動的スキーマを持つ製品に対する SDK 設計の汎用パターンが示されている点です。「低レベルクライアント+自動生成による型付きレイヤー」という2層構造は、CMDBuild に限らず Salesforce や Airtable など類似の課題を持つ製品にも転用可能です。

第二に、設計判断の思考過程が率直に言語化されている点です。ORM・jsforce・boto3 との比較を通じた「何を採用し何を捨てたか」の判断基準は、読者自身の設計判断の参考になります。

第三に、AST 検証によるコード生成テストや __getattr__ を活用した動的アクセサなど、再利用可能な実装技法が含まれている点です。AI との分業における人間側の役割についての実践的考察も、現代の開発における示唆を与えてくれるでしょう。

余談ですが、この文章を公開する前段階として記事全体の AI レビューを行っています。レビューの成果物ドキュメントから、上記の要約を生成しました。また、記事レビューの指示内容として LAPRAS さんの AI レビュー機能 で採用されていたシステムプロンプトを借用させていただきました。以下のテックブログで紹介されていますので、ご興味があればそちらもご覧ください。

参考記事: LAPRAS TECH BLOG - プロンプト公開!「AI レビュー」機能アップグレードの開発秘話

「他のエンジニアにとってどれくらい役に立つか」という観点から、5つの評価軸で構成されています。現代のブログ執筆環境でしたら Agent Skills に組み込んでみるのも良いかもしれませんね。

背景

CMDBuild という製品をご存じない読者も多いと思います。メインセクションで CMDBuild 上の概念・用語を多用することになるので、まずは製品そのものに関する簡単な説明を行い、次に本記事で行った SDK 開発の経緯・背景の話をします。

CMDBuild について

CMDBuild はいわゆる「アセット管理」とか「構成管理」と呼ばれるジャンルのソリューションを提供する OSS で、めちゃくちゃ雑に言うと「基幹業務システム向けに魔改造された、抽象レイヤーの乗っかった画面付きの RDB」のようなものです。実際にはそう単純ではないのですが、簡単のためこのように表現させていただきます。

当社の事業部においては、AWS アカウント上の顧客のインフラを預かって運用するために、IT資産や構成情報の管理を目的として本製品が使われています。この製品の際立った特徴を挙げるとすれば、次の1点です。

  • ユーザーが任意にデータ構造(スキーマ)を拡張可能である

社内で運用している CMDBuild はそれなりに長く使われていて、ユーザー定義したデータ構造もそれなりに多くありました。

顧客の案件情報の管理という最重要の役割を持つほか、各種業務ワークフローから API 経由で参照・更新される外部連携の用事が多かったり、IT 監査の要件に回答するための仕組みとして作られていたりと、役割が多いシステムです。

CMDBduild 用語解説

以降で CMDBuild 独自の用語を使用するので、先に解説しておきます。IT の一般的な用語と語彙が被っているのでご注意ください。

前節で CMDBuild を RDB なのようなものだと説明しました。オブジェクト志向的な考えを持ち込んでいる印象もあり、ここでは RDB とオブジェクト指向と対比した大まかな対応関係を整理してみます。

CMDBuild  RDB  オブジェクト指向  補足 
クラス (Class) テーブル クラス CMDBuild 上で扱えるデータ型の定義
カード (Card) レコード インスタンス 実際に登録されているデータレコードそのもの(具象値)
属性 (Attributes) カラム クラスのプロパティ宣言 -
ドメイン (Domains) 外部キーの定義、もしくは N:N 対応のための中間テーブル 別のオブジェクトを参照するプロパティ宣言 クラス間での 1:N 対応や N:N 対応の関係を定義する機能
リレーション (Relations) レコードの具象値としての外部キー、または中間テーブルのレコード 具象値としての、別のオブジェクトへの参照 CMDBuild における「ドメイン」のインスタンス(具象値)

区別のため、基本的に上述の CMDBuild 用語は英字の斜体表記でなるべく統一します。説明の便宜上カナ表記する必要がある場合は「CMDBuid クラス」のように接頭したり、前後の文脈で明示するなどできる限り区別可能な表現を行います。

開発の動機

生産性向上が狙いですが、趣味的な側面もかなり混じっています。

業務情報の管理という性質上、さまざな関係者がそれぞれの思惑で CMDBuild との接点を持ちます。ほとんどの外部連携システムは自前の開発が必要です。外部連携の用事を持つ人は CMDBuild の保守担当(私)だけではありません。このような用事を持つ人々に働きかける生産性向上の取り組みがあればハッピーですよね?という意識がありました。この課題意識に対するアプローチは様々あろうと思いますが、その中のひとつとして技術寄りの施策に「自社の運用環境に最適化された使い勝手をもつ SDK の提供」という手段があると考えていました。

開発者・メンテナとしての都合で言えば、ひとえに「CMDBuild 上のデータ構造の変更が必要な業務要求に対して、気楽に検証を回しやすい状況を整備したい」という事情が大きいです。

CMDBuild 上で動く業務システムの運営・保守だけでなく、関係する外部連携システムへの理解が必要です。なぜなら、細々した外部連携システムの多くもまた私の担当範囲だからです。API の仕様を調査したり、自分でプロトタイピングするなどの必要性が生じていました。このような状況にあって、私には以下のようなニーズがありました。

  • CMDBuild 上のデータ構造をいろいろいじって試したい
  • 変更したデータ構造が実際に機能するか、スキーマをいじくりながら API or 画面越しに試したい

直近の取り組みで Docker Compose ベースのローカル検証環境を整備しており、前者の課題はかなり緩和できました。問題は後者です。

残念ながら CMDBuild の Python 版クライアントには決定版と呼べるものがありません。社内のレポジトリを見ても、そのほとんどが requests などを使った生の HTTP 呼び出しを行うやり方をしていました。色々検証を回したいのに、使えるコード資産が最小限の抽象化しかできていないというのはあまりうれしくありません。

さらに都合の悪いことに、CMDBuild はリファレンスが少々不親切です。API の具体的な仕様が十分に示されておらず、資料の PDF を突っ込んだ NotebookLM とソースコードを往復した方が余程わかりやすいという、なかなか面倒な状況でした。接点を持つ他のメンバーに同じ苦労をさせたくはありませんし、読みづらい公式ドキュメントの翻訳係として私の時間が消費されるのも御免被りたいところです。

こうした事情から、もっと生産的な足回り、つまり「適度に抽象化された、よりマシな API ラッパー」が欲しくなりました。生 JSON を dict で取り回すような最低限のインタフェースでも十分に助かりはします。ただ、それだと前述した課題が十分に解消されません。具体的な API の使い方が把握しづらくなっているからです。

抽象度を上げた仕組みであってほしい理由がもう1つあります。それは昨今の AI コーディング事情によるものです。このあたりの事情は読者の皆さんもお察しいただけるところかと思います。

ユーザー定義した CMDBuild 上のデータ構造が詳細な型として見えていることに意義があります。生の JSON を dict で引き回す最低限の抽象化では、目の前のオブジェクトが実際にどんなプロパティを持ち、どのような名前を与えられたデータ構造なのか推測しにくくなります。AI にはそのへんの推測にコンテキストウィンドウを消費してほしくありません。一方で、CMDBuild はその性質上、コード側でデータ構造の定義を事前に決め打つことができません。ここにギャップがあり、この課題は技術的な解決が可能です。

最後に言語選定の理由ですが、Python にしたのは社内の既存コードベースの状況を鑑みた結果です。社内システムのコード資産はそのほとんどが Python/TypeScript であり、かつ CMDBuild の周辺システムとして構築された外部連携は AWS CDK の実装言語としての採用を除けばほぼ100% Python でしたので、そこに選択の余地はないと判断しました。正直なところ、なんの縛りもないのなら言語は TypeScript にしていたと思います。詳しくは後述しますが、jsforce というライブラリのやり方がほぼそのまま輸入できます。今回作った実装にはコード自動生成の用事があるので、言語標準のツールチェーンとして go generate を有する Go も良さそうですね。

設計思想

まずは考え方のところから。ざっくりした思想は以下の2点です。

  • ユーザー環境に特有な型定義を、コード自動生成の仕組みによってカバーする
    • 実環境からスキーマ定義を抽出し、その定義情報から Python モジュールを自動生成する仕組みを提供
    • 自動生成したコードは Python のクラスで表現され、その Class 自身が持つ Attiributes は型ヒント付きのプロパティとして定義される
  • 自動生成なしでも、低レベルクライアントを使うことで API は利用可能
    • 低レベルクライアントは基本的に dict や動的アクセスなどを駆使し、動的型付けの利点を活かす方針とする。これによって、最小限のコードで任意のデータ構造に対応できるようになる

実装に近い字面も見たほうがわかりやすいと思いますので、開発中に作成していた実装計画のドキュメントから抜粋して紹介します。以下。

Foo というクラスが CMDBuild 上で定義されているとき、クライアントオブジェクトを使って以下のようなアクセスが可能。 参照系の戻り値は生の JSON データを反映した dict であり、詳細な型ヒントはない。

# Foo の部分は、CMDBuild 実環境で実在するクラス名であれば任意のものを指定可能
c = client.resources.Foo.get(card_id)
print(c['Id'], c['Description'])
 
cards = client.resources.Foo.list()
for card in cards:
    print(card['Id'], card['Description'])

自動生成を利用することで、型付きでアクセスする方法も可能となる。これによって、どのような CMDBuild 環境においても、定義されたクラスに対応した型情報が IDE によって補完されるようになる。

from cmdbuild.resources.Foo import Foo, FooClient  # 自動生成によって追加されたモジュール。デフォルトでは存在しない。
 
foo_client = FooClient(client)
 
# 以下を実行して、戻り値として得られるのは Foo というリソース型。これは自動生成によって追加された型であり、デフォルトでは存在しない。
# この型は、Foo という CMDBuild 上のクラスで定義されている属性 (Attributes) を、Python のプロパティとして型情報付きでアクセスが可能。
c: Foo = foo_client.get(card_id)
print(c.Id, c.Description)
 
cards: list[Foo] = foo_client.list()
for c in cards:
    print(c.Id, c.Description)

Card 構造そのものを定義する Class や、Relations の構造を定義する Domains など、メタデータ系を操作する機能郡は優先順位を落とす。 当面は、これらについてのリッチなインタフェースや型情報は提供しない。

使用例

# 自動生成を使わない場合の使用例
from cmdbuild import CMDBuildSession, CMDBuildRestClient
from cmdbuild.resources import CardClient
 
session = CMDBuildSession(
    base_url="https://your-cmdbuild-instance.com",
    # path_prefix="/services/rest/v3"  # CMDBuild における REST API のデフォルトのパスプレフィックス
    username="your_username",
    password="your_password"
)
 
card_client: CardClient = CardClient(session)
 
# CMDBuild 上の Foo クラスのカードを扱う例
c = card_client.Foo.get(card_id)
print(c['Id'], c['Description'])
 
cards = card_client.Foo.list()
for card in cards:
    print(card['Id'], card['Code'],card['Description'])
 
    # card に従属する主要リソースである「リレーション」を参照する。リレーションのコード上の型は dict であり、詳細な型情報はない。
    for rel in card.relations():
        print(rel['Id'], rel['Description'])
# 自動生成を使う場合の使用例
from cmdbuild import CMDBuildSession
from cmdbuild.resources.cards import FooClient, Foo
 
session = CMDBuildSession(
    base_url="https://your-cmdbuild-instance.com",
    # path_prefix="/services/rest/v3"  # CMDBuild における REST API のデフォルトのパスプレフィックス
    username="your_username",
    password="your_password"
)
 
foo_client = FooClient(session)
 
c: Foo = foo_client.get(card_id)
print(c.Id, c.Code, c.Description)
 
cards: list[Foo] = foo_client.list()
for c in cards:
    print(c.Id, c.Code, c.Description)
 
    # c に従属する主要リソースである「リレーション」を参照する。
    # 自動生成版においても、リレーションのコード上の型は dict とするが、ここは将来拡張して型や操作の追加を行う可能性あり。
    for rel in c.relations():
        print(rel['Id'], rel['Description'])

コンセプト実装の成果

上記の設計を実際にコンセプト実装として作って試してみた結果として、自動生成でどのくらい楽できたかの数字も提示しておきます。CMDBuild の環境は、共用の検証環境のダンプをベースにしています。

key value
ユーザー定義の Class 総数 70個
自動生成ツールが生成したユーザー定義 class の Python コード 10,950
自分で書いたコード SDK コア: 264
自動生成ツール: 741

プログラムを行数で比較するのはナンセンスな気もいたしますが、この際のわかりやすい指標としてご勘弁いただけたら。構文的に意味のない整形のための改行も多数挟まっているので、実質量はもっと少ないと思います。

自動生成という仕組みを実装することで、約10倍量に近い仕事を省略できていることがわかります。

また、自動生成の仕組みがあることで、CDMBuild 側でデータ構造を変更した際のプログラム側の追随が容易になっています。自動生成の過程で抽出したスキーマ情報は手元に保存しておけるので、スナップショットテストのような仕組みも実現可能になります。スキーマ情報もコミットしておけば、CI 等で CMDBuild 側のスキーマ変更(の一部)を自動検出できる、ということです。このあたりはすべてを自前で実装するやり方では得られない利点だと思います。

設計検討と実装の過程

このセクションでは、だいたい以下のような話をします。

  • コンセプトの提示と、できるだけその判断理由
  • 参考にした既存の製品や概念
  • 設計上の意思決定のポイント

前提のコンセプトを立てる

大前提のコンセプトとして以下がありました。途中で迷ったりもしましたが、基本線はずっとこれを軸にしています。

  • ユーザーが拡張したスキーマを反映し、型定義付きで扱えること
  • 型定義付きではない Raw JSON ベースの操作で、任意の Class タイプの Card を扱えること

SDK 的には「ユーザー定義のスキーマを反映した型情報も提供できるが、それを使わない選択肢もある」というスタンスになります。ユーザー定義な CMDBuild クラスに対応する型定義はオプトインの存在で、なくても API ラッパーとしての基本機能が利用可能です。この方が汎用の SDK としての体験は良いと判断しました。また、この考え方を下敷きにすれば内部的な責務分界もすんなり切り分けできると思いましたので、実装上の設計思想としても自然と考えました。

このコンセプトを確立するために参考にしたのが ORM と Salesforce + jsforce です。そのあたりの思考過程について解説します。

ORM

冒頭で CMDBuild を RDB に例えた通りで、製品がやっていること自体には類似性を見い出せます。ClassDomains の構造が RDB に非常に似ていますし、ユーザーが自由に構造を定義可能であるという性質も同じです。今回の開発の検討にあたっては以下のように整理しました。

参考にした部分は「ユーザー定義した構造が Python のモジュールとして、クラスによって表現される」という点です。非常によくある形なので、私の仕組みが完成したときのコード上の字面をイメージするのに役立ちました。

一方で参考にしなかった部分もあります。それは ORM がどちらかと言えばコード側をオリジンとして扱う仕組みであることに起因します。ORM は migrate コマンドによってコード側からデータストアへの構造適用をサポートしますが、今回そのような思想は不要と判断しました。

基本的に CMDBuild 上の構造変更は、CMDBuild のアプリケーションレイヤー越しに行うケースが圧倒的に多いと想定していました。典型的なのは管理画面での変更です。このため、オリジンは環境側にあり、コード側はそれを参照しに行くだけと位置づけるのが自然であろうと考えました。双方向で同期させるような考えもなくはない気がしますが、それはコンセプト実装の段階では過剰なので考慮対象としませんでした。

Salesforce + jsforce

jsforce は当初から私がイメージする完成形にかなり近かったです。

このライブラリは基本機能として低レベルの API インタフェースを提供しつつも、ユーザーが望めば Salesforce 上でユーザー定義したオブジェクトの型定義を後付けできるようになっています。

付属の jsforce-gen-schema というツールを使うことで、Salesforce の実環境から実際のデータ構造を抽出し、それを *.d.ts に出力します。ライブラリの利用者は生成された型定義ファイルをインポートすることで、既存のコードに型定義が追加されます。そのあたりの使い方は以下の記事をご参考ください。

https://jsforce.github.io/blog/posts/20191216-jsforce20-alpha-preview-with-schema-type-feature.html

一部のコードを抜粋します。

const conn = new Connection<StandardSchema>();
// ...
 
const accs = await conn.sobject('Account').find({
  Type: ['Partner', 'Customer'],
  BillingCountry: 'JP'
});
for (const acc of accs) {
  console.log(acc.Id); // acc.Id: string
  console.log(acc.Name); // acc.Name: string
  console.log(acc.NumberOfEmployees); // acc.NumberOfEmployees: number | null
  console.log(acc.FieldNotDefined); // acc.FieldNotDefined: any
}

個人的に、このデザインは TypeScript の良いところが出ているなと感じました。ポイントは conn.sobject('Account') です。

(sobject は Salesforce におけるクラスやオブジェクト、あるいはテーブルやレコードに近い概念と思ってください)

TypeScript であれば、パラメータに与えられた文字列の値に応じた型判定の分岐が可能です。conn.sobject('Account') と書けば戻り値の型は Account 型を反映したものになりますし、別の値を与えれば戻り値もそれに応じた型に分岐します。

私が知る限り、Python でこのような器用な型判定は不可能です。仮にできたとしても相応に凝ったことをしなくてはならず、それはダックタイピングの思想を汲む Python でやるべきことか?と考えると、私には筋が悪いように思えました。動的型付けの柔軟性の利点を捨てない程度に適度にサボっていくのが Python における型との付き合い方であろうと私は考えており、それは TypeScript における型の位置づけとは全く異なるものだとも考えます。*1

こうした考えから、実装上の字面としての jsforce の体験は、そのまま Python に移植できるものではないと判断しました。Python なりのやり方を、実装者自身で見つける必要がある、ということですね。

一方で、参考になったのは次の2点です。

  1. 実環境からのスキーマ抽出と、そのスキーマを反映した型定義の生成をライブラリ本体から分離している
  2. 型定義の生成と適用をオプションとして扱い、API ラッパーとしてのコア部分は単独でも動かせる

これらは前述した「前提のコンセプト」そのものです。大枠の思想を取り込めたことが重要でした。これはコードの字面云々以前の、より大局的な意思決定に繋がるトピックです。抽象度の高い「設計」議論は非常に比重の大きい大切な要素なので、その意味で有益な示唆が得られたと思いました。

実装よりの設計方針

このへんは完成した実装の字面をイメージしながら詰めていきました。ここでも、類似する既存の実装を参考にしました。具体的には Python 版 AWS SDK である boto3 です。以下の観点に注目しました。

  • どちらも API をラップする仕組みであり、目的が同じ
  • 低レイヤーの機能(HTTP 呼び出しやセッション管理)と、それを利用した抽象度の高いサービスタイプごとに機能とが分離されている
  • SDK のリリースとは無関係に扱えるリリースタイプ(boto3 では AWS サービス)、あるいはリソース操作の定義が後付けで追加される
    • SDK としてはそれをサポートする必要がある
    • 追加された新しいリソースや操作は、それを定義するスキーマ相当の情報を介して SDK が取り込む仕組みになっている

参考にしたのはかなり実装寄りの設計要素です。具体的にはセッションや HTTP など低レベルのインタフェースを分離するやり方と、AWS サービスの種類ごとにクライアントを定義するアプローチです。CMDBuild 上のユーザー定義の Class が boto3 における AWS サービスに対応している感じですね。

目指したのはリソースの種別ごとに「クライアント」とリソース自身の「データオブジェクト」の定義が存在する、という作りになります。これが最も自然であり、また自動生成とコア機能の境界が引きやすいと考えました。

結論としては却下したのですが、別案もありました。それは、「リソースオブジェクト」自身がクライアントとしての機能を内包するような設計パターンです。たとえば ORM のモデルクラスは save や find などの操作がデータモデルを表現するクラスに同居していますし、Zendesk API の Python ラッパーである Zenpy が提供する高位のインタフェースはこのアプローチを採用しています。

foo_123: Foo = Foo.find(id=123)  # Foo クラスの id=123 のデータを GET する
 
foo_123.description = 'foo bar baz'
 
foo_123.save()  # UPDATE リクエスト

この案を却下した理由は「メタデータ系の操作」と「データの操作」の境界がわかりづらくなり、設計が複雑になると懸念したためです。

メタデータ系とデータ操作の境界をなくしてしまうと何が起こるかというと、これは ORM で言うなら ALTER TABLE のような DDL の系統の操作をモデルクラス自身に持たせる設計であると言えます。そうなると、Foo というリソースクラスに対して定義可能な操作は DDL 系と DML 系の2系統ということになってしまいます。典型的なユースケースは DML に偏っているのですから、「典型的なユーザー」が目にするインタフェースに DML 系統の機能は必要ありません。

ORM のデザインも同じように分界されています。モデルクラスを扱う開発者が CREATE TABLE/ ALTER TABLE のようなメタデータ側に介入するプロセスの詳細を直接意識することは少ないはずです。ORM ユーザーの視点では、その役割は migrate 機能を提供する CLI が担います。メタデータ側を操作する方法の詳細は ORM モデルクラスの外面から隠蔽されていて、「メタデータの世界」と「データの世界」が分離された設計になっている、と言えます。

テーブル構造あるいは Class 構造 に介入する操作はいわばメタデータ側の世界の話であって、そのような用事は(少なくとも)当社の CMDBuild の運用においては限られた管理者にしか発生しません。そして、根本の目的意識は CMDBuild を取り巻く「外部連携システム」の生産性を向上する仕組みの構築であることも鑑みると、メタデータ側を操作する用事に対する開発体験・生産性は重視する必要がないと整理されます。コンセプトの一部で「自動生成なしでも、低レベルクライアントを使うことで API は利用可能」という軸を述べていました。この低レベルクライアントがあれば最低限の仕事は可能ですから、「メタデータ側」の設計としてはこれで十分と判断しました。

これで、自動生成される型付きプログラムの世界で考えるべきことが絞れてきました。自動生成に用いるコード片は IDE 上で正しいシンタックスが効きづらく誤りを検出しにくい事情もあり、自動生成される側のコードに持たせる責務は可能な限り小さくしたい思惑もありました。そして、上記の方針はこの事情とも噛み合っています。

低レベルクライアントの実装

Session 管理や HTTP の機能をコアに据えることは確定していたので、HTTP レイヤーの話はほぼ片付いています。あとは、そこに少しだけ便利な薄いラッパー層を付け足して低レベルクライアントを作っていきます。実装の形をイメージにあたって参考にしたのは前節でも登場した boto3 と Zenpy です。

boto3 から参考にしたのは session を分離する設計とリソースタイプ別のクライアントを構築する設計指針です。Zenpy から参考にしたのは動的型付けの利点を活かした動的なアクセサの実装です。ここでは後者の、動的なアクセサの話をします。

先に具体的手段の話からしてしまいますと、__getattr__ という特殊メソッドをオーバーライドしたクラスを作ります。これを使って、定義時点では存在しなかったはずのプロパティにもアクセスが可能にします。

CMDBuild API において、Card の操作を行う場合のパス体系は概ね次のようになっています。

  • /classes/${className}/cards

  • /classes/${className}/cards/${cardId}

Class は自由にユーザー定義可能ですので、className の値も同様にコード側で事前に決め打つことができません。__getattr__ メソッドは通常のプロパティアクセスが AttributeError の場合(通常、存在しないプロパティへのアクセスが実行された場合)に呼び出されるメソッドですから、このような CMDBuild API 側の事情を反映するのには適しています。低レベルクライアントの実装と割り切っているので、リソースタイプごとの具象型を考慮する必要もなく、dict (json) を取り回す形で問題ありません。

文章だけではイメージしづらいと思うのでスニペットも掲載しておきます。

class CardAccessor:
    """特定のクラスのカード操作を提供"""
    
    def __init__(self, rest_client: "CMDBuildRestClient", class_name: str):
        self.rest_client = rest_client
        self.class_name = class_name
    
    def get(self, card_id: int) -> Dict[str, Any]:
        """カードを取得"""
        path = f"classes/{self.class_name}/cards/{card_id}"
        response = self.rest_client.get(path)
        return response.get('data', {})
      
    # ... create/delete などの他の操作の定義 ...
 
 
class CardClient:
    """カード操作のクライアント(具象型のない低レベルクライアント)"""
    
    def __init__(self, session: CMDBuildSession):
        self.session = session
        self.rest_client = CMDBuildRestClient(session)
    
    def __getattr__(self, class_name: str) -> CardAccessor:
        """動的にカードアクセサを生成
        
        例: card_client.Foo.get(card_id)
        """
        if class_name.startswith('_'):
            raise AttributeError(f"'{type(self).__name__}' object has no attribute '{class_name}'")
        return CardAccessor(self.rest_client, class_name)
 
 
# ==========
# 使用例
# ==========
sess = CMDBuildSession(**param)
card_client = CardClient(sess)
 
# CardClient に Foo というプロパティは定義されていないが、__getattr__ を実装しているため実行時アクセスが可能。
# 実際にプロパティ名に対応したパスを構築して、正しいパスで API アクセスする責務は CardAccesor に委譲。
card_client.Foo.get(card_id)

このあたりは Python っぽさが出た実装アプローチだと思います。型定義を活用した jsforce のやり方とはまた違った味がありますね。

自動生成ツールの設計検討

自動生成ツールの構成は、スキーマ抽出部分とスキーマ情報からの自動生成の2つの機能郡で分離することにしました。理由は単純に責務分界としての収まりの良さです。加えて、抽出したスキーマ情報を手元に残せる機能が独立していた方が好都合だからです。後者の理由は前半の「成果物」のところでも述べたように、コード側で CMDBuild 側の構造変化に追随しやすくなる利点を意識したものです。

最初にやったことは、自動生成した結果としてどのようなコードが出来上がるか、そのサンプル出力となる Python モジュールを先に書き下すことです。書き下したコードを実際の API 呼び出しで使ってみて、正しく動かせることを確認し、使用感がフィットするような微調整を行います。

出力のコードサンプルが形になったら、そこから逆算し、自動生成コードのうちどこがテンプレートで、どこが Class ごとの変動要素なのかを切り分けます。切り分けができれば、あとは AI に自動生成機能をよしなに書いてもらいます。対応する Class を CMDBuild 側に作ってあげて、スキーマ情報 (JSON) を抽出して、出力例のサンプルコードと一緒に参照指示してあげればサクッと精度の高いものが書けます。

自動生成ツールの開発にあたって、テスト手法を少々工夫しました。ソフトウェアの設計という観点からすると少し本旨から外れる話になりますが、次のサブセクションで説明します。

スキーマ情報からコード生成を行う機能の開発

いきなり機能の実装をせず、まずエンドツーエンドのテストを先に構築しました。これは自動生成のメタプログラミング的な側面を意識したものです。テンプレートエンジン的なやりかたで成果物を組み立てていくことになるわけですが、これは不変要素と可変要素(Class によって変わる部分。Class 名や Attibutes 定義が該当)が混在するため、合成前のテキストは Python の構文として不成立な状態です。

最終成果物は正しい Python 構文であることが求められますが、テンプレートを書いている段階では IDE が正しく構文認識できない状態なので、コードを書いている途中は生成される側のコードの構文エラーに気づけません。なので、先に入出力のサンプルを用意してあげて、それを使った E2E テストを先に書いてあげることにしました。 これで以降の立ち回りがラクになります。

何をもってテストとするかの基準ですが、以下のように考えていました。

  • 可変部分が期待通り出ているだけではなく、成果物のファイル全体として正しい Python 構文になっていることを証明したい
  • 入出力が「文字列として」完全に期待値と一致するところまで見るのは まだ 過剰。テンプレートに空行1つ追加した程度の差なら、今は手を止めたくない

こうした事情から、エンドツーエンドのテストの検証方法は、一度 AST(抽象構文木)の解釈を通し、それをダンプした文字列を比べるアプローチを採用しました。以下のような要領です。

import ast
 
 
def assert_ast_equal(code1: str, code2: str, label1: str = "actual", label2: str = "expected"):
    """AST レベルで等価性を検証"""
    try:
        tree1 = ast.parse(code1)
        tree2 = ast.parse(code2)
    except SyntaxError as e:
        raise AssertionError(f"Syntax error in generated code: {e}")
    
    dump1 = ast.dump(tree1, indent=2)
    dump2 = ast.dump(tree2, indent=2)
    
    if dump1 != dump2:
        # デバッグ用に差分を表示
        diff = difflib.unified_diff(
            dump2.splitlines(),
            dump1.splitlines(),
            fromfile=label2,
            tofile=label1,
            lineterm='',
        )
        print('\n'.join(diff))
        raise AssertionError(f"AST structures differ between {label1} and {label2}")

このアサーションをテストに組み込むことで、成果物の SyntaxError がテストで検出可能になります。これは私が期待する最低限の要求を満たしています。

1点注意があるとすれば、AST におけるコメントや docstring、型ヒントなどプログラムの振る舞いに直接影響を与えない構文要素の扱いです。今回の私の要求にとってそれなりに大事なことなので少しだけ補足しておきますと、AST 上で docstring と型ヒントは認識されます。コメントは認識されません。今回は前者が重要です。

この2つが AST 上で認識されていることは、E2E のテストを組む上でも重要です。型ヒントの存在は今回やろうとしているコアコンセプトなので、E2E テストの要求事項そのものと言えます。docstring は必須度合いこそ少々落ちますが、ClassAttributes の定義に付随する Description をコード側で docstring として反映できていると好都合です。E2E テストで検証したい要件としては、自動生成されたコードが正しく型ヒントや docstring を記述できていることが非常に大事でした。

スキーマ抽出ツールの開発

ここについては特筆すべきことがありません。次の3種類の情報を低レベルクライアント経由で参照し手元に吐き出す実装を行い、CLI としての体裁を整えたくらいです。

  1. Class のリスト
  2. Class ごとの定義情報
  3. Class ごとの、Attributes の定義情報

CLI のデザインとしていくつか考慮したこともあるっちゃあるのですが、そのへんは全体の設計タスクからするとかなり枝葉寄りのトピックですので割愛します。

まとめ

ここまでのひと通りで、CMDBuild の稼働環境から Class 定義を抽出して、それに対応した型を定義した Python モジュールの自動生成が行えるようになりました。実際に使ってみると次のような流れになります。

$ tree -d
.
├── cmdbuild  # SDK コアの実装部分。`cmdbuild.resources.cards` の配下に自動生成した定義を吐き出す想定
│   ├── metadata
│   └── resources
│       └── cards
└── cmdbuild_gen  # 自動生成のための機能を提供する CLI 実装
    └── test_data  # E2Eテスト用のアセット
        ├── class_attributes
        ├── class_definitions
        └── expected
 
# 実環境からスキーマの抽出を行う
$ python -m cmdbuild_gen fetch --help                                                                                      
usage: cmdbuild-gen fetch [-h] --base-url BASE_URL [--path-prefix PATH_PREFIX] --username USERNAME --password PASSWORD [--output OUTPUT] [--classes CLASSES] [--overwrite]
 
options:
  -h, --help            show this help message and exit
  --base-url BASE_URL   CMDBuild base URL (e.g., http://localhost:8080)
  --path-prefix PATH_PREFIX
                        API path prefix (default: /services/rest/v3)
  --username USERNAME   CMDBuild username
  --password PASSWORD   CMDBuild password
  --output OUTPUT       Output directory (default: ./schema)
  --classes CLASSES     Specific classes to fetch (comma-separated)
  --overwrite           Overwrite existing directory (default: False)
 
$ python -m cmdbuild_gen fetch --base-url http://localhost:8888 --username $CMDB_USER --password $CMDB_PASSWORD
Fetching classes list...
Saved: schema/classes.json
Fetching Foo...
  Saved: schema/class_definitions/Foo.json
  Saved: schema/class_attributes/Foo.json
Fetching Bar...
...(中略)
Completed! Schema saved to: schema
 
# 抽出したスキーマ情報から自動生成
$ python -m cmdbuild_gen generate --help
usage: cmdbuild-gen generate [-h] [--schema-dir SCHEMA_DIR] [--output OUTPUT] [--classes CLASSES]
 
options:
  -h, --help            show this help message and exit
  --schema-dir SCHEMA_DIR
                        Schema directory (default: ./schema)
  --output OUTPUT       Output directory (default: ./cmdbuild/resources/cards)
  --classes CLASSES     Specific classes to generate (comma-separated)
 
$ python -m cmdbuild_gen generate --schema-dir schema --output cmdbuild/resources/cards/ 
Generating Foo...
  Saved: cmdbuild/resources/cards/Foo.py
Generating Bar...
...(中略)
Completed! Generated 70 classes to: cmdbuild/resources/cards
 
# 実行結果
$ find cmdbuild/resources/cards -type f -name "*.py"
cmdbuild/resources/cards/Foo.py
cmdbuild/resources/cards/Bar.py

今回生成したコードは cmdbuild/resources/cards に出力していますが、出力先はどこでも構いません。

SDK のコア機能のソースがプロジェクトディレクトリに存在している必要はありません。自動生成されたモジュールは cmdbuild パッケージに依存していますが、これは pip install できるように pyproject.toml を整備すれば良いことです(まだやってないですが)。配布先が社内限定でいいのなら PyPI に公開する必要すらありません。そのまま社内の GitHub レポジトリにあげておくだけでもOKです(pip install は git スキーマが指定可能です)。

この後の展望

まずは手元の docker compose 環境を使って、今必要な検証タスクの実装にこの仕組みを使ってみようと思います。

雑に作ってみて、すでに軽く動かしてはみましたが、おそらく色々と粗が出てくるはずです。そのへんは実際に必要になった検証タスクで使ってみて、試練を受けさせるしかないかなと思います。あくまで私にとっての本命は CMDBuild をきちんと運用して、事業や業務の要請にスピーディに応えることであり、今回の仕組みの完成度を上げることではないです。

また、直近の検証の用事が終わったら、手が空いたタイミングでコードを整備したいところです。とりあえずパッケージマネージャー経由で自然にインストールできるよう pyproject.toml くらいは整理したいところですね。コアである SDK 部分をデフォルトインストールとして、cmdbuild_gen の方はオプショナルで入れられるような形にしたいと思っています。

そのあたりも整備できたら、次は機能拡充です。現状だとまだ参照系 API の用事でしか使っていないので、データ更新の用事ではロクに動作検証もできていません。きちんと動かせるようにしていきたいです。また、Card に従属する API というものも存在していて、そちらは現状最小限の対応しかしていません。たとえば Relations が該当しますが、これは実務的にも扱う頻度が非常に高い概念です。いい感じにしていきたいですね。

さいごに

日々のタスクを消化しているだけだと行き詰まってしまうし、何より私自身も消耗してしまうので、なにかしら中長期のレバレッジが効きそうな活動もねじ込んでいきたいな、と考えていたところでした。気分転換も兼ねた活動ではありましたが、まあ、約1週間という期間の割にはそれなりに有益なコンセプトが示せたのではないかなと思います。実際に使ってみて、使用感に納得できる作りにできたことも良かったと思います。

実装タスクそのものはほぼ AI (Kiro CLI) にお任せでした。

Kiro: Agentic AI development from prototype to production

大いに助かりました。ただ、体感値としてはコードを書く部分のウェイトはかなり小さいです。実装詳細に入る以前の大枠のデザインコンセプトを構築するところの比重がほとんどを占めている感触です。実際、AI に投げたチャットログの半分以上は大枠の設計についての議論をするためのものですし(体感)、私の脳のリソース配分もコーディング内容の輪郭が確定しきらない段階の検討がほとんどでした。私は入出力のサンプルを提示したり、行くべき方向性やその候補を実例とともに示して議論を提起したりなど、全体のディレクションっぽいお仕事がほぼ100%です。

多分雑に AI に丸投げしていたら今のような設計は実現していない気がします。まあ、人間と AI との分業という意味では、今回のような感じが塩梅なのかなと思わなくもないです。人間側の仕事として、方向性の道筋をきちんと示してあげる、というところは十分にやれたかなと思います。

*1:Python で上述したような器用な型判定が難しいことは事実だと思いますが、だからといって安易に「やっぱ Python は型が緩くてダメだよな〜」などと解釈しないようにお願いします。昨今では静的型付けが優位かのような風潮もありますが、思想が違えば特徴の凹凸も異なる出方になるし、その凹凸のどこをどう評価するかは人や組織・状況次第でゆらぎが出る場合があるよね、というだけの話だと思ってます

橋本 拓弥(記事一覧)

マネージドサービス部

内製開発中心にやってます。普段はサーバーレス関連や CDK を触ることが多いです