はじめに
こんにちは。京都大学大学院情報学研究科 修士1年生の有泉洵平です。11/15 から 12/3 までの期間で Wantedly の就業型インターンシップに参加していました。
インターンシップ中、私は Arch squad (Architecture を主に扱うチーム) に所属し、GraphQL 導入によって生じた問題に取り組みました。
要約
- クライアントサイドが直接扱える疑似 KVS である "options" を GraphQL の集約層を通すことで便利に使いたい
- そのための実装を手作業で行おうとすると非常に面倒である
- 面倒な作業を省くための protoc plugin を開発し、options を GraphQL に上手く繋いだ
背景
options
Wantedly には、options という「任意の設定値を扱う API」が存在しています。
任意の設定値とは、例えば以下のようなものです。
- ユーザがあるボタンを押したかどうか
- ユーザが最後にある通知を受けた日時
- ユーザの投稿の公開範囲
- ...
また、設定値はおおよそ決まった形しか存在しないため、options では、以下の形式の設定値に対応しています。
- Checkbox
- SingleValue
- SingleSelection
- MultiSelection
例えば、先ほど出した「ユーザがあるボタンを押したかどうか」という設定値は Checkbox であり、「ユーザが最後にある通知を受けた日時」という設定値は SingleValue, 「ユーザの投稿の公開範囲」という設定値は SingleSelection となります。
また、これらの設定値は、そのままアプリの UI にマッピングできるような名前になっています。
クライアントサイドで新しい設定値を使いたいと思った際に、クライアントサイドのエンジニアがサーバサイドのエンジニアに毎回依頼をして、API を実装してもらうのは非常に面倒です。
そこで、options では、設定値の情報を DB に insert する script を書くだけで、その設定値が扱えるようになるという仕組みを持っています。そのため、クライアントサイドのエンジニアだけで、高速に施策を回すことが可能になっています。
options でよく使われる API を紹介します。
- GetUserOption
- user_id と各設定値に一意に振られた key をリクエストとして受け取り、指定したユーザの設定値を取得する
- ListUserOption
- user_id と各設定値が所属する category などをリクエストとして受け取り、複数の設定値を取得する
- UpdateUserOption
- user_id と設定値をリクエストとして受け取り、指定したユーザの設定値を更新する
GraphQL
Wantedly では、クライアントとサーバの間に GraphQL の集約層を導入しようとしています。
(https://speakerdeck.com/izumin5210/jsconf-jp-2021 から引用)
GraphQL を導入することで、データの結合を GraphQL の責務とすることができるため、
- クライアントサイド
- 1 つの画面を描画する際に、複数の API を叩く必要がなくなる
- エンドポイントを複数覚えておく必要がなくなる
- サーバサイド
- クライアントサイドが扱いやすいようにレスポンス形式を考える必要がなくなる
といったメリットがあります。
また、Wantedly では、マイクロサービス間通信に gRPC を用いていることにより、GraphQL を導入すると Protobuf と GraphQL の 2 つのスキーマを管理する必要が生じてしまいます。2 つのスキーマを愚直に管理するという方法を用いると、同期が大変であったり、どちらが本当の master かわからなくなるといった問題に直面してしまいます。
この問題は Protobuf のスキーマから GraphQL のスキーマと一部実装を生成する graphql-gateway を開発し、Protobuf のスキーマを Single Sourth Of Truth(SSOT) とすることである程度解決されています。
graphql-gateway は、GraphQL Nexus をベースとして実装されているため、Protobuf のスキーマから GraphQL Nexus の DSL を生成し、そこから GraphQL のスキーマを生成するといった流れになっています。
GraphQL + options
前述の GetUserOption のレスポンスは以下のようになっています。
rpc GetUserOption(GetUserOptionRequest) returns (UserOption);
message GetUserOptionRequest {
uint64 user_id = 1;
string key = 2;
}
message UserOption {
~~ 省略 ~~
message Checkbox {
bool value = 1;
}
message SingleValue {
int32 value = 1;
Range range = 2;
}
message SingleSelection {
int32 value = 1;
repeated Selection selects = 2;
}
message MultiSelection {
repeated int32 values = 1;
repeated Selection selects = 2;
}
oneof field {
Checkbox checkbox = 100;
SingleValue single_value = 101;
SingleSelection single_selection = 102;
MultiSelection multi_selection = 103;
}
}
ここで注目してほしいのは、GetUserOption のレスポンスが Checkbox, SingleValue, SingleSelection, MultiSelection の oneof となっている部分です。
options を GraphQL にそのままつなぎこむことは可能ですが、その場合クライアントサイドでは、「Checkbox, SingleValue, SingleSelection, MultiSelection」のどれが返ってきているかをチェックする必要があります。
しかし、例えば GetUserOption の key として「ユーザの投稿の公開範囲」を指定した場合などを考えると、レスポンスは SingleSelection であり、Checkbox などの他の形式のことを想定する必要がないことがわかるはずです。
そこで、GraphQL の集約層を通る際にレスポンス形式を定めてやることを考えると、クライアントサイドでは分かりきっているレスポンス形式のハンドリングをする必要がなくなり、今までより便利に options を使うことができます。
これを実現するための方法として、options の key ごとに(すなわち設定値ごとに)GraphQL のスキーマを定義して、それに対応する resolver を実装するというものがあります。
type Query {
optionKey1: CheckboxResponse
optionKey2: SingleValueResponse
optionKey3: SingleSelectionResponse
optionKey4: MultiSelectionResponse
optionKey5: CheckboxResponse
optionKey6: SingleSelectionResponse
...
}
しかし、この実装を手作業で行おうとすると、新しい設定値を作成する際に、insert script を書く以外にも GraphQL スキーマとそれに対応する resolver を実装する必要があり、クライアントサイドのエンジニアだけで高速に施策を回すことが難しくなってしまいます。
この問題を解決し、options を GraphQL を通して使いやすくすることが本インターンの目標でした。
解決方法
Protobuf のスキーマから GraphQL のスキーマを生成する graphql-gateway が存在することに着目し、options に必要な情報を Protobuf のスキーマに載せて、そこから insert script と GraphQL Nexus の DSL を生成する protoc-plugin を生成するという方法を採用しました。
また、Protobuf のスキーマから insert script を生成する protoc-plugin を protoc-gen-options、GraphQL Nexus の DSL を生成する protoc-plugin を protoc-gen-gqlopt と名付けました。
protoc-gen-options
Protobuf のスキーマには options の設定値を作成する domain 情報が足りないため、Protobuf の extensions を用いて、以下のように必要な情報を載せられるようにしました。
syntax = "proto3";
package example.protobuf;
import "schema.proto";
message RequestExample {}
message CheckboxExample {
// here
option (options.protoc_gen_options.field) = {
type : CHECKBOX
};
string key = 1;
}
service Example {
// here
option (options.protoc_gen_options.category) = {
key : "category_key"
locales : [ {name : "category_name" locale : en} ]
};
rpc OptionKeyCheckbox(RequestExample) returns (CheckboxExample) {
// here
option (options.protoc_gen_options.option_model) = {
key : "option_key"
sort_key : "sort_key"
detail : {
default : 1
locales : [ {name : "name" description : "description" locale : en} ]
}
};
}
}
extensions の format はこれ以外にも複数の案が存在しましたが、可読性や保守性などを考えた結果、上のように 1 つの rpc に 1 つの設定値を対応させる format にしました。
また、insert script の言語にも複数の案(Ruby, Go, sql)が存在しましたが、insert script として満たしてほしい条件である、
という 2 つの条件と、options repository の主要言語が Go であることを考慮して、Go のファイルを生成することにしました。
上で載せた Protobuf のスキーマから protoc-gen-options を用いて insert script を生成すると、以下のようになります。
package example
import (
"errors"
"github.com/jinzhu/gorm"
"path/to/model"
)
// OptionKeyCheckbox stores options data.
func OptionKeyCheckbox(tx *gorm.DB) error {
optionCategoryLocales := []*model.OptionCategoryLocale{
{Locale: model.LocaleFromISOCode("ja"), Name: "category_name"},
}
optionCategory := model.OptionCategory{
Key: "category_key",
Locales: optionCategoryLocales,
}
optionDetailSelects := []model.OptionSelect{}
optionDetailLocales := []model.OptionLocale{
{
Locale: model.LocaleFromISOCode("ja"),
Name: "name",
Description: "description",
},
}
optionDetail := model.OptionDetail{
OptionID: 0,
Default: &model.Value{
Int: 0,
Bool: true,
},
Selects: optionDetailSelects,
Locales: optionDetailLocales,
}
option := model.Option{
Field: 1,
SortKey: "sort_key",
Key: "option_key",
Experiment: false,
Hidden: false,
Detail: optionDetail,
}
var existedOptionCategory model.OptionCategory
res := db.Where("key = ?", optionCategory.Key).First(&existedOptionCategory)
if errors.Is(res.Error, gorm.ErrRecordNotFound) {
option.Category = &optionCategory
} else {
option.OptionCategoryID = existedOptionCategory.ID
}
if err := db.Where(model.Option{Key: "option_key"}).FirstOrCreate(&option).Error; err != nil {
return err
}
return nil
}
package main
import (
"log"
"github.com/jinzhu/gorm"
_ "github.com/lib/pq"
"path/to/system"
"path/to/example"
)
func main() {
cfg, err := system.LoadConfig()
if err != nil {
log.Fatalln(err)
}
db, err := gorm.Open("postgres", cfg.DatabaseURL)
if err != nil {
log.Fatalln(err)
}
tx := db.Begin()
if err = run(tx); err != nil {
tx.Rollback()
log.Fatalln(err)
}
}
func run(tx *gorm.DB) error {
if err = example.OptionKeyCheckbox(tx); err != nil {
return err
}
if err = tx.Commit().Error; err != nil {
return err
}
return nil
}
protoc-gen-gqlopt
生成する GraphQL Nexus の DSL は、options の API を叩くだけの実装になります。
設定値を取得する query と、設定値を追加・更新する mutation の実装を生成する必要があるため、1 つの rpc から 設定値取得用の実装と、設定値追加・更新用の実装を生成するようにしました。
上で載せた Protobuf のスキーマから protoc-gen-gqlopt を用いて GraphQL Nexus の DSL を生成すると、以下のようになります。
import { GetUserOptionRequest } from "path/to/user_options_pb";
import { nullable, queryField } from "nexus";
export const userOptionsOptionKeyCheckbox = queryField("userOptionsOptionKeyCheckbox", {
type: nullable("UserOptionCheckbox"),
description: "name\n description",
async resolve(_root, _args, ctx, _info) {
if (ctx.currentUserId == null) throw new Error("unauthenticated");
const req = new GetUserOptionRequest();
req.setUserId(ctx.currentUserId);
req.setKey("option_key");
const resp = await ctx.dataSources.options.option.promises.getUserOption(req, ctx.grpcMetadata);
return resp.getCheckbox() ?? null;
},
});
import { UpdateUserOptionRequest } from "path/to/user_options_pb";
import { arg, nonNull, nullable, mutationField } from "nexus";
import { UpdateUserOptionRequestCheckboxInput } from "path/to/user_options_pb_nexus";
export const updateUserOptionsOptionKeyCheckbox = mutationField("updateUserOptionsOptionKeyCheckbox", {
type: nullable("UserOptionCheckbox"),
args: {
input: arg({
type: nonNull("UpdateUserOptionRequestCheckboxInput")
})
},
description: "name\n description",
async resolve(_root, { input }, ctx, _info) {
if (ctx.currentUserId == null) throw new Error("unauthenticated");
const req = new UpdateUserOptionRequest();
req.setUserId(ctx.currentUserId);
req.setOptionKey("option_key");
req.setCheckbox(UpdateUserOptionRequestCheckboxInput.toProto(input));
const resp = await ctx.dataSources.options.option.promises.updateUserOption(req, ctx.grpcMetadata);
return resp.getCheckbox() ?? null;
},
});
さらに、GraphQL Nexus の DSL から GraphQL のスキーマを生成すると、以下のようになります。
type Mutation {
"""
name
description
"""
uppdateUserOptionsOptionKeyCheckbox(input: UpdateUserOptionRequestCheckbox!): UserOptionCheckbox
}
type Query {
"""
name
description
"""
userOptionsOptionKeyCheckbox: UserOptionCheckbox
}
また、Protobuf のスキーマに書いた情報が GraphQL のスキーマのコメントにも反映されるように実装しているため、GraphQL の API ドキュメントも自動で更新されるようになっています。
まとめ
ここでは、Protobuf のスキーマから、Go で書かれた insert script と GraphQL Nexus の DSL を生成する plugin を作成し、問題を解決しました。
しかし、必ずしもこのような解決方法が適切なものとは限らず、別の場面では別の解決方法が適切なことも多々あります。
適切な方法で問題を解決していくために、今置かれている状況をしっかりと把握し、どのような目的を達成するために何を解決したいのかを明確にすることが大切です。
おわりに
今回のインターンで与えられたタスクは抽象度が高く、純粋な技術力だけではなく、不確実性の高い問題に対する姿勢なども学ぶことができました。
期間内に終わらせることができるか心配でしたが、メンターの @izumin5210 さんをはじめとして様々な人に助けていただき、完成させることができました。ありがとうございました!
Wantedly のインターンに興味のある方は、ぜひ応募してみてください!
参考文献