LayerX エンジニアブログ

LayerX の エンジニアブログです。

【GraphQL × Go】gqlgenの基本構成とオーバーフェッチを防ぐmodel resolverの実装

こんにちは。SaaS事業でLayerX ワークフローの開発を担当している@sh_komineです。 この記事は、LayerX Advent Calender 2021の16日目の記事です。

LayerX ワークフローではGoとGraphQLをフル活用して開発を行なっています。

www.layerx.jp

GraphQLの良さはいろいろと語られていますが、「Goで実際にどう実装するんだ?」と言うところは、gqlgenの簡潔なGet Startedがあるくらいでなかなか手で動かさないと理解できないなという思いがありましたので、graphqlのプロジェクトの基本構成に触れながら、オーバーフェッチを防ぐ実装の仕方について書いていきたいと思います。

本記事は実際の事例ではなく、gqlgen 初学者の全体把握、gqlgenの仕組みについての理解に焦点を当てた記事になります。

以下の流れで話をします。

  1. graphqlの基本構成
  2. オーバーフェッチングを防ぐためのmodel resolverの実装
  3. 補足 graph/schema.graphqls の分割

今日はしない話

フロントエンドと組み合わせた全体の話や、DBなどの処理も含めた全体を通した話はしません。 実装例については、尊敬する同僚の@mosaさんや@anagoさんが過去に書いた記事がありますので、そちらをご覧ください。

  • RESTとGraphQLでの実装の違いについて解説した記事 tech.layerx.co.jp
  • GraphQLを用いたフロントエンドからバックエンドまで開発の流れを紹介した記事 tech.layerx.co.jp

1. graphqlの基本構成

graphqlの基本構成について理解するために、以下の流れで見ていきます。 運用しているとどんどんファイルは大きくなり、全体像の把握が難しくなってくるので、一番最初のシンプルな構成から内容を見ていきます。

  • 1-1. スケルトンプロジェクトを作る
  • 1-2. 生成されたファイルのそれぞれの役割について

1-1. スケルトンプロジェクトを作る

Getting Startedに倣い、Goのプロジェクト内でgqlgenをインストールして、スケルトンプロジェクト(雛形)を作成します。 (多分、どのプロジェクトでも導入の際はここから始まると思います。)

# go moduleの作成
$ mkdir gqlgen-todos
$ cd gqlgen-todos
$ go mod init github.com/[username]/gqlgen-todos

# gqlgenのスケルトンプロジェクトを作成
$ go get github.com/99designs/gqlgen
$ go run github.com/99designs/gqlgen init

スケルトンプロジェクトは以下のように作成されます。

├── go.mod
├── go.sum
├── gqlgen.yml               // コード生成の設定ファイル
├── graph
│   ├── generated            // 自動生成されたパッケージ(基本的にいじらない)
│   │   └── generated.go
│   ├── model                // Goで実装したgraph model用のパッケージ(自動生成されたファイルと自分でもファイルを定義することが可能)
│   │   └── models_gen.go
│   ├── resolver.go          // ルートのresolverの型定義ファイル. 再生成で上書きされない。
│   ├── schema.graphqls      // GraphQLのスキーマ定義ファイル. 実装者が好きに分割してもOK
│   └── schema.resolvers.go  // schema.graphqlから生成されたresolverの実装ファイル
└── server.go                // アプリへのエントリポイント. 再生成で上書きされない。

めちゃくちゃ簡単に雛形ができました。これがgqlgenのシンプルな基本構成になります。 rootの構成としては、以下のようになっています。

  • gqlgen.yml: 設定ファイル
  • graph: gqlgenの開発で利用するファイルがいろいろと含まれるパッケージ(ここのファイルについては後述)
  • server.go: main関数

次は生成されたファイルのそれぞれの役割について見ていきます。

1-2. 生成されたファイルのそれぞれの役割について

graph/schema.graphqls : GraphQLのスキーマ定義ファイル

graph/schema.graphqls はGraphQLのスキーマ定義ファイルで、APIエンドポイントとその型を管理するのが役割です。 スケルトンプロジェクトでは以下のように生成されます。 以後、自分が書き加えた箇所には ⭐️ をつけていきます。

# ⭐️  type: 基本のオブジェクトタイプ
type Todo {
  id: ID!
  text: String!
  done: Boolean!
  user: User!
}

type User {
  id: ID!
  name: String!
}

# ⭐️  type Query: データフェッチ系のエンドポイント定義
type Query {
  todos: [Todo!]!
}

# ⭐️  input: Mutaion用のオブジェクト型の定義
input NewTodo {
  text: String!
  userId: String!
}

# ⭐️  type Mutation: Serversideのデータを修正する処理のエンドポイント定義
type Mutation {
  createTodo(input: NewTodo!): Todo!
}

gqlgenでは基本的にこの graph/schema.graphqlsというGraphQLの定義ファイルを編集し、

gqlgen

というコマンドを実行することで、後述する graph/model/models_gen.gograph/schema.resolvers.go を再生成して開発を進めていくので、全ての起点になるファイルです。 gqlgenの話の前にGraphQLの型定義のより詳しい仕様が知りたい方は公式のドキュメントをご覧ください。

graphql.org

graph/model/models_gen.go : graph/schema.graphqls から自動生成されたGoの型定義ファイル

graph/model/models_gen.gograph/schema.graphqls から自動生成されたGoの型定義ファイルです。 上記の graph/schema.graphqls と見比べると分かりますが、 type Querytype Mutation 以外の type Todo, type Userinput NewTodo のstructが自動生成されています。

// Code generated by github.com/99designs/gqlgen, DO NOT EDIT.

package model

// ⭐️ input NewTodoから生成されたファイル
type NewTodo struct {
    Text   string `json:"text"`
    UserID string `json:"userId"`
}

// ⭐️ type Todoから生成されたファイル
type Todo struct {
    ID   string `json:"id"`
    Text string `json:"text"`
    Done bool   `json:"done"`
    User *User  `json:"user"`
}

// ⭐️ type Userから生成されたファイル
type User struct {
    ID      string  `json:"id"`
    Name    string  `json:"name"`
    Friends []*User `json:"friends"`
}

type Querytype Mutationgraph/model/models_gen.go には作成されません。 graph/schema.resolvers.go に作成されます。

また詳しくは後述しますが、graph/model 配下には自分で定義したGoのモデル定義のファイル *.go を置くことができます。 自分で定義したGoのモデルも gqlgen コマンドで読み込まれ、graph/schema.graphqlsをベースに*.goに足りないFieldが graph/schema.resolvers.go に生成されます。 ひとまずは、graph/model パッケージはGraphQLの型オブジェクトに対応するgo のstructを管理する役割があると覚えてください。

graph/schema.resolvers.go : graph/schema.graphqls ファイルから自動生成されたエンドポイント実装用のGoファイル

graph/schema.resolvers.gograph/schema.graphqls ファイルから自動生成されたエンドポイント実装用のGoファイルです。エンドポイントの管理が役割になります。 最初の状態は以下のように未実装の状態でコードが生成されているので、そこに実装をしていく流れになります。

resolverはよくあるcontrollerやhandlerと役割は同じなので、そこまで難しく考えなくて大丈夫です。 この中の詳細の実装はresolverに処理をベタ書きでも、MVCでもDDDでも実装者の好きに実装していくことができます。

graph/schema.resolvers.go

package graph

// This file will be automatically regenerated based on the schema, any resolver implementations
// will be copied through when generating and any unknown code will be moved to the end.

import (
    "context"
    "fmt"
    "math/rand"

    "github.com/shkomine/gqlgen-todos/graph/generated"
    "github.com/shkomine/gqlgen-todos/graph/model"
)

func (r *mutationResolver) CreateTodo(ctx context.Context, input model.NewTodo) (*model.Todo, error) {
         // ⭐️ ここにデータフェッチの実装をしていく
        // 例えばこんな処理
        // r.todoRepo.Create(input)
    panic(fmt.Errorf("not implemented"))
}

func (r *queryResolver) Todos(ctx context.Context) ([]*model.Todo, error) {
         // ⭐️ ここにデータ変更の実装をしていく
        // 例えばこんな処理
        // r.todoRepo.List()
    panic(fmt.Errorf("not implemented"))
}

// Mutation returns generated.MutationResolver implementation.
func (r *Resolver) Mutation() generated.MutationResolver { return &mutationResolver{r} }

// Query returns generated.QueryResolver implementation.
func (r *Resolver) Query() generated.QueryResolver { return &queryResolver{r} }

type mutationResolver struct{ *Resolver }
type queryResolver struct{ *Resolver }

ここで、上記の mutationResolverqueryResolverのコードを見てみると

type mutationResolver struct{ *Resolver }
type queryResolver struct{ *Resolver }

どちらも Resolver の型定義を含んでおり、状態を持つ役割はResolverに集約されています。

graph/resolver.go, server.go : gqlgen コマンドで再生成されないgoパッケージ

Resolver の型定義は graph/resolver.go に定義されていて、状態を集約して管理するのが役割です。 自動生成される graph/schema.resolvers.go から参照されているので、型の名前を変えたり消すことはできませんが、この Resolver は初期生成の後gqlgenコマンドを実行しても再生成されることはないので、このstructに参照し続けたいインスタンス(pointer)などを持たせることができます。 MVCなら各model、その他 repositoryやservice, usecaseのstructを持つような実装になるかと思います。

graph/resolver.go

package graph

import "github.com/shkomine/gqlgen-todos/graph/model"

// This file will not be regenerated automatically.
//
// It serves as dependency injection for your app, add any dependencies you require here.

type Resolver struct{
  // ⭐️ TODO: ここにUserRepositoryなどのインスタンスを保持する
}

Resolver インスタンスを作成しているのは server.go です。 Goのmain関数が含まれています。 このファイルも最初にスケルトンオブジェクトで作成された後はgqlgenコマンドの実行で再生成されないので、自由に書き換えることができます。

package main

import (
    "log"
    "net/http"
    "os"

    "github.com/99designs/gqlgen/graphql/handler"
    "github.com/99designs/gqlgen/graphql/playground"
    "github.com/shkomine/gqlgen-todos/graph"
    "github.com/shkomine/gqlgen-todos/graph/generated"
)

const defaultPort = "8080"

func main() {
    port := os.Getenv("PORT")
    if port == "" {
        port = defaultPort
    }

         // ⭐️ ここでResolverのインスタンスを生成する
    srv := handler.NewDefaultServer(generated.NewExecutableSchema(generated.Config{Resolvers: &graph.Resolver{}}))

         // ⭐️ graphql playgroundを`/query` に設定
    http.Handle("/", playground.Handler("GraphQL playground", "/query"))
    http.Handle("/query", srv)

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

ここまで初期生成されたスケルトンプロジェクトを元にgqlgenの基本構成を説明してきました。 実際に動かしてみるところまでやってみたい方は、さくっと終わるので公式のGetting Startedをやってみてください。 より深く理解ができると思います。

gqlgen.com

次はいよいよオーバーフェッチングを防ぐためのmodel resolverの実装について触れていきます。

2. オーバーフェッチングを防ぐためのmodel resolverの実装

2-1. オーバーフェッチとは

GraphQLでバックエンドのコードをすっきりさせた話でも触れていますが、

オーバーフェッチ = リクエスト元で必要ないのに余分にリソースをフェッチしてしまうこと

です。

gqlgenのREADMEのよくある質問の最初にも「使わないかもしれない子オブジェクトのフェッチを防ぐにはどうしたらいいか? 」という項目が用意されており、GraphQLの重要なテーマであることがわかります。 GraphQLでは通信元のqueryで取得するリソースを制御することができ、より柔軟で効率的な情報取得が可能です。

例えば、上記のGet Startedの例では、Todoの中にUserが含まれていますが、これは、必ず必要とは限りません。

schema.graphqls

type Todo struct {
    ID   string `json:"id"`
    Text string `json:"text"`
    Done bool   `json:"done"`
    User *User  `json:"user"`
}

GraphQLでは情報の取得側がqueryで明示的に指定することによりそのデータが必要かどうかサーバー側に伝えることができます。

userの名前が必要であれば、以下のようにリクエストをします。

query findTodos {
  todos {
    text
    done
    user {
      name
    }
  }
}

結果

{
  "data": {
    "todos": [
      {
        "text": "技術記事を書く",
        "done": false,
        "user": {
          "name": "田中 太郎"
        }
      },
      {
        "text": "実装をする",
        "done": false,
        "user": {
          "name": "佐藤 二郎"
        }
      }
    ]
  }
}

また、userが必要なければ、以下のようにuserを含めずにリクエストをします。

query findTodos {
  todos {
    text
    done
  }
}

結果

{
  "data": {
    "todos": [
      {
        "text": "技術記事を書く",
        "done": false,
      },
      {
        "text": "実装をする",
        "done": false,
      }
    ]
  }
}

gqlgenではこのuserがリクエストに含まれた時だけ呼ばれるメソッドをgraph/schema.resolvers.goに作成することができます。 これによって、無駄なDBフェッチが走らない実装が可能になります。 gqlgenのドキュメントでは、特に名前がつけられていませんが、graph/schema.resolvers.goの中にqueryResolver, mutationResolver 以外の モデルごとのResolver xxxResolver が生成されるので、ここでは勝手にmodelresolverと呼ばせていただきます。(上記の例では todoResolverが生成されます。)

2-2. model resolverの2つの実装方法

gqlgenのREADMEにも書いてありますが、model resolverの実装には2つの方法があります。

  • カスタムモデルを用いた暗黙的な生成
  • gqlgen.ymlへの記載による明示的な生成

Get Startedでは「カスタムモデルを用いた暗黙的な生成」しか触れられていませんでしたし、自分が試した感触でもそちらの方が便利だったので、カスタムモデルを用いた実装方法を強くおすすめします。

カスタムモデルを用いた暗黙的な生成

model packageの説明で少し触れましたが、gqlgenはgraph/schema.graphqls での定義と model/*.go 内の型定義の差分によって graph/schama.resolvers を生成します。

今、graph/schema.graphqls でのTodoの定義は以下の通りです。

graph/schema.graphqls

type Todo struct {
    ID   string `json:"id"`
    Text string `json:"text"`
    Done bool   `json:"done"`
    User *User  `json:"user"`
}

graph/model/models_gen.go 内のTodoを削除し、graph/model/todo.goというファイルにカスタムモデルを定義します。 その際、todo.go 内のUserを丸っと消してみます。

graph/model/models_gen.go

-type Todo struct {
-       ID   string `json:"id"`
-       Text string `json:"text"`
-       Done bool   `json:"done"`
-       User *User  `json:"user"`
-}

graph/model/todo.go

package model

type Todo struct {
    ID   string `json:"id"`
    Text string `json:"text"`
    Done bool   `json:"done"`
}

つづいて、gqlgen コマンドを実行すると、graph/schema.graphqls の型定義をベースにgraph/model/*.goのカスタムモデルで足りないfieldを検知してgraph/schema.resolvers.goにmodel resolverを追加します。

graph/schema.resolvers.go

func (r *todoResolver) User(ctx context.Context, obj *model.Todo) (*model.User, error) {
        // ⭐️ ここにUserのデータフェッチ処理を書く
        // こんなメソッドになる
        // r.userRepo.GetUserByTodoID(obj.ID)
    panic(fmt.Errorf("not implemented"))
}

// ...

// Todo returns generated.TodoResolver implementation.
func (r *Resolver) Todo() generated.TodoResolver { return &todoResolver{r} }

// ...

type todoResolver struct{ *Resolver }

ただ、上記で生成された状態だとTodoのIDでUserの情報を取得しないといけません。 実際の場合はTodoがUserIDを持っていることの方が多いと思います。 そこで、model/todo.goにUserIDを足します。

model/todo.go

package model

type Todo struct {
    ID   string `json:"id"`
    Text string `json:"text"`
    Done bool   `json:"done"`
+   UserID *string `json:"user_id"`
}

これでmodel resolverの中でUserIDを利用できるようになりました。

graph/schema.resolvers.go

func (r *todoResolver) User(ctx context.Context, obj *model.Todo) (*model.User, error) {
        // ⭐️ ここにUserのデータフェッチ処理を書く
        // obj.UserIDが使えるので、こんなメソッドになる
        // r.userRepo.GetUserByID(obj.UserID)
    panic(fmt.Errorf("not implemented"))
}

graph/model/todo.goにUserIDを追加してもgraph/schema.graphqls には変更を入れていないので、GraphQLのインタフェースには変化がありません。 このように外には出したくないが、relationを追加するために必要なパラメータなどを自由に定義できるので、カスタムモデルの実装はおすすめです。

gqlgen.ymlへの記載による明示的な生成

自分もあまり使っていませんが、一応もう一つのmodel resolverの生成方法もご紹介します。 以下のように、 gqlgen.yml に具体的な指定をしても、カスタムモデルの時と同様にresolverを生成することができます。

gqlgen.yml

models:
  Todo:
    fields:
      user:
        resolver: true # force a resolver to be generated

graph/schema.resolvers.go

func (r *todoResolver) User(ctx context.Context, obj *model.Todo) (*model.User, error) {
        // ⭐️ ここにUserのデータフェッチ処理を書く
        // こんなメソッドになる
        // r.userRepo.GetUserByTodoID(obj.ID)
    panic(fmt.Errorf("not implemented"))
}

// ...

// Todo returns generated.TodoResolver implementation.
func (r *Resolver) Todo() generated.TodoResolver { return &todoResolver{r} }

// ...

type todoResolver struct{ *Resolver }

生成できました。 ですが、こちらの方法だとTodoのモデルにUserIDなどのrelationを追加するために必要なパラメータをgoのモデルに追加することができません。 また、Inline config with directives という機能でより詳細に設定できるそうですが、自分が手元で試した際にはうまく動かなかったので、「カスタムモデルを用いた暗黙的な生成」の方を利用することをおすすめします。

2-3. 具体的なmodel resolverの使用例

ここまで model resolverの実装方法について話してきましたが、重要なのはgraph/schema.graphqls の型定義をベースにgraph/model/*.goのカスタムモデルで足りないfieldを検知してgraph/schema.resolvers.goにmodel resolverを追加します。という部分です。 つまりオーバーフェッチがある部分に関しては、カスタムモデルから削除するだけでqueryで指定されなければ呼び出されないmodel resolverを生成することができます。

例えば以下のような場合に便利です。

Todoにタグ情報が複数紐づく場合にmodel resolverを活用

複数のRelationがあるケースです。

schema.graphqls

type Todo {
  id: ID!
  text: String!
  done: Boolean!
  user: User!
+  tags: [Tag!]!
}

+type Tag {
+  id: ID!
+  name: String!
+}

schema.resolvers.go

func (r *todoResolver) Tags(ctx context.Context, obj *model.Todo) ([]*model.Tag, error) {
    // ⭐️ TodoのIDでタグの一覧を取得する
    r.tagRepo.ListByTodoID(obj.ID)
}

一点注意点としては、 todoResolverのTagsメソッドは、以下のようにTodoを配列で取得する場合、返却するtodoの件数分呼び出されます。 つまり、有名なN+1問題が発生します。

query findTodos {
  todos {
    text
    done
    tags
  }
}

このN+1問題の解消には、dataloaderという仕組みがあり、実際にはそれで実装しているのですが、 話し始めるととても長くなるのでまたの機会にさせていただきます。 ざっくり言うと、データの呼び出しを一定期間待ち合わせた後に一斉にデータフェッチしてくる仕組みです。 気になる方は 「golang graphql dataloader」で調べてみてください。

provileURLを求められない限りは S3 アクセスしないためにmodel resolverを活用

単一のフィールドであってもCloudのインフラにアクセスする必要があるケースです。 relationだけではなく、こういった単一のフィールドであってもオーバーフェッチを避ける事ができます。

schema.graphqls

type User {
  id: ID!
  name: String!
  profileURL: String
}

model/user.go

package model

type User struct {
    ID   string `json:"id"`
    Name string `json:"name"`
}

schema.resolvers.go

func (r *userResolver) ProfileURL(ctx context.Context, obj *model.User) (*string, error) {
         // ⭐️ awsのS3にアクセスして署名付きURLを発行する
    r.userRepo.getSignedProfileURL()
}

3. 補足 graph/schema.graphqls の分割

余談ですが、実プロジェクトを回していると、schema.graphgqsはどんどん大きく膨れていってしまい、メンテナンスが大変になります。 この schema.graphgqs は簡単に分割できて、例えば、

  • query.graphgqs
  • mutation.graphgqs
  • input.graphqls
  • type.graphqls

という分割の仕方をすると、

  • query.resolvers
  • mutation.resolvers
  • input.resolvers
  • type.resolvers

というようにresolversファイルが分割され、全体の見通しをよくすることができます。 この際、model/models_gen.goに関しては分割されません。 ファイルがとても大きくなってきた際には是非試してみてください。

最後に

今日はgqlgenの基本構成とオーバーフェッチを防ぐmodel reolverの実装について書いてみました。 gqlgenの基本的な仕組みの理解の助けになったら嬉しいです。

We are Hiring

LayerXではエンジニアはもちろん、全職種で積極的に採用中です。 ぜひカジュアル面談からでもお話しましょう!

  • LayerXのEntranceBook

LayerXの事業やチームについて、もっと知りたい方はこちら layerx.notion.site

  • LayerXのMeety一覧

LayerXのメンバーと、カジュアルに話してみたい方はこちら meety.net

  • LayerXの募集ポジション一覧

実際に応募してみたい方はこちら herp.careers

LayerXの業務標準端末を決定するまでにやったこと

CTO室の@ken5scal です。

こんにちは、CTO室の@ken5scal です。 この記事はLayerX 2021アドベントカレンダー 14日目の記事です。昨日はcipeさんの 本番稼働でわかった秘匿化技術のチャレンジングなこと でした。明日はSaaS事業部 BizDevのgunchanさんの記事が公開される予定です。

最近、LayerXのサービスやカルチャーについて多く事例として公開されていますが、では、実際に社員がどのような環境で業務をしているかについては、まだ情報が出てなさそうでした。

特に今日において、どのようなラップトップを使えるかは非常に重要なポイントかと思います。本ブログでは当社で利用される標準業務端末について紹介します。

デフォ端末

  • 8コアCPU、8コアGPU、16コアNeural Engineを搭載したApple M1チップ
  • 16GBユニファイドメモリ
  • 1TB SSDストレージ

入社後必要に応じて

  • ビジネス向け Surface Book 3 あるいはSurface Laptop 4
    • 画面: 13.5inch, RAM: 32GB, Disk: 512GB, CPU: Corei8

以上!!

あっさりし過ぎなので、どうこの方針に落ち着いたかを移行で紹介します。

ご興味がある方は是非一読いただけると嬉しいです。

標準端末を決めるまで

スペックとバラエティ

当社では代表取締役が現役で開発をしていることもあってか、かなり業務端末において性能を重視しています。従って、どのような端末であろうと高スペックにすることが可能でした。これは非常にラッキーでした。

一方、端末の種別はかなり人の好みが反映されます。

Mac ProあるいはAirか、軽さを重視した13インチか作業を重視する15インチか、Lenovo派Dell派など、様々な勢力が入り乱れる、さながら戦国の世です。

残念ながら、当社の人的リソースはカツカツです(本当に...!)。その中でアドホックに依頼を受けた場合、どの程度の負荷がかかるかを実体験することで算出しました。以下が、自分が試しに実績のない端末をチェック〜発注までした時の経過時間になります。

  • 対象者からのヒアリング: 2h
  • 要望端末の要件調査(含むヒアリング): 6h~8h
  • 代理店の探索: 1h~3h
  • 代理店へのヒアリング(含む相見積もり): 1h~3h
  • 新規取引申請・購買申請: 1~1.5h
  • 発注: 2w~5w

代表が20分くらいの話を必ずする週次定例では、プロダクトの開発やマーケティングに投資することの一環として採用を拡大することが宣言されました。

そのようなムーブメントの中、上記のような負荷の大きい調達を続けることは合理的でありません。

標準策定

端末の選定・調達・運用の業務も担当するCTO室では、当時の実務環境やヒアリングをもとに、以下の仮定をおきました。

  • 仮定
    • 当社の資料は基本的にGoogle Slidesやスプレッドシートで作られる
    • Mac利用者の比率が高い
    • 未経験者も大体、二週間ほどで慣れる

この過程の元、Macをデフォルト機にするプロポーザルを全社向けに提出しました。その過程および回答・フィードバックはGitHubにまとまっています。

  • 提案

f:id:maasa_1023:20211014175747p:plain - 回答 f:id:maasa_1023:20211014175812p:plain

その結果、次のようにIntelベースでのMacをデフォルト機とすることを決定しました。当時はまだまだm1 Apple Siliconで動作しないアプリケーションも多くありました。現在は解消されているのでm1がデフォルトです。 f:id:maasa_1023:20211014175851p:plain

一方、実際に「やはりWindowsのが扱いやすい」と言うメンバーもいらっしゃいました。そのような声はマジョリティではありませんが真実ですので、特定の期間がすぎてそれでもWindowsが良ければ、Surface Laptopを希望できる制度にしました。

このようにして本ブログの最初に紹介した端末を選定した流れになります。

もちろん端末は運用しなければならないためJamfとIntuneを使っています...が、

本当はガリガリIaCにしてCICDを回すようなソフトウェアによる管理をしたいのですが、全く時間を取れておりません。是非、こう言ったことを一緒に目指してくれる方にご応募いただけますと幸いです。

最後に

LayerXではエンジニアはもちろん、全職種で積極的に採用中です。 ぜひカジュアル面談からでもお話しましょう!

本番稼働でわかった秘匿化技術のチャレンジングなこと

こんにちは!LayerX LabsでAnonifyを開発しているエンジニアの恩田(さいぺ)です。

この記事はLayerX 2021アドベントカレンダー 13日目の記事です。昨日はmosaさんのLayerXのカルチャーと行動指針 (2021年版)でした。明日はken5さんの記事が公開される予定です。

Anonify概略

Anonifyについては以前、秘匿化モジュールAnonifyにおけるRust featuresを活用した開発 - LayerX エンジニアブログ で紹介させて頂いただいたので詳細はブログをご覧ください。要約するとAnonifyは「秘匿性」と「透明性」という相反する2つの性質を両立する秘匿化モジュールです。要素技術はTrusted Execution Environment(TEE)で、より具体的にはIntel SGX®︎を採用しています。

いよいよ始まったAnonify本番稼働

海外では、Intel SGXを用いたDemetics社の医療イノベーションの事例なども登場してきており、徐々にIntel SGXの本番稼働が色づいてきています。

そんな中、9月21日から10月11日にかけて実施されたつくば市様の『つくば市科学技術・イノベーション振興指針』の策定に向けたWEBアンケートでAnonifyを秘匿集計基盤として採用いただきました。本件は、実際に一般の方々が触れる基盤として、Anonifyが本番で利用される一号案件となりました。

f:id:cipepser:20211012200555p:plain

https://www.city.tsukuba.lg.jp/_res/projects/default_project/_page_/001/015/751/flyer.pdf より引用)

本番稼働となるわけなので、当然に正常系・異常系を網羅的に実施し、負荷試験、障害試験といった試験も十分行いました。その他にも運用・監視体制も組み、結果として無事、実施期間を終えることができました。ただしこれらはシステムの本番稼働では一般的な話です。私自身金融SIer出身であったり、メンバーもサービス運用経験者だったので、これらの事前準備は当たり前のことを当たり前にやるというものでした。1

TEEならではの難しさ

いざAnonifyを本番稼働させるとなったことで、TEEならではの難しさにも直面しました。冒頭で言及したブログで触れたようなno-std制約による開発難易度の高さという話もあるのですが、本番稼働という文脈においては「いざ障害が起こったときに復旧が可能なのか」が一番の困難でした。

TEEを始めとする秘匿化技術はその名の通り、秘匿化が売りです。

Anonifyでいえば、クライアントからのリクエストは公開鍵暗号2で暗号化されます。この公開鍵に対応する秘密鍵はTEEの中から一切外に出ることはありません。 さて、本番サービスを運用されている方であれば、いざというときに原因究明、復旧ができるよう、サーバやアプリケーションのログやバックアップを入念に準備するのではないでしょうか。これらログやバックアップはサービス継続の最後の砦であり、一エンジニアとしても大きな心の拠り所です。しかしAnonifyではTEEの中から一切秘密鍵が出ないという売りが牙を剥きます。リクエストが復号され、平文となるのはTEEの中だけです。メモリ上でもenclaveと呼ばれる隔離保護された領域で暗号化されており、平文になるのはCPUで計算する時にだけです。

つまりいくらデータのバックアップを取っていても、それは暗号化されたリクエストであり、TEEに対応するCPUがなければ復旧できない無用の長物でしかないのです。

特にAnonifyは Azure Confidential Computing VMでAnonifyを動かそう - LayerX エンジニアブログ で紹介した通り、Azure Confidential Computing VM上に構築しています。そのため、メンテナンス等でVMと物理マシン(より詳細にはCPU)の紐付けが一度解除されてしまうと、再度同じCPUが割り当てられる保証がありません。そして何より、クラウドプロバイダー側でスケジュールされるメンテナンスは我々がコントロールできるものではありません。メンテナンスは2020年の実績で言えば3回ほど発生しています。

チームメンバー一同、サービス運用経験者だったからこそ、いざというときに復旧ができないかもしれないという事実は大きな恐怖でした。

Anonifyでの対策

Anonifyでは、これに対応するためkey-vaultノードを設計に組み込んでいます。key-vaultノードもAnonifyノードと同様にIntel SGX上に構築されますが、Anonifyノードとは異なるVM(CPUも異なる)上に構築します。実際にはAzure Kubernetes Service(AKS)を用いて構築しているので、Anonify podとkey-vault podが存在し、それぞれのpodが別のnode上で稼働するアーキテクチャです。

そして、Anonifyノード〜key-vaultノード間はmutual-TLS(mTLS)で接続しています。

f:id:cipepser:20211012202126p:plain

みなさんが普段ブラウザなどでWebサイトを閲覧する際のTLSでは、サーバ証明書がルート証明書から辿ることのできる正規の証明書であることを検証し、セキュアなコネクションを張ります。Anonifyノードとkey-vaultノード間のmTLSコネクションでは、お互いに想定通りのプログラムが動作していることを検証しています。

この「お互いに想定通りのプログラムが動作していることを検証」を実現するために、Intel SGXで提供されているRemote Attestationと呼ばれる機能を利用します。

f:id:cipepser:20211012201910p:plain

Remote Attestaitonでは、Intelが工場製造時に焼き付けた鍵を使って正規のCPUであることを検証の上、実行コード3にIntel秘密鍵で署名を付与してくれます。そしてこの署名はpodのdocker imageビルド時とruntimeに得ることができます。Anonifyノードとkey-vaultノードはimageビルド時点で、お互いのmeasumentを知ることができます。そして実際に秘密鍵をバックアップするruntimeに、相手が事前に取り決めた相手であることを認証の上、mTLSコネクションを結びます。このmTLSコネクションはAnonifyノードのTEE内〜key-vaultノードのTEE内で結ばれており、このコネクションを介して秘密鍵をセキュアにバックアップすることができます。このようにして、万が一Anonifyノードに障害が発生した場合やメンテナンスに備えています。

最後に

まずTEEの本番稼働というチャレンジングな経験ができたことは、チームとしても大きな財産です。 そして、今回紹介したkey-vaultによる秘密鍵バックアップは、あくまでTEEの本番適用で出てきた課題の一つです。他にも度々話題に上がるIntel SGXのEPCサイズ制限も涙なくしては語れません(つくば市様のWEBアンケートに向けては、負荷試験を実施し、キャパシティプラニングをした上で臨んでいます)。このように様々な難しさがあるIntel SGXを本番環境で稼働させる実績が積めたことは、これからのAnonify・秘匿化技術に向けた意義深い一歩だと感じています。

今回お話したようなm-TLSのお話だけでなく、実際にサンプルコードを動かすことができるAnonify解体新書もチームメンバーがscrapboxで執筆しています。私も隔週で計算機科学やプライバシー技術の論文紹介をしています。そして、難しい秘匿化技術やプライバシー技術を社会に実装していくには、技術視点だけでなく、Biz視点も車の両輪のように重要です。LayerX Labs Newsletterでは毎週Biz/Techの両面から執筆していますので、ぜひ以下からご購読ください。なんと無料です。

https://layerxnews.substack.com/

We are Hiring!

LayerXではエンジニアはもちろん、全職種で積極的に採用中です。 ぜひカジュアル面談からでもお話しましょう!


  1. ただしこれはこれで難しい

  2. 正確には認証付き公開鍵暗号方式

  3. 厳密にはQUOTE構造体

Zendesk Guide テーマのDX(Dev Exp)がすごい件

はじめに

こんにちは、LayerXから三井物産デジタル・アセットマネジメントに出向している武市(@tacke_jp)です。最近、ALTERNAのサポートページ作成のためZendesk Guide (helpcenter) を利用したFAQページの開発を行いました。その際にZendesk Apps Toolsを利用したローカルでのライブプレビューの機能が良い開発体験だったため、皆さんに紹介したくこの記事を書きました。

Zendesk Guideについて

はじめにZendesk Guideの機能紹介をします。Zendesk GuideはいわばFAQサイトの構築に特化したCMSで、カテゴリやセクションごとにFAQの記事を管理し、それを公開することができます。また今回私達のサイトでは利用しませんでしたが、ユーザーが記事にコメントしたり、別途記事投稿も可能といったようなコミュニティサイトやフォーラムといった用途で利用することも可能です。

このGuideのデザイン(「テーマ」呼ばれています)は自由度の高い独自カスタマイズが可能です。デフォルトのCopenhagenテーマのソースコードはgithubに公開されているため、このコードを変更してZendeskにカスタムテーマとして登録することでこれを利用することができます。このリポジトリは各ページのHTMLテンプレート(というテンプレートエンジンを利用)、CSSファイル(SASS)、JSファイルから成り立っています。CSSファイルとJSファイルはそのままZendeskのCDNから配信され、HTMLテンプレートのレンダリングはZendesk側のwebサーバーで行われます。

カスタムテーマは、テーマのディレクトリをzipファイルに固めてZendesk Guideの管理画面からアップロードすることで設定します。またgithub連携して、pushされたタイミングで変更が反映されるようにもできます。(Zendeskがすべてのprivate repository要求するため、今回の開発では利用しませんでした。)

f:id:tacke_jp:20211004214117p:plain
図1

zat コマンドでのローカルプレビュー

さて、このようにテーマをコードを編集することで自由度高くカスタマイズできるのはありがたいですが、開発の過程で毎回zipに固めて管理画面からアップロードし動作確認しているのでは骨が折れます。そのようなことをしなくても良いようにZendeskはZendesk Apps Tools (zat コマンド) でテーマのローカルプレビューを提供しています。

このプレビューは一風変わった機能で、FAQ記事のコンテンツはZendesk側に登録されたデータを使いつつ、HTMLテンプレートやスタイルファイルはlocalでの変更がwatchされており、ファイルを保存したタイミングで毎回live reloadされ、即座にデザインをブラウザ上で確認できる仕組みになっています。(zatコマンドの本機能の詳しい利用方法についてはクラスメソッドの方が詳しく解説されているので、そちらの記事をご覧頂くのがよいかと思います。)

この仕組みのおかげで、開発者はモックサーバーを手元に建てることなく、またlocalのDBやwebサーバーを立ち上げることなく、Zendesk側に登録されたデータをそのまま用い、テーマの開発を行うことができます。これはとてもよい開発体験です。ここから先は、この仕組みがどのように動いているか、プレビュー時のサイト構成を紐解きつつ少しだけ内部を覗いてみましょう。

Under the hood

まずCSSファイルとJSファイルなどの静的ファイルですが、zatコマンドの起動時にWebサーバーが起動し、CDNに変わりそちらからファイルが配信されます。ローカルプレビューモードでプレビューを表示するとHTMLテンプレートに記述されたCSSファイルやJSファイルの参照先がlocalhostに変更されます。filesystemがwatchされ、手元のファイルに更新があった場合にwebsocket経由で開いているページに更新がnotifyされ、ブラウザ側でリロードが走り最新のファイルが適用される仕組みになっています。

次にHTMLテンプレートですが、これは本来Zendesk側のwebサーバーにデプロイされて、そこでのレンダリング時に利用されるものです。しかし、プレビュー時にアクセスするwebサーバーの向き先はあくまでZendeskのものになっています。ではどうしているのかというと、手元で毎回内容に更新があったことを検知して、internalなAPIをcallして内容を毎回登録しています。(若干の力技感を感じますが、手元でのHTMLテンプレートのレンダリングを避けるとなるとこの方法が現実的に思います。)

HTMLテンプレートに手元で更新が起こった場合、internal APIを叩いた後即livereloadが走り、ブラウザからZendesk webサーバーへのリクエストが行われますが、そのタイミングで最新のテンプレートを利用してレンダリングが行われます。Zendeskの規模でのwebサーバーにこのような挙動を実装するのは簡単ではないはずで、恐らく裏側にetcdのようなwebサーバーが利用するconfigurationを管理する分散data storeのようなミドルウェアがいるのではないかと思います。(あくまで私の推測の域を出ません。)

f:id:tacke_jp:20211004214041p:plain
図2

むすび

SaaSを利用するにあたっては、その機能やデザインをどの程度カスタマイズできるかが、利用者にとては導入に際しクリティカルな点となることが多いかと思います。0から自前で開発するのは骨が折れるためSaaSを利用したいが、一方で自社の要件や要望にフィットする形でカスタマイズしたいというケースはよくあります。

今回のようにサイトのデザインをコードによりカスタマイズすることのできる機能は、利用者にとってはありがたい一方で、SaaS提供者にとってはどこまでをカスタマイズ可能とするかは頭の痛い問題かと思います。また、インフラアーキテクチャは所与のものでありつつも、利用者に最適な開発体験を提供することも大きな課題の一つと思われます。しかしながら、それを達成することでプロダクトの導入促進やcharnの抑止に寄与する部分はあるかと思います。今回のローカルプレビューは、その1つのケーススタディと捉えることができそうです。

また、みなさんが自社のプロダクト開発されるにあたって、例えば非SPA構成時にデザイナーの方に簡易的な開発環境を提供したい際などに、今回の構成は参考になるかもしれません。

最後までお読みいただき、ありがとうございました。 少しでもLayerXに興味を持っていただけたら、お気軽にご連絡ください! エンジニアはもちろん各ポジションで積極採用中です。

meety.net

mysqlsh (MySQL Shell): Dump and Restore in AWS Aurora

LayerX インボイス を開発しているDX事業部の @yyoshiki41(中川佳希)です。

DX事業部ではデータベースとして MySQL(Amazon Aurora)を利用しています。 今回のブログは、mysqlsh (MySQL Shell) を用いて、Dumpデータ取得とリストアを行う際に気をつける点です。

mysqldump, mysqlpump

Dumpデータ取得を行う際に、広く知られているのが mysqldump かと思います。

MySQL 5.7.8 からは、 mysqlpump という別のクライアントツールも提供されるようになりました。 主に下記のような特徴があります。

  • 並列での処理が行われる(Parallel)
  • Dump Progress がみれる
  • 圧縮方式は、LZ4 と ZLIB が使用可能(mysqlpump Ver 1.0.0 Distrib 5.7.35)
  • TABLE スキーマとINSERT 文の両方を出力する場合、INDEX を貼るクエリをデータリストア(INSERT文)の後に出力してくれる
    • INSERT毎での INDEX構築や KEY CHECKS なども不要になり、高速化が期待できる

実行例)

$ mysqlpump -uroot -p -B sandbox --set-gtid-purged=OFF --no-create-db --include-tables 'tests' --result-file=results.sql
Dump progress: n/n' tables, m/m' rows
Dump completed in x milliseconds

以下のようなファイルが出力されます。

-- テーブル作成
CREATE TABLE `tests` (
`id` varchar(36) COLLATE utf8mb4_bin NOT NULL,
`json` json DEFAULT NULL,
`version` int(11) GENERATED ALWAYS AS (json_extract(`json`,'$.version')) VIRTUAL,
PRIMARY KEY (`id`)
) ENGINE=InnoDB
;
-- INSERT 文
INSERT INTO `tests` (`id`,`json`) VALUES (1,"{\"props\": \"val\", \"version\": \"1\"}"),(2,"{\"props\": \"val\", \"version\": \"2\"}");
-- 最後に INDEX 作成
ALTER TABLE `tests` ADD KEY `version` (`version`);

MySQL :: MySQL 5.7 Reference Manual :: 4.5.6 mysqlpump — A Database Backup Program

mysqlsh (MySQL Shell)

mysqlsh でも、Dump & Load Utility が提供されています。

また下記のブログでは mysqlsh でのバックアップ取得とリストアがベンチマークとともに紹介されています。
特に、mysqlsh が parallel で動作することとデフォルトで使用する圧縮方式 zstd により、mysqlbump, mysqlpump 以上のパフォーマンスとなることがレポートされています。

blogs.oracle.com

AWS Aurora 環境で使ってみる

mysqlsh, Aurora(MySQL Server)のバージョンは、以下のとおりです。

$ mysqlsh --uri root@localhost:3306
...
MySQL Shell 8.0.26
...
Your MySQL connection id is 326244
Server version: 5.7.12-log MySQL Community Server (GPL)
...

いきなりですが、Dump データ取得に失敗します。

MySQL  localhost:3306 ssl  JS > util.dumpSchemas(["tests"], "/tmp")
Acquiring global read lock
ERROR: Failed to acquire global read lock: MySQL Error 1045 (28000): Access denied for user 'admin'@'%' (using password: YES)
Global read lock has been released
Util.dumpSchemas: Unable to acquire global read lock (RuntimeError)

これは AWS サポートブログ でも紹介されている、mysqldump で --master-data オプションを使用した場合と同じ原因のように見えます。 rdsadmin ユーザー以外は Super_priv を持たないため、FLUSH TABLES WITH READ LOCK が実行できずグローバルな読み取りロックを取得できないようです。

以下のようにオプションとして、 {consistent: false} を渡せば取得することは可能ですが、一貫性を保ったデータを取得するにはアプリケーションを止めるなどの必要があります。

MySQL  localhost:3306 ssl  JS > util.dumpSchemas(["tests"], "/tmp", {consistent: false})
Duration: 00:00:03s
Schemas dumped: 1
Tables dumped: 36
Uncompressed data size: 1.65 MB
Compressed data size: 342.14 KB
Compression ratio: 4.8
Rows written: 7058
Bytes written: 342.14 KB
Average uncompressed throughput: 501.88 KB/s
Average compressed throughput: 104.30 KB/s

consistent: [ true | false ] Enable (true) or disable (false) consistent data dumps by locking the instance for backup during the dump. The default is true. When true is set, the utility sets a global read lock using the FLUSH TABLES WITH READ LOCK statement (if the user ID used to run the utility has the RELOAD privilege), or a series of table locks using LOCK TABLES statements (if the user ID does not have the RELOAD privilege but does have LOCK TABLES). The transaction for each thread is started using the statements SET SESSION TRANSACTION ISOLATION LEVEL REPEATABLE READ and START TRANSACTION WITH CONSISTENT SNAPSHOT. When all threads have started their transactions, the instance is locked for backup (as described in LOCK INSTANCE FOR BACKUP and UNLOCK INSTANCE Statements) and the global read lock is released.

テーブル単位での Export / Import は、以下のように行えます。 exportTable 実行最終行に、 importTable コマンドを出力してくれて非常に親切です。

MySQL  localhost:3306 ssl  JS > util.exportTable("tests.table_A", "/tmp/table_A")
Gathering information - done
Preparing data dump for table `tests`.`table_A`
Data dump for table `dev_payer`.`clients` will use column `id` as an index
Running data dump using 1 thread.
NOTE: Progress information uses estimated values and may not be accurate.
Data dump for table `tests`.`table_A` will be written to 1 file
102% (2.38K rows / ~2.32K rows), 3.49K rows/s, 846.02 KB/s
Duration: 00:00:00s
Data size: 576.76 KB
Rows written: 2380
Bytes written: 576.76 KB
Average throughput: 576.76 KB/s

The dump can be loaded using:
util.importTable("/tmp/table_A", {
    "characterSet": "utf8mb4",
    "schema": "tests",
    "table": "table_A"
})

おわりに

単純なDBバックアップであれば、マネージドサービス側でサポートされていますが、
アプリケーション開発が進み、テーブル移行などが必要になってくるケースが出てくるとテーブル単位でのDumpと移行などが必要になってきます。

今回の FLUSH TABLES WITH READ LOCK や GTID などでハマることも多いかと思います。 ツールが変わっても気をつけるべき点としては、同じMySQL Server を使うならば基本的に同じだと感じました。 しかし、処理スピードやコマンドの使いやすさなど MySQL Shell の利点も多く、今後も楽しみなツールの1つです!


DX事業部では絶賛採用募集中です。 SaaS開発に興味があるという方は、ぜひ一度話を聞きに来てみてください!

herp.careers

エントリーはちょっとという方、こちらから中の話を聞くこともできます!

meety.net

配信メールのテンプレート管理をSendGirdからgo:embedを用いた方法に変更した話

みなさまこんにちはMDM事業部で金融DXに日々精進している @MasashiSalvadorです。 今回はメールのテンプレート管理法を変更しDX(Developer eXperience)を改善した話をします。

何をやったのか?

  • 顧客へ自動配信するメールのテンプレートの管理をSendGridから自社のGithubリポジトリに移行した。
  • 移行に際し Go 1.16から導入された go:embed (https://pkg.go.dev/embed) 機能を用いた。

お客様にサービスを利用していただくために、メールの配信機能をどんなサービスでも実装するかと思います。MDM事業部の開発しているサービス(公開されているものだと、プロ投資家の方々に不動産案件情報を定期的にお届けするあさどれ不動産 、別のサービスも絶賛開発中です)では仮登録完了をお知らせするメール、登録完了をお知らせするメール、ワンタイムトークンによる認証に用いるトークンをお知らせするメールなどが実装されています。

メールサーバを自前で立てて管理するのは骨が折れるので、SendGrid を利用しています。SendGridは開封・クリック計測の機能、メールの開封をwebhookで通知する機能、顧客リストを管理する機能、キャンペーンメールを予約する機能などがあり、痒いところに手が届く感じに機能が揃っています。

メールのテンプレート機能もその一環で、ユーザはGUIを利用してSendGrid上でHTMLメールを編集しテスト/実配信を行うことが可能です。 画像やボタンの配置なども自由にできるのでGUIはGUIで利点があり、それなりに使い勝手が良いです。

f:id:masashisalvador:20211005184552p:plain
SendGridのDynamicTemplateの編集画面

ところが、このテンプレート機能、非常に便利なのですが、当社のユースケースに合わなかったのです...😿。

なぜやったの?

  1. 開発、ステージング、本番の各環境ごとにテンプレートIDもテンプレートの実体も異なるので、各環境への反映&テストに手間がかかる。
  2. 現状のフェーズではHTMLメールをそこまでリッチに組まないためGUI上で編集できるという機能は(便利だが)オーバースペックだった

MDMのシステムでは環境ごとにSendGridのアカウントを切り分けています(主契約の下に子アカウントを作成した上で、2FAを設定する必要があるため各開発者 * 環境の数分アカウントを作成しています)。 SendGridのテンプレートや、テンプレートを利用するためのAPI Keyは各環境ごとに分かれていました。つまり、開発から本番のメールテンプレートを読み込もうとするとSendGridのAPIは40xのエラーを返します。逆もまたしかりです。

この切り分け自体は妥当なものなのですが、下記の問題が起こっていました。 1. 愚直にやるとメールのテンプレートをソースコード管理できない。 2. 各環境ごとに異なるテンプレートのIDを管理しないといけない(環境変数や設定ファイルに都度設定しないといけない) 3. 文言の変更 → 各環境への反映 → テスト(やPdMの確認)→ 再修正 などの改善プロセスを回すたびにGUIから操作する必要がある。

f:id:masashisalvador:20211005185255p:plain
GUIベースでテンプレートをいじる場合の変更プロセス(基本形)

1.はリリースの際のヒューマンエラーを引き起こし得ます(コピペミスなど)、コピペミスがないかエンジニアはドキドキしないといけません(個人的にはあんまり好きじゃない) 2は1つ新しいメールを追加する度に3つの設定値が増えることになり、チリツモで管理が煩雑化します。 特に3は、テンプレートに埋め込む変数の変更もセットで考えると、ソースコード→GUIの往復が増えてしまい、開発者の集中リソースを削ります。

MDMでは設定値の管理にtomlを用いており、実際は下記のように環境ごとの設定ファイルを管理し、そこにテンプレートのIDを記載しています。

[SENDGRID]
    [SENDGRID.TEMPLATE_ID]
    SIGNUP="d-XXXXXXXXXXXXXXXXXXX"
    INVITATION="d-YYYYYYYYYYYYYYYYYYY"
    ...
    ...
    ADD_HOGEHOGE="d-ZZZZZZZZZZZZ"

configはGitHub - spf13/viper: Go configuration with fangsで読み込んでいます。viper導入前はすべて .env ファイルで管理していたのですが、環境変数が増えすぎ問題が発生してつらかったので開発の途上で変更しました。このお話はまた今度...

どんな解決策があるの?

  1. テンプレートをソースコード管理し、SendGridのテンプレート更新のAPIを用いてCD経由で設定する
  2. テンプレート機能を使うのをやめて、Goのテンプレートでメールの本文を作成する

1も一度は検討しましたが、2を選択しました。 MDMでは各種インフラ設定はterraformで管理され、SREだけでなく開発者もterraformを普通に書く文化があるので、特段問題なく導入できるとは思ったのですが、 メールの文言を少し変更してテストするのに都度terraform applyするような世界線は(かっこいいかもしれませんが)あんまり開発者体験が良いとは思えませんでした。

今回は2.を選択し、Go1.16から導入されたembed機能を用いてテンプレート自体をGoのバイナリに埋めこんで利用することにしました。

実際にどうgo:embedを利用したか

Go 1.16 がリリースされて半年以上が経過していますので、embed機能はすでに各社実運用に載せられているのではないでしょうか。 普段からいろいろな記事を参考にさせていただいていますが、フューチャーさんの技術ブログがとてもわかりやすかったです。 future-architect.github.io

go:embedを用いることで画像、設定ファイル、テキストなど、多様なファイルをバイナリに含めることができ、Goのコード内から簡単に読み出すことができるようになります(embedしない場合に比べて記述量も減らすことができる)、ビルド成果物がシングルバイナリになるので、ただコピーして配布するだけで様々な場所で動かすことができるというGoのポータビリティ面での利点を活かすことができます。

余談ですが、静的ファイルのバイナリへの埋め込みはその昔はgo-bindataがあり、go-bindataの作者がGithubアカウントを突然削除するなどがあり、go-bindataを使っていた部分をgo-assetsに置き換える - PartyIXの記事のようにgo-assetsに置き換えるなどやったなぁなどと改めて懐かしくなりました。

実際のファイル構成とテンプレートの読み込みに関しては

main.go
- lib
    - mail
         - templates/signup_mail.tmpl
                    /reset_password.tmpl
                    /common/footer.tmpl   // 共通フッター
                    /titles.json
         - content.go   // ParseFSでテンプレートを読み込む部分
         - sendgrid.go // SendGridのAPIを叩く部分
         - template_variable.go  // 各テンプレートに埋め込む変数の構造体
 ....

content.go

package mail

import (
    "bytes"
    "context"
    "embed"
    "fmt"
    "text/template"

    "github.com/sendgrid/sendgrid-go"
    "github.com/sendgrid/sendgrid-go/helpers/mail"

    "github.com/mitsui-x/alterna-api/src/lib/env"
    "github.com/mitsui-x/alterna-api/src/lib/log"
)

//go:embed templates/*
var static embed.FS

func GenerateTemplateByTemplateName(tName string, input interface{}) (string, error) {
    templatePath := fmt.Sprintf("templates/%s", tName)
    tmpl, err := template.ParseFS(static, templatePath)
    if err != nil {
        return "", fmt.Errorf("failed to parse template path %s, %w", templatePath, err)
    }

    buf := &bytes.Buffer{}
    err = tmpl.Execute(buf, input)
    if err != nil {
        return "", fmt.Errorf("failed to execute template with input = %v, tName %s, %w", input, tName, err)
    }

        /* 共通部分のハンドリング、ブログ用にシンプルに変えています */
    footerBuf := &bytes.Buffer{}
    tmpl, err = template.ParseFS(static, "templates/footer.tmpl")
    if err != nil {
        return "", fmt.Errorf("failed to parse footer, %w", err)
    }
    err = tmpl.Execute(footerBuf, nil)
    if err != nil {
        return "", fmt.Errorf("failed to execute footer, %w", err)
    }

    return fmt.Sprintf("%s\n%s", buf.String(), footerBuf.String()), nil
}

func Send(ctx context.Context, subject, toAddress, toName string, plaintextContent, htmlContent string) error {
        /* 一部省略 */ 
    apiKey := env.GetVar("SENDGRID.API_KEY")
    client := sendgrid.NewSendClient(apiKey)

    fromEmail := mail.NewEmail(fromName, fromEmailAddress)
    toEmail := mail.NewEmail(toName, toAddress)
        // SingleEmai https://github.com/sendgrid/sendgrid-go のREADMEの先頭に載っている最もシンプルなやり方でメールを送る
    message := mail.NewSingleEmail(fromEmail, subject, toEmail, plaintextContent, htmlContent)
    response, err := client.Send(message)
    if err != nil {
                /* エラー処理 */
    }
    if response.StatusCode >= 300 {
                 /* エラー処理 */
        return err
    }

    return nil
}

signup_mail.tmpl

{{ .LastName }} {{ .FirstName }} 様<br>
<br>
この度はXXXにご登録いただき、誠にありがとうございます。<br>
... 略 ... 

テンプレートに埋め込む変数を定める構造体

package mail

type UserSignUpAtributes struct {
    LastName       string
    FirstName      string
}

実際にメールを送信するときに下記のように呼び出します。

input := &UserSignUpAtributes{ LastName: "MDM", FirstName "太郎" }
content := maillib.GenerateTemplateByTemplateName("signup_mail.tmpl", input) // 本文生成
err = maillib.Send(ctx, subject, toAddress, toName, content, content) // メール送信処理

これにより、メールのテンプレート管理をGithubに寄せることができ、各環境へのリリースも通常のソースコードと同様に扱うことができるようになりました。

おわりに

金融DXを実現するために様々な機能の開発をする必要がありますが、全てをゼロから作り上げるわけにはいかないのも事実で、セキュリティ面や開発者体験なども含めてSaaSを選定し使い倒さねばなりません。人数の少ないフェーズでは小さな認知負荷の削減やCI/CDまわりの開発者体験の向上が全体の生産性を大きく上げることにもつながるため、今後も小さな改善を繰り返していきたいと考えています。

こういった細かな改善も含め、MDMではやりたいことが山のようにあります。絶賛仲間を募集中です! herp.careers

meetyでカジュアルに色々話させていただくことも出来ますので、もしよければごらんくださいmm

meety.net

ぜひ一緒に、眠れる銭をアクティベイトしましょう!

【9/28-30開催】LayerXから2名のエンジニアがAWS Dev Day Online Japan 2021に登壇します! #AWSDevDay

f:id:shun_tak:20210921170151j:plain

こんにちは!いよいよ来週AWS Dev Dayが開催ですが、この度LayerXから2名のエンジニアが登壇することになりました。

どんな講演になるのか、本記事で簡単に紹介します!

追記:レポート記事も公開しました!
tech.layerx.co.jp

E-2 : LayerXインボイスのAI-OCRを支える非同期処理アーキテクチャ (9/29 15:10〜15:50)

スピーカー:高際 隼 (株式会社LayerX DX事業部 AI-OCRチーム Lead)

LayerX インボイスは、請求書ファイルを読み取りデータ化するAI-OCR機能を備えた請求書処理サービスで、お客様は任意のタイミングで請求書ファイルをいくらでもアップロードすることができます。

AI-OCRは処理が重たいですが、AWS LambdaやAmazon SQS等を活用し、スケーラビリティと可用性を確保しつつコスト削減も実現した手法について解説いたします。

このセッションでお伝えしたいことスライド アジェンダスライド

I-1 : やはりタグ タグは全てを解決する - Tagging Everything (9/30 14:15〜14:55)

スピーカー:鈴木 研吾 (株式会社LayerX シニアセキュリティアーキテクト)

すべての道は資産管理に通ず。17世紀のフランスの詩人はそう謳いました。 時は流れ、21世紀。

資産管理はいまだにITガバナンスにおける基礎となっています。 一方ツールは進化しており巨大なエクセルというテーブルから、資産そのものにタグという属性をつけ、そしてInfrastructure as Codeが実現したマスデプロイによって柔軟な運用を可能としました。

本講演では、LayerXにおけるガードレールを構築するうえで、どのように我々がタグ管理をして、今後発展させていくかをお話しようと思います。

本日のお題スライド

申込方法

ぜひ今すぐ参加登録しましょう!

AWS Dev Day Online Japan 参加登録ページ