1
/
5

GraphQL + options を便利に扱うための protoc plugin 開発

Photo by Sajad Nori on Unsplash

はじめに

こんにちは。京都大学大学院情報学研究科 修士1年生の有泉洵平です。11/15 から 12/3 までの期間で Wantedly の就業型インターンシップに参加していました。

バックエンドエンジニア
500万人に最高の体験を届ける23卒バックエンドインターン生Wanted!
WantedlyはビジネスSNSとして、「であい/Discover」「つながり/Connect」「つながりを深める/Engage」の3つの体験を提供しています。 ■プロダクトについて Wantedly では、ココロオドル仕事との出会いを通じて会社と人をつなぐ会社訪問アプリ「Wantedly Visit」と、名刺などのプロフィールを起点に人と人のつながりを資産に変えていくプロフィールアプリ「Wantedly People」、会社と働くメンバーの結びつきを強める「Engagement Suite」の3つのプロダクトを開発しています。 ■今後の展開 目標は全世界1000万人のユーザーにWantedlyを使っていただくこと。 そのため海外展開にも積極的に取り組んでおり、シンガポール、香港に拠点を構えています。
Wantedly, Inc.


インターンシップ中、私は Arch squad (Architecture を主に扱うチーム) に所属し、GraphQL 導入によって生じた問題に取り組みました。

要約

  • クライアントサイドが直接扱える疑似 KVS である "options" を GraphQL の集約層を通すことで便利に使いたい
  • そのための実装を手作業で行おうとすると非常に面倒である
  • 面倒な作業を省くための protoc plugin を開発し、options を GraphQL に上手く繋いだ

背景

options

Wantedly には、options という「任意の設定値を扱う API」が存在しています。

任意の設定値とは、例えば以下のようなものです。

  • ユーザがあるボタンを押したかどうか
  • ユーザが最後にある通知を受けた日時
  • ユーザの投稿の公開範囲
  • ...

また、設定値はおおよそ決まった形しか存在しないため、options では、以下の形式の設定値に対応しています。

  • Checkbox
    • boolean を保持する
  • SingleValue
    • 単一の値を保持する
  • SingleSelection
    • 複数の選択肢から 1 つを選択する
  • MultiSelection
    • 複数の選択肢から 1 つ以上を選択する

例えば、先ほど出した「ユーザがあるボタンを押したかどうか」という設定値は 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 として満たしてほしい条件である、

  • 冪等性がある
  • dry run ができる

という 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 のインターンに興味のある方は、ぜひ応募してみてください!

参考文献

Wantedly, Inc.'s job postings
5 Likes
5 Likes

Weekly ranking

Show other rankings
Invitation from Wantedly, Inc.
If this story triggered your interest, have a chat with the team?