DDD ことはじめ ~値オブジェクト・集約・エンティティ・ファクトリ~

今回は以前から気になっていたDDDの技術書とGo×DDDの実装記事を読んだので振り返りをします。

今回読んだ書籍と記事は以下です。

本:

ドメイン駆動設計入門 ボトムアップでわかる! ドメイン駆動設計の基本

記事:

towardsdatascience.com

尚、DDDの概念や思想的な説明はあまりせず各トピックの目的と実装例を振り返る形でまとめていきたいと思います。

これらの記事はいずれも基礎入門で筆者は未だエリック・エヴァンスのドメイン駆動設計を読んでいません。 部分的なHow toになるのでご留意ください。

この記事では オンライン居酒屋サービス を実装していくのでそれらを構成するドメインが登場します。

ex) Customer, order, transaction(取引の意味)

値オブジェクト

値オブジェクトはシステム固有の値を表したオブジェクトです。

単にプリミティブな型に代入するのではなくオブジェクトとして定義される値です。

goではオブジェクトをstructとして表現します。

type Transaction struct {
    amount    int
    from      uuid.UUID
    to        uuid.UUID
    createdAt time.Time
}

さらに値オブジェクトは以下の性質を持ちます。

  • 不変である
  • 交換が可能である
  • 等価性によって比較される
不変である

ここではTransactionというstructの中にIDという識別子が存在しません。後述するエンティティにはID識別子が定義されます。 これはIDを元に永続化・参照させない事を意図しています。 その為 GetByID()のように取得して Update() メソッドを使用して変更するべき値ではないという事です。

*以下のように補足があります。

実際のアプリケーションでは利便性の為にIDを付与する

交換可能である

これは代入可能であるという意味ですが、Goでも当然可能ですね。

   transaction := &Transaction{
        //
    }
    transaction = &Transaction{
        //
    }

等価性によって比較される

オブジェクト同士の特定の値のみを比較するのではなく、オブジェクト自身に比較メソッドを持たせる方法です。

このようにする事でオブジェクトのフィールドが増えても変更箇所を少なくする事が出来ます。

func (t *Transaction) Equals(equaled *Transaction) bool {
    if t.amount != equaled.amount {
        return false
    }
    if t.from != equaled.from {
        return false
    }
    if t.to != equaled.to {
        return false
    }
    if t.createdAt != equaled.createdAt {
        return false
    }

    return true
}

値オブジェクトの生成

structにする事で生成時にバリデーションや特有のルールを適用する事ができます。

Equalsと同様にロジックの分散を防ぐ事ができます。

func NewTransaction(amount int, from, to uuid.UUID, createdAt time.Time) (*Transaction, error) {
    if amount >= 0 {
        return nil, errors.New("amount is invalid")
    }
    // if from  条件 {
    //     return nil, errors.New("from is invalid")
    // }
    // if to 条件 {
    //     return nil, errors.New("to is invalid")
    // }

    transaction := &Transaction{
        amount:    amount,
        from:      from,
        to:        to,
        createdAt: createdAt,
    }
    return transaction, nil
}

エンティティ

エンティティは値オブジェクトと対を為すオブジェクトです。

特徴

  • 可変である
  • 同じ属性であっても区別される
  • 同一性によって区別される

ここではPersonとItemというオブジェクトを参考に見ていきます。

type Person struct {
    ID uuid.UUID
    Name string
    Age int
}
type Item struct {
    ID          uuid.UUID 
    Name        string    
    Description string    
}
可変である

先ほどの値オブジェクトと違いエンティティの中身は変更可能です。

名前を変更するには以下の様に表現します。

*これは集約によって変更される可能性があります。

func (p *Person) ChangeName(name string) error {
    if name == "" {
        return errors.New("name is invalid")
    }

    p.Name = name

    return nil
}

同じ属性であっても区別される・同一性によって区別される

これはIDフィールドで区別され、google製の uuidパッケージを使用しています。

集約

データを変更する単位となるオブジェクトです。 全ての変更はこのルートオブジェクトを介してのみ行われるようにします。

以下のCustomerはルートオブジェクトです。

type Customer struct {
    person       *tavern.Person
    products     []*tavern.Item
    transactions []tavern.Transaction
}

各フィールド名を小文字で始める事でパッケージ外からアクセスできないようにします。

アクセスする時はセッターとゲッターを用います。

func (c *Customer) GetID() uuid.UUID {
    return c.person.ID
}

func (c *Customer) SetID(id uuid.UUID) {
    if c.person == nil {
        c.person = &tavern.Person{}
    }
    c.person.ID = id
}

func (c *Customer) SetName(name string) {
    if c.person == nil {
        c.person = &tavern.Person{}
    }

    c.person.Name = name
}

func (c *Customer) GetName() string {
    return c.person.Name
}

ファクトリ

ファクトリパターンはオブジェクトの生成に伴う複雑な処理をカプセル化する手法です。

func NewCustomer(name string) (Customer, error) {
    if name == "" {
        return Customer{}, ErrInvalidPerson
    }

    person := &tavern.Person{
        Name: name,
        ID:   uuid.New(),
    }

    return Customer{
        person:       person,
        products:     make([]*tavern.Item, 0),
        transactions: make([]tavern.Transaction, 0),
    }, nil
}

このファクトリはかなりシンプルな方であまり複雑なロジックは書かれていません。

単なるコンストラクタに近い気がします。

ドメイン駆動設計入門 では採番処理を行うファクトリを例に専用のinterfaceを作成しています。 そのinterfaceの実装ではDBなどインフラ層へのアクセスが行われ、mockを可能にしています。

ビジネスロジックが複雑になりDBへのアクセス等が発生するのであればinterfaceを作ったほうが良い気もしますが、単純なバリデーションのみで済むなら今回のようにコンストラクタ的な関数で十分だと思いました。

今回は以上となります。

次回はサービスパターン、レポジトリパターンを振り返りたいと思います。

Go × GitHub Actions CI構築 ~ MySQL・Redis docker-compose 別リポジトリをGit clone する~

今回はGitHub ActionsでGoのAPIサーバーのCI構築を行いたいと思います。

GitHub Actionsのチュートリアル的な部分には触れませんのでご留意ください。

TL;DR


以下の構成でCI構築を行います。

上記の構成に至った理由をざっくりと説明いたします。

開発環境ではdocker-composeを使用してコンテナを制御している場合が多いと思いますが、管理するコンテナが増えるほどCI上に環境を再現することが手間になっていきます。特に今回のようにdocker-compose.yamlやDBのDockerFileを別リポジトリに置いている場合は尚更です。 そこでそっくりそのまま同じ環境を構築してCIを走らせたいという方針から本実装に至りました。 GitHub Actionsに提供されている Container serviceでMySQLやRedisを個別に立てて再現することもできましたが、 リポジトリを丸ごとcloneする方が変更箇所が少ない為この方式を採用しました。

実装

.github/workflows/cicd.yaml

今回はCI中に別のリポジトリを git cloneします。
よって二つのリポジトリをそれぞれ次のように呼びます。

{CurrentRepo}: APIサーバーのレポジトリ名
{ParRepo}: 親ディレクトリとなるdocker-compose.yamlが配置してあるレポジトリ名

以下が実装です。

on: push

defaults:
  run:
    shell: bash

env:
  DOCKER_BUILDKIT: 1
  COMPOSE_DOCKER_CLI_BUILD: 1
  IMAGE_CACHE_DIR: tmp/cache/docker-image
  IMAGE_CACHE_KEY: cache-image
  CACHE_VER: 1 # キャッシュをクリアしたい時は数値に+1する

jobs:
  image-cache-or-build: # Dockerfile.devがキャッシュキー 変更があれば Clone Build する
    runs-on: ubuntu-latest
    env:
      API_IMAGE_TAG: {docker-composeのservice名(api)}:latest
      MYSQL_IMAGE_TAG: {docker-composeのservice名(rdb)}:latest
      REDIS_IMAGE_TAG: {docker-composeのservice名(NoSQL)}:latest

    steps:
    - name: Check out code
      id: checkout
      uses: actions/checkout@v2

    - name: Cache docker
      id: cache-docker-image
      uses: actions/cache@v2
      with:
        path: ${{env.IMAGE_CACHE_DIR}}
        key: ${{ runner.os }}-${{ env.IMAGE_CACHE_KEY }}-${{ env.CACHE_VER }}-${{ hashFiles('Dockerfile.dev')}}
        restore-keys: ${{ runner.os }}-${{ env.IMAGE_CACHE_KEY }}-${{ env.CACHE_VER }}-

    - name: Clone And Setup Dir
      if: steps.cache-docker-image.outputs.cache-hit != 'true'
      env:
        TOKEN: ${{ secrets.ACCESS_TOKEN }}
      run: |
        mkdir -p tmp
        cd ..
        git config --global url."https://${TOKEN}:x-oauth-basic@github.com/".insteadOf "https://github.com/"
        git clone https://github.com/{OrganizationName}/{ParRepo}
        cp -rp {CurrentRepo} {ParRepo}/
        mv {ParRepo}/ {CurrentRepo}/tmp/

    - name: Docker build
      id: docker-build
      if: steps.cache-docker-image.outputs.cache-hit != 'true'
      run: |
        cd tmp/{ParRepo}
        docker-compose build --build-arg BUILDKIT_INLINE_CACHE=1

    - name: Docker Save
      id: docker-tag-save
      if: steps.cache-docker-image.outputs.cache-hit != 'true'
      run: |
        mkdir -p ${IMAGE_CACHE_DIR}
        docker image save -o ${IMAGE_CACHE_DIR}/api.tar ${{ env.API_IMAGE_TAG}}
        docker image save -o ${IMAGE_CACHE_DIR}/mysql.tar ${{ env.MYSQL_IMAGE_TAG}}
        docker image save -o ${IMAGE_CACHE_DIR}/redis.tar ${{ env.REDIS_IMAGE_TAG}}

  test:
    needs: image-cache-or-build
    runs-on: ubuntu-latest
    # docker-composeのenvはここに設置する

    steps:
    - name: Check out code
      id: checkout
      uses: actions/checkout@v2

    - name: Cache docker
      id: cache-docker-image
      uses: actions/cache@v2
      with:
        path: ${{env.IMAGE_CACHE_DIR}}
        key: ${{ runner.os }}-${{ env.IMAGE_CACHE_KEY }}-${{ env.CACHE_VER }}-${{ hashFiles('Dockerfile.dev')}}
        restore-keys: ${{ runner.os }}-${{ env.IMAGE_CACHE_KEY }}-${{ env.CACHE_VER }}-

    - name: Clone And Setup Dir
      env:
        TOKEN: ${{ secrets.ACCESS_TOKEN }}
      run: |
        mkdir -p tmp
        cd ..
        git config --global url."https://${TOKEN}:x-oauth-basic@github.com/".insteadOf "https://github.com/"
        git clone https://github.com/{OrganizationName}/{ParRepo}
        cp -rp {CurrentRepo} {ParRepo}/
        mv {ParRepo}/ {CurrentRepo}/tmp/

    - name: Docker load
      id: docker-load
      if: steps.cache-docker-image.outputs.cache-hit == 'true'
      run: ls  -1 ${IMAGE_CACHE_DIR}/*.tar | xargs -L 1 docker load -i

    - name: docker-compose up
      run: |
        cd tmp/ {ParRepo}
        docker-compose up -d

    - name: Generate mockgen And gqlgen
      run: |
        cd tmp/ {ParRepo}
        docker-compose exec -T api make mockgen
        docker-compose exec -T api make gqlgen

    - name: Unit Test
      run: |
        cd tmp/ {ParRepo}/{CurrentRepo}
        docker-compose exec -T -e GO_ENV=test api go test -v `go list ./src/.../. | grep -v ./src/infra/redis | grep -v ./src/infra/server`

    - name: Integration Test
      run: |
        cd tmp/ {ParRepo}/{CurrentRepo}
        docker-compose exec -T -e GO_ENV=test api go test -v ./test/...

おおきな流れとしては次のとおりです。

まず二つのjobsを定義します。

  • image-cache-or-build

  • test

jobには依存関係があり順番に処理されます。

image-cache-or-build ではdocker image をキャッシュします。 キャッシュがヒットすれば何もせずにtest jobに進み、 キャッシュがヒットしなければ新たにdocker image を build して tarファイルとしてキャッシュを更新します。

test ではcloneしてきたdocker-compose.yamlを起動して テストを行います。 image-cache-or-build でキャッシュがヒットしていれば既存のtarファイルから、 キャッシュがヒットしなければ、build したimageからdocker-composeで起動します。

それでは次に一つ一つ細かく見ていきましょう。

詳細

env

まずは環境変数です。 GitHub Actionsはenvを定義する位置によってスコープが変わります。 トップレベルのenvは次のように定義しています。

env:
  DOCKER_BUILDKIT: 1
  COMPOSE_DOCKER_CLI_BUILD: 1
  IMAGE_CACHE_DIR: tmp/cache/docker-image
  IMAGE_CACHE_KEY: cache-image
  CACHE_VER: 3 # キャッシュをクリアしたい時は数値に+1する

DOCKER_BUILDKIT,COMPOSE_DOCKER_CLI_BUILDはBuildKitを有効にします。

BuildKit によるイメージ構築 | Docker ドキュメント

IMAGE_CACHE_DIR, IMAGE_CACHE_KEYはその名の通りキャッシュに使用します。
CACHE_VERはキャッシュを操作する為の環境変数です。後述しますが、GitHub Actionsのキャッシュには公式の提供している、actions/cache@v2 を使用します。
このキャッシュ機構には既存のキャッシュをリセットする為の機能が存在しません。 故にキャッシュ KEYにバージョンナンバーとした数値を含めることで、キャッシュをリセットしたい時に手動で変更することができます。この数値を変更してActionsを走らせるとキャッシュはリセットされます。

以上がグローバルに定義した環境変数です。

次にそれぞれのjobの中身を見ていきます。

image-cache-or-build

jobs:
  image-cache-or-build:
    env:
      API_IMAGE_TAG: {docker-composeのservice名(api)}:latest
      MYSQL_IMAGE_TAG: {docker-composeのservice名(rdb)}:latest
      REDIS_IMAGE_TAG: {docker-composeのservice名(NoSQL)}:latest

    steps:
    - name: Check out code
      id: checkout
      uses: actions/checkout@v2

envにはdocker image をtarファイルに保存する時に呼び出す image名:タグ名を定義しています。 最初のステップでcheck out することでリポジトリの中に入っています。

Caceh docker

    - name: Cache docker
      id: cache-docker-image
      uses: actions/cache@v2
      with:
        path: ${{env.IMAGE_CACHE_DIR}}
        key: ${{ runner.os }}-${{ env.IMAGE_CACHE_KEY }}-${{ env.CACHE_VER }}-${{ hashFiles('Dockerfile.dev')}}
        restore-keys: ${{ runner.os }}-${{ env.IMAGE_CACHE_KEY }}-${{ env.CACHE_VER }}-

次にキャッシュ処理です
hashFiles('Dockerfile.dev') に注目して下さい。
このように記述することで引数に指定したファイル内の変更を検知してキャッシュの更新を行います。
開発環境のDockerfileはそうそう変更されることはありません。
つまり今回のCI環境ではほとんどがキャッシュされたimageからbuildすることになるので、常に高速なCIを期待できそうです。

もしもDockerfileが頻繁に変更されるのであれば今回の方式はおすすめできません。

次にGitとディレクトリの操作を行います。

Clone And Setup Dir

    - name: Clone And Setup Dir
      if: steps.cache-docker-image.outputs.cache-hit != 'true'
      env:
        TOKEN: ${{ secrets.ACCESS_TOKEN }}
      run: |
        mkdir -p tmp
        cd ..
        git config --global url."https://${TOKEN}:x-oauth-basic@github.com/".insteadOf "https://github.com/"
        git clone https://github.com/{OrganizationName}/{ParRepo}
        cp -rp {CurrentRepo} {ParRepo}/
        mv {ParRepo}/ {CurrentRepo}/tmp/

まずは if:** の記述で 前処理のキャッシュがヒットしたかどうかを判定しています。
キャッシュが残っている場合はcloneしてくる必要がない為この処理はスキップされます。

次にgit config でトークンをセットしています。 cloneしてくるリポジトリはプライベートなので認証が必要になります。 このTOKENは GitHubPersonal Access Token というものを使用します。

https://docs.github.com/ja/authentication/keeping-your-account-and-data-secure/creating-a-personal-access-token

このトークンを生成し、GitHubサービス上でリポジトリのsecretsにTOKENとしてセットしておきます。 *このトークンは権限を絞れますが、マシンユーザーとして専用のアカウントを作成する事が推奨されています。

必要なリポジトリをクローンして {CurrentRepo} の tmp以下に 作業用のディレクトリを構成しています。

CI中のディレクトリ構造はこの時点で以下のようになっています。

**/{CurrentRepo} 
└── **  配下のディレクトリ・ファイル
 └──tmp/
  └──cache/
  └──  {ParRepo} /{CurrentRepo}  ## 実際はこのディレクトリを開発環境に見立てて作業する。

冗長な構造ではありますが、上位の{CurrentRepo} を維持しないと後のstepでエラーになってしまいます。



次にdocker の image 操作です。

Docker build save

if からわかるように残り2つのstepも キャッシュヒットしなかった時だけ実行されます。

    - name: Docker build
      id: docker-build
      if: steps.cache-docker-image.outputs.cache-hit != 'true'
      run: |
        cd tmp/{ParRepo}
        docker-compose build --build-arg BUILDKIT_INLINE_CACHE=1

    - name: Docker Save
      id: docker-tag-save
      if: steps.cache-docker-image.outputs.cache-hit != 'true'
      run: |
        mkdir -p ${IMAGE_CACHE_DIR}
        docker image save -o ${IMAGE_CACHE_DIR}/api.tar ${{ env.API_IMAGE_TAG}}
        docker image save -o ${IMAGE_CACHE_DIR}/mysql.tar ${{ env.MYSQL_IMAGE_TAG}}
        docker image save -o ${IMAGE_CACHE_DIR}/redis.tar ${{ env.REDIS_IMAGE_TAG}}

Docker buildではその名の通り image をbuildしていますがこれは直前のstepで構築したtmp/{ParRepo}の中で、同ディレクトリに存在する docker-compose.yamlから起動しています。 docker-composeには API(Go), MySQL, Redis 三つのserviceが定義されております。

Docker Saveではbuild した imageをそれぞれ tar として保存しています。

ここまでが一つ目のjobです。

次は実際にtestを行うjobです。 少し長くなってしまったのだけ要点に絞って解説します。

test

  test:
    needs: image-cache-or-build
    runs-on: ubuntu-latest

needs:** では依存関係を定義しており、このjobはimage-cache-or-buildの後に実行されいます。

Docker load

 - name: Docker load
      id: docker-load
      if: steps.cache-docker-image.outputs.cache-hit == 'true'
      run: ls  -1 ${IMAGE_CACHE_DIR}/*.tar | xargs -L 1 docker load -i

このstepでは事前にキャッシュしたtarファイルのimageをloadしてimageを復元します。 if文にあるようにキャッシュがヒットした時のみ行われ、ヒットしなかった場合はすでにbuildしてあるimageを使用します。

残りの処理をまとめて説明します。

compose up , test , other

- name: docker-compose up
      run: |
        cd tmp/ {ParRepo}
        docker-compose up -d

    - name: Generate mockgen And gqlgen
      run: |
        cd tmp/ {ParRepo}
        docker-compose exec -T api make mockgen
        docker-compose exec -T api make gqlgen

    - name: Unit Test
      run: |
        cd tmp/ {ParRepo}/{CurrentRepo}
        docker-compose exec -T -e GO_ENV=test api go test -v `go list ./src/.../. | grep -v ./src/infra/redis | grep -v ./src/infra/server`

    - name: Integration Test
      run: |
        cd tmp/ {ParRepo}/{CurrentRepo}
        docker-compose exec -T -e GO_ENV=test api go test -v ./test/...

ここまで環境構築は終了しているので後は必要なコマンドを実行していくだけです。

まずは 作業ディレクトリに入りコンテナを起動します。
筆者のプロジェクトではmockgen, gqlgenというライブラリで自動生成が必要で、これらはgitignoreされている為このタイミングで生成します。

最後に ユニットテストシステムテストをコンテナの中で実行します。 docker-compose execコマンドに -T オプションを付けないとエラーになるのでご注意ください。

解説は以上となります。

最後に

いかがでしたでしょうか、このDocker save load 方式とキャッシュを利用して 2分の1 程度までCI速度を改善する事ができました。 その他にも色々とキャッシュできそうなとこはありましたが、キャッシュキーにあまり多くを含めるとヒットする確率が減ってしまうのでこのような実装となっています。

本設計の旨味としては開発環境をそのまま再現できることにありましたが、 他の案としては冒頭で述べたようにそれぞれ個別にコンテナを立てる、Goのサーバーだけローカルで動かしgo moduleをキャッシュしておく、などが考えられました。

開発環境が変わったりテストの量が増えてくると要件も変わってくるので今後もベストプラクティスを探っていこうと思います。

最後に、 このブログではweb開発について発信していくのでまたご覧頂けると嬉しいです。 最後までお読み頂きありがとうございました。

参考記事:

Dockerで構築したRailsアプリをGitHub Actionsで高速にCIする為のプラクティス(Rails 6 API編) - Qiita

Gin echo corsのオリジンをHttp Request から取得する

今回は Go のwebフレームワーク Gin, echo でCORSの設定を行う時にhttp Request からoriginを取得してHeaderに付与する方法を見ていきます。

発端はvercelのプレビュー環境(ランダムなURL)でもクライアントからAPIを叩けるようにする為でした。

*cookieを送信するようにするため、access-control-allow-credentialsをtrueにした場合、allow-originにはワイルドカードを使うことができません。

また、GinやechoのフレームワークはCORSをいい感じに設定してくれるメソッドを提供していますが、そちらを使用した場合あらかじめoriginを設定する必要がありました。どのようにカスタマイズするかも合わせて見ていきます。

なお、今回はCORS自体の説明はしないので復習したい方はこちらの記事がおすすめです。

qiita.com

ソースコード

Ginの場合

カスタム前

func NewCors() gin.HandlerFunc {
    return cors.New(cors.Config{
        AllowOrigins:[]string{"http://localhost:3000", "http://localhost:3001"},
        AllowMethods: []string{"GET", "POST"},
        AllowHeaders: []string{
            "Origin",
            "Content-Length",
            "Content-Type",
            "Authorization",
        },
        AllowCredentials: true,
        MaxAge:           12 * time.Hour,
    })
}

カスタム後

func NewCors() gin.HandlerFunc {
    return func(c *gin.Context) {
        c.Writer.Header().Set("Access-Control-Allow-Origin", c.Request.Header.Get("Origin"))
        c.Writer.Header().Set("Access-Control-Max-Age", "12h0m0s")
        c.Writer.Header().Set("Access-Control-Allow-Methods", "POST, GET")
        c.Writer.Header().Set("Access-Control-Allow-Headers", "Origin, Content-Type, Content-Length, Authorization")
        c.Writer.Header().Set("Access-Control-Expose-Headers", "Content-Length")
        c.Writer.Header().Set("Access-Control-Allow-Credentials", "true")

        if c.Request.Method == http.MethodOptions {
            c.AbortWithStatus(204)
            return
        }
        c.Next()
    }
}

echoの場合

カスタム前

func NewCors() echo.MiddlewareFunc {
    return middleware.CORSWithConfig(middleware.CORSConfig{
        AllowOrigins:[]string{"http://localhost:3000", "http://localhost:3001"},
        AllowHeaders: []string{echo.HeaderOrigin, echo.HeaderContentType, echo.HeaderAccept},
        AllowCredentials: true,
        MaxAge:           43200,
    })
}

カスタム後

func NewCors() echo.MiddlewareFunc {
    return func(next echo.HandlerFunc) echo.HandlerFunc {

        return func(c echo.Context) error {
            c.Response().Writer.Header().Set("Access-Control-Allow-Origin", c.Request().Header.Get("Origin"))
            c.Response().Header().Set("Access-Control-Max-Age", "12h0m0s")
            c.Response().Header().Set("Access-Control-Allow-Methods", "POST, GET")
            c.Response().Header().Set("Access-Control-Allow-Headers", "Origin, Content-Type, Content-Length, Authorization")
            c.Response().Header().Set("Access-Control-Expose-Headers", "Content-Length")
            c.Response().Header().Set("Access-Control-Allow-Credentials", "true")


            if c.Request().Method == http.MethodOptions {
                return c.NoContent(http.StatusNoContent)
            }

            return next(c)
        }
    }
}
  • カスタム前: フレームワーク提供のメソッドを使用 originは指定しなければならない

  • カスタム後: Http Request からoriginを取得する 実質ワイルドカードで全許可するのと同じ


いずれも以下のコードでhttp.Headerからoriginを取得しています。

c.Request().Header.Get("Origin")



ヘッダーを付与した後は プリフライトリクエストの"OPTION"メソッドで204を返すようにしています。

これでどのoriginからでもAPIを叩くことができますね!



Ginやechoはwebフレームワークとしては軽量なのでカスタマイズがしやすいと思います。 その分net/http など標準パッケージの理解を深める事が大事だと思いました。

内容は以上となります。


最後に、 このブログではweb開発について発信していくのでまたご覧頂けると嬉しいです。 最後までお読み頂きありがとうございました。

参考記事

Go gin framework CORS - Stack Overflow

Gopher道場 自習室 課題をやってみた

Go言語 そのものの力を伸ばしたいと思って メルカリのtenntennさんが開催しているGopher道場の課題に挑戦してみました。

課題1では画像形式を変更するコマンドラインを作成します。 今回はその時使ったパッケージや気づきを紹介したいと思います。 車輪の再発明ですが知識の定着を目的にアウトプットしたいと思います。

flag パッケージ

pkg.go.dev

以下のよう書くことでフラグを定義することができる。

var (
    flag1 = flag.String("flag1", "defaultFlag", "usage")
    flag2 = flag.Bool("flag2", false, "usage")
)

func main() {
    flag.Parse()
    fmt.Println(*flag1, *flag2)

}

flag.Args()ではコマンドラインから引数を取得できる。

func main() {
    flag.Parse()
    args := flag.Args()
    fmt.Println(args)
}

CLIを作成する時にコマンドをいい感じに扱える。

サードパーティと比較している面白い記事があったが、サードパーティを使用しているライブラリも多いみたいだ。

qiita.com

path/filepath パッケージ

ファイルのパスを操作することができる。

pkg.go.dev

IsAbs() 絶対パスかどうか真偽値を返す

isAbs := failePath.IsAbs(path)

Abs() 相対パス絶対パスに変換する

absPath, err := filepath.Abs(path)

Ext() ファイルパスから拡張子を返す

ext := filepath.Ext(fileName)

Walk() 第1引数で指定したパス以下に第二引数の関数を実行していく。

err := filepath.Walk(c.srcDirPath, func(path string, info os.FileInfo, err error) error {
  fmt.Println(path)
  return nil
})

Join() パスを連結する

filepath.Join("hoge", "huga") // hoge/fuga

os パッケージ

os機能

pkg.go.dev

MkdirAll()

新しいディレクトリを作るメソッド 第1引数には ディレクトリ名、第2引数はパーミッションを指定する

os.MkdirAll(newFileDirName, 0777)

image パッケージ

pkg.go.dev

Decode() image.Image型にデコード

image.Decode(file)

"image/jpeg" "image/png"

Encode() image.Image型をエンコードする

jpeg.Encode(newfile, img)
png.Encode(newfile, img)

以上 ざっと課題に使ったパッケージやメソッドを復習してみました。

さいごに

普段 BtoC webシステムを開発していてCLIファイルシステムをあまり意識していないので、大変勉強になりました。 標準パッケージ縛りというのもよかったですね。

課題は4まであるのと、その先には昇段試験もあるので挑戦していきたいと思います。

最後に、 このブログではweb開発について発信していくのでまたご覧頂けると嬉しいです。 最後までお読み頂きありがとうございました。

GraphQL APIエラー Go gqlgenで実装

今回はGraphQLのエラーについて復習してみました。 また、簡単ではありますがgqlgenを使ってカスタムエラーを実装してみたいと思います。

GraphQLのエラーハンドリングについては "GraphQL エラー" で検索するとトップにヒットするこちらの記事が大変分かりやすかったです。 実装部分以外は同じ内容になるので一読されることをおすすめします。

GraphQLにおけるエラーハンドリングの仕方 - ZOZO TECH BLOG

エラーの仕様とベストプラクティス

GraphQLはFacebook社によって開発されました。 公式のドキュメント(エラーについて)は以下になります。

GraphQL

ResponseのError仕様をおおまかに説明すると

  • Responseはmapで返す
  • errorが発生した時は "errors" フィールドを返す
  • Responseのdataフィールドがnullの時は必ず"erros"フィールドを返す
  • API仕様に対して不正なリクエストはRequest error を返す
  • errors は locations, path, messageで構成される
  • errorsでカスタムのフィールドを追加したい場合はextensionsに追加する(どんなフィールドを追加するかの規定はない)

結果としてエラーが発生した場合は以下のようなレスポンスになります。

{
  "errors": [
    {
      "message": "Name for character with ID 1002 could not be fetched.",
      "locations": [{ "line": 6, "column": 7 }],
      "path": ["hero", "heroFriends", 1, "name"],
      "extensions": {
        "code": "CAN_NOT_FETCH_BY_ID",
        "timestamp": "Fri Feb 9 14:33:09 UTC 2018"
      }
    }
  ]
  "data": null
}

ここまでが仕様となります。 今回実装するgqlgenやGraphQLのライブラリではこの仕様に従っている場合が多いので基本的にはドキュメント通りに実装していけば問題ありません。

そしてさらに主要なクライアントライブラリAPOLLO等のベストプラクティスを追加します。

ここで追加するのは

"extensions"内のフィールドとAPIのレスポンスコードです。

APOLLOのドキュメントは以下になります。

Handling operation errors - Apollo GraphQL Docs

エラーコード

  • resolver errors 200
  • server errors 4xx
  • network errors 5xx or 4xx

resolver内のエラーは 200 を返し errorsフィールドで詳細を表します。 その他サーバーやネットワークの問題でエラーになった時は 4xx や 5xxで処理します。

RestAPIはクライアント側で エラーコードによってエラーの種類を見分けますが GraphQLは詳細なエラー内容は実装でカスタマイズしていきます。

APOLLOのドキュメントでは extensions フィールドに code フィールドを作成してますね。

      "extensions": {
        "code": "CAN_NOT_FETCH_BY_ID",
      }

このようにしてエラー内容を見分けます。

以上が主な説明となります。 次は実装を行なっていきます。

gqlgenで実装

まずは簡単なスキーマを定義していきます。

type Query {
  task(id: ID!): Task!
}


type Task implements Node {
  id: ID!
  title: String!
  note: String!
}

タスクを作成するMutationを作成しました。

スキーマの詳細やDB接続の説明は省くので過去のブログ記事やGitHubをご覧ください。

GitHub - DaisukeMatsumoto0925/graph_practice

次にResolverを生成します。

$ gqlgen
func (r *queryResolver) Task(ctx context.Context, id string) (*gmodel.Task, error) {
    var task gmodel.Task

    if err := r.db.First(&task, id).Error; err != nil {
        return nil, err
    }

    return &task, nil
}

Queryのidから該当するタスクを探し出すResolverを実装しました。 しかし今のままでは エラーが発生した時に err をそのまま返すようになっています。

このままでは以下のようなレスポンスになってしまいます。

{
  "errors": [
    {
      "message": "record not found",
      "path": [
        "task"
      ]
    }
  ],
  "data": null
}

必須のmessage, path, data フィールドは gqlgenライブラリがデフォルトでカバーしてくれていますが、extensions内はさらに実装を追加する必要があります。

extensionsの追加

gqlgenのライブラリに従って実装してみましょう。

gqlgen.com

func (r *queryResolver) Task(ctx context.Context, id string) (*gmodel.Task, error) {
    var task gmodel.Task

    if err := r.db.First(&task, id).Error; err != nil {
        if errors.Is(err, gorm.ErrRecordNotFound) {
            graphql.AddError(ctx, &gqlerror.Error{
                Path:    graphql.GetPath(ctx),
                Message: fmt.Sprintf("Error %s", err),
                Extensions: map[string]interface{}{
                    "code": graphErr.NOT_FOUND_ERR,
                },
            })
        } else {
            graphql.AddError(ctx, &gqlerror.Error{
                Path:    graphql.GetPath(ctx),
                Message: fmt.Sprintf("Error %s", err),
                Extensions: map[string]interface{}{
                    "code": graphErr.DATABASE_ERR,
                },
            })
        }
        return nil, nil
    }

    return &task, nil
}
package graphErr

type ErrCode string

const (
    NOT_FOUND_ERR ErrCode = "NOT_FOUND_ERROR"
    DATABASE_ERR  ErrCode = "DATABASE_ERROR"
)

かなり冗長ですがカスタマイズする事ができました。

一度APIを実行してエラーを返してみましょう。

無事にerrorsを取得することができました!

今回はORMのgormを使用しているので if errors.Is(err, gorm.ErrRecordNotFound) でnot Foundだった場合は

NOT_FOUND_ERR を返すようにしています。

それ以外のエラーはなんらかのデータベースエラーになるので DATABASE_ERR としています エラーコード自体は別パッケージに切り出しています。

また、err != nilreturn nil, nil としているところに注目してください。 先ほどまで err を返していましたが、 AddError()メソッドでは error を返さずに追加していく処理になるので nil を返す必要があります。

これでフロント側ではRestAPIのステータスコードの代わりに extensions.codeを取得してエラーの処理を分ける事ができますね。

プロダクションレベルではエラーの処理をパッケージ化してもう少し汎用的にしたいところですが、今回はここまでとさせていただきます。

最後に、 このブログではweb開発について発信していくのでまたご覧頂けると嬉しいです。 最後までお読み頂きありがとうございました。

GraphQL Subscriptionを Redis KeySpaceで実装する ~その2~

今回は 前回に続き GraphQLのSubscriptionをRedisのkeyspace notificationを使って実装していきたいと思います。

前回はkeyspaceの概要編でしたが今回は実践編です。 以下の技術を使用します。

- Go
- gqlgen v0.13.0
- go-redis v8

今回実装する機能

今回は Redis keyspaceを使った Userの状態管理機能 を実装していきます。

機能の詳細

  • userはONLINEかOFFLINEの状態を持つ
  • userはONLINE状態を更新できる(ONLINE状態にすることができる)
  • ONLINE状態の時に一定の期間何も操作しなければOFFLINEになる
  • userの状態が変更する度にその状態を配信、通知する事ができる

以上が機能の詳細となります。

Redisのデータ設計

実装に入る前にRedisのデータ形式を設計しておく必要があります。

筆者は以下のように設計しました。

KEY {userID}
VALUE ONLINE

KEY VALUE方式で userIDに対して 状態を ONLINE にします。

OFFLINE状態の時はRedisのデータが無い状態(nil)なので今回はONLINEのみとなっいます。

処理の流れをredis-cliで見ていきます。

userID が 1のuserを ONLINE にする場合

PSUBSCRIBE __keyspace@0__:*
SET 1 ONLINE EX 6

keyspaceでは以下のように通知されます。

"__keyspace@0__:1"
"set"

"__keyspace@0__:1"
"expired"

このような状態管理をアプリケーションサーバーで実装していくのが今回の内容です。

スキーマ定義

gqlgenはスキーマ駆動開発のライブラリです。 まずはスキーマを定義していきましょう。

定義するのは以下のMutationとSubscriptionです。

Mutation
updateUserStatus(input: updateUserStatusInput!): UserStatus!

Userの状態をONLINEにします。

Subscription  
userStatusChanged(userId: String!): UserStatus!

Userの状態の変更を通知します。

それでは実際にコードを書いていきます。

schema.graphql

type Mutation {
  updateUserStatus(input: updateUserStatusInput!): UserStatus!
}

type Subscription {
  userStatusChanged(userId: String!): UserStatus!
}

さらにUserStatus タイプを定義します。

userStatus.graphql

type UserStatus {
  userId: ID!
  status: Status!
}

enum Status {
  ONLINE
  OFFLINE
}

input updateUserStatusInput {
  userID: ID!
}

今回はONLINEとOFFLINEのみですがさらに複雑な状態を管理することもできそうです。

以上でスキーマ定義が完了しました。 次にコマンドラインからResolverを生成します。

$ gqlgen
func (r *mutationResolver) UpdateUserStatus(ctx context.Context, input gmodel.UpdateUserStatusInput) (*gmodel.UserStatus, error) {
  panic("not implemented")
}

func (r *subscriptionResolver) UserStatusChanged(ctx context.Context, userID string) (<-chan *gmodel.UserStatus, error) {
  panic("not implemented")
}

無事にResolverを生成することができました。 次にResolverの中身を実装していきたいところですが、その前にRedisのkeyspaceの実装を行なっていきます。

UserStatusSubscriberの実装

この節ではアプリケーションが開始された際に実行されるRedis keyspaceを実装していきます。

まずはUserStatusSubscriberという構造体を作成します。

userStatus.go

type UserStatusSubscriber struct {
    Client             *redis.Client
    UserStatusChannels map[string]chan *gmodel.UserStatus
    Mutex              sync.Mutex
}

func NewUserStatusSubscriber(ctx context.Context, client *redis.Client) *UserStatusSubscriber {
    subscriber := &UserStatusSubscriber{
        Client:             client,
        UserStatusChannels: map[string]chan *gmodel.UserStatus{},
        Mutex:              sync.Mutex{},
    }
    subscriber.startSubscribingRedis(ctx)
    return subscriber
}

この構造体をmain.goで作成し、resolverの構造体に注入します。

main.go

    subscribers := resolver.Subscribers{
        UserStatus: subscriber.NewUserStatusSubscriber(context.Background(), redis),
    }

    resolver := resolver.New(subscribers)

NewUserStatusSubscriber() 関数の subscriber.startSubscribingRedis(ctx) に着目して下さい。 アプリケーションの開始時にmain.goからこの関数が呼び出されます。 この関数は次の説明で実装していきます。 また、Resolverの構造体にSubscriberを持たせることで各Resolverメソッドの中でSubscriberを使用できるようになりました。

次に startSubscribingRedis() を実装していきます。

startSubscribingRedis()

このメソッドではgoroutine とkeyspaceの機能を使用してRedisのデータの変更を待ち受ける処理を作成していきます。

func (m *UserStatusSubscriber) startSubscribingRedis(ctx context.Context) error {
    go func() {
        pubsub := m.Client.PSubscribe(ctx, "__keyspace@0__:*")
        defer pubsub.Close()

        ch := pubsub.Channel()

        for {
            select {
            case <-ctx.Done():
            case msg := <-ch:
                switch msg.Payload {
                case "set":
                    prefix := "__keyspace@0__:"
                    userID := strings.TrimPrefix(msg.Channel, prefix)
                    status, err := m.Client.Get(ctx, userID).Result()
                    if err != nil {
                        fmt.Println("Redis Error GET:", err)
                        continue
                    }

                    userStatus := &gmodel.UserStatus{
                        UserID: userID,
                    }
                    switch status {
                    case "ONLINE":
                        userStatus.Status = gmodel.StatusOnline
                    }

                    m.Mutex.Lock()
                    for _, ch := range m.UserStatusChannels {
                        ch <- userStatus
                    }
                    m.Mutex.Unlock()
                case "expired":
                    prefix := "__keyspace@0__:"
                    userID := strings.TrimPrefix(msg.Channel, prefix)
                    userStatus := &gmodel.UserStatus{
                        UserID: userID,
                        Status: gmodel.StatusOffline,
                    }

                    m.Mutex.Lock()
                    for _, ch := range m.UserStatusChannels {
                        ch <- userStatus
                    }
                    m.Mutex.Unlock()
                // case "expire":
                // case "del":
                default:
                }
            }
        }
    }()

順番に解説していきます。*冗長なので適宜リファクタリングして下さい。

pubsub := m.Client.PSubscribe(ctx, "__keyspace@0__:*")
defer pubsub.Close()
ch := pubsub.Channel()

m.Clientには go-redisのClientを持たせています。 PSubscribeメソッドで __keyspace@0__:* を待ち受けることで 全てのKEYに対して何のイベント(SET,DEL等)が発生したか通知を受けることができます。

これはredis-cliの以下のコマンドと等価です。

PSUBSCRIBE __keyspace@0__:*

次に無限ループとselect caseで Subscribeしているチャネルに対して分岐を行います。

for {
  select {
  case <-ctx.Done():
  case msg := <-ch:

<-ctx.Done() はチャネルが終了した時を示します。 今回の実装ではアプリケーションが起動している時にこのチャネルが終了することは無いという前提なので特に処理を書いていません。

msg := <-ch: ではチャネルにデータが配信された事を示しています。 そこからさらに 検知したデータの種類によって処理を分岐していきます。

今回はRedis のイベントの中でも SETとEXPIREDが発生した時の処理を実装します。 条件分岐は以下のように行います。

switch msg.Payload {
case "set":
//...
case "expired":
//...

次にそれぞれの処理です。

イベントSETの場合

case "set":
    prefix := "__keyspace@0__:"
    userID := strings.TrimPrefix(msg.Channel, prefix)
    status, err := m.Client.Get(ctx, userID).Result()
    if err != nil {
        fmt.Println("Redis Error GET:", err)
        continue
    }

    userStatus := &gmodel.UserStatus{
        UserID: userID,
    }
    switch status {
        case "ONLINE":
            userStatus.Status = gmodel.StatusOnline
    }

    m.Mutex.Lock()
    for _, ch := range m.UserStatusChannels {
        ch <- userStatus
    }
    m.Mutex.Unlock()

データがSETされた場合 msg.Channel (__keyspace@0__:{userID}) から userIDを抜き出し userIDがKEYとなっているRedisデータのValueを取得します。

今回の場合VALUEには ONLINEのみしか入ってきませんが例えば状態がより多くなった時この処理で見分けることになります。

次に取得したデータとuserIDを使用しuserStatusを生成します。

userStatusを UserStatusChannels に流し込む事で Subscription(gqlgen)しているクライアントに配信します。

イベントEXPIREDの場合

case "expired":
    prefix := "__keyspace@0__:"
    userID := strings.TrimPrefix(msg.Channel, prefix)
    userStatus := &gmodel.UserStatus{
          UserID: userID,
             Status: gmodel.StatusOffline,
    }

    m.Mutex.Lock()
    for _, ch := range m.UserStatusChannels {
        ch <- userStatus
    }
    m.Mutex.Unlock()

SET時にはttlを設定しデータの期限を定めています。 同一のKEYが上書きされなければSET時に設定した期限がきた時にデータはEXPIREDとなります。

今回の設計ではEXPIREDされた時はUserはOFFLINEのstatusになるので、EXPIREDしたチャネルからuserIDを取得しOFFLINEにして UserStatusChannelsに配信します。

以上でkeyspaceの実装が完了しました。 今回はSET,EXPIREDのみの処理ですが case "del": などさらに処理を追加する事も可能です。

Resolverの実装

あとはResolverを埋めていくだけです。

まずはMutationを実装しましょう。

func (r *mutationResolver) UpdateUserStatus(ctx context.Context, input gmodel.UpdateUserStatusInput) (*gmodel.UserStatus, error) {
    userStatusSubs := r.subscribers.UserStatus

    if err := userStatusSubs.Client.Set(
        ctx, input.UserID,
        string(gmodel.StatusOnline),
        time.Millisecond*time.Duration(6000),
    ).Err(); err != nil {
        return nil, err
    }

    return &gmodel.UserStatus{
        UserID: input.UserID,
        Status: gmodel.StatusOnline,
    }, nil
}

上記のように引数から userIDを KEY として ttlと共にデータをSETします。 redis-cliの以下のコマンドと等価になります。

SET {userID} {status} EX {ttl}
SET 1 ONLINE EX 6

シンプルですね。

この時にSETのイベントが発生しているので先ほど実装した goroutineの中で処理が実行されuserIDが同じchannelをSubscribeしているクライアントに userStatusが配信されます。

次にSubscriptionを実装していきます。

func (r *subscriptionResolver) UserStatusChanged(ctx context.Context, userID string) (<-chan *gmodel.UserStatus, error) {
    userStatusSubs := r.subscribers.UserStatus

    userStatusSubs.Mutex.Lock()
    channels, ok := userStatusSubs.UserStatusChannels[userID]
    if !ok {
        channels = make(chan *gmodel.UserStatus)
        userStatusSubs.UserStatusChannels[userID] = channels
    }
    userStatusSubs.Mutex.Unlock()

    go func() {
        <-ctx.Done()
        userStatusSubs.Mutex.Lock()
        delete(userStatusSubs.UserStatusChannels, userID)
        userStatusSubs.Mutex.Unlock()
    }()

    return channels, nil
}

引数のuserIDを元にUserStatusChannels内に同等の keyがあるかを判定し、なければ作成し購読するchannelを返します。

goroutineの中では このチャネルが終了した時にUserStatusChannelsの中からmapを削除するようにしています。

これで全ての実装が完了しました。

PlayGroundで実行してみましょう!

PlayGround

二つのブラウザを開きそれぞれMutationとSubscriptionを実行します。

無事にuserStatusの変更を通知する事ができました! また、Mutationの実行後に何もしなければEXPIREDとなり自動的にOFFLINEが通知されました。

内容は以上となります。

最後に、 このブログではweb開発について発信していくのでまたご覧頂けると嬉しいです。 最後までお読み頂きありがとうございました。