default eye-catch image.

Snowflake SAML2.0 Security Integrationを使用したSP/IdP Initiated SAML Federationと構成の詳細

AWS IAM Identity Center を IdP として使用し、Snowflake SAML2.0 Security Integration を構成する。 構成の際に多くのパラメタの設定が必要だが、設定可能なパラメタの意味について深掘りしてみる。 この記事は自分の学び用なので、事実の確認、説明用画像作成のために生成AIを使用するが、 記事の作成、校正には使用しない。 [arst_toc tag=\"h4\"] SP起点(SP initiated) flow SP側にSSOボタンなどを配置して、SSOボタン押下でSSO認証とSPログインを開始するフロー。 SP,User,IdP間の呼び出しシーケンスは下図の通り。 IdP起点(IdP initiated) flow IdP側にログインボタンを配置して、ログインボタン押下でIdP認証とSPログインを開始するフロー。 SP,User,IdP間の呼び出しシーケンスは下図の通り。 ログアウト SP側のセッションと、IdP側のセッションは独立している。SP起点、IdP起点のいずれにおいても、 基本的には、片方をログアウトしたからといってもう片方が勝手にログアウトしたりしない。 ChromeでSP起点でフェデレーションログインした後、ChromeでSPのセッションをログアウトした場合、 IdP側のセッションはまだ生きているので、Chromeで再度フェデレーションを開始したとき、 IdP側の認証は走らず、SPにログインできる。 ChromeでSP起点でフェデレーションした後、SafariでSP起点でフェデレーションしたとき、 Chromeでログアウトしたとしても、Safariのセッションはログアウトしないため操作を続行できる。 ただし、EntraIDのみ、IdP起点で開始したとき、「グローバルログアウト,シングルログアウト,SLO」が サポートされており、IdPからログアウトすると、全てのセッションからログアウトする。 たしかに、EntraIDが気持ち悪い動作するな、と言う時、これが動いている時がありそう。 セッションタイムアウト SP(Snowflake)のセッションがタイムアウトした場合、ユーザはIdPを介して再度認証が必要。 IdPでCancel操作をすると、そこでセッションを終了できる。 IdPのセッションがタイムアウトした場合、Snowflakeセッションに影響しない。 その時点でアクティブなSP(Snowflake)セッションは生きたままとなる。 識別子優先ログイン 組織ごとにそれぞれのIdPとSAML連携したいといった場合がある。複数のintegrationを持てる。 また、ユーザによってはフェデレーション連携させずに、Snowflake認証だけにしたいケースもある。 ユーザによって認証に必要な入力が異なるため、全てのユーザに対して最小公倍数的に入力項目を 出してしまうと、ユーザによって不要な項目が並んでいるように見える。 識別子優先ログインをONにすると、SP(Snowflake)側の認証入力が多段階となる。 つまり、1段階目で識別子(ユーザ名、またはメールアドレス) を入力させ、 入力された識別子がどの認証方式に紐づいているかを判定したのち、 ユーザに適した2段階目の認証入力画面(PW入力、SSOボタン)を表示する。 画面遷移が増えるが、不要な入力項目が現れなくなる。 SAML2.0 Security Integration これを作るだけでSnowflakeにSAML2.0 Federationを追加できる。 CREATE [ OR REPLACE ] SECURITY INTEGRATION [ IF NOT EXISTS ] <name> TYPE = SAML2 ENABLED = { TRUE | FALSE } { METADATA_URL = \'<string_literal>\' | <idp_parameters> } [ ALLOWED_USER_DOMAINS = ( \'<string_literal>\' [ , \'<string_literal>\' , ... ] ) ] [ ALLOWED_EMAIL_PATTERNS = ( \'<string_literal>\' [ , \'<string_literal>\' , ... ] ) ] [ SAML2_SP_INITIATED_LOGIN_PAGE_LABEL = \'<string_literal>\' ] [ SAML2_ENABLE_SP_INITIATED = TRUE | FALSE ] [ SAML2_SNOWFLAKE_X509_CERT = \'<string_literal>\' ] [ SAML2_SIGN_REQUEST = TRUE | FALSE ] [ SAML2_REQUESTED_NAMEID_FORMAT = \'<string_literal>\' ] [ SAML2_POST_LOGOUT_REDIRECT_URL = \'<string_literal>\' ] [ SAML2_FORCE_AUTHN = TRUE | FALSE ] [ SAML2_SNOWFLAKE_ISSUER_URL = \'<string_literal>\' ] [ SAML2_SNOWFLAKE_ACS_URL = \'<string_literal>\' ] [ COMMENT = \'<string_literal>\' ] パラメタの意味を1つずつ解釈していく。 METADATA_URLとidp_parameters まず、METADATA_URL について、IdPが証明書の更新などのIdP構成設定を動的に 取得・同期できるように、URLを公開している場合がある。 その場合、METADATA_URL に指定することができる。ない場合は idp_parameters を手動で設定する。 idp_parameters として設定できる項目は以下の通り。 SAML2_ISSUER IdPメタデータの entityID(IdP Entity ID)。IdP(AWS IAM Identity Center)を一意に識別する文字列。Snowflakeはこの値をIdPからの応答(SAML Response)の Issuer と照合し、正規のIdPからの応答かを検証する。 SAML2_SSO_URL IdPメタデータの SingleSignOnService Location。SP-initiatedログイン時、SnowflakeがブラウザをリダイレクトさせるIdPのログインエンドポイント。 SAML2_PROVIDER IdPの種別。OKTA、ADFS、Customのいずれか。専用値がないIdP(AWS IAM Identity Centerを含む)はすべて Custom を指定する。 SAML2_X509_CERT IdPメタデータの X509Certificate。IdPがSAML Responseに付与する署名を検証するための公開鍵証明書。ヘッダー(-----BEGIN CERTIFICATE-----)・フッター・改行を除いたBase64本文のみを指定する。 多層防御 ALLOWED_USER_DOMAINS SAML2セキュリティ統合で認証できるメールドメインの許可リスト。IdPから返ってきた識別子(login_name/emailとして扱われる値)の @ 以降がこのリストに含まれていないと、SAMLレスポンス自体は正当でも認証を拒否する。IdP設定ミスや、共有IdPで意図しないドメインのユーザーがログインできてしまう事故を防ぐための追加防御層。ただし、NameIDにメールアドレスではなくusernameを使う場合、@domain部分が存在せず、このパラメータで意味のある制限をかけることができない。メールアドレス形式のNameIDに切り替える場合は設定を検討する。 ALLOWED_EMAIL_PATTERNS SAML2セキュリティ統合で認証するメールアドレスが一致すべき正規表現のリスト。ALLOWED_USER_DOMAINS のドメイン単位の許可リストより柔軟な条件(例: .*@example.com$ や特定のローカルパートのみ許可など)を指定したい場合に使う。こちらも同様に、NameID(メールアドレス形式ではない)には適用できない。 SP_INITIATED flowの設定 SAML2_SP_INITIATED_LOGIN_PAGE_LABEL Snowflakeのログイン画面の「Log In With」ボタンの後に表示するラベル文字列。任意の名称でよい。 SAML2_ENABLE_SP_INITIATED ログインページに「Log In With」ボタンを表示するかどうか。TRUEでSP-initiatedログインが有効になる。FALSEの場合はIdP-initiatedのみ許可。 SAMLメッセージの署名 SAML2_SIGN_REQUEST SnowflakeがIdPに送るSAMLリクエスト(AuthnRequest)に署名するかどうか。署名させたい場合は TRUE にし、AWS側アプリ設定でリクエスト署名検証を有効化する必要がある。TRUEにするメリット: IdPからSPへの応答(SAMLレスポンス)はSAML2_X509_CERTによる署名検証で既に真正性が保証されているが、逆方向(SPからIdPへのAuthnRequest)は署名なしだと改ざん・なりすましを検知できない。TRUEにすることでIdP側がリクエストの送信元(Snowflake)と内容の完全性を検証できるようになり、偽装されたAuthnRequestでユーザーを不正なフローに誘導される攻撃への耐性が上がる。特にセキュリティ要件が厳しい環境や監査対応が必要な場合はTRUEを推奨。 SAML2_SNOWFLAKE_X509_CERT 指定しない場合Snowflakeが自己署名した証明書が使われるが、SAML2_SNOWFLAKE_X509_CERT により、第三者(CA)による証明書を設定できる。これにより、SPからIdPへのAuthnRequestを第三者(CA)による証明書(の秘密鍵)で署名するようになる。 SPからIdPへのAuthnRequestを署名する限界と価値 そもそも「証明書」とは「公開鍵」+「(誰かの)秘密鍵による発行者の署名」という構造となっている。第三者が署名するといっても、その第三者が何者か、についてはより上位の第三者が署名することで証明される。最終的には、ルートCAと呼ばれるトラストストアに登録されている事前登録済みの発行者に突き当たり、ルートCAは自己署名を行っている。ルートCAは特権的に自己署名が信頼される様子。WebサイトのSSL/TLS証明書と検証は、まさにこの証明書チェーンの検証を行なっている。 SAML連携において、SPとIdPはこうしたチェーン検証を行わず「指定された証明書を信頼する」という形で個別に相手の証明書を事前登録するだけなので、CA発行か自己署名かどうかは実質的にセキュリティ上の差を生まない。例えば次のようなシナリオでCA発行証明書が意味を持つ。 既に証明書の発行・更新・監査の仕組みが既にある場合、Snowflake固有の自己署名証明書を個別管理する対象として放置せず、他のシステムと同じ棚卸し、アラート、更新プロセスに組み込める。「Snowflakeだけの特殊な例外」として管理外になるリスクを排除できる。 ISO27001/SOC2などの監査で「証明書は組織の承認されたCAから発行されたものであること」という統制が求められている場合、自己署名証明書だと指摘対象になり得る。CA発行にしておけば、発行記録・Subject検証済みという裏付けを監査人に示せる。 NameID規格化 SAML2_REQUESTED_NAMEID_FORMAT SPからIdP方向の AuthnRequest 送信時、「私(SP)はこの形式のNameIDが欲しい」というIdPへのリクエストを送る。このフォーマットを SAML2_REQUESTED_NAMEID_FORMAT で指定する。 一方、IdPからSP方向の SAML Response受信時、Snowflakeが受け取ったNameIDの値をLOGIN_NAMEと文字列比較する処理において、単に文字列として比較されるだけなので、SAML2_REQUESTED_NAMEID_FORMAT に一致していなくて良い。 ログイン・ログアウト時の挙動 SAML2_POST_LOGOUT_REDIRECT_URL Snowflake Webインターフェースの「Log Out」ボタンをクリックした後にSnowflakeがユーザーをリダイレクトするエンドポイント。未設定の場合はSnowflakeの標準ログイン画面に戻る。IdPのログアウトページや社内ポータルなど任意のURLへ誘導したい場合に使う。ただしこれはSAML Single Logout(SLO、IdP側セッションも含めた一括ログアウト)ではなく、あくまでSnowflake側のログアウト完了後にブラウザを1回リダイレクトさせるだけの機能の様子。 SAML2_FORCE_AUTHN ユーザーが最初の認証フロー中に、Snowflakeへアクセスするための再認証を強制されるかどうか。TRUEにすると、ユーザーがAWSアクセスポータルに既にログイン済み(SSOセッションが生きている)でも、Snowflakeへのログインのたびに毎回IdPでの認証をやり直させる。NameIDの形式に関係なく設定可能。特に理由がなければデフォルトのままでよい。 SAML2_SNOWFLAKE_ISSUER_URL SnowflakeサービスプロバイダーのEntityID/Issuerを明示的に指定するパラメータ。省略時はSnowflakeが https://.snowflakecomputing.com をデフォルトとして自動生成する。DESC SECURITY INTEGRATION では、明示指定の有無にかかわらず現在の値が確認できる。 SAML2_SNOWFLAKE_ACS_URL IdPがSAML認証応答を送り返すSnowflakeのAssertion Consumer Service URLを明示的に指定するパラメータ。省略時はSnowflakeが https://.snowflakecomputing.com/fed/login をデフォルトとして自動生成する。DESC SECURITY INTEGRATION では、明示指定の有無にかかわらず現在の値が確認できる。 おわりに Snowflake SAML2.0 Security Integrationを使用したSP/IdP Initiated SAML Federationと構成について ドキュメントをまとめてみた。諸々煩雑な構成が必要だが、Snowflakeが綺麗にラップしていて、パラメタを理解して 設定するだけで、うまくSAML2.0フェデレーションを構成できることがわかった。

default eye-catch image.

Streamlit appをrestricted caller’s rightsで動作させる場合にコンテナインスタンスが必須となる背景を考えた話

行レベルセキュリティに頼ったマルチテナントは、実装ミスによる認可破綻が起こりやすいので怖い。 RBAC・インフラレベルで認可する仕組みができれば、appは認可コードを一切かかずに、 Snowflakeに認可の安全性を移譲できる。 しかし、これまでStreamlit in SnowflakeではOwner\'s rightsでしか動作せず、 実現ができなかった。6月1日に「Restricted caller\'s rights」が一般提供(GA)され、 caller\'s rightsで Streamlit を動作させられるようになった。 ただし、コンテナインスタンスが必須となる。 どういう仕組みで機能するのか気になったので調べてみた。 [arst_toc tag=\"h4\"] Restricted caller\'s rightsが一般提供(GA)された これまで、ストアドプロシージャ、SPCSサービス、Streamlit in SnowflakeアプリはOwner\'s role、 すなわち、リソースの所有者の権限でしか動作させることができなかった。 2026年6月1日に「Restricted caller’s rights」がGAされたことで、 これらのリソースをCaller\'s role、すなわち呼び出し元権限で動作させることが可能となった。 呼び出し元の権限次第で、Snowflake側のガバナンスが全く効かない、という世界線は存在せず、 「呼び出し元の権限」に対して「別のロールによる許可」で制限した権限でのみ、これらを実行できる。 混乱する代理人問題とCaller\'s right Caller\'s rightで動作する、ということは、所有者が書いたコードを、閲覧者の権限で実行するということ。 閲覧者の強い権限により「閲覧するだけのつもりだったがDROPできてしまった」みたいなことになる。 これを混乱する代理人問題と言い、権限を持つ閲覧者が所有者のコードに意図せず権限を貸している。 アプリが悪意を持っていたりアホだったりした場合に被害が拡大する要因となる。 GRANT CallerとCaller\'s rightの権限波及の仕組み 実行主体が所有者(Owner)から呼び出し元(Caller)に移るためSnowflake側の権限波及が大きく変わる。 管理者が別の管理者に MANAGE CALLER GRANTS することで、別の管理者は CALLER GRANTS できる。 別の管理者は CALLER USAGE, CALLER SELECT 等で「このアプリ(Owner\'s role)が閲覧者の代理として 使って良い権限」をホワイトリスト形式で指定する。 結果、閲覧者がたまたま ACCOUNTADMIN でも 管理者はアプリが許可した対象へのSELECTしか 行使できない。つまり、閲覧者がDROPやALTERを持っていたとしてもアプリ経由では実行できない。 閲覧者の身元・ポリシー(行/列制御)は活かしつつアプリが行使できる権限の上限は管理者が固定する、 が実現できるようになる。 なぜウェアハウスランタイムではCaller\'s right動作ができないのか Restricted caller’s rights and Streamlit in Snowflake 公式によると、ウェアハウスランタイムではCaller\'s right動作できない。 By default, all Streamlit in Snowflake apps run with the privileges of the owner, not the privileges of the caller. The Streamlit app developer can define whether a container-runtime app runs with owner’s rights or restricted caller’s rights. Restricted caller’s rights aren’t supported in warehouse runtimes. Restricted caller’s rights requires Streamlit version 1.53.1 or later. 以下が核心。Streamlit in SnowflakeアプリはSQLをOwner権限のストアドプロシージャ相当のサンドボックスで動く。だから、DESCRIBE/SHOW/LIST 制限などの「ストアドプロシージャ相当の制約」が付く。 Runtime environments for Streamlit apps Warehouse runtimes provide an on-demand, personal instance of the Streamlit app for each viewer. When a viewer opens the app, a new instance of the app is created for that viewer. Each viewer has their own isolated environment, which increases user load times. While both runtimes execute SQL queries using the owner’s privileges, apps using warehouse runtimes are subject to similar restrictions as owner’s rights stored procedures. For more information, see Owner’s rights stored procedures. ストアドプロシージャ準拠の権限モデルにはCaller\'s rightが想定されていなかったため、 同じ理由でStreamlit in SnowflakeでもCaller\'s rightができなかった。 Caller\'s right の本質は、所有者とは別の閲覧者としてのセッションを張る、ということ。 これは、アプリプロセスが複数の認証コンテキスト(OwnerとCaller)を同時に保持・維持する必要がある。 ステートレスなアプリのホスティングでセッション変数が揮発する問題の解決に、 ウェアハウスにステートを持たせるという解決策をとらないのは、それは当たり前だし、 そういう用途なら、常時稼働でステートを持てるコンテナウェアハウスが適当だよ、ということかな。 ストアドプロシージャにおけるOwner\'s right、Caller\'s rightについては以下があたる。 [clink implicit=\"false\" url=\"https://docs.snowflake.com/en/developer-guide/stored-procedure/stored-procedures-rights\" imgurl=\"https://www.snowflake.com/content/snowflake-site/global/ja/lp/snowflake-ai-data-predictions/_jcr_content/root/responsivegrid/container_882109576/container/flexible_column_cont/flexible_column_content_container_1/container/image.coreimg.svg/1768274028304/snowflake-logo-blue.svg\" title=\"Understanding caller’s rights and owner’s rights stored procedures\" excerpt=\"A stored procedure runs with either the caller’s rights or the owner’s rights. It cannot run with both at the same time. This topic describes the differences between a caller’s rights stored procedure and an owner’s rights stored procedure.\"] まとめ Streamlit in Snowflakeのクエリ実行は背後でストアドプロシージャが動作していることを理解した。 ストアドプロシージャはセキュリティのため、制限されたサンドボックスで動作している。 混乱する代理人問題の対応として、SnowflakeではCaller\'s rightsによる動作を許可していない。 このモデルでは、Caller\'s rights の入り込む余地がないか、既存の破壊的変更が必要となる。 そこで、常時稼働、ステートフルを前提としたコンテナインスタンスに Caller\'s rights を ホワイトリスト式で制限する「Restricted caller\'s rights」が導入された。

default eye-catch image.

Snowflake External OAuthについての公式ドキュメントを読んでみた話

はじめに Enterpriseにおいて「お前は誰か?」を確認する手段は非常に多岐にわたる。 セキュリティと絡んで手段は拡大傾向にあり、新しい認証手段への追従が求められるケースは多い。 自前で認証情報を保有、管理し、セキュリティの保証を担保した手順を用意するのは不可能に近い。 現実的には認証情報の保有と管理、および認証手段を専用のプラットフォームに移譲させたい。 実際、認証の泥臭いプロセスはIdP(Identity Provider)が面倒を見てくれる。 SnowflakeはIdPと薄く関係して、IdPによる認証結果を使い回すことができる。 SnowflakeはIdPがどういったプロセスで認証したのかは一切関与しない。 認証後、「お前にこの権限を与えて良いか?」を実装しなければならない場合、 アプリ側に機能サポートがなければ、コードでそれを保証しなければならない。 Snowflakeは、ここをExternal OAuth統合として汎化しフルにサポートしている。 具体的には、SnowflakeはExternal OAuth統合として汎化していて、 OAuth2.0認可サーバと統合し、RBACとの紐付けまでを面倒みてくれる。 RBACの最小範囲であるスキーマより細かい粒度を区別する場合でなければ、 RBACだけで区別が完了することとなり、大幅な工数削減と品質安定化を達成できる。 昔Fitbit APIのOAuth2.0フローを実装した時から始まり、 過去に何件かWebアプリ開発で認証認可まわりの実装をしたと思う。 Webアプリの認証認可F/Wはかなり枯れていて、正直中身を知らなくても書けてしまう。 開発者人口が少ないSaaSサービスであるSnowflakeがブラックボックス化した 認証認可の仕組みを読み解くのは、Webアプリのそれとは次元の違う大変さがある。 (こと認証認可の文脈では安全性の保証がセットとなるため) Snowflake External OAuthについて厳密に調べる機会があったので、 生成AIを使わず100%自分の思考と言葉で記事を起こしていく。 [arst_toc tag=\"h4\"] 認証(AuthN) 認証、つまり、Authenticationは、「お前は誰か」を確認すること。 IdPにID/PWを登録しておきID/PWを入力したりMFAを通ることで「確かに〇〇さんだ」と確認すること。 単一要素認証(SFA)、多要素認証(MFA)、パスキー認証、FIDO2認証、他、多様な認証方式がある。 またシングルサインオン(SSO)、により組織を跨ぐ連携を行うことができる。 サービス間のSSO方式としてSAML2.0、API等のSSO方式としてOIDC2.0が広く使われている。 顧客管理のIdPによる認証を本IdPに引き継ぐIDフェデレーションにより組織間認証連携を実現できる。 認可(AuthZ) 一方認可、つまり、Authorizationは、「お前にこの権限を与えて良いか」を確認すること。 認可とは「誰がどのデータにどんなルールでアクセスして良いか」をコントロールする設計パターン。 「ルール作りの設計思想」と「システム間で権限をやり取りする技術規格」がごっちゃに扱われがち だが、レイヤが異なる2つの話を分けておくと少しわかりやすくなる。 「ルール作りの設計思想」 例えば以下のようにルールを定める。 ロールベースアクセス制御/Role Based Access Control ユーザ個人ではなく役割に対して権限を付与しユーザをそのロールに所属させる方式。管理者権限のユーザには作成・削除を与え、一般権限のユーザには閲覧のみを与えるなど、一般的な認可方式。SnowflakeのロールモデルはまさにRBACに基づく。 属性ベースアクセス制御/Attribute Based Access Control ロールだけでなくユーザの所属、勤務地、アクセスする時間帯、デバイスの種類など、複数の属性(コンテキスト)を組み合わせて動的に認可を判断する方式。 「システム間で権限をやり取りする技術規格」 例えば以下のようにルールを実現する技術規格を表す。 OAuth2.0 現在のWebで最も普及している「トークンベース」の認可フレームワーク。認可サーバーが発行した「アクセストークン(時限式のカードキー)」をアプリが提示し、リソースサーバー(Snowflakeなど)がそれを検証してアクセスを許可する。「権限の証明書」としてJWT(JSON Web Token)が実際にやり取りされる。JWTは、SON形式のデータを暗号論的に署名したもので、中身に「ユーザー名」「有効期限」、「付与されたロール(権限スコープ)」などが書き込まれている。 ケルベロス認証・認可 (Kerberos) 主に一昔前からの 社内ネットワーク(Active Directory)環境などで広く使われている方式。チケット」と呼ばれる暗号化されたデータをやり取りすることで一度のログインで社内のファイルサーバーやプリンタなどの利用権限(認可)をシームレスに得る。 あああ External OAuth External OAuthは顧客のOAuth2.0認可サーバを統合してシームレスなSSOを実現する。 認証プロセスはサービス側が気にするものではなく、本機能は認可の統合であることに注意すること。 なお公式(外部 OAuth の概要)は間違いなく認証・認可と言う言葉をごっちゃにしている。 OAuth2.0はRFC6749でThe OAuth2.0 Authorization Frameworkと定義されている。 受け渡しされるトークンはOIDCのような認証トークンではなく、OAuth2.0の認可トークンである。 外部OAuthという(認可の)仕組みをSnowflakeに設定しておくことで、 「外部のIdPが認証したという証明書」をSnowflakeが安全に受け取ってデータアクセス認可する仕組みだ。 公式(外部 OAuth の概要)によると、以下に公式に対応している。 公式にない場合は、外部 OAuth 用のカスタム認証サーバーを構成するで構成できる。 なお「公式」でないからといって「非対応」ではない。「公式」になくても汎用OAuth2.0用のカスタム認証サーバーとして構成できる。 Okta - 外部OAuth用Oktaの構成 Auth0はOktaファミリーだが↑では構成できない。カスタム認証サーバーとして構成が必要 Microsoft EntraID - 外部 OAuth 用 Microsoft Entra ID の構成 Ping Identity PingFederate - 外部 OAuth 用 Microsoft Entra ID の構成 Microsoft PowerBI - Power BI SSO からSnowflakeへ 公式にはExternal OAuthのメリットとして以下が挙げられている。 トークンの発行を認証サーバーに委任し、発行されたトークンの管理に集中できるようになる。 ログイン時のセキュリティルール(MFAやIP制限、承認フローなど)を、Prj IdP側に統合できる。 ユーザがその認証と許可に関する厳しいルール(テスト)をクリアしない限り、IdPはトークンを発行しない。 怪しいユーザはSnowflakeの入り口にすら辿り着けず、データは完璧に守られる。 認証をIdPに持たせることでSnowflake側から認証情報を除去できるためセキュアになる。 一見して認証のことしか書かれていないようだが、implicitに認可が書かれている。 Snowflakeは認可をIdPに完全に移譲し、認証とセットで認可が行われたトークンを確認するだけ、 ということは、Snowflake側に認可コードを一切書くことなしに認可を実現することと同義。 External OAuthの認証部分の基本フロー 公式に基本フローの図が貼ってある。ステップ1だけ構成時にのみ行う。他は都度実行される。 最初にセキュリティ統合の構成と、アプリ内の実装が開発者側の責務となる。 ベスプラに従ってルールから逸脱しないように構成することで、後はSaaSサービス間の自動連携となる。 外部OAuth認証サーバとSnowflakeのセキュリティ統合を構成し信頼性を確立する ユーザはアプリを介してSnowflakeにアクセスしようとする。アプリはユーザを確認しようとする 認証サーバはOAuthトークンをアプリに返す SnowflakeドライバはOAuthトークンを使用して接続文字列をSnowflakeに渡す SnowflakeはOAuthトークンを検証する Snowflakeはユーザ検索を実行する Snowflakeはユーザのロールに基づいてセッションをインスタンス化する External OAuthの認可部分、スコープ いきなり「スコープ」というワードが出てくるが、これ、JWTの\"scope\"キー/バリューのこと。 OAuth2.0においてJWTで認可範囲を設定するのだ、という理解と記憶がなければ読めない。 JWTは以下のような構成となっておりscopeを格納する場所がある。 認可サーバ側で何らかの許可処理の結果、ユーザのスコープが決まり、Snowflakeに送られる。 このトークンがSnowflakeに届くと、Snowflakeはscopeキーのバリューを読み取り、 「このユーザにはST_USER_ROLEというロール(権限)を適用してセッションを始めるべき」と判断する。 { \"iss\": \"https://your-project-idp.auth0.com/\", \"sub\": \"user_12345\", \"email\": \"user@client.com\", \"exp\": 1719100000, \"scope\": \"session:role:ST_USER_ROLE\" <-- 🌟これが「スコープ」 } Okta, PingFederate, カスタムの場合は以下のパターンを使用しなければならない。 スコープ 説明 session:role:<custom_role> Snowflakeのカスタムロールにマップする。例えばsession:role:ST_USER_ROLEで、ST_USER_ROLEにマップ session:role:public Snowflakeの PUBLIC ロールにマップ session:role-any 外部OAuthサーバでのSnowflakeロール管理を行わない場合これを渡す。特定のロールを固定せず、そのユーザに付与されているロールであれば、ログイン後に自由に切り替えて(USE ROLEして)使って良い、という少し緩めの認可 なお、以下のビルトインロールはデフォルトではブロックされる。 ACCOUNTADMIN GLOBALORGADMIN ORGADMIN SECURITYADMIN Snowflake OAuthは、セッション中のロールのセカンダリロールへの切り替えをサポートしていないが、 External OAuthでのセカンダリロールの使用はサポートしている。 External OAuth特有のセキュリティの抜け穴と対策 Snowflakeにおいて、アカウントレベルでネットワークポリシーによりIP制限をかけていたとしても、 External OAuthと合わせて構成するSecurity Integrationを経由してログインしてくる場合、 そのユーザ個人のIP制限が無視されてしまう、という仕様がある。 つまり、IdP側のIP制限が破られたり、トークンが盗まれたりした場合、 攻撃者はどこからでもSnowflakeのデータにアクセスできてしまう状態になる。 Snowflakeは、External OAuth自体にもネットワークポリシーを直接紐づけることを推奨している。 具体的にはSecurity Integrationにネットワークポリシーを直接紐づける。 これによりIdPから届いたトークンであっても、ネットワークポリシーで許可されたIPアドレス以外からの リクエストであれば、Snowflakeはセッションを開始しない。 これはIdPフェデレーション等で複雑化したユーザ組織の通信経路を全て把握する必要性を言っている。 こういうの、デフォルトで安全側に振って欲しいなとは思う。 カスタム認証サーバーの構成・トークンペイロード要件 カスタム認証サーバーがSnowflakeに送信するアクセストークンには、下表が含まれている必要がある。 クレーム 説明 scp Snowflake のカスタムロールを指定する文字列が含まれていること。値として session:role:ST_USER_ROLE のような Snowflake 指定の形式の文字列を、配列またはスペース区切りの文字列で必ず埋め込まなければならない。 scope 同上。IdPプロダクトによりscpかscopeのどちらかを入れる。 aud Snowflake アカウントの完全な URL(https://.snowflakecomputing.com)が含まれている必要がある。 exp 有効時間。トークンの有効期限が UNIX タイムスタンプ(エポック秒)で刻まれている必要がある。Snowflake はトークンを受け取った瞬間の時刻とこの exp を比較します。有効期限が過去の時刻になっている(期限切れ)場合は、その時点で認可を即座に拒否する。 iss 発行者。アクセストークンを発行したプリンシパルを文字列 URI として識別。つまりトークンを発行した IdPのアイデンティティ(例: https://your-project-idp.auth0.com/)。最後のスラッシュ(/)の有無まで1文字違わず一致させる必要がある。Snowflake 側の EXTERNAL_OAUTH_ISSUER で指定した文字列と完全に一致する必要がある。 iat 発行時刻。必須。JWT が発行された時刻を識別 カスタム認証サーバーの構成・セキュリティ統合の作成 External OAuth を実現する Snowflakeのリソースの実体。 カスタム認証サーバからのアクセストークンと安全に通信して検証し、アクセストークンに 関連付けられたユーザーロールに基づいてSnowflakeへのアクセスをユーザに提供する。 create security integration external_oauth_custom type = external_oauth enabled = true external_oauth_type = custom external_oauth_issuer = \'\' external_oauth_rsa_public_key = \'\' external_oauth_audience_list = (\'\', \'\') external_oauth_token_user_mapping_claim = \'upn\' external_oauth_snowflake_user_mapping_attribute = \'login_name\'; それぞれの内容は下表の通り。 パラメータ 説明 EXTERNAL_OAUTH_ISSUER 外部認証サーバー(IdP)を一意に識別するURL(発行元URL)を指定する。IdPから発行されるアクセストークン(JWT)の iss クレームの値と完全に一致する必要がある。 EXTERNAL_OAUTH_JWS_KEYS_URL 外部認証サーバーが公開している、デジタル署名の検証に必要な公開鍵(JWKS)が配置されたURLを指定する。SnowflakeはこのURLにアクセスしてトークンの妥当性を検証する。 EXTERNAL_OAUTH_TOKEN_USER_MAPPING_CLAIM 外部認証サーバーが発行するアクセストークン(JWT)の中で、ユーザーの識別情報(メールアドレスやユーザーIDなど)が格納されている「キー(クレーム名)」を指定する。 EXTERNAL_OAUTH_SNOWFLAKE_USER_MAPPING_ATTRIBUTE トークンから抽出したユーザー識別情報を、Snowflake側の USER オブジェクトのどの属性(EMAIL_ADDRESS または LOGIN_NAME)と一致させるかを指定する。 カスタム認証サーバーの構成・テスト 公式では、最短パスで構成を検証するため、curl で HTTP Post を送る手順が書かれている。 IdP側にテストユーザを作成しておく。テストユーザはパスワードを持つ必要がある Snowflake側にも、上記と同じメールアドレス(または識別子)を持つ USER オブジェクトを事前に作っておく。login_name, または emailでマッピングする IdP側の画面でこのテスト用のアカウントを作成し、専用のClientID, ClinetSecretを取得する 次のように、 OAuth 2.0クライアントがカスタムトークンエンドポイントに POST リクエストすることを許可 OAuth 2.0の用語でいう grant_type = password(Resource Owner Password Credentials Grant)方式を使うこと。すなわち「リソース所有者に設定された付与タイプ」であり、アプリ画面を介さず、ユーザーのID/PWを直接リクエストに含めてトークンを即時発行してもらう、テスト専用の最短ルートを構築する。 準備で用意したclientID と clientSecretをHTTP Basic認証ヘッダーに含めること リクエストのBody(送信データ)には、FORM形式(application/x-www-form-urlencoded)で、テストユーザーのID/PWと、Snowflakeに渡したいスコープを指定すること curl -X POST -H \"Content-Type: application/x-www-form-urlencoded;charset=UTF-8\" --user : --data-urlencode \"username=\" --data-urlencode \"password=\" --data-urlencode \"grant_type=password\" --data-urlencode \"scope=session:role:analyst\" 公式対応認証サーバーと非公式(カスタム対応)の違い 公式対応認証サーバーと、非公式(カスタム対応)の違いをまとめてみる。 ケース1:IdPの「署名用公開鍵」がローテーション(変更)されたとき JWT(トークン)が偽造されていないかを証明するための「公開鍵」は、 セキュリティ担保のために数ヶ月ごとに自動で新しいものにローテーションするのが一般的。 公式対応の場合、SnowflakeがOkta側の鍵更新スケジュールや新しい公開鍵の 取得先をあらかじめ知っているため、Snowflake側が自動で追従する。 開発者は何のアクションも起こす必要はなく、システムは止まらない。 カスタム、つまり非公式の場合であっても基本的には指定したURL (.well-known/jwks.json)を見に行ってくれるので自動追従するが、 もしIdP側のメジャーアップデート等で「公開鍵を配置するURLの仕様そのもの」 が変わった場合は、Snowflakeの設定パラメータ(EXTERNAL_OAUTH_JWS_KEYS_URL) を開発者が手動で新しいURLに書き換えるまで、認証・認可がすべてエラーになってシステムが停止する。 IdP側のセキュリティ仕様やエンドポイントの仕様が変更されたとき 近年、サイバー攻撃の高度化に伴い、IdP側(OktaやMicrosoftなど)がトークンの発行ルールや、 検証用APIの仕様(プロトコル)をより安全なものへ強制アップデートすることがある。 SnowflakeはOktaやMicrosoftと強固な技術パートナーシップを結んでいるため、 IdP側の仕様変更がリリースされる前に、Snowflake側の「特急レーン(専用プログラム)」を 事前にアップデートして追従させる。そのため、開発者がコードや設定を修正することなく、 シームレスに新しいセキュリティ基準へ移行できる。 カスタム、つまり非マネージドの場合、Snowflakeは「汎用的なOAuth 2.0の標準規格(RFC)」に 準拠していることしか保証しない。そのため、IdP側が独自のセキュリティ拡張を行ったり、 標準規格の解釈を変更したりした場合、トークンのペイロード構造(キー・バリュー)が変わり、 Snowflakeがトークンを解読できなくなるリスクがある。 この場合、開発者がIdP側の設定を手動で修正して追従する必要がある。 まとめ SnowflakeにおけるExternal OAuth統合の仕組みを「認証」と「認可」のレイヤを分離して読んでみた。 認証・認可を完全にIdPに移譲し、Snowflakeアプリケーション内で一切の認可コードを書かずに済む。 数あるIdPのうち、いくつかについてはSnowflakeが公式対応している。 公式IdP構成はテクノロジーパートナーシップに基づき、Snowflakeのマネージド構成の一部として、 Snowflake側がIdP側の変更に自動追従する可能性が高い。結果としてダウンタイムの発生を回避できる。 公式対応IdPでなくても、OAuth2.0 RFC準拠の認証サーバとしてカスタム連携することができるが、 SnowflakeがIdP側の変更に自動追従する性質ではなく、運用者・開発者がIdP側の変更に適用する必要がある。

default eye-catch image.

Streamlit in Snowflakeの開発環境を整備して初めてのアプリケーションを実装した話

はじめに Streamlit in Snowflakeの開発を開始するには、Snowflakeアカウント、適切なIDE設定、ローカル開発環境の構築といった複数のステップが必要。この記事では、前提条件の確認、アプリケーション実装といった標準的なセットアップ手順をまとめる。 前提条件と必須の準備作業 Streamlit in Snowflakeの開発を始める前に、複数の前提条件を満たす必要がある。 前提条件の詳細: Snowflakeアカウントへのアクセス - 有効なSnowflakeアカウントと、CREATE APPLICATION PACKAGE 権限を持つロールが必須である。ロール設計を行い、この権限を付与したカスタムロールを使用する Pythonの開発環境 - Python 3.8以上がインストールされており、pipやcondaといったパッケージ管理ツールが動作する状態が前提である。Streamlit in SnowflakeはPython 3.10以上での動作を推奨している Snowparkライブラリ - ローカル開発環境にsnowpark、snowflake-snowpark-python といったパッケージをインストール済みであることが必須 Snowflake CLIツール - Snowflake提供の公式CLIツール(snow)をシステムに導入する必要がある。このツールを通じてSnowflakeを対話的に操作する 認証情報の管理 - ローカル開発では、Snowflakeへの接続情報をコードに埋め込まないことが重要である。環境変数、~/.snowsql/config ファイル、またはキーペア認証を使用して管理する。本番環境へのデプロイ時には、AWS Secrets Manager、Azure Key Vault、HashiCorp Vaultといった外部認証サービスの利用が推奨される IDE統合と開発環境の構築 Visual Studio Codeの統合により、ローカル開発フェーズ全体をエディタ内で完結させられる。Pythonコード編集、ローカルテスト実行(`streamlit run` での動作確認)、Snowflakeへのクエリ検証、デプロイまでの準備がVS Code内で実現される。一方、デプロイ後の本番環境ではSnowflakeウェブコンソール内でアプリケーションが動作する。公式のSnowflake拡張機能を利用することで、Snowflakeへの接続管理、SQL文の実行、デバッグが統一されたインターフェース内で実現される。 IDE統合のセットアップ手順: Visual Studio CodeにSnowflake拡張機能をインストールする。拡張機能マーケットプレイスから「Snowflake」を検索し、公式のSnowflake Inc.提供版をインストールする 拡張機能をインストール後、接続設定ファイル(通常は~/.snowsql/config)を確認し、接続情報が正確に記述されていることを検証する コマンドパレット(Ctrl+Shift+P または Cmd+Shift+P)からSnowflakeの接続を確立する。接続テストが成功することで、Snowflakeへの通信が確認される SQLエディタを開き、簡単なクエリ(例:SELECT CURRENT_USER())を実行してSnowflakeとの疎通確認を行う IDE統合によって、ローカルでのPythonコード編集とSnowflakeのデータ参照が同一画面で実現され、開発の効率が劇的に向上する。 GitHub Codespacesでの開発も可能: ローカルマシンの環境管理を避けたい場合、GitHub Codespacesを使用した開発も実用的に使用できる。CodespacesにおいてSnowflake拡張機能、Snowflake CLI、Streamlit CLIがサポートされている。`streamlit run` コマンドで起動したアプリケーションはCodespaces内で自動的にポート転送され、ブラウザプレビューが利用可能である。環境構築を.devcontainer/devcontainer.jsonで定義すれば、チーム全体で統一された開発環境を即座に立ち上げられる。 ローカル開発環境のセットアップ ローカルマシンでStreamlit in Snowflakeアプリケーションを開発するには、複数のPythonパッケージが必要である。仮想環境の構築を通じて、プロジェクト固有の依存関係を隔離することが実務上の標準である。 開発環境の選択肢について: Docker環境を用いてローカル開発環境を構築することも技術的には可能だが、本番環境がSnowflake内の管理コンテナ上で実行されるため、ローカルのDocker環境と本番環境の構成を統一することはできない。開発環境をDockerで隔離したとしても、本番デプロイ時には別の実行環境へ移行するため、Docker化による環境共通化のメリットは限定的である。仮想環境による環境隔離で十分であり、Docker導入による複雑さの増加は費用対効果が低い。チーム規模が大きく、開発環境の統一が重要な場合のみDocker化を検討する価値がある。 # 仮想環境を作成 python3 -m venv streamlit_env # 仮想環境を有効化(macOS/Linux) source streamlit_env/bin/activate # 仮想環境を有効化(Windows) streamlit_envScriptsactivate # 必須パッケージをインストール pip install streamlit pip install snowflake-snowpark-python pip install snowflake-cli-labs # requirements.txtを作成し、プロジェクトの依存関係を記録 pip freeze > requirements.txt requirements.txtファイルの内容例: streamlit==1.28.0 snowflake-snowpark-python==1.10.0 snowflake-cli-labs==2.0.0 pandas==2.0.0 仮想環境の隔離により、異なるプロジェクト間での依存パッケージの競合を回避できる。これは本番環境へのデプロイ時にも重要であり、requirements.txtはアプリケーションと共にSnowflakeにアップロードされる。 ローカルでの初期テストと動作確認 ローカル開発環境が構築されたら、Streamlitが正常に動作するか確認する必要がある。最小限のアプリケーションコードでSnowflakeへの接続テストを行う。 # app.py import streamlit as st from snowflake.snowpark.context import get_active_session st.title(\"Streamlit in Snowflake - 初期テスト\") try: session = get_active_session() user = session.sql(\"SELECT CURRENT_USER()\").collect()[0][0] st.success(f\"Snowflakeへの接続成功。現在のユーザー: {user}\") except Exception as e: st.error(f\"接続エラー: {str(e)}\") # 簡単なデータクエリ if st.checkbox(\"テーブル一覧を表示\"): try: databases = session.sql(\"SHOW DATABASES\").collect() st.write(f\"利用可能なデータベース数: {len(databases)}\") except Exception as e: st.error(f\"クエリ実行エラー: {str(e)}\") このテストアプリケーションを実行する場合、ローカルではStreamlit CLIでの実行が可能である。 streamlit run app.py ただし、ローカルでの実行にはSnowflakeへの認証情報が必要である。環境変数で接続情報を提供する方法が一般的である。 必須の環境変数: SNOWFLAKE_ACCOUNT - Snowflakeアカウント識別子(例:xy12345.us-east-1) SNOWFLAKE_USER - ログインユーザー名 SNOWFLAKE_PASSWORD - ユーザーのパスワード(パスワード認証の場合) SNOWFLAKE_WAREHOUSE - クエリ実行用のウェアハウス名 SNOWFLAKE_DATABASE - デフォルトのデータベース名 SNOWFLAKE_SCHEMA - デフォルトのスキーマ名 キーペア認証を用いる場合は、SNOWFLAKE_PASSWORD の代わりに SNOWFLAKE_PRIVATE_KEY_PATH と SNOWFLAKE_PRIVATE_KEY_PASSPHRASE を設定する。環境変数の設定例: export SNOWFLAKE_ACCOUNT=\"xy12345.us-east-1\" export SNOWFLAKE_USER=\"developer_user\" export SNOWFLAKE_PASSWORD=\"your_secure_password\" export SNOWFLAKE_WAREHOUSE=\"dev_warehouse\" export SNOWFLAKE_DATABASE=\"analytics_db\" export SNOWFLAKE_SCHEMA=\"dev_schema\" # その後、streamlit run app.py を実行 streamlit run app.py 別の方法として、~/.snowsql/config ファイルに接続情報を記述し、Snowpark が自動的に読み込む設定も可能である。 初めてのアプリケーション実装 前提条件とローカル環境が整備されたら、Snowflakeアカウント内に実際のアプリケーションを作成する準備が整う。最小限のアプリケーションを実装し、Snowflakeへのデプロイが正常に機能することを確認する。 最小限のアプリケーション実装: # app.py import streamlit as st st.title(\"初めてのアプリケーション\") st.write(\"Hello World.\") このシンプルな実装で、Streamlit in Snowflakeへのデプロイが正常に完了し、本番環境でUIが表示されることを確認できる。Snowflake側でアプリケーション作成用のステージとメタデータを準備する必要がある。 -- Snowflakeで実行:アプリケーション用のステージを作成 CREATE STAGE IF NOT EXISTS app_stage; -- アプリケーション設定ファイルを準備 -- manifest.ymlを作成してステージにアップロード Snowflake CLIを使用して、ローカルのアプリケーションコードをSnowflakeにデプロイする。 # Snowflake CLIでプロジェクトを初期化 snow project init # ローカルの開発コードをSnowflakeにデプロイ snow app deploy デプロイ後の検証: Snowflakeウェブコンソールにログインし、アプリケーション一覧から新規作成したアプリケーションが表示されていることを確認する アプリケーションをクリックして開き、UIが正常に表示され、Snowflakeへのクエリが実行される状態を確認する デプロイ後の最初の実行はコールドスタートのため、数秒の遅延が発生するが、以後のアクセスは高速化される 以下はエラーハンドリングを組み込んだ実装例。Snowflake環境において発生したネットワークエラー、タイムアウト、権限不足といった例外を補足し表示してみた。 # エラーハンドリングを含む実装例 import streamlit as st from snowflake.snowpark.context import get_active_session st.set_page_config(page_title=\"データダッシュボード\", layout=\"wide\") try: session = get_active_session() st.header(\"データ参照アプリケーション\") # ユーザー情報の取得 current_user = session.sql(\"SELECT CURRENT_USER()\").collect()[0][0] st.sidebar.write(f\"ユーザー: {current_user}\") # データベース選択 db_list = session.sql(\"SHOW DATABASES\").collect() databases = [row[1] for row in db_list] selected_db = st.selectbox(\"データベースを選択\", databases) st.success(f\"接続完了\") except Exception as e: st.error(f\"エラーが発生しました: {type(e).__name__}\") st.info(\"管理者に連絡してください\") まとめ 本記事では、前提条件の確認、IDE統合(Visual Studio Code、Snowflake拡張機能のセットアップ)、GitHub Codespacesでの開発環境構築の検討、ローカル開発環境の準備(仮想環境、パッケージインストール、Docker化の考慮を含む)について言及した。また、ローカルテスト実行時の環境変数設定方法についてまとめた。最後に、最小限のアプリケーションを実装し、Snowflakeへデプロイ後、動作確認を行なった。

default eye-catch image.

Streamlit in Snowflakeにおける分離コンテナ環境とセッション管理の仕組みを理解した話

はじめに Streamlit in Snowflakeで本番環境のアプリケーションを構築する際、実行環境とセッション管理の仕組みを理解することは必須である。標準的なStreamlitとは異なり、Snowflake統合版はSnowflakeの管理するコンテナ内で実行され、アプリケーションのライフサイクル、パフォーマンス特性、状態管理が大きく異なる。本稿では、この実行モデルの核心部分に焦点を当て、本番環境での実装判断に必要な知識を整理する。標準的なStreamlitの開発経験がある技術者であっても、Snowflake統合版の独特なアーキテクチャを把握することで、より堅牢で効率的なアプリケーション設計が可能となる。 Snowflakeの管理するコンテナ内での実行 Streamlit in Snowflakeのアプリケーションは、Snowflakeのアカウント内で管理された隔離されたコンテナプロセス上で実行される。ローカルマシンのPythonプロセスのように直接制御することはなく、Snowflakeのインフラストラクチャが実行環境全体を統制する。 実行環境の核心的な特性: 各アプリケーションはSnowflakeのアカウント領域内で独立した仮想環境として分離されており、他のテナントや他のアプリケーションとの干渉を受けない アプリケーションの起動、実行、終了はSnowflakeの制御下にあり、ユーザーのアクセスパターンに応じた動的なスケーリングが自動的に実行される Pythonランタイムは事前にコンテナ内にプリロードされており、ユーザーがアプリケーションにアクセスした時点でコードの実行が即座に開始される コンテナはステートレスな設計であり、複数のユーザーセッション間でローカルのファイルシステム上の状態は保持されない メモリ、CPU、ネットワーク帯域幅などのリソースは制限されており、無限に大規模なデータセットをメモリに展開することはできない この設計により、スケーラビリティと管理負荷の削減が実現される。開発者はインフラストラクチャの保守運用から解放され、アプリケーション本体の開発に集中できる。一方で、アプリケーション開発者は「各セッションは独立している」「ローカル状態は永続しない」という前提でコーディングする必要があり、この認識がなければ本番環境で予期しない動作が発生する可能性がある。 ExecutionContextとSnowflakeのセッション情報へのアクセス Streamlit in Snowflakeで最も重要な概念がExecutionContextである。これはSnowflakeのセッション情報とアプリケーション実行の状態を統合したオブジェクトであり、アプリケーションコード内から直接アクセスすることが可能である。 ExecutionContextを通じて、認証済みユーザーの識別子、割り当てられたウェアハウス、セッションのロール情報、現在のデータベースとスキーマといった情報が取得できる。これらの情報はSnowflakeの権限管理体系と一体化しており、アプリケーションが実行するすべてのSQL文はこのコンテキストの権限に基づいて検証される。 from snowflake.snowpark.context import get_active_session session = get_active_session() # 現在のユーザーを取得 current_user = session.sql(\"SELECT CURRENT_USER()\").collect()[0][0] # 割り当てられたウェアハウスを確認 current_warehouse = session.sql(\"SELECT CURRENT_WAREHOUSE()\").collect()[0][0] # 現在のロール情報 current_role = session.sql(\"SELECT CURRENT_ROLE()\").collect()[0][0] # アプリケーション領域のスキーマを取得 current_schema = session.sql(\"SELECT CURRENT_SCHEMA()\").collect()[0][0] ExecutionContextから取得可能な情報の実用的な用途: 認証済みユーザーID:このユーザーが属するテナント、部門、権限レベルをデータベースから検索し、表示内容を動的に制御する基盤となる 割り当てられたウェアハウス:クエリの実行リソースがどのウェアハウスに割り当てられるかを把握し、多くの重い処理が実行される時間帯を避けるといった最適化判断に活用される セッションのロール情報:ロールベースアクセス制御の実装において、現在のユーザーが実行可能な操作を制限する際に利用される 現在のデータベースおよびスキーマ:アプリケーションが参照するテーブルやストアドプロシージャの名前空間を把握し、正確なクエリを構築する際に用いられる ExecutionContextはSnowflakeの行レベルセキュリティ(RLS)および動的データマスキング(DDM)と組み合わせることで、マルチテナント環境でのデータ分離を実装できる。ユーザーが属するテナント情報をExecutionContextから抽出し、その情報をSQLクエリに動的にフィルタリング条件として付与するパターンが一般的である。 セッション状態の管理と永続化戦略 Streamlit in Snowflakeでは、標準的なStreamlitの`st.session_state`メカニズムが使用される。ただし、その永続性と可用性については、通常のStreamlitと異なる考慮が必要である。 セッション状態の保持期間と動作: ユーザーがブラウザを閉じるまで、またはセッションのタイムアウト(デフォルト約60分)が発生するまで、`st.session_state`に格納されたPythonオブジェクトは保持される セッション終了後、メモリ上の状態は完全に消失し、その後のユーザーアクセスでは初期化された状態から再出発する 複数のユーザーセッションが並行して実行される場合、各セッションのメモリ空間は完全に独立しており、相互干渉は発生しない 分散環境ではコンテナのリバランシングが発生する可能性があり、メモリ内状態への依存度が高いと予期しない状態喪失が発生する危険性がある セッション状態を効果的に使用するパターンとしては、ユーザーの入力フォーム状態、フィルタ条件、ページネーション状態、一時的なキャッシュなど、セッション内での短期的な状態に限定することが推奨される。 import streamlit as st from snowflake.snowpark.context import get_active_session session = get_active_session() # セッション状態で一時的なUIフィルタを保持 if \'selected_date_range\' not in st.session_state: st.session_state.selected_date_range = (None, None) if \'filter_status\' not in st.session_state: st.session_state.filter_status = \'all\' # ユーザーインタラクションでセッション状態を更新 date_range = st.date_input(\"期間を選択\", st.session_state.selected_date_range) st.session_state.selected_date_range = date_range # 永続化が必要な設定はSnowflakeテーブルに明示的に保存 if st.button(\'設定を保存\'): current_user = session.sql(\"SELECT CURRENT_USER()\").collect()[0][0] session.sql(f\"\"\" UPDATE user_preferences SET ui_settings = parse_json(?) WHERE user_id = ? \"\"\", params=[str(st.session_state.filter_status), current_user]).collect() st.success(\"設定を保存しました\") セッション終了後も保持する必要があるデータ(ユーザー設定、保存された検索条件、レポート結果など)は、Snowflakeのテーブルに明示的に書き込む必要がある。この明確な分離により、アプリケーションの動作が予測可能になり、バグの温床となる隠れた状態共有が回避される。 パフォーマンス特性とコールドスタート最適化 Streamlit in Snowflakeのパフォーマンス特性は、コンテナの起動時間、リソースの割り当て、クエリの実行効率によって大きく影響を受ける。 パフォーマンスに関わる重要な指標: 初期化時間:ユーザーがアプリケーションに初めてアクセスする際、Snowflakeがコンテナを起動し、Pythonランタイムを初期化するまでに3秒から10秒程度要する場合がある。これをコールドスタートと呼ぶ SQLクエリ実行時間:SQLクエリの実行時間は主にSnowflakeのクエリプランニングと分散処理の時間に依存し、ネットワークレイテンシは相対的に最小限である メモリ制限:各コンテナプロセスのメモリは制限されており、gigabyte単位の大規模なデータセットを一度にメモリにロードすることは技術的に不可能である リソース競合:同一のウェアハウス上で複数のアプリケーションやクエリが並行実行される場合、リソース争奪による性能低下が発生する可能性がある キャッシュ効果:頻繁にアクセスされるテーブルやクエリ結果はSnowflakeの内部キャッシュに保持され、2度目以降のアクセスは高速化される 本番環境ではコールドスタート対策として、アプリケーション初期化時の処理を最小化し、必要なデータは遅延読み込みするパターンが採用される。また、複雑な分析処理やデータ変換はSnowflakeのストアドプロシージャに委譲し、アプリケーション層では結果の表示と対話的なUIの提供に専念することが効率的である。 ウェアハウスとリソース割り当ての考慮 Streamlit in Snowflakeのアプリケーションが実行するすべてのSQL文は、指定されたウェアハウスのコンピュート能力を消費する。ウェアハウスの選択は、クエリの実行速度、同時実行可能なセッション数、運用コストに大きな影響を与える。 ウェアハウス選択の実務的考慮: 小規模なウェアハウス(XSMALL、SMALL)はコストが低く、軽量なクエリや低アクセス頻度のアプリケーションに適しており、一方で大量のユーザーからの並行アクセスには不向きである 大規模なウェアハウス(LARGE、XLARGE以上)は並行クエリ処理の能力が高く、多くのユーザーからの同時アクセスに対応できるが、アイドル状態であってもコストが発生する オートスケール機能を有効にすることで、負荷に応じたウェアハウスの自動拡張が可能になり、ピーク時の対応と非ピーク時のコスト削減を両立させられる 複数のアプリケーションが同一ウェアハウスを共有する場合、負荷分散戦略を立案し、一つのアプリケーションの過度なリソース消費が他のアプリケーションに悪影響を与えないようにする必要がある リソースの効率的な利用と高いパフォーマンスの両立は、ウェアハウスのサイズ選択、クエリの最適化、適切なキャッシング戦略によって初めて実現される。 まとめ Streamlit in Snowflakeは、Snowflakeの管理するマネージドコンテナ環境内で動作し、ExecutionContextを通じてSnowflake側のセッション情報に直接アクセスできるアーキテクチャである。セッション状態は短期的なUI状態の保持に限定し、永続化が必要なデータはSnowflakeテーブルに委譲することが設計の原則である。また、コールドスタートやリソース共有の課題を念頭に置いて、初期化処理の最小化とクエリの最適化によるパフォーマンス改善アプローチを検討する必要がある。これらの理解があれば、本番環境での実装判断が格段に容易になり、堅牢で拡張性の高いアプリケーション設計が可能となる。

default eye-catch image.

React+Next.jsでDummy JSONのCRUDをCSR/SSRの両方で作成して違いを調べてみた話

Next.jsでCRUDを作りSSRの挙動を調べてみた。いったんPage Routerを選択。 バックエンドとなるAPIとしてDummyJSONのPosts-Docs APIを使用した。 一覧、詳細、更新、削除が用意される。ただし更新、削除はダミーで永続化されない。 [clink implicit=\"false\" url=\"https://dummyjson.com/docs/posts\" imgurl=\"https://dummyjson.com/public/img/hero-image.svg\" title=\"Free Fake REST API for Placeholder JSON Data\" excerpt=\"Develop, Build, Test.Get instant dummy JSON data for your frontend with DummyJSON Server — no backend setup needed!\"] 目次は以下。 [arst_toc tag=\"h4\"] 構成 CSR版/SSR版の2パターンについてCRUDを行うアプリをClaude(Sonnet4.5)で環境を構築した。 ルーティングについては今回の調査範囲外のため、いったんシンプルなPage Routerを使用した。 npm run dev で next dev --turbopack が動く何かが作られた。turbopackはrust製のwebpackの後継。 いったん実行環境の詳細な把握をスキップして上物の理解を進めることにする。上物の構成は以下。 . ├── app/ │ ├── page.tsx # ホームページ │ └── posts/ │ ├── page.tsx # 投稿一覧選択(CSR/SSR) │ ├── csr/ │ │ ├── page.tsx # 投稿一覧(Client Component版) │ │ ├── [id]/ │ │ │ ├── page.tsx # 投稿詳細(Client Component版) │ │ │ └── edit/ │ │ │ └── page.tsx # 投稿編集(Client Component版) │ │ └── new/ │ │ └── page.tsx # 新規投稿作成(Client Component版) │ ├── ssr/ │ │ ├── page.tsx # 投稿一覧(Server Component版) │ │ ├── [id]/ │ │ │ ├── page.tsx # 投稿詳細(Server Component版) │ │ │ └── edit/ │ │ │ └── page.tsx # 投稿編集(Server Actions版) │ │ └── new/ │ │ └── page.tsx # 新規投稿作成(Server Actions版) │ └── _components/ │ └── DeleteButton.tsx # 削除ボタン(Client Component) ├── lib/ │ └── api.ts # API関数 ├── types/ │ └── post.ts # 型定義 ├── Dockerfile # Dockerイメージ設定 ├── docker-compose.yml # Docker Compose設定 └── next.config.ts # Next.js設定 以下のようなアプリができた。 [video width=\"2280\" height=\"1792\" webm=\"https://ikuty.com/wp-content/uploads/2025/10/recording.webm\"][/video] ReactとNext.jsの関係性と役割分担 ReactはUIを作るためのJavaScriptライブラリとして機能する。コンポーネント、フック、JSXを提供する。 Next.jsはReactを使ったフレームワークであり、ルーティング、ビルド、最適化などの機能を提供する。 React (ライブラリ) Next.js (フレームワーク) 役割の焦点 UI (ユーザーインターフェース) 構築 Webアプリケーション全体の構築 主な提供物 コンポーネント (UI要素)、JSX、Hooks (状態管理など) ルーティング、レンダリング戦略、最適化機能、バックエンド連携 ルーティング 非搭載。別途React Routerなどのライブラリが必要。 ファイルベースルーティングが組み込みで提供される。 レンダリング クライアントサイドレンダリング (CSR) が基本。ブラウザでJavaScriptが実行されてUIを描画する。 プリレンダリング (SSR/SSG) が組み込みで提供される。レンディングのタイミングと場所を制御する。 データ取得 非搭載。fetch APIなどをコンポーネント内で使用する。 データ取得パターン (Server Components, Route Handlersなど) とキャッシュの仕組みが組み込みで提供される。 クライアントサイドレンダリング(CSR) ブラウザ側で動的にHTMLを生成する。useState、useEffect、イベントハンドラが使える。 Step1.初期レンダリング(サーバ側) app/layout.tsx がサーバーで実行される。<html><body>の枠組みを作る。 app/posts/[id]/page.tsxがClinet Componentとして認識され初期HTMLを作る。 ブラウザに初期HTMLを送り、ブラウザは初期HTMLを表示する // app/layout.tsx (20-34行目) export default function RootLayout({ children }: { children: React.ReactNode }) { return ( <html lang=\"en\"> <body> {children} {/* ← ここに子コンポーネントが入る */} </body> </html> ); } ... //ブラウザに送られる初期HTMLの例 <html> <body> <div class=\"min-h-screen p-8\"> <p>読み込み中...</p> ← loading=trueの状態 </div> <script src=\"/_next/...\"></script> ← クライアント用JS </body> </html> Step2.ハイドレーション(Hydration) JavaScriptが読み込まれる Reactがコンポーネントを「水分補給」(Hydrate)、HTMLに機能を追加 初期state: loading = true, post = null Step3.useEffectの実行(副作用) コンポーネントが画面に表示された直後に1回実行される。 // app/posts/[id]/page.tsx (16-18行目) useEffect(() => { loadPost(); // ← コンポーネントがマウントされたら実行 }, [params.id]); Step4.データフェッチとstate更新 api.getPost()を実行。-> fetch(\'https://dummyjson.com/posts/1\') const data でレスポンスを受け取る setPost(data)でstateを更新 setLoading(false)でローディング終了。loading=false // app/posts/[id]/page.tsx (20-32行目) const loadPost = async () => { try { setLoading(true); // ローディング表示 const data = await api.getPost(Number(params.id)); // API呼び出し setPost(data); // ← state更新! setError(null); } catch (err) { setError(\'投稿の読み込みに失敗しました\'); } finally { setLoading(false); // ← state更新! } }; Step5.再レンダリング(stateが変わったので) post stateが更新されたので再レンダリング 条件分岐を再評価 最終的なJSXをDOMに反映 // app/posts/[id]/page.tsx (47-55行目) if (loading) { // loading = false なので通過 return 読み込み中...; } if (error || !post) { // error = null, post = データあり なので通過 return エラー表示; } // ここが実行される! return ( <div className=\"min-h-screen p-8\"> <h1>{post.title}</h1> {/* ← post.title を表示 */} <p>{post.body}</p> {/* ← post.body を表示 */} {/* ... */} </div> ); Step6.リストの動的レンダリング // app/posts/[id]/page.tsx (133-140行目) {post.tags.map((tag, index) => ( // ← 配列をループ <span key={index} className=\"...\"> {tag} {/* ← 各タグを表示 */} </span> ))} 実行結果: post.tags = [\"history\", \"american\", \"crime\"] ↓ map() で変換 <span key={0}>history</span> <span key={1}>american</span> <span key={2}>crime</span> 全体のレンダリングフロー [ユーザーが /posts/1 にアクセス] ↓ ┌──────────────────────────────┐ │ サーバー側(Next.js Server) │ ├──────────────────────────────┤ │ 1. app/layout.tsx を実行 │ │ → を生成 │ │ │ │ 2. app/posts/[id]/page.tsx │ │ を「クライアントコンポーネント」│ │ として認識 │ │ → 初期HTML生成 │ │ (loading=true状態) │ └──────────────────────────────┘ ↓ HTML + JS送信 ┌──────────────────────────────┐ │ ブラウザ側(Client) │ ├──────────────────────────────┤ │ 3. HTMLを表示 │ │ 「読み込み中...」 │ │ │ │ 4. JavaScriptロード │ │ → Hydration実行 │ │ │ │ 5. useEffect発火 │ │ → loadPost()実行 │ │ │ │ 6. API呼び出し │ │ fetch(https://dummyjson.com/posts/1) │ ↓ │ │ レスポンス受信 │ │ │ │ 7. setState実行 │ │ setPost(data) │ │ setLoading(false) │ │ ↓ │ │ 8. 再レンダリング │ │ → 投稿詳細を表示 │ └──────────────────────────────┘ サーバーサイドレンダリング(SSR) サーバ側でHTMLが生成される。DBやAPIに直接アクセスできる。useState,useEffectを使わない。 例えば、当アプリにおいて / へのアクセスに対してNext.jsが app/page.tsx を実行する。 HTMLを生成してブラウザに送信し、ブラウザはHTMLを表示する。 1周回って戻ってきたというか、LaravelやRailsにフロントエンドから寄っていくスタイル。 バックエンドにAPIを用意せずDBを直接操作できるため、SPAが不要な簡易的な管理画面など、 大幅な工数削減が可能になると思う。 Laravel,Railsだと、フロントエンドの記述にVue/Reactを導入する必要があるため、 バックエンド・フロントエンド、という棲み分けが発生してしまうが、 Next.jsのSSR(+CSR混在)により、フロントエンドとバックエンドを同じ仕組みで実現できる点で 管理する対象が大幅に減るのもメリットだと思う。 import Link from \'next/link\'; import { api } from \'@/lib/api\'; import DeleteButton from \'@/app/posts/_components/DeleteButton\'; // Server Component(デフォルト) // \'use client\' ディレクティブがないため、サーバー側で実行される export default async function PostsPageSSR() { // サーバー側で直接データ取得 // useEffect や useState は不要 const data = await api.getPosts(); const posts = data.posts; return ( <div className=\"min-h-screen p-8 bg-gray-50\"> <div className=\"max-w-4xl mx-auto\"> {/* ヘッダー部分 */} <div className=\"bg-blue-50 border border-blue-200 rounded-lg p-4 mb-6\"> <p className=\"text-sm text-blue-800\"> <strong>Server Component版 - このページはサーバー側でレンダリングされ、HTMLに既にデータが含まれています </p> </div> ... ); } SSRとCSRの統合 SSRモードとCSRモードの2つのモードが存在する訳ではなく、SSRとCSRは同時に存在し得る。 例えば、今回作成したSSR版アプリの投稿一覧画面において、CSRで削除ボタンを実現している。 コンポーネント単位でSSR/CSRの分離が起こるだけで、アーキ全体ではSSRとCSRは混在できる。 TypeScriptにより型安全にpropsを渡せるし、状態管理がReactの仕組みで統一できる。 部分的な更新は可能 (router.refresh() )。 // app/posts/ssr/page.tsx (Server Component) export default async function PostsPageSSR() { const data = await api.getPosts(); // サーバー側で実行 const posts = data.posts; return ( <div> {posts.map(post => ( <div key={post.id}> <h2>{post.title}</h2> {/* Client Componentをそのまま埋め込める */} <DeleteButton postId={post.id} /> </div> ))} </div> ); } // app/posts/_components/DeleteButton.tsx (Client Component) \'use client\'; export default function DeleteButton({ postId }: { postId: number }) { const router = useRouter(); const handleDelete = async () => { if (!confirm(\'削除しますか?\')) return; await api.deletePost(postId); router.refresh(); // この部分だけ更新 }; return ( <button onClick={handleDelete}>削除</button> ); } まとめ Next.jsのHello WorldをしつつSSRとCSRの挙動を確認した。 フロント側フレームワークの枠組みを越え、フロント・バックエンドを統一的に扱えることを確認した。 アプリケーションの要件次第で、SSRを中心に部分的にCSRとすることで大幅な工数削減を期待できそう。

default eye-catch image.

go-txdbを使ってgolang, gin, gorm(gen)+sqlite構成のAPI をテストケース毎に管理する

データベース依存のテストケースを作る際に、テストケース毎にDBがクリーンな状態を維持したい。 go-txdbはDBへの接続時にトランザクションを開始、切断時にトランザクションを終了するSQLドライバ。 テスト実行中にトランザクション内で発行したステートメント・行はテスト終了時には消滅する。 DB毎に実装方法は異なり、例えばSQLiteでは\"トランザクション\"ではなくsaveponitで実装される。 [clink implicit=\"false\" url=\"https://github.com/DATA-DOG/go-txdb\" imgurl=\"https://avatars.githubusercontent.com/u/6613360?s=48&v=4\" title=\"Single transaction based sql.Driver for GO\" excerpt=\"Package txdb is a single transaction based database sql driver. When the connection is opened, it starts a transaction and all operations performed on this sql.DB will be within that transaction. If concurrent actions are performed, the lock is acquired and connection is always released the statements and rows are not holding the connection.\"] [arst_toc tag=\"h4\"] 環境構築 Claude Code (Sonnet4.5) で以下の環境を構成した。途中15回のエラーリカバリを挟んで 期待通りの環境が出来上がった。 main.goがアプリケーションのルーティング(ハンドラ共有)、 main_test.goが main.goのルートに対するテスト。テストにはTestMain()が含まれている。 test_repository_test.goはGinが生成したリポジトリ層(モデル)をルートを経由せずテストする。 $ tree . -L 2 . ├── data │   └── db.sqlite # SQLite DBファイル ├── docker-compose.yml # Go+sqlite ├── Dockerfile # golang:1.23-alpineベース ├── gen.go # GORM Genコード生成スクリプト ├── go.mod # 依存関係の定義(go getやgo mod tidyで更新) ├── go.sum # 依存関係の検証用ハッシュ(自動) ├── init.sql # DDL,初期レコード ├── main.go # Gin初期化,ルーティング ├── main_test.go # main.goのルーティングに対するテストコード ├── models # モデル │   ├── model # testsテーブルと対応する構造体定義 (自動生成) │   └── query # (自動生成) ├── repository │   └── test_repository_test.go # リポジトリ層(データアクセス層)のテスト └── testhelper └── db.go # TxDB初期化等テスト用ヘルパー サンプルデータの準備 testsというテーブルに id, value というカラムを用意し、hoge, fuga レコードを挿入しておく。 簡略化のためにSQLiteを使用しており、ホスト側のファイルをbindマウントし初期実行判定して投入した。 -- Create tests table CREATE TABLE IF NOT EXISTS tests ( id INTEGER PRIMARY KEY, value TEXT NOT NULL ); -- Insert initial data INSERT OR IGNORE INTO tests (id, value) VALUES (1, \'hoge\'); INSERT OR IGNORE INTO tests (id, value) VALUES (2, \'fuga\'); CRUD ルーティング gin, gorm(gen) を使用して testsテーブルに対するCRUDを行う以下のルートを定義した。 それぞれ、genを使用しGolang言語のレベルでオブジェクトを操作している。 | メソッド | エンドポイント| 説明 | 仕様 | |--------|------------|----------------|-------------------------------------------------| | GET | /hello | 全レコード取得 | Find()で全レコードを取得し返却 | | GET | /hello/:id | 指定IDのレコード取得 | URLパラメータからIDを取得し、該当レコードを返却 | | POST | /hello | 新規レコード追加 | JSONリクエストボディからidとvalueを受け取り新規作成 | | PATCH | /hello/:id | 指定IDのレコード更新 | URLパラメータのIDとJSONボディのvalueでレコード更新 | | DELETE | /hello/:id | 指定IDのレコード削除 | URLパラメータのIDでレコード削除. | 各ハンドラの詳細な実装は冗長なので割愛。 手動リクエストと応答 各エンドポイント に対するリクエストとレスポンスの関係は以下。期待通り。 # 全件取得し応答 $ curl http://localhost:8080/hello [{\"id\":1,\"value\":\"hoge\"},{\"id\":2,\"value\":\"fuga\"}] # id=1を取得し応答 $ curl http://localhost:8080/hello/1 {\"id\":1,\"value\":\"hoge\"} # id=3を追加 $ curl -X POST http://localhost:8080/hello -H \"Content-Type: application/json\" -d \'{\"id\":3,\"value\":\"piyo\"}\' {\"id\":3,\"value\":\"piyo\"} $ curl http://localhost:8080/hello [{\"id\":1,\"value\":\"hoge\"},{\"id\":2,\"value\":\"fuga\"},{\"id\":3,\"value\":\"piyo\"}] # id=3を変更 $ curl -X PATCH http://localhost:8080/hello/3 -H \"Content-Type: application/json\" -d \'{\"value\":\"updated_piyo\"}\' {\"id\":3,\"value\":\"updated_piyo\"} # id=3を削除 $ curl -X DELETE http://localhost:8080/hello/3 {\"message\":\"record deleted successfully\"} # 全件取得し応答 $ curl http://localhost:8080/hello [{\"id\":1,\"value\":\"hoge\"},{\"id\":2,\"value\":\"fuga\"}] txdbを使用するためのテスト用ヘルパー関数 txdbを使用するためのテスト用ヘルパー関数を以下のように定義しておく。 package testhelper import ( \"database/sql\" \"fmt\" \"os\" \"sync\" \"sync/atomic\" \"github.com/DATA-DOG/go-txdb\" _ \"github.com/mattn/go-sqlite3\" \"gorm.io/driver/sqlite\" \"gorm.io/gorm\" ) var ( once sync.Once connID atomic.Uint64 ) // SetupTxDB initializes txdb driver for testing func SetupTxDB() { once.Do(func() { // Get database path dbPath := os.Getenv(\"DB_PATH\") if dbPath == \"\" { dbPath = \"./data/db.sqlite\" } // Register txdb driver with SQLite-specific options // Use WAL mode and configure for better concurrent access dsn := fmt.Sprintf(\"%s?_journal_mode=WAL&_busy_timeout=5000\", dbPath) txdb.Register(\"txdb\", \"sqlite3\", dsn) }) } // NewTestDB creates a new test database connection with txdb // Each connection will be isolated in a transaction and rolled back after test func NewTestDB() (*gorm.DB, error) { SetupTxDB() // Open connection using txdb driver with unique connection ID // This ensures each test gets its own isolated transaction id := connID.Add(1) sqlDB, err := sql.Open(\"txdb\", fmt.Sprintf(\"connection_%d\", id)) if err != nil { return nil, fmt.Errorf(\"failed to open txdb connection: %w\", err) } // Wrap with GORM db, err := gorm.Open(sqlite.Dialector{ Conn: sqlDB, }, &gorm.Config{}) if err != nil { return nil, fmt.Errorf(\"failed to open gorm connection: %w\", err) } return db, nil } テストの命名規則と共通処理 テストの関数名はTestXXX()のようにTestから始まりキャメルケースを続ける。 TestMain()内に全ての処理の前に実行する処理、後に実行する処理を記述できる。 package main import ( \"bytes\" \"encoding/json\" \"net/http\" \"net/http/httptest\" \"os\" \"testing\" \"gin_txdb/testhelper\" \"github.com/gin-gonic/gin\" \"github.com/stretchr/testify/assert\" \"github.com/stretchr/testify/require\" ) func TestMain(m *testing.M) { // Set DB_PATH for testing os.Setenv(\"DB_PATH\", \"./data/db.sqlite\") // Set Gin to test mode gin.SetMode(gin.TestMode) // Run tests code := m.Run() os.Exit(code) } 全件取得のテスト ヘルパー関数のNewTestDB()を使用することでtxdbを使用してDBに接続している。 defer func()内でコネクションを明示的にクローズする処理を遅延評価(=テスト完了時評価)しているが、 テスト実行中にエラーやpanicが起きた場合に開いたDBを切ることができなくなる問題への対処。 特にSQLiteの場合「接続は常に1つ」なので、切り忘れで接続が開きっぱなしになると、 次のテスト実行でロックエラーが発生する。明示的に閉じることでこの問題を確実に回避できる。 後はアサートを書いていく。 func TestGetAllTests(t *testing.T) { // Setup test database with txdb db, err := testhelper.NewTestDB() require.NoError(t, err) defer func() { sqlDB, _ := db.DB() sqlDB.Close() }() // Setup router using main.go\'s SetupRouter router := SetupRouter(db) // Create request req, _ := http.NewRequest(http.MethodGet, \"/hello\", nil) w := httptest.NewRecorder() // Perform request router.ServeHTTP(w, req) // Assert response assert.Equal(t, http.StatusOK, w.Code) var response []map[string]interface{} err = json.Unmarshal(w.Body.Bytes(), &response) require.NoError(t, err) // Should have 2 initial records assert.Len(t, response, 2) assert.Equal(t, float64(1), response[0][\"id\"]) assert.Equal(t, \"hoge\", response[0][\"value\"]) assert.Equal(t, float64(2), response[1][\"id\"]) assert.Equal(t, \"fuga\", response[1][\"value\"]) } このテストだけ実行してみる。-run オプションでテスト名を指定する。 $ go test -run TestGetAllTests [GIN] 2025/10/15 - 17:17:44 | 200 | 238.666µs | | GET \"/hello\" PASS ok gin_txdb 0.496s 1件取得のテスト(正常系) [GET] /hello/:id のテスト。指定したIDが存在する正常系。 func TestGetTestByID_Success(t *testing.T) { // Setup test database with txdb db, err := testhelper.NewTestDB() require.NoError(t, err) defer func() { sqlDB, _ := db.DB() sqlDB.Close() }() // Setup router router := SetupRouter(db) // Create request req, _ := http.NewRequest(http.MethodGet, \"/hello/1\", nil) w := httptest.NewRecorder() // Perform request router.ServeHTTP(w, req) // Assert response assert.Equal(t, http.StatusOK, w.Code) var response map[string]interface{} err = json.Unmarshal(w.Body.Bytes(), &response) require.NoError(t, err) assert.Equal(t, float64(1), response[\"id\"]) assert.Equal(t, \"hoge\", response[\"value\"]) } 実行結果は以下の通り。 go test -run TestGetTestByID_Success [GIN] 2025/10/15 - 17:24:41 | 200 | 207.25µs | | GET \"/hello/1\" PASS ok gin_txdb 0.330s 1件取得のテスト(異常系) [GET] /hello/:idのテスト。指定したIDが見つからない異常系。 func TestGetTestByID_NotFound(t *testing.T) { // Setup test database with txdb db, err := testhelper.NewTestDB() require.NoError(t, err) defer func() { sqlDB, _ := db.DB() sqlDB.Close() }() // Setup router router := SetupRouter(db) // Create request for non-existent ID req, _ := http.NewRequest(http.MethodGet, \"/hello/999\", nil) w := httptest.NewRecorder() // Perform request router.ServeHTTP(w, req) // Assert response assert.Equal(t, http.StatusNotFound, w.Code) var response map[string]interface{} err = json.Unmarshal(w.Body.Bytes(), &response) require.NoError(t, err) assert.Equal(t, \"record not found\", response[\"error\"]) } 実行結果は以下の通り。 go test -run TestGetTestByID_NotFound ./gin_txdb/main.go:52 record not found [0.105ms] [rows:0] SELECT * FROM `tests` WHERE `tests`.`id` = 999 ORDER BY `tests`.`id` LIMIT 1 [GIN] 2025/10/15 - 17:22:45 | 404 | 542.875µs | | GET \"/hello/999\" PASS ok gin_txdb 0.672s 1件追加のテスト(正常系) [POST] /helloが正常終了した場合、追加したレコードをレスポンスで返す処理のため、 レスポンスで返ってきたデータをアサートしている。 その後、[GET] /hello/:id のレスポンスを使ってアサートしている。 func TestCreateTest_Success(t *testing.T) { // Setup test database with txdb db, err := testhelper.NewTestDB() require.NoError(t, err) defer func() { sqlDB, _ := db.DB() sqlDB.Close() }() // Setup router router := SetupRouter(db) // Create request body payload := map[string]interface{}{ \"id\": 100, \"value\": \"test_value\", } body, _ := json.Marshal(payload) // Create request req, _ := http.NewRequest(http.MethodPost, \"/hello\", bytes.NewBuffer(body)) req.Header.Set(\"Content-Type\", \"application/json\") w := httptest.NewRecorder() // Perform request router.ServeHTTP(w, req) // Assert response assert.Equal(t, http.StatusCreated, w.Code) var response map[string]interface{} err = json.Unmarshal(w.Body.Bytes(), &response) require.NoError(t, err) assert.Equal(t, float64(100), response[\"id\"]) assert.Equal(t, \"test_value\", response[\"value\"]) // Verify the record was actually created req2, _ := http.NewRequest(http.MethodGet, \"/hello/100\", nil) w2 := httptest.NewRecorder() router.ServeHTTP(w2, req2) assert.Equal(t, http.StatusOK, w2.Code) } 実行結果は以下の通り。 $ go test -run TestCreateTest_Success [GIN] 2025/10/15 - 17:30:04 | 201 | 398.167µs | | POST \"/hello\" [GIN] 2025/10/15 - 17:30:04 | 200 | 47.625µs | | GET \"/hello/100\" PASS ok gin_txdb 0.505s 1件追加のテスト(異常なパラメタ。異常系) testsレコードはid,valueカラムを持つ。idのみ(valueなし)を渡した場合400エラーを返す。 func TestCreateTest_MissingFields(t *testing.T) { // Setup test database with txdb db, err := testhelper.NewTestDB() require.NoError(t, err) defer func() { sqlDB, _ := db.DB() sqlDB.Close() }() // Setup router router := SetupRouter(db) // Create request body with missing value field payload := map[string]interface{}{ \"id\": 100, } body, _ := json.Marshal(payload) // Create request req, _ := http.NewRequest(http.MethodPost, \"/hello\", bytes.NewBuffer(body)) req.Header.Set(\"Content-Type\", \"application/json\") w := httptest.NewRecorder() // Perform request router.ServeHTTP(w, req) // Assert response assert.Equal(t, http.StatusBadRequest, w.Code) } 実行結果は以下の通り。 期待通り400エラーが返ったことをアサートできた。 go test -run TestCreateTest_MissingFields [GIN] 2025/10/15 - 17:36:49 | 400 | 139.709µs | | POST \"/hello\" PASS ok gin_txdb 0.501s txdbが正しくトランザクションを分離していることのテスト Claude Code (Sonnet4.5) が (指示していないのに) 自動的にこのテストを作成してくれた。 お勉強を兼ねたテストプロジェクトであることを伝えたために、気を利かせてくれた感がある。 以下をテストする。 トランザクション内での一貫性 (トランザクション内で作成したデータを同一トランザクション内で観察できる) トランザクション間の分離 (別のトランザクションで作成したデータを観察できない。テストは独立している) 自動ロールバックの動作確認 (txdbがClose()時に自動的にロールバックを実行している。DBは初期状態に戻る) あくまで、一貫性、分離、ロールバックの一例を見せてもらうだけなのだが、 こういうことをやりたいのだな、という背景を理解できたのでお勉強としては十分。 func TestTransactionIsolation(t *testing.T) { // This test demonstrates that each test gets isolated transactions t.Run(\"Test1_CreateRecord\", func(t *testing.T) { db, err := testhelper.NewTestDB() require.NoError(t, err) defer func() { sqlDB, _ := db.DB() sqlDB.Close() }() router := SetupRouter(db) // Create a new record payload := map[string]interface{}{ \"id\": 200, \"value\": \"test200\", } body, _ := json.Marshal(payload) req, _ := http.NewRequest(http.MethodPost, \"/hello\", bytes.NewBuffer(body)) req.Header.Set(\"Content-Type\", \"application/json\") w := httptest.NewRecorder() router.ServeHTTP(w, req) assert.Equal(t, http.StatusCreated, w.Code) // Verify it exists in this transaction req2, _ := http.NewRequest(http.MethodGet, \"/hello/200\", nil) w2 := httptest.NewRecorder() router.ServeHTTP(w2, req2) assert.Equal(t, http.StatusOK, w2.Code) }) t.Run(\"Test2_VerifyRollback\", func(t *testing.T) { db, err := testhelper.NewTestDB() require.NoError(t, err) defer func() { sqlDB, _ := db.DB() sqlDB.Close() }() router := SetupRouter(db) // The record created in Test1 should not exist (rolled back) req, _ := http.NewRequest(http.MethodGet, \"/hello/200\", nil) w := httptest.NewRecorder() router.ServeHTTP(w, req) assert.Equal(t, http.StatusNotFound, w.Code) // Should still have only 2 original records req2, _ := http.NewRequest(http.MethodGet, \"/hello\", nil) w2 := httptest.NewRecorder() router.ServeHTTP(w2, req2) var response []map[string]interface{} json.Unmarshal(w2.Body.Bytes(), &response) assert.Len(t, response, 2) }) } まとめ go-txdbを使うことで、テストケース毎にDBを分離できることを確認した。 あればかなり便利だと思う。

default eye-catch image.

gorm互換の型安全なORMであるgenでCRUD APIを試作

GolangとGinでAPIを書くために必要な要素技術を学習中、 ORMは何が良いか考えてみたが、gormは無いなぁと思うに至った。 ORマッパーがどの程度の抽象化を担うべきか、については答えはないと思うが、 Webアプリケーションのシナリオで出てくるテーブル構造と関係程度は完全にSQLを排除して欲しい。 SQLを排除することで可読性が向上するし、静的型付けによる恩恵を得られる。 Genには以下のような特徴がある。 型安全: コンパイル時にエラー検出 自動補完: IDEでメソッドとフィールドが補完される クエリビルダー: Where(q.Product.Name.Like(\"%...\"))のような直感的なAPI GORM互換: 既存のGORMモデルをそのまま使用可能 なぜ\"Gen\"なのかは、ビルド時にGolangコードから静的に(ビルド前に)オブジェクトにアクセスする ために必要なGoオブジェクトを生成する、という仕組みから来ているのではないかと思う。 [clink implicit=\"false\" url=\"https://gorm.io/gen/index.html\" imgurl=\"https://gorm.io/gorm.svg\" title=\"Gen Guides\" excerpt=\"GEN: Friendly & Safer GORM powered by Code Generation.Idiomatic & Reusable API from Dynamic Raw SQL.100% Type-safe DAO API without interface{}.Database To Struct follows GORM conventions.GORM under the hood, supports all features, plugins, DBMS that GORM supports.\"] [arst_toc tag=\"h4\"] 環境構築 サクッとClaudeで環境を作った。実際に商用環境を作るとしたら必要な理解の度合いは上がるだろうが、 試してみるまでの時間が無駄にかかって勿体無いのと、Claudeに入口を教わるのは悪くない。 以下の構成で、Golang+GinにCRUDルートを設定しgenを介してDBアクセスできる。 models以下にテーブルと対応する型定義された構造体が格納される。 また、query以下にGormレベルの操作をGen(Golang)レベルに抽象化する自動生成コードが格納される。 query以下を読むと、GenがGormのラッパーであることが良くわかる。 $ tree . -n 2 . ├── cmd │   └── generate │   └── main.go # マイグレーション ├── config │   └── database.go # DB接続設定 ├── database │   └── database.go # Conenct(), Close(), GetDB()など ├── docker-compose.yml # Golangアプリケーション(8080), PostgreSQL(5432) ├── Dockerfile ├── go.mod ├── go.sum ├── handlers │   └── product.go ├── main.go # CRUD APIのルーティング ├── models │   └── product.go # テーブル->モデル ├── query │   ├── gen.go # モデルを操作するラッパー │   └── products.gen.go # SQLレベルのモデル操作をGolangレベルに抽象化するためのIF └── README.md CRUDルート 早速、CRUD APIのルートを作っていく。Claudeにお任せしたところ商品(Product)のCRUD APIが出来た。 その位置にMigrate置くの本当に良いの? という感があるが、本題はそこではないので省略。 package main import ( \"log\" \"github.com/gin-gonic/gin\" \"github.com/gin-gonic/gin/binding\" \"github.com/ikuty/golang-gin/database\" \"github.com/ikuty/golang-gin/handlers\" \"github.com/ikuty/golang-gin/models\" \"github.com/ikuty/golang-gin/query\" ) func main() { // データベース接続 if err := database.Connect(); err != nil { log.Fatalf(\"Failed to connect to database: %v\", err) } defer database.Close() // マイグレーション実行 db := database.GetDB() if err := db.AutoMigrate(&models.Product{}); err != nil { log.Fatalf(\"Failed to migrate database: %v\", err) } // Gen初期化 query.SetDefault(db) // Ginエンジンの初期化 r := gin.Default() // 8. GORM + PostgreSQL - CRUD操作 r.GET(\"/api/products\", handlers.GetProductsHandler) // 全商品取得 r.GET(\"/api/products/:id\", handlers.GetProductHandler) // 商品詳細取得 r.POST(\"/api/products\", handlers.CreateProductHandler) // 商品作成 r.PUT(\"/api/products/:id\", handlers.UpdateProductHandler) // 商品更新 r.DELETE(\"/api/products/:id\", handlers.DeleteProductHandler) // 商品削除 r.GET(\"/api/products/search\", handlers.SearchProductsHandler) // 商品検索 // サーバー起動 r.Run(\":8080\") } モデル さて、モデル定義(=テーブル構造)はどうなっているかというと、以下の通り。 フィールドの物理型をGenを介してGolangで厳密で管理できるのは動的型付け言語にはない利点。 package models import ( \"time\" \"gorm.io/gorm\" ) // Product は商品モデル type Product struct { ID uint `gorm:\"primarykey\" json:\"id\"` Name string `gorm:\"size:100;not null\" json:\"name\" binding:\"required\"` Description string `gorm:\"size:500\" json:\"description\"` Price float64 `gorm:\"not null\" json:\"price\" binding:\"required,gt=0\"` Stock int `gorm:\"default:0\" json:\"stock\"` Category string `gorm:\"size:50\" json:\"category\"` CreatedAt time.Time `json:\"created_at\"` UpdatedAt time.Time `json:\"updated_at\"` DeletedAt gorm.DeletedAt `gorm:\"index\" json:\"-\"` } // TableName はテーブル名を指定 func (Product) TableName() string { return \"products\" } ハンドラ(商品詳細取得) 素晴らしい。説明が不要なくらいDBアクセスが抽象化されている。 ただ、依存性注入があるEloquentと比べるとロジックと関係ない冗長な処理が残っている。 db,q,Contextは裏側に隠して欲しいという思いはあるものの、これでも良いかとも思う。 Find()はGenにより自動生成される。interfaceが用意されビルド時に全て解決される。 なお、VSCodeなどで補完が効く、というのは、例えば JetBrains環境であれば、 動的型付け言語であってもほぼ実現されているので、それほど実利があるメリットではない。 package handlers import ( \"net/http\" \"strconv\" \"github.com/gin-gonic/gin\" \"github.com/ikuty/golang-gin/database\" \"github.com/ikuty/golang-gin/models\" \"github.com/ikuty/golang-gin/query\" ) // GetProductsHandler は全商品を取得 func GetProductsHandler(c *gin.Context) { db := database.GetDB() q := query.Use(db) products, err := q.Product.WithContext(c.Request.Context()).Find() if err != nil { c.JSON(http.StatusInternalServerError, gin.H{ \"error\": \"Failed to fetch products\", }) return } c.JSON(http.StatusOK, gin.H{ \"data\": products, \"count\": len(products), }) } ハンドラ(指定の商品を取得) バリデータを介さず自力でバリデーション(IDがUintか)を行っている。 Productに対してWhereで条件指定し(Order By Ascした後に)先頭のオブジェクトを取得している。 もはや他に説明が必要ないくらい抽象化されていて良い。 // GetProductHandler は指定IDの商品を取得 func GetProductHandler(c *gin.Context) { id := c.Param(\"id\") idUint, err := strconv.ParseUint(id, 10, 32) if err != nil { c.JSON(http.StatusBadRequest, gin.H{ \"error\": \"Invalid ID\", }) return } db := database.GetDB() q := query.Use(db) product, err := q.Product.WithContext(c.Request.Context()).Where(q.Product.ID.Eq(uint(idUint))).First() if err != nil { c.JSON(http.StatusNotFound, gin.H{ \"error\": \"Product not found\", }) return } c.JSON(http.StatusOK, gin.H{ \"data\": product, }) } ハンドラ(商品作成) 次はCreate。モデルオブジェクトを空から生成し入力値をバインドして整形した後に、 Create()に渡している。Create()の内部はGormレベルの(低レイヤの)コードが動く。 // CreateProductHandler は商品を作成 func CreateProductHandler(c *gin.Context) { var product models.Product if err := c.ShouldBindJSON(&product); err != nil { c.JSON(http.StatusBadRequest, gin.H{ \"error\": \"Invalid request\", \"details\": err.Error(), }) return } db := database.GetDB() q := query.Use(db) if err := q.Product.WithContext(c.Request.Context()).Create(&product); err != nil { c.JSON(http.StatusInternalServerError, gin.H{ \"error\": \"Failed to create product\", }) return } c.JSON(http.StatusCreated, gin.H{ \"message\": \"Product created successfully\", \"data\": product, }) } ハンドラ(商品更新) 基本的にはCreate()と同じ。空モデルに入力値をバインドしUpdate()に渡している。 実行後に更新対象のオブジェクトを取得しているがEloquentは確か更新の戻りがオブジェクトだった。 // UpdateProductHandler は商品を更新 func UpdateProductHandler(c *gin.Context) { id := c.Param(\"id\") idUint, err := strconv.ParseUint(id, 10, 32) if err != nil { c.JSON(http.StatusBadRequest, gin.H{ \"error\": \"Invalid ID\", }) return } db := database.GetDB() q := query.Use(db) ctx := c.Request.Context() // 既存の商品を取得 product, err := q.Product.WithContext(ctx).Where(q.Product.ID.Eq(uint(idUint))).First() if err != nil { c.JSON(http.StatusNotFound, gin.H{ \"error\": \"Product not found\", }) return } // 更新データをバインド var updateData models.Product if err := c.ShouldBindJSON(&updateData); err != nil { c.JSON(http.StatusBadRequest, gin.H{ \"error\": \"Invalid request\", \"details\": err.Error(), }) return } // 更新実行 _, err = q.Product.WithContext(ctx).Where(q.Product.ID.Eq(uint(idUint))).Updates(&updateData) if err != nil { c.JSON(http.StatusInternalServerError, gin.H{ \"error\": \"Failed to update product\", }) return } // 更新後のデータを取得 product, _ = q.Product.WithContext(ctx).Where(q.Product.ID.Eq(uint(idUint))).First() c.JSON(http.StatusOK, gin.H{ \"message\": \"Product updated successfully\", \"data\": product, }) } ハンドラ(論理削除) DeletedAtフィールドがNULLの場合、そのレコードはアクティブ。非Nullなら論理削除済み。 Unscoped()を介さずDelete()した場合(つまりデフォルトでは)論理削除となる。 DeletedAtは他のAPIから透過的に扱われる。論理削除状態かどうかは把握しなくて良い。 DeletedAtはデフォルトでは*time.Time型だが、のデータ形式の対応も可能。 // DeleteProductHandler は商品を削除(ソフトデリート) func DeleteProductHandler(c *gin.Context) { id := c.Param(\"id\") idUint, err := strconv.ParseUint(id, 10, 32) if err != nil { c.JSON(http.StatusBadRequest, gin.H{ \"error\": \"Invalid ID\", }) return } db := database.GetDB() q := query.Use(db) // ソフトデリート実行 _, err = q.Product.WithContext(c.Request.Context()).Where(q.Product.ID.Eq(uint(idUint))).Delete() if err != nil { c.JSON(http.StatusInternalServerError, gin.H{ \"error\": \"Failed to delete product\", }) return } c.JSON(http.StatusOK, gin.H{ \"message\": \"Product deleted successfully\", }) } ハンドラ(商品検索) Where句を複数記述する場合など、手続き的に条件用のオブジェクトを足していける。 一見、productQueryを上から上書きしているように見えるが、Genのクエリビルダーはimmutableパターン として振る舞い、都度実行によりWhereの戻りとなるオブジェクトが累積していく動作となる。 // SearchProductsHandler は商品を検索 func SearchProductsHandler(c *gin.Context) { db := database.GetDB() q := query.Use(db) ctx := c.Request.Context() // クエリパラメータを取得 name := c.Query(\"name\") category := c.Query(\"category\") minPrice := c.Query(\"min_price\") maxPrice := c.Query(\"max_price\") // クエリビルダー productQuery := q.Product.WithContext(ctx) if name != \"\" { productQuery = productQuery.Where(q.Product.Name.Like(\"%\" + name + \"%\")) } if category != \"\" { productQuery = productQuery.Where(q.Product.Category.Eq(category)) } if minPrice != \"\" { if price, err := strconv.ParseFloat(minPrice, 64); err == nil { productQuery = productQuery.Where(q.Product.Price.Gte(price)) } } if maxPrice != \"\" { if price, err := strconv.ParseFloat(maxPrice, 64); err == nil { productQuery = productQuery.Where(q.Product.Price.Lte(price)) } } // 検索実行 products, err := productQuery.Find() if err != nil { c.JSON(http.StatusInternalServerError, gin.H{ \"error\": \"Failed to search products\", }) return } c.JSON(http.StatusOK, gin.H{ \"data\": products, \"count\": len(products), }) } 変換後のクエリを見てみる。 $ http://localhost:8080/api/products/search?name=Test&category=Electronics&min_price=1400&max_price=1600 SELECT * FROM \"products\" WHERE \"products\".\"name\" LIKE \'%Test%\' AND \"products\".\"category\" = \'Electronics\' AND \"products\".\"price\" >= 1400 AND \"products\".\"price\" <= 1600 AND "products"."deleted_at" IS NULL まとめ GolangのORMであるGormをラップしたGenを使って、CRUDを行うAPIをGinで書いて動かしてみた。 確かにGormレベル(SQLレベル)の記述が不要であることを確認した。 (まだ見ていないが)テーブルをJOINしていった先にGormを素で触らないといけない場面は発生するだろうが、 多くのシナリオでGenだけで行けるのであれば、Genを導入するメリットとなるのではないだろうか。

default eye-catch image.

Golang + Gin カスタムバリデーション

Golang+GinによるAPI構築で使いそうなフィーチャーを試してみるシリーズ。 今回はカスタムバリデーションを試してみる。 [clink implicit=\"false\" url=\"https://gin-gonic.com/ja/docs/examples/custom-validators/\" imgurl=\"https://gin-gonic.com/_astro/gin.D6H2T_2v_ZD2G7l.webp\" title=\"カスタムバリデーション\" excerpt=\"カスタムしたバリデーションを使用することもできます。サンプルコード も見てみてください。\"] [arst_toc tag=\"h4\"] ルーティング バリデーションを外部に移譲することで、ハンドラからロジック以外の冗長な処理を除くことができる。 Ginはカスタムバリデータを用意している。以下の例では、ユーザ登録を行うPOSTリクエストの例。 組み込みのバリデーション・バインディングと合わせて、パスワードバリデーションロジックの 追加を行っている。 package main import ( \"github.com/gin-gonic/gin\" \"github.com/gin-gonic/gin/binding\" \"github.com/go-playground/validator/v10\" \"github.com/ikuty/golang-gin/handlers\" ) func main() { // Ginエンジンの初期化 r := gin.Default() // カスタムバリデーターを登録 if v, ok := binding.Validator.Engine().(*validator.Validate); ok { handlers.InitCustomValidators(v) } // 7. カスタムバリデーション r.POST(\"/api/register\", handlers.RegisterValidatorHandler) // サーバー起動 r.Run(\":8080\") } ハンドラ リクエストで受けたJSONをRegisterRequest構造体にバインディングする際に、組み込みの バリデーションルールを定義するのとは別に、strongpassword というカスタムルールを定義している。 strongpasswordルールの実体は strongPassword() 。 例に出現するオブジェクトの使い方は、まぁこう使うのかぐらいで、ありがちな感じ。 カスタムバリデータ関数がチェック結果をTrue/Falseで返せばよさそう。 組み込みバリデータ、または、カスタムバリデータのバリデーション結果と文字列の対応を定義し、 その文字列をレスポンスに付与して返す、というのは良くあるパターンで、 Ginで実装する場合は、 また、カスタムバリデータのバリデーション結果と文字列の対応を定義しレスポンスに含める、 というパターンは良くありそうで、構造体へのバインディングで発生したエラー(err)を取得し、 errに対する型アサーションを行った上で、errを validator.ValidationErrors型として扱う。 動的型付けだと、発生したerrが本当に期待したオブジェクトなのか実行するまで分からなが、 全ての処理が静的型付けを通して、実行前に実行可能であることが確認される。 package handlers import ( \"net/http\" \"regexp\" \"github.com/gin-gonic/gin\" \"github.com/go-playground/validator/v10\" ) // RegisterRequest はユーザー登録リクエストの構造体(高度なバリデーション付き) type RegisterRequest struct { Username string `json:\"username\" binding:\"required,min=3,max=20,alphanum\"` Email string `json:\"email\" binding:\"required,email\"` Password string `json:\"password\" binding:\"required,min=8,max=50,strongpassword\"` Age int `json:\"age\" binding:\"required,gte=18,lte=100\"` Website string `json:\"website\" binding:\"omitempty,url\"` Phone string `json:\"phone\" binding:\"omitempty,e164\"` // E.164 形式の電話番号 } // カスタムバリデーター: 強力なパスワードチェック func strongPassword(fl validator.FieldLevel) bool { password := fl.Field().String() // 最低1つの大文字、1つの小文字、1つの数字を含む hasUpper := regexp.MustCompile(`[A-Z]`).MatchString(password) hasLower := regexp.MustCompile(`[a-z]`).MatchString(password) hasNumber := regexp.MustCompile(`[0-9]`).MatchString(password) return hasUpper && hasLower && hasNumber } // RegisterValidatorHandler はカスタムバリデーターを使用するハンドラー func RegisterValidatorHandler(c *gin.Context) { var req RegisterRequest // JSON をバインド if err := c.ShouldBindJSON(&req); err != nil { // バリデーションエラーを詳細に返す c.JSON(http.StatusBadRequest, gin.H{ \"error\": \"Validation failed\", \"details\": formatValidationError(err), }) return } c.JSON(http.StatusCreated, gin.H{ \"message\": \"Registration successful\", \"username\": req.Username, \"email\": req.Email, }) } // formatValidationError はバリデーションエラーをわかりやすく整形 func formatValidationError(err error) []string { var errors []string if validationErrors, ok := err.(validator.ValidationErrors); ok { for _, e := range validationErrors { var message string switch e.Tag() { case \"required\": message = e.Field() + \" is required\" case \"email\": message = e.Field() + \" must be a valid email address\" case \"min\": message = e.Field() + \" must be at least \" + e.Param() + \" characters\" case \"max\": message = e.Field() + \" must be at most \" + e.Param() + \" characters\" case \"alphanum\": message = e.Field() + \" must contain only letters and numbers\" case \"gte\": message = e.Field() + \" must be greater than or equal to \" + e.Param() case \"lte\": message = e.Field() + \" must be less than or equal to \" + e.Param() case \"url\": message = e.Field() + \" must be a valid URL\" case \"e164\": message = e.Field() + \" must be a valid phone number (E.164 format)\" case \"strongpassword\": message = e.Field() + \" must contain at least one uppercase letter, one lowercase letter, and one number\" default: message = e.Field() + \" is invalid\" } errors = append(errors, message) } } else { errors = append(errors, err.Error()) } return errors } // InitCustomValidators はカスタムバリデーターを登録する func InitCustomValidators(v *validator.Validate) { v.RegisterValidation(\"strongpassword\", strongPassword) } 実行結果 リクエストに対してバリデーションが行われ、期待通りバリデーションエラーがアサートされていて、 アサートと対応するカスタム文字列がレスポンスに含まれていることが確認できる。 $ curl -X POST http://localhost:8080/api/register -H \"Content-Type: application/json\" -d \'{\"username\":\"john123\",\"email\":\"john@example.com\",\"password\":\"SecurePass123\",\"age\":25,\"website\":\"https://example.com\"}\' {\"email\":\"john@example.com\",\"message\":\"Registration successful\",\"username\":\"john123\"} 2. ユーザー名が短すぎる {\"details\":[\"Username must be at least 3 characters\"],\"error\":\"Validation failed\"} 3. 弱いパスワード(カスタムバリデーター) {\"details\":[\"Password must contain at least one uppercase letter, one lowercase letter, and one number\"],\"error\":\"Validation failed\"} 4. 年齢が18歳未満 {\"details\":[\"Age must be greater than or equal to 18\"],\"error\":\"Validation failed\"} まとめ 組み込みバリデーションの他に、カスタムバリデーションを追加できることを確認した。 静的型付けにより実行時エラーに頼ることのないある種の堅牢さがあることも見てとれた。

default eye-catch image.

Golang + Gin Framework で Hello World してみた話 〜基本的なルーティング、バスパラメタ・クエリパラメタ・JSON Req/Res、フォームデータ

Golang+GinでAPIを大量に書くことになりそうなので予習することにする。 コード自体はAI Agentで書こうと思うが、まずはGinのフィーチャーを把握する必要がある。 AI Agentを使用してAPI毎にフィーチャーを試せる学習用プロジェクトを構築する。 著者のスペックは、昔仕事でLaravelでWebアプリを書いたことがある。 [arst_toc tag=\"h4\"] Ginについて 🚀 高速なパフォーマンス martini に似たAPIを持ちながら、httprouter のおかげでそれより40倍以上も速いパフォーマンスがあります。 **基数木(Radix Tree)**ベースのルーティングを採用しており、メモリ効率が良く、高速なルーティングを実現しています。 他のGo製Webフレームワークと比較して、ベンチマークで優れた速度を示すことが多く、特に高スループットな REST API や マイクロサービス の構築に適しています。 Laravelは遅くて有名だったが、速いのは良いこと。 Golang自体ネイティブ実行だし、Golang用フレームワークの中でも速度にフィーチャーした構造。 たいした同時実行数を捌かないなら別に遅くても良いし、速いなら良いよね、ぐらい。 🧩 ミドルウェアのサポート 受信したHTTPリクエストを、ミドルウェアのチェーンと最終的なアクション(ハンドラー)で処理する仕組みを提供します。 ロガー、認証、GZIP圧縮など、様々な機能を簡単に組み込むことができます。 ミドルウェアくらい使えないと困るよね。認証を書きたい。 🛡️ クラッシュフリー HTTPリクエスト処理中に発生したpanicをキャッチし、**リカバリー(回復)**する機能が組み込まれています。 これにより、サーバーがクラッシュするのを防ぎ、サービスを常に利用可能な状態に保ちます。 🔗 ルートのグループ化 認証が必要なルートやAPIのバージョンごとなど、関連するルートをグループ化して整理する機能があり、共通のミドルウェアを適用しやすいです。 フルスタックフレームワークではないので、これだけしか書かれていない。 シンプルであることは良いこと。 学習用プロジェクトの構成 いったん、こんな感じで構成。 golang-gin/ ├── docker-compose.yml ├── Dockerfile ├── go.mod ├── go.sum ├── main.go ├── README.md └── handlers/ ├── hello.go # Hello World API ├── params.go # パラメータ処理 ├── json.go # JSON処理 ├── middleware.go # ミドルウェア ├── validation.go # バリデーション ├── file.go # ファイルアップロード └── grouping.go # ルートグループ化 学習計画とAPI API毎にフィーチャーを実装していくスタイルとする。 Claude Codeにその一覧を出力すると以下の通り。 | No. | 機能 | エンドポイント | メソッド | 説明 | |-----|--------------|----------------------|------|----------------------| | 1 | 基本的なルーティング | /hello | GET | Hello World を返す基本API | | 2 | パスパラメータ | /users/:id | GET | URL パスからパラメータを取得 | | 3 | クエリパラメータ | /search | GET | クエリ文字列からパラメータを取得 | | 4 | JSON レスポンス | /api/user | GET | 構造体を JSON で返す | | 5 | JSON リクエスト | /api/user | POST | JSON をバインドして処理 | | 6 | フォームデータ | /form | POST | フォームデータの受け取り | | 7 | バリデーション | /api/register | POST | 入力データのバリデーション | | 8 | ファイルアップロード | /upload | POST | 単一ファイルのアップロード | | 9 | 複数ファイルアップロード | /upload/multiple | POST | 複数ファイルのアップロード | | 10 | ミドルウェア (ログ) | /api/protected | GET | カスタムミドルウェアの実装 | | 11 | ルートグループ化 | /v1/users, /v2/users | GET | API バージョニング | | 12 | エラーハンドリング | /error | GET | エラーレスポンスの処理 | | 13 | カスタムバリデーター | /api/validate | POST | カスタムバリデーションルール | | 14 | リダイレクト | /redirect | GET | リダイレクト処理 | | 15 | 静的ファイル配信 | /static/* | GET | 静的ファイルの提供 | Hello World まずは Hello World を返すAPIを作る。 main.goは以下の通り。./handlers 以下に実態を書いていく。 package main import ( \"github.com/gin-gonic/gin\" \"github.com/ikuty/golang-gin/handlers\" ) func main() { // Ginエンジンの初期化 r := gin.Default() // Hello World API r.GET(\"/hello\", handlers.HelloHandler) // サーバー起動 r.Run(\":8080\") } ./handlers/hello.go は以下の通り。 package handlers import ( \"net/http\" \"github.com/gin-gonic/gin\" ) // HelloHandler は Hello World を返すハンドラー func HelloHandler(c *gin.Context) { c.JSON(http.StatusOK, gin.H{ \"message\": \"Hello World\", }) } 試す。入門した。 $ curl http://localhost:8080/hello {\"message\":\"Hello World\"} パスパラメータ URL内にプレースホルダを設定し、URLのプレースホルダと対応する値を変数で受けられる機能。 package main import ( \"github.com/gin-gonic/gin\" \"github.com/ikuty/golang-gin/handlers\" ) func main() { // Ginエンジンの初期化 r := gin.Default() // 1. 基本的なルーティング r.GET(\"/hello\", handlers.HelloHandler) // 2. パスパラメータ r.GET(\"/users/:id\", handlers.GetUserByIDHandler) // サーバー起動 r.Run(\":8080\") } ./handlers/params.goは以下。 Laravelと同じところに違和感。型はどこいった..? Ginでは、パスパラメータは常に文字列(string)として取得される。 URLから取得したパラメータを別の型(intやuintなど)として扱いたい場合は、 取得した文字列を明示的に型変換する必要がある。 package handlers import ( \"net/http\" \"github.com/gin-gonic/gin\" ) // GetUserByIDHandler は URL パスパラメータからユーザーIDを取得するハンドラー func GetUserByIDHandler(c *gin.Context) { // パスパラメータ :id を取得 id := c.Param(\"id\") c.JSON(http.StatusOK, gin.H{ \"user_id\": id, \"message\": \"User ID retrieved from path parameter\", }) } 実行。 # 数値IDのテスト $ curl http://localhost:8080/users/123 {\"message\":\"User ID retrieved from path parameter\",\"user_id\":\"123\"} # 文字列IDのテスト $ curl http://localhost:8080/users/alice {\"message\":\"User ID retrieved from path parameter\",\"user_id\":\"alice\"} クエリパラメータ クエリパラメータを受け取る方法は以下。 まぁシンプル。 package handlers import ( \"net/http\" \"github.com/gin-gonic/gin\" ) // SearchHandler は クエリパラメータから検索条件を取得するハンドラー func SearchHandler(c *gin.Context) { // クエリパラメータを取得 query := c.Query(\"q\") // ?q=keyword page := c.DefaultQuery(\"page\", \"1\") // ?page=2 (デフォルト値: \"1\") limit := c.DefaultQuery(\"limit\", \"10\") // ?limit=20 (デフォルト値: \"10\") // オプショナルなパラメータ sort := c.Query(\"sort\") // 値がない場合は空文字列 c.JSON(http.StatusOK, gin.H{ \"query\": query, \"page\": page, \"limit\": limit, \"sort\": sort, \"message\": \"Query parameters retrieved successfully\", }) } 実行結果は以下。 # パスパラメータ $ curl http://localhost:8080/users/123 {\"message\":\"User ID retrieved from path parameter\",\"user_id\":\"123\"} # クエリパラメータ $ curl \"http://localhost:8080/search?q=test&page=2\" {\"limit\":\"10\",\"message\":\"Query parameters retrieved successfully\",\"page\":\"2\",\"query\":\"test\",\"sort\":\"\"} JSONリクエスト/JSONレスポンス Content-Type: application/json で半構造化データ(JSON)を送り、構造体で受けることができる。 また、構造体を Content-Type: application/json でJSON文字列を返すことができる。 構造体のメンバに型を定義しておくことで、文字列がメンバ型に変換(バインド)できる。 まずルーティングは以下の通り。 package main import ( \"github.com/gin-gonic/gin\" \"github.com/ikuty/golang-gin/handlers\" ) func main() { // Ginエンジンの初期化 r := gin.Default() // 4. JSON レスポンス r.GET(\"/api/user\", handlers.GetUserHandler) // 5. JSON リクエスト r.POST(\"/api/user\", handlers.CreateUserHandler) // サーバー起動 r.Run(\":8080\") } ハンドラは以下の通り。 バインドの記述が興味深い。バインド時にバリデーションを実行している。 package handlers import ( \"net/http\" \"github.com/gin-gonic/gin\" ) // User 構造体 type User struct { ID int `json:\"id\"` Name string `json:\"name\"` Email string `json:\"email\"` Age int `json:\"age\"` IsActive bool `json:\"is_active\"` } // GetUserHandler は 構造体を JSON で返すハンドラー func GetUserHandler(c *gin.Context) { // サンプルユーザーデータ user := User{ ID: 1, Name: \"John Doe\", Email: \"john@example.com\", Age: 30, IsActive: true, } c.JSON(http.StatusOK, user) } // CreateUserRequest はユーザー作成リクエストの構造体 type CreateUserRequest struct { Name string `json:\"name\" binding:\"required\"` Email string `json:\"email\" binding:\"required,email\"` Age int `json:\"age\" binding:\"required,gte=0,lte=150\"` } // CreateUserHandler は JSON リクエストをバインドして処理するハンドラー func CreateUserHandler(c *gin.Context) { var req CreateUserRequest // JSON をバインド(バリデーションも実行される) if err := c.ShouldBindJSON(&req); err != nil { c.JSON(http.StatusBadRequest, gin.H{ \"error\": \"Invalid request\", \"details\": err.Error(), }) return } // 作成されたユーザーを返す(実際はDBに保存する) user := User{ ID: 100, // 仮のID Name: req.Name, Email: req.Email, Age: req.Age, IsActive: true, } c.JSON(http.StatusCreated, gin.H{ \"message\": \"User created successfully\", \"user\": user, }) } 実行結果は以下。 1. GET - JSON レスポンス $ curl http://localhost:8080/api/user {\"id\":1,\"name\":\"John Doe\",\"email\":\"john@example.com\",\"age\":30,\"is_active\":true} 2. POST - 正常なリクエスト $ curl -X POST http://localhost:8080/api/user -H \"Content-Type: application/json\" -d \'{\"name\":\"Alice\",\"email\":\"alice@example.com\",\"age\":25}\' {\"message\":\"User created successfully\",\"user\":{\"id\":100,\"name\":\"Alice\",\"email\":\"alice@example.com\",\"age\":25,\"is_active\":true}} 3. POST - バリデーションエラー(メール形式) $ curl -X POST http://localhost:8080/api/user -H \"Content-Type: application/json\" -d \'{\"name\":\"Bob\",\"email\":\"invalid-email\",\"age\":30}\' {\"details\":\"Key: \'CreateUserRequest.Email\' Error:Field validation for \'Email\' failed on the \'email\' tag\",\"error\":\"Invalid request\"} 4. POST - バリデーションエラー(年齢範囲) $ curl -X POST http://localhost:8080/api/user -H \"Content-Type: application/json\" -d \'{\"name\":\"Charlie\",\"email\":\"charlie@example.com\",\"age\":200}\' {\"details\":\"Key: \'CreateUserRequest.Age\' Error:Field validation for \'Age\' failed on the \'lte\' tag\",\"error\":\"Invalid request\"} フォームデータ フォームデータの送信例。ルーティングは以下。 POSTで送ったフィールドを丸っと構造体にする例と、 それぞれのフィールドを個別に取得する例の2つ。 package main import ( \"github.com/gin-gonic/gin\" \"github.com/ikuty/golang-gin/handlers\" ) func main() { // Ginエンジンの初期化 r := gin.Default() // 6. フォームデータ r.POST(\"/form/login\", handlers.LoginHandler) r.POST(\"/form/post\", handlers.PostFormHandler) // サーバー起動 r.Run(\":8080\") } ハンドラは以下。丸っとフォームデータを構造体にバインドできるし、 個別にアクセスすることもできる。 シンプルというか、少ない道具でなんとかするタイプ。 package handlers import ( \"net/http\" \"github.com/gin-gonic/gin\" ) // LoginForm はログインフォームの構造体 type LoginForm struct { Username string `form:\"username\" binding:\"required\"` Password string `form:\"password\" binding:\"required,min=6\"` Remember bool `form:\"remember\"` } // LoginHandler はフォームデータを受け取るハンドラー func LoginHandler(c *gin.Context) { var form LoginForm // フォームデータをバインド if err := c.ShouldBind(&form); err != nil { c.JSON(http.StatusBadRequest, gin.H{ \"error\": \"Invalid form data\", \"details\": err.Error(), }) return } // 実際はここで認証処理を行う c.JSON(http.StatusOK, gin.H{ \"message\": \"Login successful\", \"username\": form.Username, \"remember\": form.Remember, }) } // PostFormHandler は個別にフォームフィールドを取得するハンドラー func PostFormHandler(c *gin.Context) { // 個別のフォームフィールドを取得 title := c.PostForm(\"title\") content := c.DefaultPostForm(\"content\", \"No content provided\") tags := c.PostFormArray(\"tags\") // 配列として取得 c.JSON(http.StatusOK, gin.H{ \"message\": \"Form data received\", \"title\": title, \"content\": content, \"tags\": tags, }) } 実行例は以下。 1. ログインフォーム - 正常 $ curl -X POST http://localhost:8080/form/login -d \"username=john&password=secret123\" {\"message\":\"Login successful\",\"remember\":false,\"username\":\"john\"} 2. ログインフォーム - remember 付き $ curl -X POST http://localhost:8080/form/login -d \"username=alice&password=pass123&remember=true\" {\"message\":\"Login successful\",\"remember\":true,\"username\":\"alice\"} 3. ログインフォーム - バリデーションエラー $ curl -X POST http://localhost:8080/form/login -d \"username=bob&password=123\" {\"details\":\"Key: \'LoginForm.Password\' Error:Field validation for \'Password\' failed on the \'min\' tag\",\"error\":\"Invalid form data\"} 4. 投稿フォーム - 配列データ $ curl -X POST http://localhost:8080/form/post -d \"title=Hello&content=World&tags=go&tags=gin&tags=api\" {\"content\":\"World\",\"message\":\"Form data received\",\"tags\":[\"go\",\"gin\",\"api\"],\"title\":\"Hello\"} まとめ いったん、以下を試した。 基本的なルーティング バスパラメタ・クエリパラメタ JSON Request/Response フォームデータ シンプルすぎてClaude Codeが機能を絞っているのか疑ったが、 公式を読む限り、若干バリエーションが増える程度の様子。 これならわざわざClaudeに入門コースを作ってもらわなくても上から読めば良いかな。