コネヒト開発者ブログ

コネヒト開発者ブログ

In App Purchaseの VerifyReceipt APIからApp Store Server APIに移行しました

はじめに

こんにちは! otukutun.bsky.social です。 今回はIn-App PurchaseのサーバーサイドAPIを、VerifyReceipt API から App Store Server API に移行した経験を共有できればと思います。

WWDC2023 で、VerifyReceipt APIのdeprecated化が発表され、 WWDC2024 では従来のStoreKit1がdeprecated化と宣言され、これまでのAPI群は「original API for In-App Purchase」に改名されました。まだ廃止日時などは明言されていないと思いますが、今後はApp Store Server APIやStoreKit 2に移行しなけばいけません。

弊社が提供している ママリプレミアム のサブスクリプションはiOSではIn-App Purchaseを使用し提供しています。今回はVerifyReceipt APIからApp Store Server APIに移行した際の手順や実装について解説していきます。主にPHPでの実装方法に焦点を当てています。

なお、この記事はコネヒトアドベントカレンダー 16日目の記事になります。

App Store Server APIの概要

App Store Server APIは、Appleが提供する新しい課金情報管理APIです。用途ごとに様々なAPIが提供され、購読情報だけでなく返金情報も取得できるようになりました。今回は GET /inApps/v1/subscriptions/{transactionId} を使用し、トランザクションIDをもとに最新の課金情報を取得することにしました。

PHPでの実装

JWSの取り扱い

App Store Server APIでは、レスポンスにJWS(JSON Web Signature)が返却されそれを検証することで購入情報の正当性を確認できるようになりました。AppleはPHP向けの公式ライブラリを提供しておらず、PHPでのJWT検証ライブラリとしてメジャーな firebase/php-jwt はx5c形式をサポートしていないため、PHP標準のOpenSSL 関数と組み合わせることで対応しました。

  1. firebase/php-jwt ライブラリを利用
    • JWTのデコードと検証を行います
  2. PHP標準の openssl ラッパー関数を活用
    • 証明書の有効性や署名の検証を行います

証明書の有効性の検証

Appleから返却されるJWSには、証明書情報(x5cフィールド)が含まれています。これを使用して、以下の手順で証明書の有効性を確認します。

  1. リーフ証明書、中間証明書、ルート証明書を検証
  2. 各証明書の情報(有効期限や発行者など)を検証
  3. JWSの検証

以下は具体的なコード例です。

1. リーフ証明書、中間証明書、ルート証明書を検証

<?php

use Firebase\JWT\JWT;
use Firebase\JWT\Key;

function toPem($certificate): string {
    return join("\\n", [
        "-----BEGIN CERTIFICATE-----",
        trim(chunk_split($certificate, 64)),
        "-----END CERTIFICATE-----",
    ]);
}

// サンプルJWS(ChatGPTに生成してもらいました
$jws = 'eyJhbGciOiJFUzI1NiIsImtpZCI6IjEiLCJ0eXAiOiJKV1QiLCJ4NXQiOiJleGFtcGxleDVjIn0.eyJ0cmFuc2FjdGlvbl9pZCI6IjEwMDAwMDAxMjM0NTY3ODkiLCJvcmlnaW5hbF90cmFuc2FjdGlvbl9pZCI6IjEwMDAwMDAxMjM0NTY3ODkiLCJ3ZWJfb3JkZXJfbGluZV9pdGVtX2lkIjoiMTAwMDAwMDEyMzQ1Njc4OSIsInByb2R1Y3RfaWQiOiJjb20uZXhhbXBsZS5wcm9kdWN0Iiwic3Vic2NyaXB0aW9uX2dyb3VwX2lkZW50aWZpZXIiOiIxMjM0NTY3OCIsInB1cmNoYXNlX2RhdGUiOiIyMDI0LTExLTAxVDEyOjM0OjU2WiIsImV4cGlyZXNfZGF0ZSI6IjIwMjUtMTEtMDFUMTI6MzQ6NTZaIiwiaXNfaW5fYmlsbGluZ19yZXRyeV9wZXJpb2QiOmZhbHNlLCJlblZpcm9ubWVudCI6IlByb2R1Y3Rpb24ifQ.SIGxEcD9EXAMPLESIGNATURE';

// JWSのデコード
list($header64, $body64, $cryptob64) = explode('.', $jws);

// ヘッダー情報の取得
$headerText = JWT::urlsafeB64Decode($header64);
$header = JWT::jsonDecode($headerText);

// 証明書を取得してPEM形式に変換
$leafCertificate = toPem($header->x5c[0]);
$intermediateCertificate = toPem($header->x5c[1]);
$rootCertificate = toPem($header->x5c[2]);
// URLは適切なものを設定してください
$rootCertificateFromApple = toPem(JWT::urlsafeB64Encode(file_get_contents('APPLE_ROOT_G3_CERTIFICATE_URL')));

// リーフ証明書の検証
$result = openssl_x509_verify($leafCertificate, $intermediateCertificate);
if ($result !== 1) {
    throw new Exception('リーフ証明書の検証に失敗しました');
}

// 中間証明書の検証
$result = openssl_x509_verify($intermediateCertificate, $rootCertificate);
if ($result !== 1) {
    throw new Exception('中間証明書の検証に失敗しました');
}

// ルート証明書の検証
$result = openssl_x509_verify($rootCertificate, $rootCertificateFromApple);
if ($result !== 1) {
    throw new Exception('ルート証明書の検証に失敗しました');
}

リーフ証明書、中間証明書はJWSのheaderに付与されているのでそれらを使って検証します。PEM形式に変換し、openssl_x509_verifyを使えば検証できます。1なら有効、0なら無効なものになります。

ルート証明書の検証はルート証明書自体で行います。検証用の証明書はアップルのサイトからダウンロードできます。CER形式なので、PEM形式に変換する必要があります。

2. 各証明書の情報(有効期限や発行者など)を検証

<?php

// 各証明書情報の検証
$leafInfo = openssl_x509_parse($leafCertificate);
$intermediateInfo = openssl_x509_parse($intermediateCertificate);
$rootInfo = openssl_x509_parse($rootCertificate);

// 検証する情報は項目が多数あるのでここでは省略します

openssl_x509_parseを使って証明書の情報が取得できるので、各証明書の有効期限やOID、issuer情報などを検証すると良いと思います。有効期限はJWSに含まれているsignedDateを使って検証できます。

3. JWSの検証

<?php

$publicKey = openssl_pkey_get_public($leafCertificate);
$result = JWT::decode($jws, new Key($publicKey, $header->alg));
if (!$result) {
    throw new Exception('JWSの署名検証に失敗しました');
}

最後に、JWS自体の署名を検証します。これで検証が無事完了されれば、Transaction情報 を使って課金ステータスなどを更新すれば良いと思います。

おわりに

App Store Server APIは、従来のVerifyReceipt APIに比べてモダンな仕様であり、より強力な課金管理機能を提供されています。実装としてはライブラリが提供されていない言語では各自で対応しなければいけないことはいくつかありますが、PHPでも firebase/php-jwt とPHP標準のOpenSSL関数を使えば、対応は難しくないかなと感じました。またこの実装に加えて、OCSPやCRLなどのオンラインでの証明書期限切れの仕組みを導入することでより強固な検証プロセスを構築できると思います。

実際にご自身で実装する際にはApple提供のライブラリの実装やWWDCの動画を実際に見られることをお勧めします。ReveneuCat提供の記事はStoreKit 2について最初に理解するのにとても役立ちました。こちらが主に参考にした動画やページになります。

今回の記事が、同じ課題に直面している開発者の参考になれば幸いです!

やらないとどういった問題が起こるかという観点で SPF/DKIM/DMARC を理解する

本エントリは「コネヒト Advent Calendar 2024」14日目の記事です!

adventar.org

こんにちは。本年アドカレ2回目の登場となる @sasashuuu です。

先日 村営山中湖キャンプ場 に行ってまいりました。さすがにもう冬ということもありかなり寒さが厳しくなっておりましたが、雰囲気が良く富士山や山中湖の近さも相まって道中にも素敵な景色を楽しめる最高なキャンプ場でした...!

さて、本日はメール技術である SPF/DKIM/DMARC についての記事となります。

背景

Email sender guidelines に記載されているように Gmail のスパム規制が本格化している昨今ですね。

最近弊社内でも従業員から「xxx を使ったメールが届かなくなった」、「xxx を最後にメールが届いていない」という問い合わせが発生しておりました。

取り急ぎ現状復旧はできているものの、追われるがままの対応となっており、「そのそものメールの認証技術についてちゃんと理解できているのか?」とふと思い、このブログの執筆に至りました。

より理解を促すために、やらない場合どのような問題が発生するのかという観点で内容をまとめました。この情報が誰かのお役に立てば幸いです。

はじめに

SPF/DKIM/DMARC はメールの差出人から正規のメールサーバーを経由して受信者に届いたかを認証する技術です。これがないと不正なメールが送信者・受信者に届くリスクが高まります。それぞれの認証技術は単体で利用するのではなく組み合わせて利用することに意味があります。

各認証技術については追って説明していきます。

前提となる用語の基礎知識

SPF/DKIM/DMARC を理解するにあたり、整理しておいた方が良さそうなメール技術における用語を一部ピックアップして解説しておきます。

  • ヘッダー From
    • 差出人の情報です。メールヘッダー上で「From」と表記されています
    • メールを郵便物に模して「封筒の中の便箋に書かれている名前」と例えられることがあります
    • 容易に偽装することが可能です
  • エンベロープ From
    • こちらも差出人の情報です
    • メールを郵便物に模して「便箋が入った封筒に書かれている名前」と例えられることがあります
    • メール送信時に「エンベロープ From」という情報として送られますが、受信後は「Return-Path」という情報でメールヘッダーに追加されます(Return-Path の用途としてはバウンスメールなどの発生時の返送先として機能します)
    • ヘッダー From ほど容易には偽装できませんが、メールサーバーへログインした上でメールを送信することで偽装が可能です

SPF の認証

概要

メールが正当な送信元ドメインから送られたものであるかを検証する目的で用いられます。

なりすまし防止の目的で用いられます。

設定例

例として以下のように DNS において SPF レコードを登録します。RFC7208 に記載されていたサンプルです。

example.com.   IN TXT  "v=spf1 ip4:192.0.2.1 ip4:192.0.2.129 -all"

"v=spf1" ではバージョンを指定しています。

"ip4:x.x.x.x" では送信元として許可をするメールサーバーの ip アドレスを指定しています。

"-all" は指定されたもの以外からのメール送信を許可しないという設定です。この場合は "ip4:x.x.x.x" で指定されたものが許可対象ということになります。

このような設定によって、SPF は「エンベロープ From の情報」と「実際の送信元メールサーバーの ip アドレス」が一致しているかを検証しています。

上記は ip アドレスの指定というシンプルな例を紹介しましたが、他にも記法が色々とあるので RFC7208 を参考にしてください。

DKIM の認証

概要

公開鍵暗号方式を使った認証技術です。

メールが改ざんされていないかを検証する目的で用いられます。

設定例

例として以下のように DNS において DKIM レコードを登録します。RFC6376 を参考に組み立てたものです。

brisbane._domainkey.example.com. IN TXT (
    "v=DKIM1; p=MIGfMA0GCSqGSIb3DQEBAQUAA4GNADCBiQ"
    "KBgQDwIRP/UC3SBsEmGqZ9ZJW3/DkMoGeLnQg1fWn7/zYt"
    "IxN2SnFCjxOCKG9v3b4jYfcTNh5ijSsq631uBItLa7od+v"
    "/RtdC2UzJ1lWT947qR+Rcac2gbto/NMqJ0fzfVjH4OuKhi"
    "tdY9tf6mcwGjaNBcWToIMmPSPDdQPNUYckcQ2QIDAQAB"
)

"brisbane" はセレクタと呼ばれる設定値で受信側のメールサーバーにて公開鍵を探すための識別となるものです。一意であれば任意の値で良いものですが、サンプルでは RFC に記載の文字列をそのまま引用しました。

"v=DKIM1" ではバージョンを指定しています。

重要となるのが "p=..." の部分です。公開鍵を Base64 でエンコードしたものです。ポイントとなるのは全文を1つの文字列として取り扱っていない点です。これは RFC1035 の仕様上、最大文字数制限(255 byte 以内)があるためです。

DMARC の認証

概要

DMARC は一言で表現しづらいですが、以下を担う認証技術です。

  • ヘッダー From を組み合わせた認証(SPF/DKIM で認証した送信元と一致しているか)
  • SPF ã‚„ DKIM の検証結果をもとに受信側でメールをどう処理するかを制御
  • SPF ã‚„ DKIM の検証結果をレポートとして通知

設定例

例として以下のように DNS において DMARC レコードを登録します。RFC7489 を参考に組み立てたものです。

_dmarc.example.com. IN TXT "v=DMARC1; p=none; rua=mailto:[email protected]"

"_dmarc" は DMARC を利用するにあたり必要となるサブドメインです。

"v=DMARC1" ではバージョンを指定しています。

"p=none" は適用ポリシーを指定しています。これが検証に失敗したメールをどうするかという制御を司る部分です。以下のような種類があります。

ポリシー 内容
none 何もしないことを意味します。DMARC の検証に失敗した旨の情報は追加されますが、通常通りメールは届きます。
quarantine 隔離を意味します。迷惑メールフォルダに振り分けられます。
reject 拒否を意味します。バウンスメールとして返送されます。

上記の例では RFC のサンプルの通り "none" を指定していますが、これだと受信側の制御としては何もしないため隔離や拒否の対応が必要な場合は "quarantine" や "reject" にする必要があります。

"rua=" は DMARC のレポートの報告するためのメールの送信先です。検証の結果がこのメールアドレス宛に届くようになります。

その他の仕様については、RFC7489 を参考にしてください。

SPF/DKIM/DMARC を適切に設定しないとどういった問題が起こるのか

SPF はなりすまし防止、DKIM は改ざん防止、そして DMARC は SPF と DKIM の検証結果をもとに検証を行う技術かつ SPF や DKIM で補えない脆弱性をカバーしているといった関係性があります。

したがって先述したように認証技術としては分かれているものの単体で利用するのではなく、組み合わせて利用することに意味があります。

ここからはさらに掘り下げて、SPF/DKIM/DMARC の3つを適切に設定をしないとどうなってしまうのかについて見ていきます。

ケース1:何も設定していない場合

言わずともがなで不正メールが送られ放題です...。

設定していないことで必ず不正メールが送られてしまうとは限りませんが、「容易に送ることができる環境を提供する」ことに繋がります。

ケース2:SPF 認証は設定済みで DMARC は未設定の場合(DKIM は不問)

まず1つめに「ヘッダー From は偽装しつつ本命のドメインの SPF の検証は pass しない状態でメールが届く」といったことが起こり得ます。

例えば SPF 設定済みの「[email protected]」を装い、SPF 未設定の「[email protected]」からメールを送信する例を考えます。

エンベロープ From は「[email protected]」とし、ヘッダー From は「[email protected]」とします。すると 「[email protected]」では SPF の検証は行われず検証結果が「spf=none」となり、結果的に不正なメールが届いてしまうことになります。

受信者側で届いたメールの検証結果のステータス(このケースでは「spf=none」)を見抜くことができれば怪しいメールだと気づけるかもしれませんが、毎回人力で目を通してチェックするのは大変です。よって DMARC による検証が必要です。

2つめに「ヘッダー From は偽装しつつ偽のドメインの SPF 認証を pass した状態でメールが届く」といったことも起こり得ます。

送信例は先ほどと同じですが、今度は「[email protected]」で SPF を設定済みにしておく場合を考えます。

すると SPF の検証結果が「spf=pass」となり、SPF 検証が pass した状態で不正なメールが届きます。

エンベロープ From は転送などの関係で必ずしもヘッダー From と一致するとは限らないため、受信者側では「正規のメールサーバーから送られてきたか」を判断するのは困難となります。よって DMARC による検証が必要です。

ケース3:DKIM は設定済みで DMARC は未設定の場合(SPF は不問)

まず1つめに「ヘッダー From は偽装しつつ偽のドメインの DKIM 検証を pass しない状態でメールが届く」といったことが起こり得ます。

詳細なフローは割愛しますが、DKIM における検証はざっくり以下のようなフローです。

  1. (送信側メールサーバー)公開鍵/秘密鍵ペアを生成
  2. (送信側メールサーバー)公開鍵は DNS に TXT レコードとして登録、秘密鍵は安全にメールサーバーで保持
  3. (送信側メールサーバー)送信時にメールの一部(ヘッダーや本文)をハッシュ化し、秘密鍵で署名を生成して DKIM-Signature ヘッダーとして追加
  4. (受信側メールサーバー)DNS から公開鍵を取得し、メールの内容から計算したハッシュ値と署名を照合して検証

この上で DKIM 設定済みの「[email protected]」を装い(ヘッダー From に「[email protected]」を指定)、DKIM 未設定の「[email protected]」からメールを送信する例を考えます。

「[email protected]」から送られたメールは DKIM の検証を行いませんので、DKIM の検証結果は「dkim=none」となった上で不正なメールが届きます。

SPF の時と同様、ヘッダー From を偽装してなりすました上で改ざんメールを送ることが可能ですし、受信者側で「DKIM の検証が pass しているか」と毎回確認するのも大変です。よって DMARC による検証が必要です。

2つ目に「ヘッダー From は偽装しつつ偽のドメインの DKIM 検証を pass した状態でメールが届く」といったことも起こり得ます。

「[email protected]」で DKIM を設定済みにしておく場合を考えます。

すると DKIM の検証結果が「dkim=pass」となり、DKIM 検証が pass した状態で不正なメールが届きます。

こちらも SPF の際と同様、受信者側では「正規のメールサーバーから送られてきたか」を判断するのは困難となります。よって DMARC による検証が必要です。

ケース4:DMARC のみ設定している場合

基本的に DMARC は SPF や DKIM の検証結果をもとに検証を行う技術ですので、意味がありません。SPF や DKIM の設定を行った上で DMARC の設定を行ってください。

参考

SPF/DKIM/DMARC に限ったことではありませんが、メールに関する技術仕様等を体系的に学ぶのには以下の書籍が参考になりました。プロトコルレベルの話からサーバー構築方法、本エントリで解説した SPF/DKIM/DMARC 等網羅的に解説されているのでおすすめです。

実務で使える メール技術の教科書 基本のしくみからプロトコル・サーバー構築・送信ドメイン認証・添付ファイル・暗号化・セキュリティ対策まで

また、その他 RFC では以下が該当するドキュメントとなりますので、詳細な技術仕様等はそちらを見てもらうのがよいかと思います。

おわりに

Gmail のスパム規制がきっかけとなりましたが、SPF、DKIM、DMARC という3つの認証技術の役割を深く理解することができました。 Saas 側の規制強化の意図やどうして認証技術が必要なのかといった部分の解像度が上がった気がします。 メールセキュリティを高めるための実践的な知識を得るとともに、今後のメール運用にも活かしていきたいと思います。

試行錯誤から見えた「個人の成長」と「目標管理」を結びつける試み


1. はじめに: 「個人の成長」と「目標管理」を結びつける試み

これはコネヒトアドベントカレンダー2024の11日目の記事です。

本記事は、キャリア形成や目標達成支援に悩むマネージャー を対象としています。チームメンバーの成長支援と組織の目標管理をどのように結びつけるかに課題を感じている方に向け、実際の運用事例として「個人のバックログ管理」を用いた取り組みを紹介します。

具体的には、以下のような課題を感じている方に役立つ内容です。

  • メンバーのキャリア志向が把握できていない
  • 成長の方向性が見えず、次に支援すべきステップが不明確
  • 日々の業務の中から成長実感を感じてもらうことが難しい
  • 組織の目標と個人のキャリア形成を結びつける運用例を知りたい

私はコネヒトで働くエンジニアリングマネージャーとして、組織の戦略と個人の成長を掛け合わせで、より大きな成果を生み出すことを重要なテーマと考えています。この問題に向き合うため、プロダクトバックログの考え方を取り入れ、目標管理と成長支援の両立を目指すアプローチを試行錯誤しています。

長期的なビジョンや中長期的なゴールを設定し、その達成を目指すバックログ管理 は、目標管理とキャリア形成を結びつけるうえで適していると判断しました。また、メンバーとキャリア形成を話す上で 個人を「プロダクト」として捉え、成長の方向性確認する ことで、遊び心を持ってキャリア志向を話せるようにしたかったことが理由の一つです。


2. 運用のプロセス: 個人の成長を支えるフレームワーク

プロダクトバックログ管理を個人の成長支援に応用するにあたり、長期・中期・短期の3つの視点 を以下の図のように整理をしました。この視点は、個人のキャリア形成と組織の目標管理を効果的に結びつける重要な要素です。

個人の成長を支えるプロダクトバックログ管理のフレームワーク

カテゴリ 定義 活用方法
プロダクトビジョン
(長期:3〜5年)
キャリアにおける長期的な方向性や理想の状態 定期的に中長期的なキャリア志向を可視化し、道筋を立てる
プロダクトゴール
(中期:6ヶ月〜1年)
ビジョンを実現するための中長期的な目標。インクリメンタルに進めるため、バックログを作る 半期の目標を立てるための判断材料にする
スプリントゴール
(短期:1ヶ月)
毎月設定する短期的な取り組みの目標 毎月のふりかえりで検査・適応を行い、業務上の成果と個人の成長を対話する

例えば、以下のようなアウトプットが作られるイメージです。*1

例
プロダクトビジョン 「技術力だけでなくリーダーシップも発揮できるエンジニアとして成長し、チームの成長を牽引できる存在になることを目指す」
プロダクトゴール 「次の1年で主要プロジェクトの技術リードを担当する2回経験し、リーダーシップを実践する」
スプリントゴール 「現状のアーキテクチャの課題をリストアップし、改善策を考える」

このフレームを元に、全体の流れとしては以下のような取り組みを進めています。

  1. コネヒト所属に依存しない、長期的なキャリア志向を考える
  2. コネヒト所属を踏まえて、マネージャーと中期的な目標を設定する
  3. 1ヶ月ごとの短期ゴールを設定し、マネージャーと検査・適応をする

2.のステップを丁寧に進めることで、会社の求める成果と個人の成長を繋げるように仕掛けています。


自己分析を行い、ビジョン/ゴールを決める

メンバーには、まずは個人のプロダクトビジョンを埋めることから始めてもらいました。 以下のような設問を用意し、自己分析を促すことで、長期的なキャリア志向を考えるきっかけを作りました。 ここではコネヒト所属、有無関係なく自分のキャリアについて考えることを強調しています。

  • 過去の振り返り
    • 過去数年間でどんな経験をして、どんな成長をしたか?逆にやり残したことは何か?
    • 自分の強み、特徴は何か?逆に苦手なことや過去のフィードバックで印象に残っていることは?
    • 人生や仕事上で大事にしていることは?
  • 長期的な目標の設定
    • バックキャスティングで考える
      1. 2~5年のうちに成し遂げたいことは何か?
      2. ワクワクすることは何か?
      3. それはなぜ?
    • フォアキャスティングで考える
      1. 過去の棚卸しを見て、2~5年のうちにどんな価値が生み出せそう?
      2. ロールモデルや憧れる人は?
      3. それはなぜ?

個人のプロダクトビジョンを設定した上で、会社の目標達成との掛け算になるプロダクトゴールを設定します。 もちろん、必ずしもプロダクトビジョンに直結する目標を置けるわけではないですが、キャリア志向を把握しておくことで、今後の機会提供の方法を考えられるようになっています。

このフレームワークで、半期ごとの成果を単なる「点」としてではなく、「中期的な視点」を踏まえて「線のコミュニケーション」が取りやすくなりました。 このプロダクトゴールを設定したうえで、1ヶ月で目指すスプリントゴールを決め、1on1や振り返りを行なっています。

3. 振り返りの仕組み: 成長に気づく対話の工夫

1on1の進め方

1on1では、スプリントゴールへの進捗度合いを、信号機に例えて確認しています。具体的には以下のテンプレートを進めています。 各色の意味合いはあえて明確に決めず、メンバーに自分で考えてもらい、なぜその色なのかを説明してもらうことで、課題の理解に努めています。

# **yyyy/MM/dd**

---

## 信号の色はいかがですか?

赤、黄色、青、もうすぐ変わりそうなど教えてください!

## 今日特にテーマはありますか?

- [ ]  仕事のパフォーマンス!
- [ ]  ワークライフバランス!
- [ ]  キャリア・転職・独立!
- [ ]  ぽじねの意見を聞きたい!
- [ ]  ゆるく話したい!
- [ ]  その他なんでも!

## 特に話したいこと

- 

## うまくいっている/いってないこと

-

信号機の色を通じてコミュニケーションをとることで、アイスブレイクも兼ねて課題をはなしてもらうきっかけになっています。


振り返りの視点

月に一度のふりかえりでは、スプリントゴールを軸に以下の視点でふりかえりをしています。

1. タスクの進め方の工夫

  • 今月、自分から進んで取り組んだ新しいタスクや役割はありましたか?
  • その業務範囲の拡大がチームやプロジェクトにどのような影響を与えましたか?

2. 仕事の認知の工夫

  • 今月直面した課題や困難は何でしたか?
  • それにどう向き合い、成長のチャンスに変える工夫をしましたか?

3. 周りのメンバーとの関係性の工夫

  • クライアントやチームメンバーとの関係をどう改善・強化しましたか?
  • 来月は誰とのコミュニケーションを強化し、どのような新しい連携を築きたいと考えていますか?

この視点で振り返ると、日々の業務が「やったことの積み重ね」ではなく、自ら工夫して得た 「成長のプロセス」 として捉えられるようになりました。 どの領域(タスク、認知、関係性)で、どのような工夫をするかを対話し、次のアクションを明確にすることができるようになっています。

4. 成果と変化: 点から線へのコミュニケーションへ

個人のプロダクトバックログ管理を取り入れたことで、仕事が断片的な「点」として扱われることが多かったのですが、今ではビジョン達成への成長のプロセスも踏まえて「線」として見えるようになりました。

これは、毎月のスプリントゴールの進捗確認に加え、成果がプロダクトゴール(半年〜1年)やプロダクトビジョン(2〜5年)にどうつながっているかを話し合うようになったことが大きな要因です。

「今取り組んでいること」が「これからの成長」にどう結びつくのかを意識する対話が増え、業務が単発のタスク処理ではなく、キャリア形成に向けた積み重ねとして見えるようになりました。

5. 今後の課題と改善点: プロダクトバックログの運用を継続するために

この取り組みを2ヶ月程継続し、以下の課題が見えてきました。

  1. 一つのことに集中するため、やったことを網羅的に振り返ることが難しい
  2. マネージャーとメンバーで閉じているため、他のメンバーに意図が伝わらない

スプリントゴールに集中しながらも、月次の振り返りではゴール以外の取り組みにも目を向ける工夫や、他のメンバーとの共有を意識した工夫について模索をしています。 特にマネージャーからも支援を受けられる状態を作れれば、より効果的に業務的な成果と成長支援を実現できると考えているため、今後の課題として取り組んでいきます。

最後までお読みいただき、ありがとうございました!

*1:この例は架空のエンジニアを元にChatGPTに生成してもらいました。

(Google Cloud 向け)Terramate と Workload Identity 連携で始める楽でセキュアな GitHub Actions の構築

本エントリは「コネヒト Advent Calendar 2024」9日目の記事です!

adventar.org

こんにちは。ついこの前引っ越しをしたのですが、自宅から富士山が眺められることに最近気づきテンションが上がっている @sasashuuu です。 本日は以前行った CI/CD 構築 についてのブログを発信します。

背景

弊社のインフラに関しては主に AWS を使用していますが、データ基盤等のシステムは Google Cloud で管理するなどマルチクラウドの構成を取っています。

AWS に関しては IaC 管理のための基盤が存在しているものの、Google Cloud には手が回っておらずほぼ管理できていない状態でした。

そこで IaC のオーケストレーションツールである Terramate や OIDC などの認証を実現するための Google Cloud の Workload Identity 連携を使用し、Google Cloud 側の IaC 管理基盤および CI/CD を GitHub Actions にてゼロベースで構築しました。

本エントリでは、特に CI/CD にフォーカスする形で内容を発信しようと思います。構築方法や使って見た所感などをつらつらとまとめる形にはなりますが、技術スタックとしてこういったツールやサービスでの構成があるという引き出しにつながる一助になれば幸いです。

Terramate とは

IaC 管理のためのオーケストレーションツールです。Terraform、OpenTofu、Terragrunt を管理対象に「スタック」と呼ばれる独自の単位(Terraform であればリソースそのものや設定を管理する *.tf ファイルや state などの組み合わせもとに構成される単位)をもとに、それらをオーケストレーションすることができます。具体のユースケースについては後述しますが、例として以下のようなことが可能です。

  • スタックに対して一括でのコマンド実行
  • ファイルの変更があったスタックのみを対象とするコマンド実行

terramate-io/terramate

Workload Identity 連携とは

従来のやり方であるサービスアカウント(サービスアカウントキー)を使った認証と異なり、ID フェデレーションを使用したセキュアな認証方法です。 この方法により、クレデンシャル情報の「管理コスト」や「流出によるセキュリティリスク」を減らすことが可能です。本エントリでは GitHub Actions から Google Cloud へアクセスする際の OIDC 認証のために使用します。

Workload Identity 連携

Terramate の導入

始めにCI/CD を組み込んだ対象リポジトリの全体像を書いておくと以下のようなイメージです。

GitHub Actions 用ワークフローファイルは .github/workflows に、config.tm.hcl を除く Terramate や Terraform 関連のファイルはプロジェクトごとのディレクトリ(例:projectA)に作成しています。

.
├── .github
│   └── workflows
│       ├── projectA-pull-request.yaml
│       ├── projectA-push-tag.yaml
│       ├── template-pull-request.yaml
│       └── template-push-tag.yaml
├── config.tm.hcl
├── projectA
│   └── terraform
│       ├── backend.tf
│       ├── iam.tf
│       ├── monitoring.tf
│       ├── provider.tf
│       ├── stack.tm.hcl
│       └── version.tf
└── projectB
...

Terramate においてポイントとなるのは、次の2ファイルです。

config.tm.hcl - Terramate で自動的に生成するための各種 tf ファイル(backend.tf、provider.tf、version.tf 等)の内容を定義している。

stack.tm.hcl - Terramate によって自動生成されるもので、スタックの管理に利用されます。基本的に手動で編集することはないファイル。

config.tm.hcl の内容

globals {
  terraform_version = "x.x.x"
  provider_version = "x.x.x"
}

generate_hcl "backend.tf" {
  content {
    terraform {
      backend "gcs" {
        bucket = "projectA-tfstate"
          prefix = "hoge/terraform/${terramate.stack.tags[0]}"
      }
    }
  }
}

generate_hcl "provider.tf" {
  content {
    provider "google" {
      project = terramate.stack.tags[0]
      }
  }
}

generate_hcl "version.tf" {
  content {
    terraform {
      required_version = global.terraform_version
        required_providers {
          google = {
            source  = "hashicorp/google"
            version = global.provider_version
          }
        }
    }
  }
}

globals は変数、generate_hcl はファイル生成のための block といった具合です。

stack.tm.hcl は Terramate によって自動生成されるもので、スタックの管理に利用されます。基本的に手動で編集することはないファイルです。

stack.tm.hcl の内容

stack {
  name        = "terraform"
  description = "terraform"
  tags        = ["projectA"]
  id          = "xxxxxxxx-xxxx-xxxx-xxxx-xxxxxxxxxxxx"
}

tags は 後に解説しますが、terramate コマンドで特定のスタックを対象に実行する際に便利な機能です。

ここからは実際の導入方法について解説します。今回は何もない状態のリポジトリで一から作成するパターンとしています。

まずは対象のディレクトリで git init を実行します。

git init

前述した config.tm.hcl を作成します(※内容は前述した定義を参照)。

.
└── config.tm.hcl

この状態で terramate create を実行します。

terramate create projectA/terraform --tags=projectA
Created stack /projectA/terraform

以下のように stack.tm.hcl 含め関連ファイルが自動生成されます。

.
├── projectA
│   └── terraform
│       ├── backend.tf
│       ├── provider.tf
│       ├── stack.tm.hcl
│       └── version.tf
└── config.tm.hcl

それぞれ出来上がったファイルの中身は以下のような内容です。補足ですが、state ファイルの保存場所 は Cloud Strage を指定しています。terraform の実行前にはあらかじめ Cloud Storage に state ファイルを管理するためのバケットは作成しておいてください。

backend.tf

// TERRAMATE: GENERATED AUTOMATICALLY DO NOT EDIT

terraform {
  backend "gcs" {
    bucket = "projectA-tfstate"
    prefix = "hoge/terraform/projectA"
  }
}

provider.tf

// TERRAMATE: GENERATED AUTOMATICALLY DO NOT EDIT

provider "google" {
  project = "projectA"
}

version.tf

// TERRAMATE: GENERATED AUTOMATICALLY DO NOT EDIT

terraform {
  required_version = "x.x.x"
  required_providers {
    google = {
      source  = "hashicorp/google"
      version = "x.x.x"
    }
  }
}

上記で生成されたファイルは基本的には手動で編集することはなく、試しにファイル内を見てもらうとわかるように「DO NOT EDIT」の定義が存在しています。

// TERRAMATE: GENERATED AUTOMATICALLY DO NOT EDIT

...

あらかじめ Terraform の lock ファイルの生成も行っておきます。stack を作成したディレクトリに作られるよう実行します。

terraform -chdir=projectA/terraform providers lock \
    -platform=linux_amd64 \
    -platform=linux_arm64 \
    -platform=darwin_amd64 \
    -platform=darwin_arm64 \
    -platform=windows_amd64

後に実行する terramate run について、git 上でコミットしていないファイルの変更があると実行できないという制約があるためこの対応を行なっています。GitHub Actions 上のホステッドランナーで実行した terraform init により lock ファイルに変更がかかり、ファイル差分が発生したまま後続する terraform plan が実行できないという事態を防ぐ目的で行います(また、幅広いプラットフォームへの対応も兼ね上記のように実行)。

ここまでで生成された各種ファイルは一度コミットしておいてください(前述したようにコミットしていないファイルがあると terramate run が実行できないためです)。

その後はスタックがある階層(ここでは projectA/terraform 配下)に ec2.tf 等などのリソース作成用の terraform 定義を記載した tf ファイルを配置していけば、terraform 側の準備は完了です。

続いて GitHub Actions 側の実装を見ていきます。

再度 GitHub Actions に関するファイルが置かれているディレクトリ構成を見ておくと以下のようになっております。

├── .github
│   └── workflows
│       ├── projectA-pull-request.yaml
│       ├── projectA-push-tag.yaml
│       ├── template-pull-request.yaml
│       └── template-push-tag.yaml

ざっくりとしたファイルの役割について触れておくと以下のようになっています。

  • template-pull-request.yaml
  • projectA-pull-request.yaml
    • 再利用可能なワークフローのテンプレート(template-pull-request.yaml)を呼び出すファイル(※今回の例ではプロジェクトごとに作成)
  • template-push-tag.yaml
    • template-pull-request.yaml 同様に再利用可能なワークフローのテンプレートファイル
    • tags のイベントで terraform apply が実行される
  • projectA-push-tag.yaml
    • 再利用可能なワークフローのテンプレート(template-push-tag.yaml)を呼び出すファイル(※今回の例ではプロジェクトごとに作成)

中身を見ていきます。まずはテンプレートとなる template-pull-request.yaml です。

on:
  workflow_call:
    inputs:
      workload_identity_provider:
        description: '使用する Workload Identity Provider'
        required: true
        type: string
      service_account:
        description: 'Workload Identity 経由で認証するサービスアカウント'
        required: true
        type: string
      terraform_version:
        description: 'Terraform のバージョン'
        required: true
        type: string
      tag_name:
        description: 'Terramate で使うタグ名(Google Cloud のプロジェクト名)'
        required: true
        type: string

permissions:
  id-token: write
  contents: read
  pull-requests: read
  checks: read

jobs:
  terraform-plan:
    runs-on: ubuntu-latest
    steps:
      - name: Checkout repository
        uses: actions/checkout@v4
        with:
          fetch-depth: 0

      - name: Set up Terramate
        uses: terramate-io/terramate-action@v2

      - name: Set up Terraform
        uses: hashicorp/setup-terraform@v3
        with:
          # terraform の action を利用する場合は wrapper を無効化する必要があるため false に設定
          # ref. https://terramate.io/docs/cli/automation/github-actions/#:~:text=To%20install%20Terraform%20using%20the%20hashicorp/setup%2Dterraform%20GitHub%20Action%2C%20you%20must%20disable%20the%20included%20wrapper.
          terraform_wrapper: false  # Terramate 経由 で Terraform を実行するためラッパーを無効化
          terraform_version: ${{ inputs.terraform_version }}

      - name: 'Authenticate to Google Cloud'
        uses: 'google-github-actions/auth@v1'
        with:
          workload_identity_provider: ${{ inputs.workload_identity_provider }}
          service_account: ${{ inputs.service_account }}

      - name: Initialize Terraform
        run: terramate run --tags=${{ inputs.tag_name }} -- terraform init

      - name: Plan Terraform
        run: terramate run --tags=${{ inputs.tag_name }} --changed -- terraform plan

ポイントは以下です。

  • terraform_wrapper の無効化
  • google-github-actions/auth の利用
  • terramate run の実行
  • terraform_wrapper の無効化

Terramate のドキュメントにも記載がありますが、HashiCorp の Terraform Setup GitHub Action を使う場合は terraform_wrapper を無効化する必要があります。

terraform_wrapper: false  # Terramate 経由 で Terraform を実行するためラッパーを無効化

Terramate - Automating Terramate in GitHub Actions

  • google-github-actions/auth の利用

google-github-actions/auth という専用のアクションを使っています。 workload_identity_provider と service_account は inputs 経由で渡していますが、後述する Workload Identity 連携のために作成したプロバイダーとサービスアカウントを指定する必要があります。

workload_identity_provider: ${{ inputs.workload_identity_provider }}
service_account: ${{ inputs.service_account }}
  • terramate run の実行

terramate run は Terramate を利用する上でキモとなるコマンド実行です。

- name: Plan Terraform
  run: terramate run --tags=${{ inputs.tag_name }} --changed -- terraform plan

基本的には terramate run -- <実行したいコマンド> で管理している全スタックに対して、一括で<実行したいコマンド>が実行されるという仕様です。ここでは併せて --tags と --changed のオプションをつけています。--tags は実行対象となるスタックを制限するためのオプション、--changed は git をもとにファイルの変更のあったスタックのみを対象に実行してくれるオプションです。

ここで紹介しているオプションや terramate コマンドの使い方はほんの一部に過ぎず他にも便利な機能(parallel 等)があるので、詳しくは公式のドキュメントをご参照ください。

Terramate - Orchestration

そして再利用可能なワークフローの呼び出し側である projectA-pull-request.yaml は以下のような内容です。

name: Create Pull Request

on:
  pull_request:

jobs:
  terraform-plan:
    uses: ./.github/workflows/template-pull-request.yaml
    with:
      workload_identity_provider: '<後ほど作成する Workload Identity プロバイダー名>'
      service_account: '<後ほど作成するサービスアカウント名>'
      terraform_version: 'x.x.x'
      tag_name: 'projectA'

基本的にはテンプレートの呼び出しやテンプレートへの変数受け渡しを定義しているような内容です。

template-push-tag.yaml や projectA-push-tag.yaml の内容も一応記載しておきますが、基本的には同じ要領で実装しているため解説は割愛します。

template-push-tag.yaml

on:
  workflow_call:
    inputs:
      workload_identity_provider:
        description: '使用する Workload Identity Provider'
        required: true
        type: string
      service_account:
        description: 'Workload Identity 経由で認証するサービスアカウント'
        required: true
        type: string
      terraform_version:
        description: 'Terraform のバージョン'
        required: true
        type: string
      tag_name:
        description: 'Terramate で使うタグ名(Google Cloud のプロジェクト名)'
        required: true
        type: string

permissions:
  id-token: write
  contents: read
  pull-requests: read
  checks: read

jobs:
  terraform-apply:
    runs-on: ubuntu-latest
    steps:
      - name: Checkout repository
        uses: actions/checkout@v4
        with:
          fetch-depth: 0

      - name: Set up Terramate
        uses: terramate-io/terramate-action@v2

      - name: Set up Terraform
        uses: hashicorp/setup-terraform@v3
        with:
          # terraform の action を利用する場合は wrapper を無効化する必要があるため false に設定
          # ref. https://terramate.io/docs/cli/automation/github-actions/#:~:text=To%20install%20Terraform%20using%20the%20hashicorp/setup%2Dterraform%20GitHub%20Action%2C%20you%20must%20disable%20the%20included%20wrapper.
          terraform_wrapper: false
          terraform_version: ${{ inputs.terraform_version }}

      - name: 'Authenticate to Google Cloud'
        uses: 'google-github-actions/auth@v1'
        with:
          workload_identity_provider: ${{ inputs.workload_identity_provider }}
          service_account: ${{ inputs.service_account }}

      - name: Initialize Terraform
        run: terramate run --tags=${{ inputs.tag_name }} -- terraform init

      - name: Apply Terraform
        run: terramate run --tags=${{ inputs.tag_name }} --changed -- terraform apply -auto-approve

projectA-push-tag.yaml

name: Push Tag

on:
  push:
    tags:
      - "*"

jobs:
  terraform-apply:
    uses: ./.github/workflows/template-push-tag.yaml
    with:
      workload_identity_provider: '<後ほど作成する Workload Identity プロバイダー名>'
      service_account: '<後ほど作成するサービスアカウント名>'
      terraform_version: 'x.x.x'
      tag_name: 'projectA'

Workload Identity 連携用リソースの構築

続いて Workload Identity 連携用リソースを構築していきます。

まず Workload Identity 連携で使用するサービスアカウントを作成しておいてください。 サービスアカウントキーは使用しませんが、Workload Identity 連携を経由して Google Cloud リソースを操作するための IAM そのものは必要となります(また、1つ注意として roles/iam.workloadIdentityUser のロールを持つサービスアカウントが必要となりますで必要権限も付与しておいてください)。

Workload Identity 連携のコアとなるリソースを作成していきます。

Google Cloud Console にログインし、「IAM と管理」>「Workload Identity 連携」へ移動します。

プロバイダの追加・プールの作成をしていきます。

設定項目を見ていきます。

名前やプールについてはなんでも良いです。ここでは「github」としておきます。

プロバイダ設定は以下のように行います。

  • プロバイダの選択:OpenID Connect(OIDC)
  • プロバイダ名:任意のもの
  • 発行元 URL:https://token.actions.githubusercontent.com

次に OIDC プロバイダーから送られるトークンを Google Cloud 内で扱えるようにするためのマッピングを行います。今回は特定のリポジトリからのみのアクセスに限定するため、repository 情報が扱えるようなマッピングにします。

Google側 OIDC側(GitHub)
google.subject assertion.sub
attribute.repository assertion.repository

google.subject は必須設定という仕様のため、assertion.sub でマッピングしています。

attribute.repository は assertion.repository でマッピングし、後述する特定のリポジトリアクセスの制御に使います。

条件 CEL は マッピングした属性値を使う形で以下のようにします(OWNER や REPO の値は書き換えてください)。CEL という 言語を使用します。

assertion.repository == "<OWNER>/<REPO>"

マッピングをしているので、CEL では attribute を使用すると思いきやここでは assertion を使用することになるため注意が必要です(と言いつつ attribute でも問題ないのかは試していません)。

マッピングに関するドキュメントは以下が参考になります。

Google Cloud - Workload Identity 連携

OpenID Connect を使ったセキュリティ強化について

上記設定でプロバイダの追加とプールの作成を進めてください。

その後、プールで使用するサービスアカウントの紐付けを行います(プールの画面に戻ると「アクセスを許可」があるのでそちらから設定します)。

使用するサービスアカウントを選択し、マッピングした属性を使う形でサービスアカウントへの制御もかけます。ここでは attribute を使用しています(こちらでも OWNER や REPO の値は書き換えてください)。

ここまでの手順を終えたら、Pull Request の更新や tag の push で GitHub Actions の CI/CD が動くようになっているはずです。

感想

Terramate は簡単にコマンドの一括実行や差分検出によるフィルターなど Terraform のオーケストレーションが行えるため重宝しそうです(前述したように OpenTofu、Terragrunt なども)。変更があったファイルを対象に Terraform を実行するという制御のために独自のスクリプトなどを実装する必要もなく、オプション1つで制御できるのは大きな魅力です。導入も簡単で CI/CD もシンプルな構成になるので、とても良いなと思っています。

弊社においては正直なところ Google Cloud 向けの IaC 管理基盤は導入したばかりで、まだまだ IaC 化自体が推進できていないため Terramate の大きな恩恵はそれほど実感できていませんが、今後の IaC 化の推進でさらに役立ってくれそうな気配を感じています(まだ利用していない parallel やその他機能など)。

また、Workload Identity 連携についてもサービスアカウントキーの管理が発生しないというのはセキュリティ面で大きな魅力です。専用の action も存在するので CI/CD への組み込みも容易でした。

おわりに

今回は Terramate と Workload Identity 連携を使った GitHub Actions の CI/CD の構築方法について書きました。とても便利でセキュアですので良ければみなさんも候補の1つに入れてみてください。

LLMを使って分かち書きフィルタを書かずにテキスト処理をする

こんにちは。CTOの永井(shnagai)です。

この記事はコネヒト & コネヒト生成AI Advent Calendar 2024 の8日目の記事です。

adventar.org

今日は、LLMを使って形態素解析処理をいかに楽に出来るかを実験したので、その内容について書いていきたいと思います。

やりたいこと・モチベーション

  • 社内の盛り上げツールとして、ワードクラウドを作りたい
  • ワードクラウドは文章の特長を捉えているとよりうれしい
  • 一般的なワードは省きたい
  • Pythonを使う
  • フリーテキストなので形態素解析が必要だが、テンポラリな処理なので辞書等はない
  • できるだけ楽して作りたい(一番大事なモチベーション)

形態素解析する時につらい処理

形態素解析をする時に一番気を遣う部分が不要な単語をいかに削り意味のある文字を残せるかという部分です。 例えば、これまでだとPythonで形態素解析をするときは、 下記のように不要な単語を消すフィルタを書いていました。

秘伝のタレと化したreplace処理が使うごとに拡張されていきます。

def japanese_wakati(text):
    tokens = tokenizer.tokenize(text)
    words = " ".join(
        token.base_form
        for token in tokens
        if token.part_of_speech.split(",")[0] in ["名詞"]
    )
    return words

wakati_text = japanese_wakati(text)

wakati_text = (
    wakati_text.replace("https", "")
    .replace("今", "")
)

for char in "abcdefghijklmnopqrstuvwxyzABCDEFGHIJKLMNOPQRSTUVWXYZ":
    wakati_text = wakati_text.replace(char, "")

# ワードクラウドを生成する処理
...

LLMを使った関数を追加してみる

延々とフィルタを追加しているときに、ふとLLMを使ったらもっとうまくいくのではと思いつき試してみました。 is_not_significant という特長的でない単語をOpenAI APIを利用して削除する関数を用意します。 この関数内でのプロンプト内容を変えることで、最終的な出力がどう変わるかを実験しました。

def is_not_significant(text):
    response = openai.chat.completions.create(
        messages=[
            {
                "role": "user",
                "content": f"以下のテキストから特徴的ではない単語を削除してください:\n{text}\n",

            }
        ],
        model="gpt-4o",
    )
    return response.choices[0].message.content

# 日本語のテキストを分かち書き
wakati_text = japanese_wakati(text)

# 特徴的ではない文字列を削除
wakati_text = is_not_significant(wakati_text)

# ワードクラウドを生成する処理
...

最終的なアウトプットにどんな変化が起きたか

ここからは、上で説明した関数のプロンプトを変えることで最終的なアウトプットであるワードクラウドがどのように変わったかを示していきます。

0. まずは、LLMを使っていない元々のワードクラウド

1. [プロンプト]以下のテキストから最終的にワードクラウドを生成します。特徴的なワードクラウドを作りたいので不要な単語を削除してください

指示文に対する回答が返ってきていてワードクラウドに反映されています。それはそう。 また、この指示では特徴的な文章だけを残してしまいイメージと違うものが出来上がっています。

2. [プロンプト]以下のテキストから最終的にワードクラウドを生成します。不要な単語を削除してください。尚、出力はテキストの羅列のみで受け答えはしないでください

いい感じで元のワードクラウドに近いものが出てきました。いくつか不要なワードが削られていますが元のワードクラウドとの差分はいまいちわかりません。

3. 手動フィルタを外す+[プロンプト]以下のテキストから最終的にワードクラウドを生成します。不要な単語を削除してください。尚、出力はテキストの羅列のみで受け答えはしないでください

最後に手動フィルタを外して実行してみました。そうです、OpenAI APIでよしなに手動で除去していたようなノイズは削除されていました。 しかも泣く泣く諦めていた英単語も表示されるようになっています。 優秀すぎて、これまで自分が頑張って溜めてきたフィルタは一瞬にして用無しとなりました。 これが正解だなと個人的には納得して、フィルタをすべてコメントアウトしました。

まとめ

便利の一言です。 これまでフィルタや正規表現で頑張っていたテキスト処理の前処理において、LLMを活用して精度向上と開発効率向上を実現出来る可能性を感じました。 今回の例は一例で、社内ツールなので特に制約なく使えたという面が強いですが、遊び心をもって普段から知見を溜めていくことでプロダクトにも活用出来る武器を日常から増やしていけるといいなと思っております。

Jetpack ComposeのPreview、どう管理と活用してる?

この記事はコネヒトAdvent Calendar 2024 07日目の記事です。

コネヒトのエンジニアやデザイナーやPdMがお送りするアドベントカレンダーです。

コネヒトは「家族像」というテーマを取りまく様々な課題の解決を目指す会社で、
ママの一歩を支えるアプリ「ママリ」などを運営しています。

adventar.org


こんにちは、コネヒトAndroidエンジニアの中島(id:nacatl)です。 早いものでコネヒトにジョインしてもう1年半近くなりました。 今回はママリAndroidアプリにおけるJetpack ComposeのPreviewについて紹介いたします。

背景

ママリAndroidアプリでは、昨今のAndroid開発事情に漏れずJetpack Composeを取り入れ始めています。 新しく開発する箇所は、画面単位でなくても、例えばRecyclerViewのListItem単位などからも置き換えているところです。

さらにごく最近ではNavigation Composeの採用も始めています。 詳しくは同Advent Calendarの04日目の記事をご覧ください。

tech.connehito.com

さて、Composeといえば、開発中にPreviewを活用されている方が多いと思います。

xmlによるPreviewに比べ、Columnなどで並べることでStateの違いにおける表示を比較しやすくなっています。 さらに、ライト/ダーク設定や文字サイズ設定をはじめとして、Previewアノテーションの引数によってさまざまな設定が扱える点もあります。 総合的に取り回しが向上し、整備することでUIにおける一種のユニットテストのように扱えると感じています。

一方で、PreviewもKotlinで書く以上、Previewコードの量と置き場に課題を感じています。 手軽さで言えば、実態のコードと同じファイルに置きがちです。 しかし、そうするとPreviewのビルドの影響か、増えすぎるとAndroid Studioのパフォーマンスに影響が出ることがあります。 加えて上述のようにユニットテストのように扱おうとすると、サンプルにするStateも増えていくのでより顕著になります。

この記事では、これらの課題に対してのママリAndroidでのComposeのアプローチ、およびその先の活用をご紹介します。

ママリAndroidでの現状

前提として、執筆時点で Android Studio Ladybug | 2024.2.1 Patch 2、Kotlin 2.0.21を利用しています。

Previewコードの管理

1. ファイルを分ける

まずは基本という感じで、ファイルを分割します。 ママリAndroidでは、.previewとsuffixを追加することでどのComposeのPreviewかを示しています。

  • FeatureFooItem.kt
  • FeatureFooItem.preview.kt

最初はこれだけでも十分効果を発揮します。 同階層に置けばComposeのvisibilityもinternalなどに絞りやすくなります。 しかし、Compose置換が進みファイルが増えるにつれ、プロジェクトのファイルツリーが長くなり煩わしさも出てきます。

2. File Nestingを活用する

IntelliJ IDEAには、ルールを指定してファイルをまとめてネスティングできる機能があります。

www.jetbrains.com

IntelliJ IDEAにある機能ということは、つまりAndroid Studioでも使える機能でもあります(もちろん基になったバージョンは確認必要ですが)。

この機能は、筆者がFlutterを開発しているときによく利用していました。 当時、DartにおいてKotlin data class相当の機能を扱うためにfreezedを利用しておりました。 freezedはAnnotationProcessorのように、data class的な機能やJsonパーサー相当の機能などの部分をコード生成してくれるパッケージです。 この生成されたコードは foo.dart に対して foo.freezed.dart foo.g.dartなどといった別ファイルで形成され、自動生成であるため開発者自身が編集することはありません。 普段は参照しない、名前の似たファイルがツリーに常駐してしまう煩わしさがどうしてもあったため、これらをまとめるために重宝していました。

これをComposeのPreviewファイル管理に応用します。

QuestionShow.preview.ktをQuestionShow.ktに、QuestionShowAnswer.preview.ktをQuestionShowAnswer.ktにそれぞれネスティングした結果のファイルツリーのスクリーンショット
File Nesting によるファイルツリー管理

これでPreviewファイルを折りたためるようになり、同階層に並べて一覧性を維持しつつ、ツリーの圧迫も緩和されました。 インデントが付くことで見分けやすくもなります。

Previewコードの活用

管理がひと段落したところで、活用の方に移ります。

最初に前提説明ですが、ママリAndroidは接続環境の分岐を Product Flavor の dev prod で管理しています。 社内にテスト配布する dev には、variant専用のコードとして開発用画面のActivityを追加しています。

結論から言うと、この開発用ActivityとしてPreviewコードをそのまま流用したサンプルコンポーネント画面の整備を進めています。 目的としては以下の二つです。

  1. いわゆるモックデータでのUI実装を、リリースコードのUI層と切り離しつつ、サーバー側の開発と並行して行える
    • 表示だけなら素のPreviewだけで十二分ですが、実機上の確認をしたい場合に既存UIと隔離して表示できるのは便利です
  2. 非エンジニアメンバーが配布されたアプリでPreviewを確認できる
    • そもそもこの拡張をしようと思ったきっかけです。実際の操作では作りにくいパターンなどの表示も確認できるため、開発中の連携を強められます

縦向きライトモードのスクリーンショット縦向きダークモードのスクリーンショット
コンポーネントサンプル(縦向き)

横向きライトモードのスクリーンショット横向きダークモードのスクリーンショット
コンポーネントサンプル(横向き)

PreviewComponentActivity ~ ComponentPreviewRoute

ベースとなるActivity ~ Route Composeでは、あまり特殊なことはしていません。 表示したいPreviewを選択表示できる、ダークモードなどの設定を変更できるように、よしなにStateおよびUIを組み立てるだけです。 強いて言えばNow in Androidを参考に、リリースコードに先行してEdgeToEdgeやアダプティブレイアウト対応を軽く取り込んでいる程度です。

    override fun onCreate(savedInstanceState: Bundle?) {
        super.onCreate(savedInstanceState)
        enableEdgeToEdge()
        setContent {
            // ダーク設定remember
            val defaultDark = isSystemInDarkTheme()
            var isDarkTheme by rememberSaveable { mutableStateOf(defaultDark) }

            DisposableEffect(isDarkTheme) {
                enableEdgeToEdge(
                    ~~~~,
                )
                onDispose {}
            }
            ComponentPreviewRoute(
                isDarkTheme = isDarkTheme,
                windowAdaptiveInfo = currentWindowAdaptiveInfo(),
                onDarkThemeChanged = { isDarkTheme = it },
                onBackPressed = { onBackPressedDispatcher.onBackPressed() },
            )
        }
    }

各Previewの工夫

基本的にはそのまま呼び出せると言っても、多少手を加える必要はありました。

1. MaterialThemeで括りなおしてしまうとDarkThemeが切り替わらない

普通にPreviewを作る場合、Previewアノテーションを付けたComposableにそのままThemeから全部書くことが多いと思います。 ですが、Preview用ビルドだけでなく外からも呼び出せるようにする場合、Themeで括ってしまっていると呼び出し側のTheme設定を上書きしてしまいます。 これを回避するにはPreview用の本体と、アノテーションの付いたTheme呼び出しを切り分けます。

// FeatureFooItem.preview.kt

internal val sampleFeatureFoo = FeatureFoo(id = 1, ~~)

/**
 * Preview表示用ビルドはこれを参照する
 */
@Preview(
    heightDp = PreviewHeight, // LazyColumnにできるようにした関係もあり、この辺もアプリ全体で少し整備しています
    ~~,
)
@Composable
private fun FeatureFooItemPreviewBase() {
    MaterialTheme(
        isDarkTheme = isSystemInDarkTheme(),
    ) {
        FeatureFooItemPreview(
            modifier = Modifier.height(PreviewHeight),
            baseFeatureFoo = sampleFeatureFoo,
        )
    }
}

/**
 * プレビュー描画の実態。開発用Activityからの呼び出しではこちらを使う
 */
@Composable
internal fun FeatureFooItemPreview(
    modifier: Modifier = Modifier,
    baseFeatureFoo: FeatureFoo,
) {
    val sampleList = listOf(
        sampleFeatureFoo,
        sampleFeatureFoo.copy( /* 並べて見たい別パターンになるようフィールドを変更 */ ),
        ~~,
    )
    LazyColumn(
        modifier = modifier,
    ) {
        items(sampleList.size) { index ->
            FeatureFooItem( ~~ )
        }
    }
}
2. 基本のサンプルデータを編集できるようにする

前セクションで書いたように、サンプルとなるデータはPreviewコードのファイルに用意しています。

これらのデータを開発用ActivityのUiStateに持たせますが、さらにViewModelの持つStateFlowの形で保持しています。 Flowにする理由としては、実機上でサンプルデータを変更可能にする目的です。 これは我々Androidエンジニアの開発というよりは、特にPdMやデザイナー、非エンジニアの方々の検証目的に役立ちます。

サンプルデータの編集UIのスクリーンショット
コンポーネントサンプル(編集UI)

// FeatureFooPreviewViewModel.kt

    val uiState: StateFlow<FeatureFooPreviewUiState>
        field = MutableStateFlow(FeatureFooPreviewUiState())

    // 表示するPreviewの切り替え
    fun changeComponent(component: FeatureFooPreviewComponent) =
        uiState.update { it.changeComponent(component) }

    // ベースとなる基本サンプルの編集
    fun updateBaseFeatureFoo(featureFoo: FeatureFoo) =
        uiState.update { it.updateBaseFeatureFoo(featureFoo) }

    // リセット
    fun initBaseFeatureFoo() =
        uiState.update { it.initBaseFeatureFoo() }
@Stable
data class FeatureFooPreviewUiState(
    val currentComponent: FeatureFooPreviewComponent = FeatureFooPreviewComponent.ITEM_A,
    val baseFeatureFoo: FeatureFoo = defaultFeatureFoo,
) {
    fun changeComponent(component: FeatureFooPreviewComponent) = copy(
        currentComponent = component,
    )

    fun updateBaseFeatureFoo(featureFoo: FeatureFoo) = copy(
        baseFeatureFoo = FeatureFoo,
    )

    fun initBaseFeatureFoo() = updateBaseFeatureFoo(defaultFeatureFoo)
}

// Compose側でPreview funの呼び出しを切り替えるためのenum
enum class FeatureFooPreviewComponent : PreviewComponent {
    ITEM_A,
    ITEM_B,
    ITEM_C,
    ;
}

利点

このPreview表示の開発用Activityを運用して良かった点を紹介します。

  • 開発中のUIテスト的利用
    • Previewの領域を超えるような数でも、LazyColumnなどでスクロールしながら確認できる
    • ボタンなどユーザーアクションも合わせてテストできる(適当にToast表示などをするだけでも配置ミス確認になります)
    • アダプティブレイアウト対応の実機テストにも転用できる
    • 画面が存在するようになるため、MagicPod等のUIテストサービスにも転用できる
  • 非エンジニアとのコミュニケーション
    • Firebase App Distributionでの配布を通じて、サーバーに依存せず、実機で手軽にPdM / デザイナーへの表示別確認などが行える
    • サンプルデータの編集まで実装できればより確認が捗る

前者については特に複雑な表示分岐仕様があるパーツに対してAndroidView→Composeに置換する際の利点が大きかったと感じます。 まだ始めて日が浅い事もあり後者についてはまだ周知し始めているところですが、「確認しやすくて便利」という声を頂き始めています。今後も浸透させていきたいです。

欠点

はっきり言ってしまえば相応のコーディング工数はあります。

表示に関する工数

Preview自体はそもそもこの活用がなくても作るでしょうし、後からの追加はenumへの追加程度で済みます。 なので、この段階では最初の画面作成 / Previewへの変更周りが主な工数となります。

編集に関する工数

各サンプルデータそれぞれについて編集UIを作る必要があるので、こちらに関してはそれなりに見込む必要があります。 ママリAndroidでも、現状では隙間を縫って実装を進めているような状況です。

if ルート

半分おまけで、現状では採用していませんが「こういうのもいいのでは?」と考えている草案も軽く紹介します。

previewコードの管理

File Nestingの欠点

File Nestingの活用によってファイル分割で起きる問題はほぼ解決します。 ですが、Android Studioのインターフェース面で地味に影響があります。

ダブルクリックで親ファイルが開かない

通常のディレクトリと同じように、ダブルクリックはツリーの collapse / expand に消費されます。 回避策はワンクリックでフォーカスを当てた後にenterです、もしくはファイル検索など経由で開けばいつも通りです。 ただやはり、咄嗟にダブルクリックする癖がいまだに抜けていません。

ネストの子ファイルに対し、Select Opened Fileが効かない

正直、これ不具合では??と思っていますが、効かないものはしょうがありません。 これについては、親ファイルに対して実行するしかないかと思われます。

3. devビルドのディレクトリに置く

File Nestingは確かに便利なのですが、上述のようにちょっとクセがあります。 代替案として現状ぼんやり考えているのは、「開発用Activity用に書いたComposeと同様にdev配下に置いてしまう」ことです。 そもそもリリースビルドには影響しないファイルですし、妥当性は感じます。

ただこの手法では、package上は同一になりますが実際の置き場は離れてしまいますのでツリー上での一覧性は劣ります。

どうするにしろ、トレードオフは何かしら発生するかなとも感じています。

おわりに

今回は、ママリAndroidにおけるCompose Previewの管理方法と活用について紹介させていただきました。

紹介した方法が正解かどうかははっきり言って「わかりません」。 今後も色々思いついたらチーム内で相談し、検証していきたいと思っています。 この記事が皆様のComposeライフをより豊かにする一助となれば幸いです。

LLMを使ってNotion上にデータカタログを自動生成している話

みなさんこんにちは。今期からPdMも兼務しております、たかぱい(@takapy0210)です。

先日、横浜マラソンのペアリレー(ハーフマラソン)に参加しました。
20km以上の距離を走るのは人生で初めてだったこともあり、3〜4ヶ月前くらいから練習をし本番に臨みました。
結果として完走はできたのですが、目標としていた2時間というタイムは達成できなかったので、2025年3月リベンジマラソンをする予定です。(ちなみにこのマラソンで右膝に爆弾を抱えたので、まずはこの爆弾を除去するところからのスタートです)

さて本日は、データカタログを自動生成する機構を作ってみたので、そのお話をしようと思います。

この記事はコネヒト Advent Calendar 2024 6日目の記事です。

adventar.org


目次


どんなものが自動生成されるのか

先に結論からということで、実際に自動生成されるものをお見せします。

以下は実際に弊社が運営する「ママリ」の質問情報が格納されているテーブルのデータカタログ例です。
メタ情報だけでなく、サンプルSQLなども合わせて生成することで、どのようなクエリを書けば良いかがパッと見で分かるような工夫をしています。
このサンプルSQLはLLMを用いて生成しており、この辺がLLMを使っている理由の1つとなります。

実際に作成されるデータカタログのページ(一部省略)

以降で、導入背景や実装の工夫についてお話ししていきます。

背景

2024/12現在、弊社ではデータETLツールとしてDataformを採用しています。
よくdbtと比較検討されるツールですが、学習コストや運用コストが低く導入ハードルが低い点において、専属のデータエンジニアがいない現在の組織状況とフィットしていることもあり採用に至りました。

このDataformですが、SQLファイルを拡張したSQLXと呼ばれるファイルを用いて、ETLワークフローを定義していきます。

SQLXファイルは以下のように記述でき、これらのファイルをGithubで管理しています。

config {
    type: "incremental",
    database: "dwh",
    schema: "warehouse",
    name: "users",
    columns: {
        user_id: "ユーザーID",
        user_name: "ユーザー名",
        ...
    },
    bigquery: {
        partitionBy: "meta_exec_date",
        partitionExpirationDays: 4000,
        requirePartitionFilter: true,
    },
    ...
}

SELECT
    user_id,
    user_name,
    ...
FROM ...

このDataform自体にはデータカタログの生成機能は備わっていないため、データカタログを作成・運用したい場合は、Google Cloudの他サービス(例:Data Catalog など)を用いて作成するか、自前でデータカタログを作成・運用していく必要があります。

今回はNotion上に自前でデータカタログを作成する運用にしました。

なぜNotionを使うことにしたのか

弊社では、ドキュメンテーションツールとして既にNotionを導入しており、全社員が日常的に利用しています。
このため、Notionの使い方に慣れており、他のツールやサービスを新たに導入する場合に比べて学習コストがかかりません。
日々利用しているツール上にデータカタログがあることで、誰でも簡単にアクセスできるという利点があります。

また、データカタログが見づらかったり操作が煩雑だったりすれば、せっかく整備したとしても使われないものになってしまう可能性があります。
データ分析を社内文化として定着させるためには、誰でも簡単に情報にアクセスでき、活用しやすい環境を整えることが不可欠だと思っています。

これらの点を考慮して今回はNotion上に作成することにしました。

全体像

主な使用技術と全体のアーキテクチャは以下の通りです。

  • OpenAI API: データカタログのサンプルSQLの自動生成などに利用
  • Notion API: Notionにデータカタログを作成・更新するために利用
  • GitHub Actions: データカタログ生成ワークフローの構築に利用
  • Python: OpenAI APIとNotion APIを用いてNotionのページを作成する処理に利用

全体の処理の流れとしては以下のようになっており、Dataformで開発を行っているだけで、意識せずにデータカタログが作成・更新されるようになっています。

全体の流れ

以降では、Github Actionsとそこから呼ばれるPythonの実装について詳しく紹介します。

データカタログ自動生成処理について

処理の流れはシンプルで、Github Actions上で以下2つの条件いずれかをトリガーとし、Notion上のデータカタログDBに新規追加 or 更新を行うPythonスクリプトを動かす、というものです。

  • Githubのmainブランチにマージしたタイミングで変更のあったSQLXファイルを対象に自動実行
  • 特定のディレクトリを指定して、そのディレクトリ配下のSQLXファイル全てを対象に手動実行

Github Actions

Actionsの処理としては、データカタログの生成対象となるSQLXファイルのリストを取得し、そのリストを CHANGED_FILES という環境変数に設定した状態で、指定したPythonスクリプトを実行する、というシンプルなワークフローとなっています。(コードは一部省略)

name: Update data catalog

on:
  push:
    branches:
      - main
    paths:
      - 'definitions/dwh/**/*.sqlx'
  workflow_dispatch:
    inputs:
      directory:
        description: 'Please enter the path of the directory you want to process'
        required: true
        default: 'definitions/dwh'

jobs:
  update-notion:
    runs-on: ubuntu-latest

    steps:
      - name: Checking out a repository
        uses: actions/checkout@v3

      - name: Python setup
        uses: actions/setup-python@v4
        with:
          python-version: '3.12'

      - name: Installing dependencies
        run: |
          python3 -m pip install -r workflow_scripts/requirements.txt

      # 自動実行:変更のあったSQLXファイル一覧を取得
      - name: Execute the process using the modified file (main push)
        id: get-changed-files
        if: github.event_name == 'push'
        uses: tj-actions/changed-files@v45
        with:
          files: |
            definitions/dwh/**/*.sqlx
          files_ignore: |
            definitions/dwh/sources/**/*.sqlx

      # 手動実行:inputsとして入力されたディレクトリ配下のSQLXファイル一覧を取得
      - name: Processes all files in the specified directory and sub-directories (workflow_dispatch)
        id: get-all-files
        if: github.event_name == 'workflow_dispatch'
        run: |
          DIRECTORY="${{ github.event.inputs.directory }}"
          if [ -d "$DIRECTORY" ]; then
            FILES=$(find "$DIRECTORY" -type f -name '*.sqlx' ! -path "$DIRECTORY/sources/*")
            FILES="${FILES//$'\n'/' '}"
            echo "all_files=$FILES" >> $GITHUB_OUTPUT
          else
            echo "The specified directory cannot be found: $DIRECTORY"
            exit 1
          fi

      # Pythonスクリプトを実行
      - name: Executing Python scripts
        env:
          OPENAI_API_KEY: ${{ secrets.OPENAI_API_KEY }}
          NOTION_API_KEY: ${{ secrets.NOTION_API_KEY }}
          NOTION_DB_ID: ${{ secrets.NOTION_DB_ID }}
          CHANGED_FILES: ${{ steps.get-changed-files.outputs.all_modified_files || steps.get-all-files.outputs.all_files }}
        run: |
          echo "Processing files: $CHANGED_FILES"
          python3 create_data_catalog.py

実際に動くPythonの処理について事項で紹介します。

実際に動いているPythonの処理

以下がOpenAI APIとNotion APIを用いてデータカタログを自動生成するPythonスクリプトのサンプルです(コードは一部省略)

この処理でのポイントは以下の2点です。

import os
import re

from openai import OpenAI
from notion_client import Client
from dotenv import load_dotenv

from logger import get_logger

LOGGER = get_logger(name=__name__)

load_dotenv()
openai = OpenAI(api_key=os.getenv("OPENAI_API_KEY"),)
notion = Client(auth=os.getenv("NOTION_API_KEY"))
database_id = os.getenv("NOTION_DB_ID")

# 環境変数から変更されたファイルのリストを取得
changed_files_env = os.getenv("CHANGED_FILES")
changed_files = changed_files_env.strip().split()


def read_sqlx_file(file_path: str) -> str:
    """sqlxファイルを読み込み、その内容を返す
    """
    with open(file_path, 'r') as file:
        return file.read()


def get_table_name(file_content: str) -> str:
    """sqlxファイルの内容からFROM句のテーブル名を取得する
    """
    # configブロックを抽出
    config_match = re.search(r'config\s*\{([^}]*)\}', file_content, re.DOTALL)
    table_name = ""

    if config_match:
        config_content = config_match.group(1)
        # database、schema、nameを抽出
        database_match = re.search(r'database\s*:\s*"([^"]+)"', config_content)
        schema_match = re.search(r'schema\s*:\s*"([^"]+)"', config_content)
        name_match = re.search(r'name\s*:\s*"([^"]+)"', config_content)
        
        if database_match and schema_match and name_match:
            database = database_match.group(1)
            schema = schema_match.group(1)
            name = name_match.group(1)
            table_name = f'{database}.{schema}.{name}'
            LOGGER.info(f"Table name is {table_name}")
        else:
            LOGGER.info("The database, schema, or name was not found")
            return ""
    else:
        LOGGER.info("The config block was not found")
        return ""
    
    return table_name

def generate_data_catalog(table_name:str, file_content: str) -> str:
    """LLMを用いてsqlxファイルからデータカタログを生成する
    """
    # プロンプト(一部省略)
    prompt = f"""
    弊社はDataformを用いて、BigQueryのデータ基盤を構築しています。
    そこで、添付のsqlxファイルからデータカタログを作りたいです。以下の注意事項を守りつつ、「出力したい項目」を抽出し、Markdown形式で出力してください。

    ...
    
    ### 出力したい項目
    以下の項目をH1「#」で出力し、その中身をsqlxファイルから抽出して記述してください。
    - ...
    - どのような指標を算出するのに使えるテーブルか
        - テーブルの説明やカラム名から推測して記述してください
        - 指標算出に使えるサンプルSQLの出力が可能であれば、それもセットで出力してください。自信がなければ出力しなくてOKです。
            - こちらのサンプルSQLもパーティション列を使用して出力してください。可能であれば1つだけでなく複数のサンプルがあると嬉しいです。
    - ...

    ### 実際のsqlxファイル
    """

    # LLMによるカタログの生成
    chat_completion = openai.chat.completions.create(
        messages=[
            {
                "role": "user",
                "content": prompt + "\n\n" + file_content,
            }
        ],
        model="gpt-4o-mini",
    )

    return chat_completion.choices[0].message.content


def markdown_to_notion_blocks(markdown_text: str) -> list[str]:
    """Markdown形式のテキストをNotionのBlockに変換する
    """

    blocks = []
    lines = markdown_text.split('\n')
    for line in lines:
        # ヘッディングの解析
        if re.match(r'^# ', line):
            blocks.append({
                "type": "heading_1",
                "heading_1": {
                    "rich_text": [{"type": "text", "text": {"content": line[2:]}}],
                }
            })
        elif re.match(r'^## ', line):
            blocks.append({
                "type": "heading_2",
                "heading_2": {
                    "rich_text": [{"type": "text", "text": {"content": line[3:]}}],
                }
            })
        elif re.match(r'^### ', line):
            blocks.append({
                "type": "heading_3",
                "heading_3": {
                    "rich_text": [{"type": "text", "text": {"content": line[4:]}}],
                }
            })

        # コードブロックの解析
        elif re.match(r'^```', line):
            # コードブロックの開始または終了
            if 'in_code_block' in locals() and in_code_block:
                in_code_block = False
            else:
                in_code_block = True
                code_lines = []
        elif 'in_code_block' in locals() and in_code_block:
            code_lines.append(line)
            # コードブロック内では何もしない

        # 箇条書きの解析
        elif re.match(r'^- ', line):
            blocks.append({
                "type": "bulleted_list_item",
                "bulleted_list_item": {
                    "rich_text": [{"type": "text", "text": {"content": line[2:]}}],
                }
            })

        # 空行の処理
        elif line.strip() == '':
            pass  # 空行は無視
        else:
            # 通常のパラグラフ
            blocks.append({
                "type": "paragraph",
                "paragraph": {
                    "rich_text": [{"type": "text", "text": {"content": line}}],
                }
            })

        # コードブロックの終了時にブロックを追加
        if 'in_code_block' in locals() and not in_code_block and 'code_lines' in locals():
            blocks.append({
                "type": "code",
                "code": {
                    "rich_text": [{"type": "text", "text": {"content": '\n'.join(code_lines)}}],
                    "language": "sql",  # 必要に応じて言語を設定
                }
            })
            del code_lines  # コードラインをリセット

    return blocks


def update_notion_page(table_name: str, catalog_content: str) -> dict:
    """Notionのデータベースにデータカタログを作成or更新する
    """

    search_response = notion.databases.query(database_id=database_id, filter={"property": "Name", "title": {"equals": table_name}})
    results = search_response.get("results")

    # markdown形式でページを作成するため、NotionのBlockに適応した形のdictに変換する
    markdown_blocks = markdown_to_notion_blocks(catalog_content)

    # rich_textプロパティに設定できる文字列の長さは2000文字以下なので、それを超える場合は文字列をカットする
    if len(catalog_content) > 2000:
        catalog_content = catalog_content[:2000]

    if results:
        page_id = results[0]["id"]

        # 「Description」プロパティを更新
        notion.pages.update(
            page_id=page_id,
            properties={
                "Description": {
                    "rich_text": [{"type": "text", "text": {"content": catalog_content}}]
                }
            }
        )

        # Notion APIではページの更新ができないため、一度ページ内全ての子ブロックを取得し削除する
        existing_children = notion.blocks.children.list(page_id)
        for child in existing_children['results']:
            block_id = child['id']
            notion.blocks.delete(block_id)

    else:
        # ページが存在しない場合は新しく作成する
        new_page = {
            "parent": {"database_id": database_id},
            "properties": {
                "Name": {
                    "title": [{"type": "text", "text": {"content": table_name}}]
                },
                "Description": {
                    "rich_text": [{"type": "text", "text": {"content": catalog_content}}]
                },
            },
        }
        page = notion.pages.create(**new_page)
        page_id = page["id"]

    res = notion.blocks.children.append(page_id, children=markdown_blocks)

    return res


if __name__ == "__main__":

    # actions/checkoutで取得したファイルのリストを処理
    for file_path in changed_files:
        if not file_path.endswith('.sqlx'):
            continue

        # ファイルの内容を取得
        file_content = read_sqlx_file(file_path)
        # sqlxの内容から、FROM句のテーブル名を取得
        table_name = get_table_name(file_content)
        # LLMを用いてデータカタログを生成
        catalog_content = generate_data_catalog(table_name, file_content)
        # Notionのデータベースにデータカタログを作成or更新
        res = update_notion_page(table_name, catalog_content)
        LOGGER.info(f"{file_path} has been processed")

サンプルSQLも自動生成している点

冒頭で紹介した通り、テーブルのメタ情報だけではなく、どのような指標を計算するのに使えるテーブルなのか、また、その指標はどのようなSQLで記述できるのか?という情報も合わせてカタログに出力しています。

このSQLの生成は、LLMを用いることで実現しています。

データカタログに付与されるサンプルSQL

NotionのURLが変更されないようにしている点

2024/12現在、Notion APIにUpdateのメソッドは無く、Delete or Createのみが行えます。

Notion上に既に存在するデータカタログをアップデートする場合、そのページ自体を削除し新規ページを作成してしまうと、新規ページのURLが元のページと異なってしまうため、例えばURLでブックマークなどをしているユーザーからしてみると、ページにアクセスできなくなってしまい不便です。

そこで、Notionのページとしては残しつつ、中身(Notion用語では子ブロック)だけを全部削除し、まっさらな状態にしたのち、更新されたSQLXの内容を用いてページに書き込む、といった処理を行なっています。
こうすることで、URLは変わらずにページの中身のみが更新されます。

実際の処理としては以下の部分です。
子ブロックを全て取得し、その要素を1つずつループしながらDelete APIを呼ぶ、というかなり泥臭いことをやっています。 (親ページのIDだけ指定したら中身を全部DeleteするAPIが欲しい・・・)

# Notion APIではページの更新ができないため、一度ページ内全ての子ブロックを取得し削除する
existing_children = notion.blocks.children.list(page_id)
for child in existing_children['results']:
    block_id = child['id']
    notion.blocks.delete(block_id)

蛇足ですが、Markdowm形式の文字列をそのままNotionのページに書き込むとUIが崩れるので、以下のようにブロック要素に適宜書き換えてあげる必要があります。
この辺はNotion APIを使う辛みだなと思いながら手を動かしていました。

def markdown_to_notion_blocks(markdown_text: str) -> list[str]:
    """Markdown形式のテキストをNotionのBlockに変換する
    """

    blocks = []
    lines = markdown_text.split('\n')
    for line in lines:
        # ヘッディングの解析
        if re.match(r'^# ', line):
            blocks.append({
                "type": "heading_1",
                "heading_1": {
                    "rich_text": [{"type": "text", "text": {"content": line[2:]}}],
                }
            })
        elif re.match(r'^## ', line):
            blocks.append({
                "type": "heading_2",
                "heading_2": {
                    "rich_text": [{"type": "text", "text": {"content": line[3:]}}],
                }
            })
        elif re.match(r'^### ', line):
            blocks.append({
                "type": "heading_3",
                "heading_3": {
                    "rich_text": [{"type": "text", "text": {"content": line[4:]}}],
                }
            })

        # コードブロックの解析
        elif re.match(r'^```', line):
            # コードブロックの開始または終了
            if 'in_code_block' in locals() and in_code_block:
                in_code_block = False
            else:
                in_code_block = True
                code_lines = []
        elif 'in_code_block' in locals() and in_code_block:
            code_lines.append(line)
            # コードブロック内では何もしない

        # 箇条書きの解析
        elif re.match(r'^- ', line):
            blocks.append({
                "type": "bulleted_list_item",
                "bulleted_list_item": {
                    "rich_text": [{"type": "text", "text": {"content": line[2:]}}],
                }
            })

        # 空行の処理
        elif line.strip() == '':
            pass  # 空行は無視
        else:
            # 通常のパラグラフ
            blocks.append({
                "type": "paragraph",
                "paragraph": {
                    "rich_text": [{"type": "text", "text": {"content": line}}],
                }
            })

        # コードブロックの終了時にブロックを追加
        if 'in_code_block' in locals() and not in_code_block and 'code_lines' in locals():
            blocks.append({
                "type": "code",
                "code": {
                    "rich_text": [{"type": "text", "text": {"content": '\n'.join(code_lines)}}],
                    "language": "sql",  # 必要に応じて言語を設定
                }
            })
            del code_lines  # コードラインをリセット

    return blocks

まとめ

本日はLLMを用いてNotion上にデータカタログを自動生成する方法について紹介しました。
Notionはブロックという概念で構成されていることもあり、APIの使い方は少々クセがあるので、その辺も踏まえてサンプルコードがみなさんの一助になればと思っています。

データカタログに関しては運用を始めて間もないこともあり、データ基盤の整備とセットで、今後使ってくれる人を増やす動きをしていきたいと考えています。