ALPS, ASD でセマンティクス重視のドキュメントを作成する

この記事は個人ブログと同じ内容です


ソフトウェア設計をする際に以下のような課題が発生する場合があります。

  • ドキュメント化のコストと、管理コストの増加
  • システムが複雑化するにつれて、コードの理解が難しくなる
  • 一貫性のない命名規則

これらの課題を解決するために、今回は ALPS という情報設計手法を学習しました。

ALPS とは

ALPS QuickStart によると、以下のように説明されています。

Application-Level Profile Semantics (ALPS)は、アプリケーションレベルのセマンティクス(語句の意味や構造)を表現するフォーマットです。JSON、HTMLなど汎用メディアにアプリケーション固有のセマンティックスを加え、情報の説明や操作の理解に役立てます。

特定のメディアタイプに囚われず、アプリケーションで利用する言葉の意味や構造に焦点を当てている、ドキュメントを作成するためのフレームワークと言えそうです。

ASD とは

ASD は ALPS を可視化するためのツールです。 こちらの説明も ALPS QuickStart から引用させていただきます。

ASD(Application State Diagram)とはALPSドキュメントから生成されるアプリケーション状態遷移図、およびその遷移図を含むドキュメンテーション生成ツールの名前です。RESTアプリケーションを純粋な情報設計の視点で俯瞰する事ができ、状態の詳細ドキュメントが遷移図からリンクされます。

EC サイトの例

ASD の公式サイトAmazon のような EC サイトをALPS, ASD でドキュメントにした例が載っています。

このようにリソースの URL は登場せず、意味や操作を相互リンクで辿ることが可能なため、直感的にドキュメントを理解しやすいのが特徴です。

オンライン予約システムを想定して作ってみた

今回は、オンライン予約システムを想定して ALPS ドキュメントを作成してみました。

<?xml version="1.0" encoding="UTF-8"?>
<alps
        xmlns:xsi="http://www.w3.org/2001/XMLSchema-instance"
        xsi:noNamespaceSchemaLocation="https://alps-io.github.io/schemas/alps.xsd">

    <!-- オントロジー -->
    <descriptor id="scheduleId" title="スケジュールID">
        <doc format="text">スケジュールの一意の識別子</doc>
    </descriptor>
    <descriptor id="startAt" title="開始日時"></descriptor>
    <descriptor id="endAt" title="終了日時"></descriptor>
    <descriptor id="capacity" title="定員"></descriptor>
    <descriptor id="reservationStatus" title="予約ステータス">
        <doc format="text">予約の現在の状態 (確定済み、キャンセル済み)</doc>
    </descriptor>
    <descriptor id="reservedAt" title="予約日時"></descriptor>
    <descriptor id="reservationMemo" title="予約備考">
        <doc format="text">予約時にユーザーが入力する備考</doc>
    </descriptor>
    <descriptor id="userName" title="ユーザー名"></descriptor>
    <descriptor id="userEmail" title="ユーザーのメールアドレス"></descriptor>

    <!-- タクソノミー -->
    <descriptor id="Schedule" title="スケジュール情報">
        <descriptor href="#scheduleId"></descriptor>
        <descriptor href="#startAt"></descriptor>
        <descriptor href="#endAt"></descriptor>
        <descriptor href="#capacity"></descriptor>
    </descriptor>
    <descriptor id="Reservation" title="予約情報">
        <descriptor href="#scheduleId"></descriptor>
        <descriptor href="#reservationStatus"></descriptor>
        <descriptor href="#reservedAt"></descriptor>
        <descriptor href="#reservationMemo"></descriptor>
        <descriptor href="#userName"></descriptor>
        <descriptor href="#userEmail"></descriptor>
    </descriptor>
    <descriptor id="ScheduleList" title="予約日時選択">
        <descriptor href="#doSelectSchedule"/>
    </descriptor>
    <descriptor id="ReservationUserInfo" title="ユーザー情報入力">
        <descriptor href="#doSubmitUserInfo"/>
    </descriptor>
    <descriptor id="ReservationConfirm" title="予約内容確認">
        <descriptor href="#doReserve"/>
    </descriptor>
    <descriptor id="ReservationCompleted" title="予約完了">
    </descriptor>

    <!-- コレオグラフィー -->
    <descriptor id="doSelectSchedule" type="unsafe" rt="#ReservationUserInfo" title="予約日時を選択">
        <descriptor href="#scheduleId"/>
    </descriptor>
    <descriptor id="doSubmitUserInfo" type="unsafe" rt="#ReservationConfirm" title="ユーザー情報の入力を確定する">
        <descriptor href="#userName"/>
        <descriptor href="#userEmail"/>
        <descriptor href="#reservationMemo"/>
    </descriptor>
    <descriptor id="doReserve" type="unsafe" rt="#ReservationCompleted" title="予約を確定する">
    </descriptor>
</alps>

ALPS の書き方

ALPS ドキュメントは、XML または JSON 形式で記述できます。 今回はコメントアウトを利用したかったので XML で記述しました。 どちらの形式を使っても機能的な違いはないため、チームの好みや普段使っているツールに合わせて選ぶと良いそうです。

記述は descriptor タグを中心に行います。 そこに属性を追加したり、ネストすることで、意味や操作を表現することができます。

次に ALPS を構成する 3つの要素について説明します。 以下の要素を定義した ALPS を元に、ASD が相互リンクされたドキュメントを自動で生成してくれます。

1. オントロジー

アプリケーションで使用する言葉の意味を定義します。 id は 要素の一意な識別子、title は見出しのような簡潔な表現、doc はより長いテキストでの説明です。

<descriptor id="reservationStatus" title="予約ステータス">
    <doc format="text">予約の現在の状態 (確定済み、キャンセル済み)</doc>
</descriptor>

2. タクソノミー

情報の構造を定義します。 定義した用語(オントロジー)を組み合わせて、より大きな概念を表現します。

<descriptor id="Reservation" title="予約情報">
    <descriptor href="#scheduleId"></descriptor>
    <descriptor href="#reservationStatus"></descriptor>
    <descriptor href="#reservedAt"></descriptor>
    <descriptor href="#reservationMemo"></descriptor>
    <descriptor href="#userName"></descriptor>
    <descriptor href="#userEmail"></descriptor>
</descriptor>

3. コレオグラフィー

操作の流れを定義します。 type は操作の種類、rt は遷移先の指定です。 操作に必要な情報は descriptor に含めます。

<descriptor id="doSelectSchedule" type="unsafe" rt="#ReservationUserInfo" title="予約日時を選択">
    <descriptor href="#scheduleId"/>
</descriptor>

ALPSでは、操作は以下の種類に分かれます。

操作 メソッド HTTP メソッド説明
safe GET アプリケーションの状態のみを変更
unsafe POST 新しいリソース状態を作成
idempotent PUT/DELETE リソース状態を更新/削除

また命名規則プレフィックスは以下のようになっています。

  • safe 遷移では go を使用します
  • unsafe 遷移では doCreate を使用します
  • idempotent 遷移では doUpdate/doDelete を使用します

セマンティック

ALPS は言葉の意味(セマンティクス)に重きを置いています。 アプリケーションの語句の辞書になることが重要な役割の1つです。 そのため ASD では推奨セマンティック用語が提供されており、それに従って記述することで定義に一貫性を持たせることを推奨しています。

例:

用語 説明
givenName
familyName
termsOfService 利用規約
keywords キーワード
abstract 要約

できるだけSchema.orgの用語を基盤としつつ、必要な拡張を行うことを推奨します。

と記されている通り、個人的にはこれらの用語は命名する際の参考にしつつ、プロジェクトに合わせて開発メンバーやユーザーが理解しやすいドメイン用語を使うことが重要だと思います。 ドメイン用語を利用したとしても、その用語を元にタクソノミー(情報の構造)やコレオグラフィー(操作の流れ)を定義していくので、一貫性のあるドキュメントを作成することが可能です。

オンラインエディター

ASD 公式サイトには、入力した ALPS をリアルタイムでプレビューできるオンラインエディターが用意されています。 ローカルにインストールすることもできますが、まず軽く試してみたい場合はこちらを利用すると良いでしょう。

まとめ

見慣れない言葉が多く最初は戸惑いましたが、慣れてくると言葉の組み合わせで情報の構造を表現することができる ALPS と ASD は非常に役立つツールだと感じました。 最初に記した課題の解決のために以下のようなメリットが挙げられます。

  • JSONXML でシンプルに記述でき、Git などのバージョン管理ツールで管理できる
  • 意味や操作を相互にリンクで辿ることができ、ドキュメントを理解しやすくなる
  • セマンティクスに重きを置いているため、一貫性のある命名が可能

目の前のタスク単位で利用するというよりは、アプリケーション全体の設計で利用するツールだと感じました。 私自身のドキュメント作成ツールに対する知見は少ないのですが、今後画面遷移や API 設計のドキュメントを作成する際には候補に挙げたいです。 API のドキュメント作成に何を使うか迷っている方や、ドキュメントの一貫性を保ちたい方は、ぜひ他のツールと比較検討してみてください。 ASD 公式サイトにチュートリアルもあるので、まずは手を動かしてみるのがおすすめです。

参考

GraphQL + gRPC + Go で作るバックエンド環境

この記事は個人ブログと同じ内容です

www.ritolab.com


GraphQL サーバと gRPC サーバを Go で実装し、それらを連携させた簡単なローカル環境を構築しました。この記事では、その手順と体験をシェアしていきます。

全体アーキテクチャの概要

全体のアーキテクチャは以下のような構成になります。

  1. フロントエンド(クライアント)からのリクエストは、GraphQL サーバのエンドポイントに送信されます。
  2. GraphQL サーバは受け取ったクエリを解析し、必要なデータを取得するために内部で gRPC クライアントとして動作します。
  3. gRPC サーバは、GraphQL サーバからのリクエストを受け取り、実際のビジネスロジックを実行してレスポンスを返します。
  4. GraphQL サーバは、gRPC サーバから受け取ったレスポンスを、GraphQL のスキーマに従った形式に変換してクライアントに返します。

この構成を採用する利点としては以下が挙げられます。

  • フロントエンドは柔軟なデータ取得が可能なGraphQLを利用できる
  • バックエンド間の通信には、型安全で高速な gRPC を使用できる
  • Go の強力な型システムとコード生成ツールにより、安全で保守性の高いコードを実現できる

この構成を図にすると以下のイメージです。

+------------------------+
|                        |
|  Frontend (Client)     |
|                        |
+------------------------+
           |
        GraphQL
     (HTTP/HTTP2)
           |
           v
+------------------------+
|                        |
|   GraphQL Server (Go)  |
|    - Schema           |
|    - Resolver         |
|    [gRPC Client]      |
|                        |
+------------------------+
           |
         gRPC
       (HTTP/2)
           |
           v
+------------------------+
|                        |
|    gRPC Server (Go)    |
|    - Proto            |
|    - Service          |
|    - Business Logic   |
|                        |
+------------------------+
           |
           v
+------------------------+
|                        |
|      Database          |
|                        |
+------------------------+
Frontend (Client)     GraphQL Server (Go)     gRPC Server (Go)
     |                      |                      |
     |                      |                      |
     |  GraphQL Request     |                      |
     |--------------------->|                      |
     |                      |                      |
     |                      |    gRPC Request      |
     |                      |--------------------->|
     |                      |                      |
     |                      |    gRPC Response     |
     |                      |<---------------------|
     |                      |                      |
     |  GraphQL Response    |                      |
     |<---------------------|                      |
     |                      |                      |

なお、まずは基本的な機能実装に焦点を当てるため、ディレクトリ構成やファイル分割、ネーミングなどは簡易的です。

また、実験的な環境であり、本番用には適切なエラーハンドリングやセキュリティ対策の追加が必要です。

開発環境

端末は Mac(M2)で、Go がインストール済みの状態です。

%go version
go version: go1.23.2 darwin/arm64

プロジェクトの作成

まずは、開発を進めていくプロジェクトを作成します。新しいディレクトリを作成し、以下のコマンドで Go モジュールとして初期化します。

mkdir <新しいディレクトリ>

cd <新しいディレクトリ>

go mod init <プロジェクト名>

GraphQL サーバ構築

インストールとセットアップ

GraphQL サーバ構築には 99designs/gqlgen を利用します。このパッケージは、GraphQL スキーマから Go のコードを自動生成してくれる便利なツールです。

まず、必要なパッケージをインストールします。

go get -u github.com/99designs/gqlgen

次に、gqlgen の初期設定を行います。プロジェクトのルートディレクトリで以下コマンドを実行します。

go run github.com/99designs/gqlgen init

ルート配下に graph ディレクトリが作成され、配下に以下のファイルが作成されます。

graph
├── model
│   └── models_gen.go
├── generated.go
├── resolver.go
├── schema.graphqls
└── schema.resolver.go
gqlgen.yml
  • gqlgen.yml: gqlgen の設定ファイル
  • graph/schema.graphqls: GraphQLスキーマ定義ファイル
  • graph/generated/generated.go: 自動生成されたコード
  • graph/model/models_gen.go: スキーマから生成されたモデル
  • graph/resolver.go: リゾルバーの実装ファイル
  • server.go: GraphQLサーバのエントリーポイント

デフォルトの設定が既に生成されているため、この時点で GraphQL サーバを起動できます。以下のコマンドを実行して GraphQL サーバを起動してみます。

go run server.go

http://localhost:8080/ にアクセスすると、GraphQL Playground が表示されたことが確認できました。

GraphQL Playground は、GraphQLクエリのテストや、スキーマの確認ができる Web UI ツールです。クエリの入力、実行、レスポンスの確認が簡単にでき、開発時に便利です。また、スキーマのドキュメントも自動生成されるため、API の仕様確認にも活用できます。

スキーマの定義

次に、スキーマを定義します。スキーマは、GraphQL の API で利用可能な型、クエリ、ミューテーションを定義するものです。graph/schema.graphqls ファイルを編集します。

今回は例として、よくあるブログシステムを想定し、User(ユーザー)と、そこにひも付く Post(記事)で定義します。また、ユーザーの一覧を取得するリゾルバ(GraphQL のクエリを受け取って実際のデータを返す関数)users を定義します。

type Query {
  users: [User!]!
}

type User {
  id: ID!
  name: String!
  posts: [Post!]!
}

type Post {
  id: ID!
  title: String!
}

スキーマの定義ができたら、gqlgen というツールを使用しこのスキーマに基づいて Go のコードを自動生成します。この自動生成では、スキーマで定義した型の構造体や、GraphQL のクエリを処理するためのリゾルバの雛形が作成されます。手作業でこれらのコードを書く必要がなく、開発効率を高めることができます。

以下のようなコードが自動的に生成されます。

  • スキーマで定義した型(UserやPost)の Go 構造体
  • GraphQL のクエリを適切な Go 関数呼び出しに変換するためのコード
  • ゾルバ関数の雛形

コード生成を行う前に、まず gqlgen ツールの設定として tools.go を作成し、以下を記述しておきます。

//go:build tools
// +build tools

package tools

import (
  _ "github.com/99designs/gqlgen"
  _ "github.com/99designs/gqlgen/graphql/introspection"
)

この tools.go は、プロジェクトで使用するツールの依存関係を管理するためのものです。後ほど go mod tidy を実行する際に、これらの開発ツールの依存関係が go.mod ファイルに適切に含まれるようになります。

ここまでできたら、以下のコマンドを実行してコードを自動生成します。

go run github.com/99designs/gqlgen generate

graph/model/models_gen.go が生成されます。ファイルを見てみると、先程定義したスキーマが出力されていることが確認できます。

type Post struct {
  ID    string `json:"id"`
  Title string `json:"title"`
}

type Query struct {
}

type User struct {
  ID    string  `json:"id"`
  Name  string  `json:"name"`
  Posts []*Post `json:"posts"`
}

また、graph/schema.resolvers.go にリゾルバの雛形が生成されます。

func (r *queryResolver) Users(ctx context.Context) ([]*model.User, error) {
  panic(fmt.Errorf("not implemented: Users - users"))
}

これに仮の実装を行い、結果を返すようにしてみましょう。

func (r *queryResolver) Users(ctx context.Context) ([]*model.User, error) {
  return []*model.User{
    {
      ID:   "1",
      Name: "たろう",
      Posts: []*model.Post{
        {ID: "101", Title: "サンタさんは本当にいるかもしれない"},
      },
    },
    {
      ID:   "2",
      Name: "はなこ",
      Posts: []*model.Post{
        {ID: "102", Title: "近所に KFC があってよかった"},
      },
    },
  }, nil
}

ここまでできたら、GraphQL Playground に以下を入力してリクエストを送信してみます。

query {
  users {
    id
    name
    posts {
      id
      title
    }
  }
}

レスポンスが返ってきたことが確認できました。スキーマ定義通りにユーザー情報と、各ユーザーの投稿が取得できていることが分かります。GraphQL サーバの構築はここまでで完了です。

gRPC サーバの構築

次に、gRPC サーバを構築していきます。

インストールとセットアップ

Protocol Buffers Compiler と、gRPC と Protocol Buffers 用の Go プラグインをインストールします。

Protocol Buffers Compiler インストール

Protocol Buffers Compiler のインストールは OS によって変わりますので下記の公式ドキュメントを参考にしてください。

Protocol Buffers Compiler インストール

Mac は homebrew でいけます。

brew install protobuf

gRPC と Protocol Buffers 用の Go プラグインをインストール

gRPC と Protocol Buffers 用の Go プラグインをインストールします。

go install google.golang.org/protobuf/cmd/protoc-gen-go@latest
go install google.golang.org/grpc/cmd/protoc-gen-go-grpc@latest

以下をプロジェクトにインストールします。

go get -u google.golang.org/grpc
go get -u google.golang.org/protobuf

proto ファイル作成

proto ファイルは、Protocol Buffers(プロトコルバッファ)という Google が開発したデータ記述言語で使用されるスキーマ定義ファイルです。このファイルをもとに、さまざまなプログラミング言語でデータのシリアライズとデシリアライズを効率的に行うコードを自動生成できます。

また、gRPC では proto ファイルを使用して、RPC(リモートプロシージャコール)サービスやメッセージ構造を定義します。

proto/user/service.proto を作成して、以下を定義していきます。

  • package: プロトコルバッファ定義の名前空間
  • option go_package: Go コード生成時のパッケージ名
  • service: gRPC サービスの定義
  • message: リクエストやレスポンスのデータ構造
syntax = "proto3";

package user;

option go_package = "proto/user";

// Query サービスの定義
service QueryService {
  rpc Users (UsersInput) returns (UsersResponse);
}

// ユーザー情報の定義
message User {
  string id = 1;
  string name = 2;
  repeated Post posts = 3;
}

// 投稿情報の定義
message Post {
  string id = 1;
  string title = 2;
}

// Users メソッドのリクエスト(今回は何も指定しない)
message UsersInput {}

// Users メソッドのレスポンス
message UsersResponse {
  repeated User users = 1;
}

コード生成

以下の protoc コマンドで .proto ファイルから Go コードを生成します。

protoc --go_out=<出力ディレクトリ> --go-grpc_out=<出力ディレクトリ> <protoファイルのパス>

今回の例だとコマンドは以下になります。

protoc --proto_path=proto --go_out=. --go-grpc_out=. proto/**/*.proto
  1. --proto_path=proto:
    • .proto ファイルを検索する基準ディレクトリを指定。
    • 今回は proto/ 配下を基準にして .proto ファイルを検索します。
    • これにより、proto/ 配下でインポートされる他の .proto ファイルも自動的に解決されます。
  2. --go_out=.:
  3. --go-grpc_out=.:
    • gRPC 用のコード (_grpc.pb.go ファイル) を生成する出力先ディレクトリ。
    • これも今回は . で現在のディレクトリを指定。
  4. proto/**/*.proto:
  5. コンパイル対象の .proto ファイルのパス。
    • ワイルドカード (**/*.proto) を使用して proto/ 配下のすべての .proto ファイルを対象とします。

protoc コマンドを実行すると、proto ディレクトリに service.pd.goservice_grpc.pd.go ファイルが生成されます。

proto/
└── user/
   ├── service.pd.go
   ├── service.proto
   └── service_grpc.pd.go

gRPC メソッドの実装

次に、ユーザー一覧を返却する gRPC メソッド Users を実装します。

grpcserver/server.go を作成し、Users を実装します。次工程で gRPC サーバを起動するため、StartGRPCServer 関数も一緒に定義しています。

package grpcserver

import (
  "context"
  "graphql-dataloader-demo/proto/user"
  "log"
  "net"

  "google.golang.org/grpc"
)

// gRPC サーバの実装
type userServiceServer struct {
  user.UnimplementedQueryServiceServer
}

// Users ユーザーの一覧を返却します
func (s *userServiceServer) Users(ctx context.Context, req *user.UsersInput) (*user.UsersResponse, error) {
  // 仮のユーザーリストを返す
  users := []*user.User{
    {Id: "1", Name: "ミギ"},
    {Id: "2", Name: "ダリ"},
  }

  return &user.UsersResponse{
    Users: users,
  }, nil
}

// StartGRPCServer gRPC サーバを起動します
func StartGRPCServer(port string) error {
  listener, err := net.Listen("tcp", ":"+port)
  if err != nil {
    return err
  }

  grpcServer := grpc.NewServer()
  user.RegisterQueryServiceServer(grpcServer, &userServiceServer{})

  log.Printf("gRPC server is running on port %s", port)
  return grpcServer.Serve(listener)
}

gRPC サーバの起動

ここで一度、gRPC サーバを起動してみます。

server.go に gRPC サーバを起動させるための記述を行っていきます。これまでは GraphQL サーバの起動のみ定義していましたが、ここに gRPC サーバの起動も追記していきます。

package main

import (
  "graphql-dataloader-demo/graph"
  "graphql-dataloader-demo/grpcserver"
  "log"
  "net/http"
  "os"

  "github.com/99designs/gqlgen/graphql/handler"
  "github.com/99designs/gqlgen/graphql/handler/extension"
  "github.com/99designs/gqlgen/graphql/handler/lru"
  "github.com/99designs/gqlgen/graphql/handler/transport"
  "github.com/99designs/gqlgen/graphql/playground"
  "github.com/vektah/gqlparser/v2/ast"
)

const (
  defaultPort     = "8080"
  defaultGRPCPort = "50051" // 追加
)

func main() {
  // HTTP サーバのポート設定
  port := os.Getenv("PORT")
  if port == "" {
    port = defaultPort
  }

  // [追加] gRPC サーバのポート設定
  grpcPort := os.Getenv("GRPC_PORT")
  if grpcPort == "" {
    grpcPort = defaultGRPCPort
  }

  // [追加] GraphQL リゾルバを gRPC クライアントとともに初期化
  grpcAddr := "localhost:" + grpcPort
  resolver := graph.NewResolver(grpcAddr)

  // GraphQL サーバの設定
  srv := handler.New(graph.NewExecutableSchema(graph.Config{Resolvers: resolver}))
  srv.AddTransport(transport.Options{})
  srv.AddTransport(transport.GET{})
  srv.AddTransport(transport.POST{})
  srv.SetQueryCache(lru.New[*ast.QueryDocument](1000))
  srv.Use(extension.Introspection{})
  srv.Use(extension.AutomaticPersistedQuery{
    Cache: lru.New[string](100),
  })

  http.Handle("/", playground.Handler("GraphQL playground", "/query"))
  http.Handle("/query", srv)

  // [追加] gRPC サーバを起動
  go func() {
    log.Printf("Starting gRPC server on port %s", grpcPort)
    if err := grpcserver.StartGRPCServer(grpcPort); err != nil {
      log.Fatalf("failed to start gRPC server: %v", err)
    }
  }()

  log.Printf("connect to http://localhost:%s/ for GraphQL playground", port)
  log.Fatal(http.ListenAndServe(":"+port, nil))
}

以下のコマンドを実行して GraphQL サーバに加えて gRPC サーバを起動します。

go run server.go
% go run server.go
connect to http://localhost:8080/ for GraphQL playground
Starting gRPC server on port 50051
gRPC server is running on port 50051

gRPC サーバも無事に起動しました。

gRPC クライアントのセットアップ

最後に、GraphQL リゾルバで gRPC メソッドを使えるようにするために、gRPC クライアントのセットアップを行っていきます。

gRPC クライアントの追加

graph/resolver.go にて、リゾルバに gRPC クライアントを追加します。

package graph

import (
  "google.golang.org/grpc"
  "google.golang.org/grpc/credentials/insecure"
  "graphql-dataloader-demo/proto/user"
  "log"
)

type Resolver struct {
  GRPCClient user.QueryServiceClient // gRPC クライアントを格納するフィールド
}

// NewResolver は gRPC クライアントを初期化して Resolver を返します
func NewResolver(grpcAddr string) *Resolver {
  // gRPC の接続設定
  // 非セキュア接続(plaintext)を使用しています。
  // 本番環境では適切な証明書を使用してセキュア接続に切り替えてください。
  opts := []grpc.DialOption{
    grpc.WithTransportCredentials(insecure.NewCredentials()), // 非セキュア接続(開発用)
  }

  // gRPC サーバに接続
  conn, err := grpc.NewClient(grpcAddr, opts...)
  if err != nil {
    log.Fatalf("gRPC サーバへの接続に失敗しました: %v", err)
  }

  // Resolver に gRPC クライアントを設定して返却
  return &Resolver{
    GRPCClient: user.NewQueryServiceClient(conn),
  }
}

GraphQL リゾルバで gRPC を呼び出す

最後に、GraphQL の Usersゾルバで gRPC メソッドを呼び出します。

graph/schema.resolvers.go

func (r *queryResolver) Users(ctx context.Context) ([]*model.User, error) {

  // gRPC メソッドへリクエスト
  res, err := r.Resolver.GRPCClient.Users(ctx, &user.UsersInput{})
  if err != nil {
    return nil, err
  }

  // gRPC の User を GraphQL の User に変換
  users := make([]*model.User, len(res.Users))
  for i, u := range res.Users {
    users[i] = &model.User{
      ID:   u.Id,
      Name: u.Name,
    }
  }

  return users, nil
}

go run server.go でサーバを起動後、GraphQL Playground に以下を入力してリクエストを送信します。

query {
  users {
    id
    name
  }
}

すると、gRPC サーバからのレスポンスを受け取って返したことが確認できました。

{
  "data": {
    "users": [
      {
        "id": "1",
        "name": "ミギ"
      },
      {
        "id": "2",
        "name": "ダリ"
      }
    ]
  }
}

ここまでで、GraphQL サーバと gRPC サーバの連携は完了です。

まとめ

本記事では、GraphQL サーバと gRPC サーバを連携させたローカル開発環境を構築しました。

  1. GraphQLサーバ(99designs/gqlgen)
    • スキーマファイルの作成とコード生成
    • ゾルバの実装
    • GraphQL Playground の活用
  2. gRPCサーバ
    • Protocol Buffers によるスキーマ定義
    • サービス実装とサーバの起動
    • 型安全なコード生成の活用
  3. GraphQLサーバとgRPCサーバの連携
    • gRPC クライアントの実装
    • GraphQL リゾルバからの gRPC 呼び出し
    • データ変換処理の実装

実験的な構成ではありますが、GraphQL と gRPC を組み合わせることで、フロントエンドに柔軟な GraphQL インターフェースを提供しながらも、バックエンド間では効率的な gRPC 通信を実現し、Go の強力な型システムで開発するというそれぞれの強みを活かしたシステムが構築できる可能性を感じました。

特に、コードの自動生成は大きな強みです。品質が一定に保たれるだけでなく、実装コストの削減にもつながります。

これからこれらのツールを色々試していこうと思います。

Go 言語における Functional Option Pattern をラーメン屋で理解する

この記事は個人ブログと同じ内容です

www.ritolab.com


Functional Option Pattern は、Go 言語でよく使用される設計パターンの 1 つです。このパターンを使用することで、柔軟で拡張性の高い API 設計が可能になります。この記事では、Functional Option Pattern の基本的な考え方と実装方法について解説します。

Functional Option Pattern

Functional Option パターンは、関数を使ってオプションの設定を定義し、適用するデザインパターンです。

長いパラメータリスト(その多くがオプション)をコンストラクタや関数に渡す代わりに、設定用の関数(オプション)をリストとして渡します。これにより、デフォルトの設定や挙動を柔軟に変更できます。

Functional Option Pattern の主な利点

  1. オプショナルな設定を柔軟に扱える
    • 必要なオプションのみを指定でき、不要な設定は省略できる
    • オプションの順序も自由に変更できる
    • オプション間の依存関係も明確に表現できる(例:特定の設定が有効な場合のみ必要となる追加設定など)
  2. デフォルト値を自然に設定できる
    • 新しい設定項目を追加する際に、デフォルト値を設定できる
    • デフォルト値の管理が一箇所(コンストラクタ)に集中するため、保守が容易
    • デフォルト値の変更も、既存コードに影響を与えずに行える
  3. コードの可読性が高い
    • パラメータリストが長くなることを防ぎ、コードの見通しを良くする
    • 各オプションの目的が関数名から明確に分かる
    • IDE補完が効きやすく、どのようなオプションが利用可能か分かりやすい
  4. 後方互換性を保ちやすい
    • 新しいオプションを追加しても既存のコードに影響を与えない
    • 既存のオプションの動作を変更する際も、新しいオプション関数を追加することで対応可能
    • 非推奨になったオプションも、段階的に廃止することが容易

簡単な実装例

例えば、サーバーの設定には色々な設定項目がありますが、これを Functional Option Pattern で作成してみます。

まずはサーバーの構造体

package main

import "fmt"

// サーバーの設定を保持する構造体
type Server struct {
    Host string
    Port int
    TLS  bool
}

次に、それぞれのオプション設定を関数として定義します。それぞれのオプション関数は、シンプルにサーバー構造体が保持している値を設定しているだけです。

// ホスト名を設定するオプション
func WithHost(host string) func(*Server) {
    return func(s *Server) {
        s.Host = host
    }
}

// ポート番号を設定するオプション
func WithPort(port int) func(*Server) {
    return func(s *Server) {
        s.Port = port
    }
}

// TLS を有効化するオプション
func WithTLS() func(*Server) {
    return func(s *Server) {
        s.TLS = true
    }
}

最後に、サーバー構造体のコンストラクタ関数を定義します。この中で、オプションを適用するようにしていきます。(必ずしもコンストラクタ関数で適用しないといけないということではありません)

func NewServer(options ...func(*Server)) *Server { // [1] 引数でオプション関数のスライスが渡ってくる
    server := &Server{
        Host: "localhost",
        Port: 8080,
        TLS:  false,
    }
    
    // [2] オプション関数のスライスをループで回して、オプションを適用
    for _, optionFunc := range options {
        optionFunc(server)
    }
    
    return server
}

[2] の部分が Functional Option Pattern のポイントです。オプション関数のスライスをループで回して、サーバー構造体に対してオプション関数を実行し、値を設定しています。

オプションがあるだけ関数を実行し設定しますし、オプションがなければ関数は実行されないのでオプションのままです。この設計が柔軟性を生み出しています。

また、各オプションを関数で定義するため、それぞれの意図もわかりやすく、可読性も高いです。

実際に適用している箇所も、ループで回して設定しているだけですから、記述量も最小限ですし、オプションが増えても既存のコードに影響を与えずに済みます。後方互換性も保てるというわけです。

さて、これでオプション適用の実装ができたので、実際にこれを使用してみます。

func main() {
    // デフォルト設定でサーバーを作成
    defaultServer := NewServer()
    fmt.Printf("Default Server: %+v\n", defaultServer)

    // カスタム設定でサーバーを作成
    customServer := NewServer(
        WithHost("example.com"), // 1 つ目のオプション
        WithPort(9090),          // 2 つ目のオプション
        WithTLS(),               // 3 つ目のオプション
    )
    fmt.Printf("Custom Server: %+v\n", customServer)
}

上記のコードを実行すると、以下のような出力が得られます

Default Server: {Host:localhost Port:8080 TLS:false}
Custom Server: {Host:example.com Port:9090 TLS:true}

Functional Option パターンは上記のようにして実装できます。

ラーメン屋さんで理解する Functional Option Pattern

Web サーバーなんて滅多に立てるものでもないですから、もっと具体的な例も見てみましょう。

私たちは日頃、ラーメン屋さんにとてもお世話になっているわけですが、行きつけの「ジローラ系モラーメン」は、あの独特なオプションで有名です。

私たちの注文をスタッフさんが厨房に届けるまでを Functional Option パターンで実装してみます。

ソースコードの全容は以下です。前述したこと以上のものはありませんので、読んでみてください。

package main

import (
  "fmt"
  "strings"
)

// RamenOrder ラーメンの注文を表す構造体
type RamenOrder struct {
  garlic     Amount // にんにく
  vegetables Amount // 野菜
  oilLevel   Amount // 油の量
}

// RamenOption ラーメンのオプションを設定する関数の型
type RamenOption func(*RamenOrder)

// デフォルトのラーメン設定
func defaultRamenSetting() *RamenOrder {
  return &RamenOrder{
    garlic:     Regular,
    vegetables: Regular,
    oilLevel:   Regular,
  }
}

// 量に関する Enum を表現(ジローラモ系は特殊なため)
type Amount int

const (
  Light Amount = iota
  Regular
  Extra
  Ultimate
)

// String メソッドを実装して、各値を文字列として表現できるようにする
func (a Amount) String() string {
  switch a {
  case Light:
    return "少なめ"
  case Regular:
    return "普通"
  case Extra:
    return "マシ"
  case Ultimate:
    return "マシマシ"
  default:
    return "多分マシマシ" // スタッフの気分による
  }
}

// -- ここから Functional Option Pattern -- //

// WithGarlic にんにく量を指定するオプション
func WithGarlic(a Amount) RamenOption {
  return func(r *RamenOrder) {
    r.garlic = a
  }
}

// WithVegetables 野菜の量を指定するオプション
func WithVegetables(a Amount) RamenOption {
  return func(r *RamenOrder) {
    r.vegetables = a
  }
}

// WithOilLevel 油の量を設定するオプション
func WithOilLevel(a Amount) RamenOption {
  return func(r *RamenOrder) {
    r.oilLevel = a
  }
}

// NewRamenOrder 新しいラーメンの注文を作成する
func NewRamenOrder(opts ...RamenOption) *RamenOrder {
  r := defaultRamenSetting()
  
  // ここでオプションを適用
  for _, opt := range opts {
    opt(r)
  }
  
  return r
}

// Call Ramenのオプション情報を文字列として返す(スタッフさんが厨房にオーダーを通す)
func (r *RamenOrder) Call() string {
  options := []string{}

  if r.garlic != Regular {
    options = append(options, fmt.Sprintf("にんにく%s", r.garlic))
  }

  if r.vegetables != Regular {
    options = append(options, fmt.Sprintf("野菜%s", r.vegetables))
  }

  if r.oilLevel != Regular {
    options = append(options, fmt.Sprintf("油%s", r.oilLevel))
  }

  optionStr := ""
  if len(options) > 0 {
    optionStr = fmt.Sprintf("、%sで!!", strings.Join(options, "、"))
  }

  return fmt.Sprintf("注文入りました!ラーメン一丁%s", optionStr)
}

func main() {
  // はじめて
  ramenOrder1 := NewRamenOrder()
  fmt.Println(ramenOrder1.Call())

  // 健康志向
  ramenOrder2 := NewRamenOrder(
    WithVegetables(Ultimate),
  )
  fmt.Println(ramenOrder2.Call())

  // 深夜の背徳感
  ramenOrder3 := NewRamenOrder(
    WithGarlic(Ultimate),
    WithOilLevel(Extra),
  )
  fmt.Println(ramenOrder3.Call())

  // フードファイター
  ramenOrder4 := NewRamenOrder(
    WithGarlic(Ultimate),
    WithVegetables(Ultimate),
    WithOilLevel(Ultimate),
  )
  fmt.Println(ramenOrder4.Call())
}

上記実装の実行結果は以下になります。

注文入りました!ラーメン一丁
注文入りました!ラーメン一丁、野菜マシマシで!!
注文入りました!ラーメン一丁、にんにくマシマシ、油マシで!!
注文入りました!ラーメン一丁、にんにくマシマシ、野菜マシマシ、油マシマシで!!

ちなみに、全てマシマシのときは本当は「全マシマシ」とかいうらしいです。

まとめ

Functional Option パターンは、コードの柔軟性と可読性を向上させるだけでなく、拡張性にも優れています。特に、構造体や関数に対して多くのオプションを扱う必要がある場合に、このパターンを採用すると効果的です。

  • オプション設定が直感的に理解できる。
  • デフォルト値の管理が簡単になる。
  • 新しい設定項目を追加する際のコード変更が最小限で済む。

このパターンを活用して、よりメンテナンス性の高い Go コードを目指しましょう。

XP入門3

この記事は個人ブログと同じ内容です


前回までの振り返り

第1章~第16章は実践的な内容で、XP (エクストリームプログラミング)における開発手法や考え方について学びました。

今回の内容

今回は17~25章を読み進め、XP の哲学や歴史的な背景について学びました。 その中でも印象的だった章についてまとめていきます。

エクストリームプログラミング / ケント・ベック

テイラー主義とソフトウェア

  • 世界初の生産技術者である、フレドリック・テイラーという人物について。
    • 工場の生産性を飛躍的に向上させる、科学的管理法を提唱した。
    • これが「テイラー主義」と呼ばれており、技術的、社会的、経済的に影響を与えた。
  • テイラー主義には好ましい効果もあるが、いくつか深刻な欠点もある。これらの欠点は、以下の3つの単純化された仮定によるもの。
    • 通常、物事は計画通りに進む。
    • 局所最適化は、全体最適化につながる。
    • 人はほぼ代替可能であり、何をすべきかを指示する必要がある。
  • テイラー主義によるソーシャルエンジニアリングの手順。
    • その1
      • 計画立案と実行作業の分離。
      • 作業方法や作業期間を決定するのは、教育を受けたエンジニアである。
      • 作業者は、与えられた仕事を、与えられた方法で、与えられた時間内に、忠実に実行しなければいけない。
      • 権限のある人が他人の作業の見積もりを作成したり、変更したりする。
    • その2
      • 独立した品質管理部門の設置。
      • テイラーは品質管理部門を設置することで、作業者に適度なペースかつ規定の方法で作業させるようにして、適切な品質レベルを保った。
      • 多くのソフトウェア開発組織は、品質部門を別に設置しているという意味で、まぎれもなくテイラー主義
      • 品質部門を別にすれば、エンジニアリングにおける品質の重要性が、マーケティングや営業における品質と同等だというメッセージを送ることになる。
      • エンジニアリングで品質に責任を持つ人がいなくなる。
  • テイラー主義は生産性を向上させる一方で、ソフトウェア開発のような複雑で創造性を必要とする分野には適していない。
    • ソフトウェア開発においては、個々のエンジニアの能力を最大限に引き出し、チーム間のコミュニケーションを促進するような柔軟な開発体制が求められる。

トヨタ生産方式

  • トヨタは最も利益性の高い自動車メーカーのひとつ。
    • その理由は無理をしているからではない。
    • 車を製造するプロセスのすべての工程で、無駄な労力を削減しているから。
    • 無駄を十分に排除すれば、単に速く進もうとするよりも、ずっと速く進むことができる。
  • 従来と異なる仕事の社会構造が、トヨタの成功には絶対不可欠。
    • 全ての作業者が、生産ライン全体に責任を持つ。
    • 欠陥を見つけたら、紐を引っ張ってラインを止める。
    • そしてラインの全員で問題の根本原因を発見し、それを修正する。
    • 「ラインの最後」で品質に責任を持つ人がいる大量生産ラインとは異なり、後工程の品質保証が必要ないくらいにラインの品質を保つのが TPS (Toyota Production System)。
  • TPS では、作業者個人が作業のやり方や改善について多くの意見を述べる。
    • 無駄は改善イベントで削減していく。
    • まずは作業者が無駄(品質問題や非効率)の源泉を特定する。
    • そして率先して問題を分析して、実験して、その結果を標準化する。
  • TPS では、テイラー主義の工場で見られた厳格な社会的成層が排除されている。
    • 日常的なメンテナンスはエリート階級の技術者ではなく、普通の作業者が行っている。
    • 独立した品質部門は存在しない。組織全体が品質部門。
  • 最大の無駄は「つくりすぎの無駄」
    • 何かを作り、それが売れないとなると、作った労力の行き場がない。
    • 生産ラインで何かを作り、それをすぐに使わないとなると、情報のバリューが消えてしまう。保管コストもかかってしまう。
  • ソフトウェア開発には「つくりすぎの無駄」が満ち溢れている。
    • すぐに陳腐化する分厚い要求文書。
    • 全く使われない精巧なアーキテクチャ
    • 数ヶ月も放置され、インテグレーション、テスト、本番環境での実行が全くされないコード。
    • 不適切で誤解を招くようになるまで誰にも読まれないドキュメント。
  • 無駄を排除するためには、アウトプットをすぐに利用する必要がある。
    • 要件収集を改善するには、要件収集のプロセスを念入りに行うのではなく、詳細な要件の作成とソフトウェアのデプロイの間隔を短縮すればいい。
    • すぐに利用するのであれば、要件収集は静的なドキュメントを作成するフェーズではなく、開発で必要になった詳細な情報を作り出す機会になる。
  • TPS の考え方は、ソフトウェア開発にも多くの共通点がある。
    • 特に「無駄の排除」という点で深い繋がりがある

時を超えたプログラミングの道

  • クリストファー・アレグザンダーという建築家について。
    • アレグザンダーは、建築家の自己中心的な関心事は、施主の関心事と一致していないと指摘した。
    • 建築家は仕事をすぐに終わらせて、賞を獲得したいと思っているが、重要な情報を見逃している。
    • それは、施主がどのような生活をしたいかという情報。
    • ソフトウェア開発においても、エンジニアの利益や技術的な興味事項ばかりに注目し、ユーザーのニーズを見落としてしまうことがある。
  • 筆者が育ったシリコンバレーではエンジニアリングが王様だった。
    • 「あなたに必要なものを与えよう。必要かどうかは知らなくても構わない」がモットーだった。
    • このようにして作られたソフトウェアは、技術的には優れていたが、役に立たないものが多かった。
  • 経験を積んでいくと、正反対の不均衡を目にするようになった。
    • ビジネスの関心ごとが開発を支配する世界。
    • ビジネス上の理由だけで期日やスコープを設定すると、チームの誠実性を維持できない。
  • 高みに到達することが目的であれば、ソフトウェア開発は「プログラマーとその他大勢」で成立するものではない。
    • 関係者全員の関心事のバランスがとれていなければ、開発に貢献できない人が出てくる。
  • XP の成功は、信頼できるソフトウェアのすばやい見積もり、実装、デプロイができる優秀なプログラマーの増加にかかっている。
    • このようなプログラマーがいれば、チームのビジネス担当者に意思決定を任せることができるはず。
  • ツールや技法は何度も変化するが、大きく変化するわけではない。
    • 一方、人はゆっくりとだが、深く変化する。
    • XP の課題は、このような深い変化を促し、個人の価値と相互の人間関係を新しいものにして、ソフトウェア業界に次の50年間の居場所を用意すること。

コミュニティーと XP

  • サポートしてくれるコミュニティーの存在は、ソフトウェア開発の偉大な資産である。
    • 自分に共感してくれる人を見つけたり、誰かの声に自分の耳を傾けたりする絶好の場。
    • 人と人との関係は、安全に実験ができる安定した場を提供してくれる。
  • コミュニティーでは、自分から話すよりも耳を傾けるスキルの方が重要。
  • コミュニティーは一緒に勉強する場にもなる。
    • XP には、練習が必要なスキルも数多く含まれている。
  • コミュニティーは説明責任の場でもある。
    • コミュニティーでは、自分の発言に責任を持たなければいけない。
    • 説明責任を果たすためにも、コミュニティーには安全性が必要である。
    • 相手の秘密を大切にしたり、求められたときにだけアドバイスしたり、よく考えてから判断したりすることは、すべてが安全性につながっている。
  • コミュニティーは質問や疑問を投げかける場でもある。
    • コミュニティーには、個人の意見が重要である。
    • 衝突や意見の不一致は、共に学習するための下地となる。
    • 衝突を抑え込んでしまうのは、コミュニティーが弱い証拠。
    • 本当に重要なアイデアならば、そのようなことで重要性が失われることはない。
    • 常に意見を一致させる必要はない。お互いをリスペクトしながら、意見の不一致に対応すればいい。

今回のまとめ

  • テイラー主義
    • 専門化することで作業時間を短縮し、生産性を向上させ、品質の安定化を図る
    • 管理者と労働者を明確に分離する
  • トヨタ生産方式
    • 過剰生産や加工の「無駄」を排除することで、生産性を向上させる
    • すべての作業者が品質管理に責任を持つ
  • XP
    • 顧客と密なコミュニケーションをとることで、期待とのズレを生じにくくし、生産性を向上させる
    • ペアプログラミングを行いコードの品質向上や知識共有を行う
    • 信頼関係を重要視し、チーム全体で協力して問題を解決する

例えば XP のペアプログラミングは、一人当たりの労働時間を短縮し、生産性を向上させようとするテイラー主義では考えられない手法だと思います。 チーム全体で取り組む点と、管理者と労働者を明確に分離する点も対照的です。

また XP とトヨタ生産方式を比較すると、どちらもチームワークを重要視し、個人の能力を超えた成果を目指している点は共通しているように思います。

まとめると、工場生産が中心だった産業革命時代から、コンピューターの発明とインターネットの普及により、ソフトウェア開発が登場した時代に合わせて、生産性を向上させるための手法が変化してきたと言えそうです。 そしてその手法は徐々に、人間性やコミュニケーションを重要視するようになってきており、XP はその流れの中で生まれたものだと感じました。 どの手法も無駄を排除することで生産性を向上させようという点では共通しているように思いますが、歴史的背景を知ることで、何を重視して現在のプラクティスが生まれたのか理解し、より効果的な取り組みを行えそうです。

本書を読み終えて

今回で「エクストリームプログラミング」を読み終えました。 ソフトウェア開発は、単なる技術的な作業ではなく、人々が協力して行う創造的な活動であるということを改めて感じました。 本書でも触れられていたのですが、XP は問題解決に取り組むための新しい文脈であり、それ自体では問題を解決するものではありません。 アジャイルが健全に回るように、これからも継続的な学習・改善を行っていきたいと思います。

BigQuery パイプ構文を試す - 直感的で読みやすい新しいSQLクエリの書き方

この記事は個人ブログと同じ内容です

www.ritolab.com


2024 年 10 月 9 日の Google Cloud Blog にて、BigQuery と Cloud Logging における「パイプ構文」導入の記事がアップされました。

Introducing pipe syntax in BigQuery and Cloud Logging | Google Cloud Blog

SQL のクエリといえば、長らく同じ記法で歴史を刻んできた古に伝わる呪文ですが、今回の「パイプ構文」は、それに一石を投じる新しい書き方になっています。まさに黒船。ペリー来航です。(といいつつ、今回紹介するパイプ構文はあくまでも GoogleSQL としての機能です。MySQLPostgreSQL などでは現状パイプ構文をサポートしていません)

そんなパイプ構文を、BigQuery で試してみたいと思います。

パイプ構文

BigQuery のパイプ構文は、SQL クエリをより直感的で読みやすくするための記法です。パイプ演算子 (|>) を使って処理を順番にチェーンすることで、クエリの各操作をステップごとに記述できるのが特徴です。 これにより、クエリの記述量の削減、可読性の向上が期待できます。

従来の SQL のようにすべての操作を 1 つのブロックでまとめるのではなく、フィルタリング、選択、集約などの操作をそれぞれの行で分けて記述します。詳細な記法は公式ドキュメントをご確認ください。

パイプ構文を体験

では、実際にデータを用意してクエリを書いてみましょう。

今回用意したのは、e-sports のゲームをプレイした履歴のデータです。

esports_play_sessions テーブル(n=30,000)

player_id esports_genre session_date match_result
31 sports_games 2024/01/01 win
133 moba 2024/01/01 lose
32 sports_games 2024/01/01 win

どのプレイヤーが、いつ、どのジャンルのゲームをプレイして、勝敗がどうだったか。を収録したデータです。

players テーブル(n=163)

id name
1 Caspian
2 Kamryn
3 Charmaine

こちらはプレイヤーを収録したテーブルです。

さて、こんなオーダーが来ました。

「sports_games と、card_games をプレイしたプレイヤーの、最終プレイ日を抽出したい」

このとき、どんなクエリを書くでしょうか。通常の SQL なら、以下のようなクエリになると思います。

SELECT
    p.id as player_id,
    p.name,
    s.esports_genre,
    MAX(s.session_date) AS session_date
FROM `sample.players` p
INNER JOIN `sample.esports_play_sessions` s ON p.id=s.player_id
WHERE s.esports_genre IN ('sports_games', 'card_games')
GROUP BY p.id, p.name, s.esports_genre
ORDER BY p.id, MAX(s.session_date)

ジャンルを絞り込み、それに対して集計。ユーザーごとの sports_games, card_games をプレイした最新の日時を抽出します。

これをパイプ構文を使用してクエリを組み立てると、以下になります。

FROM `sample.players` AS p
|> JOIN `sample.esports_play_sessions` AS s ON p.id = s.player_id
|> WHERE s.esports_genre IN ('sports_games', 'card_games')
|> AGGREGATE MAX(s.session_date) AS session_date GROUP BY p.id, p.name, s.esports_genre
|> SELECT id as player_id, name, esports_genre, session_date
|> ORDER BY player_id, session_date;
  1. [|> JOIN] players テーブルに esports_play_sessions テーブルを結合
  2. [|> WHERE] ジャンルを絞り込む
  3. [|> AGGREGATE] 集計し最新日を算出
  4. [|> SELECT] 出力するカラムを指定
  5. [|> ORDER BY] 並び替え

上から順番に読めるので、可読性は確かに向上するかもしれません。一方で、記述量はあまり変わってはいない印象を受けます。

もう少し複雑なクエリで試してみます。 各ジャンルにおける、一年間のプレイ数トップ 3 を出してみます。まずは通常の SQL です。

WITH ranked_sessions AS (
    SELECT
        esports_genre, player_id, COUNT(*) AS session_count,
        RANK() OVER (PARTITION BY esports_genre ORDER BY COUNT(*) DESC) AS session_rank
    FROM `sample.esports_play_sessions`
    WHERE session_date BETWEEN '2024-01-01' AND '2024-12-31'
    GROUP BY esports_genre, player_id
)
SELECT
    s.esports_genre, s.session_rank, p.id, p.name, s.session_count
FROM `sample.players` p
INNER JOIN ranked_sessions s ON p.id=s.player_id
WHERE s.session_rank <= 3
ORDER BY s.esports_genre, s.session_rank;

ランキングで絞りトップ 3 としたいため、一度 CTE を定義し session_rank を付与、それから絞り込んでいます。

対して、このクエリをパイプ構文で記述すると以下になります。

FROM `sample.esports_play_sessions` AS s
|> WHERE session_date BETWEEN '2024-01-01' AND '2024-12-31'
|> AGGREGATE COUNT(*) AS session_count GROUP BY esports_genre, player_id
|> WINDOW RANK() OVER (PARTITION BY esports_genre ORDER BY session_count DESC) AS session_rank
|> WHERE session_rank <= 3
|> JOIN `sample.players` AS p ON player_id=p.id
|> SELECT esports_genre, session_rank, p.id AS player_id, p.name, session_count
|> ORDER BY esports_genre, session_rank;
  1. [|> WHERE] 日付を絞り込む
  2. [|> AGGREGATE] 集計し各ジャンルのプレイ数を算出
  3. [|> WINDOW RANK()] プレイ数によってジャンルごとにユーザーをランク付け
  4. [|> WHERE] トップ 3 に絞り込み
  5. [|> JOIN] esports_play_sessions テーブルに players テーブルを結合
  6. [|> SELECT] 出力するカラムを指定
  7. [|> ORDER BY] 並び替え

抽出の順は確かに追いやすいですね。ただ、記述量は減ったか?と言われればそうでもない気がします。

最後に、もう少し複雑なクエリで試してみます。各ジャンルにおいて、勝率が最も高いユーザートップ 3 を抽出してみます。

まずは、通常の SQL です。

WITH player_stats AS ( -- 各プレイヤーの各ジャンルにおける総試合数と勝利数を算出
    SELECT
        e.player_id,
        p.name as player_name,
        e.esports_genre,
        COUNT(*) AS total_matches,
        SUM(CASE WHEN e.match_result = 'win' THEN 1 ELSE 0 END) AS wins
    FROM `sample.esports_play_sessions` e
    JOIN `sample.players` p ON e.player_id = p.id
    GROUP BY e.player_id, p.name, e.esports_genre
),
ranked_players AS ( -- 勝率を算出し、各ジャンルごとに勝率の高い順にランク付け
    SELECT
        esports_genre,
        player_id,
        player_name,
        ROUND(SAFE_DIVIDE(wins, total_matches), 2) AS win_rate,
        ROW_NUMBER() OVER (PARTITION BY esports_genre ORDER BY SAFE_DIVIDE(wins, total_matches) DESC) AS rank
    FROM player_stats
)
SELECT
    esports_genre,
    rank,
    player_id,
    player_name,
    win_rate
FROM ranked_players
WHERE rank <= 3
ORDER BY esports_genre, rank;

これをパイプ構文で記述すると以下になります。

FROM `sample.esports_play_sessions` AS s
|> JOIN `sample.players` AS p ON s.player_id = p.id
|> AGGREGATE COUNT(*) AS total_matches, SUM(CASE WHEN s.match_result = 'win' THEN 1 ELSE 0 END) AS wins GROUP BY s.player_id, p.name, s.esports_genre
|> EXTEND ROUND(SAFE_DIVIDE(wins, total_matches) * 100, 2) AS win_rate
|> WINDOW ROW_NUMBER() OVER (PARTITION BY esports_genre ORDER BY win_rate DESC) AS rank
|> RENAME name as player_name
|> WHERE rank <= 3
|> SELECT esports_genre, rank, player_id, player_name, win_rate
|> ORDER BY esports_genre, rank;
  1. [|> JOIN] esports_play_sessions テーブルに players テーブルを結合
  2. [|> AGGREGATE] 各プレイヤーの各ジャンルにおける総試合数と勝利数を算出
  3. [|> EXTEND] 勝率を算出
  4. [|> WINDOW] 勝率によってランク付け
  5. [|> RENAME] カラム名 name を player_name に変更
  6. [|> WHERE] トップ 3 に絞り込み
  7. [|> SELECT] 出力するカラムを指定
  8. [|> ORDER BY] 並び替え

追いやすさはこれまで通りポジティブですが、今回は記述量が大分減りました。CTE で切り出し、ないしはサブクエリの記述が無くなった分、SELECT 文分の記述量が主に削減されています。パイプ構文では、SELECT や集計でカラムが絞られない限りはそのまま次の行に持ち越されるため、追加したい新たなカラムだけを記述すればよい点が削減に寄与しています。

もう一点、通常の SQL では、ranked_players 定義時に win_rate と rank を算出していますが、その両方で SAFE_DIVIDE(wins, total_matches) を行っています。対してパイプ構文だと同じ計算は 1 回済んでおり、冗長さが解消されています。こういった点は地味にうれしいポイントです。

直感的で追いやすくクエリ冗長に成り難し

2024 年 10 月 16 日現在、パイプ構文のリリースステージは「プレビュー」であり、一般提供はされていません。

BigQuery でパイプ構文を使ってみたい場合は、BigQuery パイプ構文登録フォームに記入し、パイプ構文プレビューにプロジェクトを登録する必要があります。

また、今回紹介したパイプ構文は、GoogleSQL としての機能です。MySQLPostgreSQL などでは現状パイプ構文をサポートしていません。

さて、パイプ構文を使ってみましたが、読みやすさの向上、記述量の削減など、複雑なクエリほどパイプ構文の恩恵を受けやすくて良い機能でした。パイプ構文で記述したクエリも CTE として切り出せました。

「直感的で追いやすくクエリ冗長に成り難し」

BigQuery のパイプ構文、是非使ってみてください。

271_sample_data - Google スプレッドシート

(今回使用したサンプルデータはスプレッドシートで公開しています。CSV ダウンロードしてご自身の BigQuery にインポートするなど、ご自由にお使いください。)

XP入門2

この記事は個人ブログと同じ内容です


前回の振り返り

簡単に前回を振り返ると、XP (エクストリームプログラミング) は、アジャイル開発の手法の一つです。 XP において「価値」「原則」「プラクティス」は重要な概念であり、それぞれの要素が連携してソフトウェア開発を効率化します。

前回の記事はこちら

今回の内容

今回は9~16章を読み進めました。 その中で印象的だった、導出プラクティス、チーム全体、制約理論と時間の重要性についてまとめていきます。

エクストリームプログラミング / ケント・ベック

導出プラクティス

導出プラクティスは主要プラクティスを補強し、より効果的な開発を可能にするプラクティスです。 そのため主要プラクティスを実践していて、ある程度チームが XP に成熟していることが推奨されます。

本物の顧客参加

  • 自分たちのシステムによって生活やビジネスに影響を受ける人をチームの一員にすること。
  • 顧客参加のポイントは、ニーズを持つ人とそれを満たす人が直接やりとりをして、ムダな労力を減らすこと。
  • 信頼できる行動をとり、何も隠さなければ、生産性は高まる。(隠すことや取り繕うことに時間を費やす必要がないため)

チームの継続

  • 優秀なチームは継続させること。
  • 大きな組織は、ヒトをモノに抽象化する傾向がある。互換性のあるプログラミングユニットだと考えている。
  • ソフトウェアのバリューは、みんなが知っていることや行なっていることだけでなく、人間関係やみんなで一緒に成し遂げることによっても生み出される。
  • 要員計画の問題を単純化するためだけに、人間関係や信頼の大切さを無視するのは経済的ではない。

チームの縮小

  • チームの能力が高まったら、仕事量を維持しながら少しずつチームの規模を縮小すること。
  • チームを離れた人は、また別のチームを作ることができる。
  • より多くの仕事量をこなすために、チームの規模を拡大するような戦略もあるが、それではうまくいかない。他の方法を考えるべき。
  • チームメンバーの誰かの手が空くまで、開発を改善していくこと。そうすれば、規模を縮小しながらチームを継続できるはず。

コードとテスト

  • コードとテストだけを永続的な作成物として保守すること。
  • その他のドキュメントについては、コードとテストから生成すること。
  • プロジェクトの重要な履歴の維持については、社会的な仕組みに任せること。
  • 顧客は、システムの今日の挙動と、チームが開発する明日のシステムの挙動に対してお金を支払っている。
    • この2つのバリューの源泉に貢献する作成物は、それ自体にバリューがある。

利用都度課金

  • 利用都度課金システムがあれば、システムが利用されるたびにお金を請求することができる。
  • お金は究極のフィードバック。
    • お金には実体があり、これから自分で使うこともできる。
    • お金の流れをソフトウェア開発に直接接続すれば、改善を推進するための正確でタイムリーな情報が得られるはず。
  • 利用都度課金にできなくても、サブスクリプションモデルに移行することはできるかもしれない。
    • チームは自分たちの行動の状況を把握する情報源として、少なくとも定着率(サブスクライブを継続する顧客数)を見ることができる。
  • ライセンス収益のフィードバックだけを頼りにしているチームよりも、利用都度課金の情報を使っているチームの方が効果的な仕事ができるはず。

XP チーム全体

XP における「チーム全体」は、単なる開発チームではなく、プロジェクトの成功のために協力し合う、より広義のチームを指します。 さまざまな人の視点を注ぎ込み、チーム全体が一体となって取り組むことで、より良いソフトウェアを開発し、ユーザーの満足度を高めることができます。

この章では次の比喩を用いて、チーム全体の重要性が説明されていました。

  • 異なる視点を持つ人たちがロープに結ばれて氷河の上を歩いているときに、誰が先頭になるかは重要ではない。
  • 本当に重要なのは、全員がロープに結ばれているという感覚をチーム全体が共有していること。
  • 誰かが先頭になって他の人を追従させるよりも、全員で足並みをそろえて歩いたほうが、ずっと先まで進める。

また XP においてそれぞれの役割は固定化されるものではなく、チーム全体の成功のために柔軟に変化することが重要です。 チームの状況や目標に合わせて変化し、チーム全体の成功に貢献することが求められます。

プロジェクトマネージャー

  • XP チームのプロジェクトマネージャーは、チーム内のコミュニケーションを円滑にしたり、顧客、サプライヤー、その他のチーム外の組織とのコミュニケーションを調整したりする。
  • チームの歴史学者となり、チームに進捗状況を思い出させる。
  • プロジェクトの情報をまとめて、経営幹部や同僚にプレゼンするために、クリエイティブでなければいけない。
  • 正確性を保つために、プロジェクトの情報を頻繁に変更することになる。
    • したがってプロジェクトマネージャーには変更をうまく伝える能力が求められる。
  • チーム内のコミュニケーションを円滑にして、一体感や信頼関係を築くようにしなければいけない。
    • そのためには、重要な情報の管理者になるよりも、効果的なファシリテーターになるほうが、得られる力は大きい。

プロダクトマネージャー

  • XP のプロダクトマネージャーは、ストーリーを書いたり、四半期サイクルのテーマやストーリーを選択したり、週次サイクルのストーリーを選択したり、実装によって明らかになったストーリーのあいまいな部分の質問に答えたりする。
  • チームがオーバーコミットしていたら、想定していた要件と現実の違いを分析して、チームが優先順位をつけられるように支援する。
  • プロダクトマネージャーは、今実際に起きていることにストーリーやテーマを適応させる。
  • ストーリーの順番は、技術ではなくビジネスの理由で決めるべき。
  • プロダクトマネージャーは、顧客とプログラマーのコミュニケーションを促進する。
    • 顧客の最も重要な課題がチームに伝わり、きちんと対処されるようにしなければいけない。
    • チームが本物の顧客参加を実践していれば、ストーリーを選択した顧客やマーケット全体のニーズを満たせるように、システムの成長を促さなければいけない。

経営幹部

  • 経営幹部は、XP チームに勇気、自信、説明責任を提供する。
  • 共通の目標に向かって一緒に進んでいく XP チームの強みは、弱みにもなり得る。
    • チームのゴールが会社のゴールと合っていなかったらどうなるだろう?
    • 成功のプレッシャーと興奮によって、ゴールを見失ってしまったらどうなるだろう?
    • 大きなゴールの明確化と維持は、XP チームのスポンサーや監督を務める経営幹部の仕事。
  • もうひとつの仕事は、改善の監視、促進、円滑化。
    • 経営幹部はチームが作り出す優れたソフトウェアだけではなく、継続的な改善についても目を配らなければいけない。
  • 経営幹部は、XP プロジェクトのあらゆる側面について自由に説明を求めることができる。
    • 説明は筋が通ったものでなければいけない。
    • 筋が通っていなかったなら、経営幹部はチームに対して考察と明確な説明の提供を求めるべき。
  • XP チームの評価を決める人たちは、優秀なチームがどのようなものかを理解するべき。
    • XP チームは会話しながら仕事をする。
    • にぎやかな話し声は健全である証拠。
    • 静寂はリスクがたまっている音色。
    • 経営幹部が XP チームを理解し、自身の経験や視点をうまく適用するには、新しい経験則を学ぶ必要がある。

テクニカルライター

  • XP チームにおけるテクニカルライターの役割は、フィーチャーのフィードバックを早期に提供したり、ユーザーとの密接な関係を築いたりすることである。
    • フィーチャーのフィードバックを早期に提供。
      • 文章と図を使ったシステムの説明は、チームにフィードバックをもたらす要素のひとつである。
    • ユーザーとの密接な関係を築くこと。
      • ユーザーがプロダクトを学習できるように支援したり、ユーザーからのフィードバックを受け取ったり、ユーザーが混乱しないように発表資料や新しいストーリーを追加したりする。
  • XP チームは実際の利用状況からフィードバックを得るべき。
    • マニュアルサイトを掲載しているなら、利用状況を監視できる。
    • ユーザーがドキュメントを見ていなかったら、その部分を書くのはやめる。
    • そして、空いた時間をもっとうまく活用する。

ユーザー

  • XP チームのユーザーは、開発中のストーリーの記述や選択の支援をしたり、専門領域の意思決定をしたりする。
    • 構築中のシステムと類似したシステムに関する幅広い知識や経験を持っていたり、システムを実際に利用するユーザーコミュニティとの強い関係性を持っていたりすれば、そのユーザーは非常に大切な存在だ。
    • ユーザーはコミュニティの代表者であることを忘れないようにしなければいけない。

プログラマー

  • XP チームのプログラマーがやること。
    • ストーリーやタスクを見積もる。
    • ストーリーをタスクに分解する。
    • テストを書く。
    • フィーチャーを実装するコードを書く。
    • 退屈なプロセスを自動化する。
    • システムの設計を少しずつ改善したりする。
    • 技術的に密接に協力しながら一緒に働く。
      • つまりプログラマーは社交性や人間関係のスキルを身に付ける必要がある。

人事

  • チームが XP を適用し始めるとき、人事評価と雇用という2つの課題が発生する。
    • 人事評価の課題
      • XP はチームのパフォーマンスに集中しているのに、実際の人事評価や昇給は個人の目標や達成度に対して行われているから。
      • XP 適用前の評価の仕方を大きく変える必要はない。
      • 以下は、XP における重要性の高い従業員
        • リスペクトを持って行動できる。
        • 他人とうまくやれる。
        • イニシアチブをとれる。
        • 約束したものをデリバリーできる。
      • 2つの方法で解決できる。
        • このまま個人ベースの目標、評価、昇給を続ける。
        • もしくはチームベースのインセンティブや昇給に移行するか。
    • 雇用の課題

制約理論

制約理論とは、システムのボトルネック(制約)を特定し、それを改善することで、システム全体の効率を最大化する理論です。 導入するには組織全体の意識改革が求められます。個人の生産性よりも、システム全体の効率を重視し、ボトルネック解消に協力する体制作りが重要です。

  • 先ずはどの問題が開発の問題かを見極めるところから、ソフトウェア開発の改善の機会を発見すること。
  • 洗濯を例にした理論の説明
    • 洗濯機が衣類を洗濯するのに45分かかり、乾燥機が衣類を乾燥するのに90分かかり、衣類を畳むのに15分かかるとする。
    • このシステムのボトルネックは乾燥。洗濯機が2台あっても、洗濯がすべて完了した衣類が増えるわけではない。
    • 洗濯だけが終わった衣類は一時的に増えるかもしれないが、濡れたままの衣類が至るところに山積みになり、その対応をしなければいけなくなる。
    • より多くの衣類の洗濯をすべて完了させたければ、乾燥をどうにかする以外に選択肢はない。
  • システム全体のスループットを改善するには、最初に制約を見つけなければいけない。
    • 次に、その制約が最大限に稼働していることを確認する。
    • そして、制約のキャパシティーを増やすか、制約以外の負荷を下げるか、制約を完全に排除するかのいずれかの方法を探す。
  • システムの制約をどのように発見するか。
    • 仕掛品が山積みになっているところが制約。
    • 洗濯の例では、これから畳まなければいけない乾燥した衣類は山積みになっておらず、これから乾燥する濡れた衣類が山積みになっている。
    • ER 図が多くのフィーチャーを網羅しているにもかかわらず、やることが多すぎて実装から外されている場合は、実装プロセスが制約になっているかもしれない。
    • 多くのフィーチャーの実装が終わっているにもかかわらず、インテグレーションやデプロイが待機中になっている場合は、インテグレーションプロセスが制約になっているかもしれない。

設計 : 時間の重要性

ソフトウェア開発における設計は、一度で完璧なものを作り上げるのではなく、段階的に改善していくことが重要です。 柔軟性、シンプルさ、チームとの連携を意識することで、より良いソフトウェアを開発することができます。

  • インクリメンタルな設計は、機能を早期に届ける方法であり、プロジェクトの全期間にわたって毎週継続して機能を届ける方法。
  • 設計が日常の業務の一環になれば、プロジェクトがもっとスムーズに進む。
  • ソフトウェアはレバレッジゲーム
    • ひとつの優れたアイデアが何百万ドルものコストを削減したり、何百万ドルもの収益を生み出したりする。
  • 残念ながらソフトウェアの設計は、物理的な設計活動のメタファーにとらわれている。
    • たとえば、50階建てのビルを所有しており、すべての空間をすでに貸し出しているからといって、そこに別の50階を付け足すことはできない。
    • ソフトウェア開発における実践的でリスクの低い方法。
      • 犬小屋から開始して、少しずつ部品を置き換えながら、基本的な構造はそのままにして、最終的に超高層ビルにする。
  • ソフトウェアの世界でインクリメンタルな設計が重要なのは、アプリケーションをはじめて書く機会が多いから。
  • 設計には大きな影響力があり、設計のアイデアは経験によって改善される。
    • しかがって、ソフトウェア設計者が持つべき最も重要なスキルのひとつは忍耐。
    • フィードバックが十分に得られる分だけ設計を行い、そこから得られたフィードバックを使って、次回のフィードバックが十分に得られる分だけ設計を改善する。そうした技能が求められる。
  • 前もった設計は必要だが、最初の実装ができるだけで十分。
    • それ以上の設計は実装後に設計の本当の制約が明らかになってから行えばいい。
    • XP の戦略は「何も設計しない」ではなく「常に設計する」。
  • ソフトウェア設計のおもしろいところ。
    • 設計の品質は成功を約束するものではないが、設計の失敗は確実に失敗につながる。
  • 最も強力な設計方法は、「Once and Only Once (一度、ただ一度)」
    • データ、構造、ロジックなどは、システムのひとつの場所に存在するべき。
    • 重複を見つけたら設計を改善する必要がある。
    • 重複した表現をひとつにまとめる方法を思いつくまで、設計の改善に取り組んでいく。
  • ソフトウェアの設計はそれ自体では完結しない。
    • 設計とは、技術側の人間とビジネス側の人間の信頼関係を築くためのもの。
    • 要求された機能を毎週デリバリーすることが、信頼関係の構築に欠かせない。
    • チームの中でバリューを生み出す多様な関係性を維持することに比べたら、設計者の利便性は優先順位が低い。
  • XP チームはできるだけシンプルな解決策を好む。
    • 設計のシンプリシティを評価する4つの基準
      1. 対象者に適している
        • 設計がいかに見事で洗練されているかは重要ではない。その設計を使うべき人たちが理解できなければ、それはシンプルではない。
      2. 情報が伝わりやすい
        • 伝えるべきすべてのアイデアがシステムに表現されている。
        • システムの要素は、用語の単語と同じように、未来の読者に情報を伝えるものである。
      3. うまく分割されている
        • ロジックや構造の重複は、コードの理解や修正を困難にする。
      4. 最小限である
        • 上記の3つの制約を守った上で、システムの要素はできるだけ少なくする。
        • 要素が少なければ、その分だけ必要なテスト、ドキュメント、コミュニケーションが少なくなる。

まとめ

今回読んだ範囲では、より実践的な側面に焦点を当てた内容が多かったと感じました。 特に印象的だったのは「チーム全体」で、経営幹部や人事など、チーム外の人たちがどのような役割を果たすべきかも学べたので、メンバーをサポートするためにより大きな視点を得ることができました。 XP は単なる開発手法ではなく、組織全体の文化を変えるための取り組みとも言えそうです。 メンバーがチームに最善を尽くせるように、これからも学びを深めていきたいと思います。

Workload Identity 連携で GithubActions から GCP リソースをデプロイする

この記事は個人ブログと同じ内容です

www.ritolab.com


GCP 外のアプリケーションから GCP リソースを操作する場合に、サービスアカウントキーを用いずに安全にリソースへアクセスできる Workload Identity 連携を用いて、Github Actions から GCP リソースのデプロイを行います。

Workload Identity 連携:簡単で安全な Google Cloud アクセス

Workload Identity 連携は、Google Cloud外のアプリケーションが安全かつ簡単にGoogle Cloudリソースを利用できるようにする機能です。

従来、外部アプリケーションはサービスアカウントキーを使ってGoogle Cloudにアクセスしていました。しかし、このキーは、漏洩リスクやローテーションなど管理が難しく、セキュリティリスクも高いものです。

Workload Identity 連携はこの問題を解決します。キーの代わりに、外部の ID システム(例:AWS IAMなど)と Google Cloud の IAM を連携させます。これにより、以下の利点があります。

  1. セキュリティが向上します:キーの漏洩リスクがなくなります。
  2. 管理が楽になります:キーの作成や更新、削除の手間が不要になります。
  3. きめ細かな制御が可能になります:外部 ID に直接権限を付与したり、一時的に権限を貸し出したりできます。

例えば、AWSで動いているアプリケーションが Google Cloud Storage のデータを読み取りたい場合、Workload Identity 連携を使えば、AWS の IAMロールに Google Cloud の読み取り権限を付与できます。これにより、安全かつ簡単にクラウド間でのデータアクセスが実現します。

Workload Identity 連携は、こういったマルチクラウド環境や、クラウドとオンプレミス環境を跨ぐシステムで特に力を発揮します。

GithubActions から GCP リソースのデプロイ

今回は、GithubGCP を連携させて動作させてみます。

例えば、CloudFunctions の関数を Git 管理しているとして、それらのデプロイを Workload Identity 連携で実施するようなイメージです。本来ならば、デプロイヤーとして作成したサービスアカウントとして GithubActions からデプロイを実行する場合、サービスアカウントキーを用いた認証が必要ですが、それが不要になります。

まずは、GCP リソースをデプロイするサービスアカウントを作成します。Terraform で定義していきます。

resource "google_service_account" "github_action_deploy" {
  account_id   = "github-action-deploy"
  display_name = "github_action_deploy"
  project      = var.project_id
}

resource "google_project_iam_member" "github_action_deploy_roles" {
  for_each = toset([
    .
    .
    (GCPリソース操作に必要な権限)
    .
    .
  ])
  project = var.project_id
  role    = each.value
  member  = "serviceAccount:${google_service_account.github_action_deploy.email}"
}

Workload Identity 連携における pool と provider

ここから Workload Identity 連携を構築していきますが、プールとプロパイダという概念があります。

  • Workload Identity Pool (プール):
    • 外部のIDシステム(例:AWS、Azure, Github)からのアイデンティティを一つにまとめて管理する仕組みです。これにより、複数の外部システムのユーザーやアプリケーションを一元的に扱うことができます。プールは、これらの外部 ID を GoogleCloud のリソースにアクセスできるよう橋渡しする役割を果たします。
    • 一般には、外部アプリの環境(開発環境、ステージング環境、本番環境など)ごとにプールは分けるのが望ましいとされています。
  • Workload Identity Pool Provider (プロバイダー):
    • プール内に存在し、特定の外部 ID プロバイダー(例:AWS IAM、Azure AD)と GoogleCloud を接続する設定(認証方法)を管理します。プロバイダーは、外部システムの認証情報を GoogleCloud が理解できる形式に変換(属性マッピング)し、適切な権限を割り当てる手助けをします。
    • 各プールには、複数のプロバイダーを設定できます。

例えると、プール (Pool) は「空港」のようなものです。様々な航空会社(プロバイダー)からの乗客(認証要求)を受け入れます。

プロバイダー (Provider) は「特定の航空会社」のようなものです。その航空会社特有のチケット(認証情報)を持つ乗客を確認し、適切に処理します。

この構造により、複数の外部IDソースを柔軟に管理し、それぞれに対して細かな設定を行うことが可能になります。

では、プールとプロパイダを定義します。

resource "google_iam_workload_identity_pool" "github_pool" {
  provider                  = google-beta
  project                   = var.project_id
  workload_identity_pool_id = "github-pool"
  display_name              = "GitHub Pool"
}

resource "google_iam_workload_identity_pool_provider" "github_provider" {
  provider                           = google-beta
  project                            = var.project_id
  workload_identity_pool_id          = google_iam_workload_identity_pool.github_pool.workload_identity_pool_id
  workload_identity_pool_provider_id = "github-provider"
  display_name                       = "GitHub Provider"
  attribute_condition                = "assertion.repository_owner_id == 'xxxxxxxxx'"
  attribute_mapping = {
    "google.subject"       = "assertion.sub"
    "attribute.actor"      = "assertion.actor"
    "attribute.aud"        = "assertion.aud"
    "attribute.repository" = "assertion.repository"
  }
  oidc {
    issuer_uri = "https://token.actions.githubusercontent.com"
  }
}

プールとプロバイダを作成し、プールにプロバイダを紐づけています。

プロバイダでは、属性のマッピングと、認証方法として OIDC を指定しています。

また、attribute_condition を設定し、特定の組織でのみ利用可能に制限しています。
(repository_owner_id の値は https://api.github.com/users/<user_or_org_name> の id を指定)

参考: GitHub または他のマルチテナント ID プロバイダと連携する場合に属性条件を使用する - Google Cloud

そして最後に、デプロイ用のサービスアカウントとこれらを紐づけます。

resource "google_service_account_iam_member" "github_action_deploy_workload_identity" {
  service_account_id = google_service_account.github_action_deploy.name
  role               = "roles/iam.workloadIdentityUser"
  member             = "principalSet://iam.googleapis.com/${google_iam_workload_identity_pool.github_pool.name}/attribute.repository/${var.github_org}/${var.github_repo}"
}

デプロイ用のサービスアカウントは、先ほど作成した Workload Identity Pool と Provider を介して、GitHub Actions からの認証を受け付けるように設定されます。

この設定により、GitHub Actions のワークフローが Google Cloud のリソースにアクセスする際、このサービスアカウントの権限を使用することができます。

具体的には、google_service_account_iam_member リソースを使用して、以下の設定を行っています:

  1. service_account_id: デプロイ用に作成したサービスアカウントを指定します。
  2. role: "roles/iam.workloadIdentityUser" を指定することで、外部IDシステム(この場合はGitHub)からこのサービスアカウントを使用する権限を付与します。
  3. member: Workload Identity Pool と、GitHubリポジトリを指定します。これにより、特定の GitHub リポジトリからの認証要求のみを受け付けるよう制限します。

この設定が完了すると、指定された GitHub リポジトリの Actions ワークフローから、Google Cloud のリソースに安全にアクセスできるようになります。ワークフロー内では、OpenID Connect (OIDC) トークンを使用して認証を行い、このサービスアカウントの権限で Google Cloud API を呼び出すことが可能になります。

GithubActions ワークフロー

最後のステップとして、GitHub Actions のワークフローを定義します。ワークフロー内では、公式から提供されている google-github-actions/auth アクションを使用して認証を行い、その後 Google Cloud CLI や他の関連アクションを使用してデプロイやその他の操作を実行できます。

- name: Authenticate to Google Cloud
  uses: google-github-actions/auth@v2
  with:
    workload_identity_provider: ${{ secrets.GCP_WORKLOAD_IDENTITY_PROVIDER }}
    service_account: ${{ secrets.GCP_SERVICE_ACCOUNT }}

ここで渡している workload_identity_provider は、プロジェクト番号、プール名、プロバイダー名を含む、ワークロード ID プロバイダーの完全な識別子です。コンソール画面から取得するか、以下の gcloud コマンドでも取得できます。

gcloud iam workload-identity-pools providers describe <プロパイダ名 今回の例では github-provider> \
--workload-identity-pool="<プール名 今回の例では github-pool>" \
--location="global" \
--format="value(name)"

# -> projects/123456789/locations/global/workloadIdentityPools/github-pool/providers/github-provider

https://cloud.google.com/sdk/gcloud/reference/iam/workload-identity-pools/providers/describe

service_account は、デプロイ用に作成したサービスアカウント(email)です。

このように、Workload Identity 連携を使用することで、サービスアカウントのキーファイルを管理する必要がなくなります。GithubActions でデプロイを実施する際に、よりセキュアな CI/CD パイプラインを構築できます。

まとめ

Workload Identity 連携は、GCP 外のアプリケーションから GCP リソースを安全に操作する革新的な方法です。本記事では、GithubActions から GCP リソースをデプロイする過程を通じて、その実装方法を解説しました。

この技術を活用することで、サービスアカウントキーの管理が不要となり、セキュリティが向上し、運用負荷が大幅に軽減されます。より安全で効率的な CI/CD パイプラインや外部アプリケーション連携を構築できますので、是非試してみてください。