ScalaのマクロをつかったGraphQLサーバの構築

冨田

ScalaのマクロをつかったGraphQLサーバの構築の目次

  • GraphQLの立ち位置
  • GraphQL Schemaの要素と組み立て方
  • 自動化=最適化
  • 最小レベルの表現
  • 自動化
  • その他の機能
  • 効果

はじめまして。冨田です。 Kipp Financial TechnologiesのCo-Founderであり、CTOに就いています。 大学卒業後、ウェブペイ株式会社に勤務し、その後買収されLINE株式会社に勤めました。 この2社でクレジットカードやプリペイドカードを扱うアプリケーションを開発しました。 業務外の時間を利用して様々なプロジェクトにも関係させてもらい、サーバサイドを中心にいろんな技術スタックを経験しました。 現在はこれまでの知見を生かして、仕様策定から実装、インフラ、運用までKippの技術全体を統括しています。

スタートアップ企業は限られたリソースで高度なアプリケーションの開発を求められるのが常です。 Kippは社全体で少人数精鋭チームを目指しています。 開発チームでは生産性が高い技術を採用し、社内でより発展させてこの目標にアプローチしています。 今回のブログ記事ではその実例としてGraphQLサーバの構築事例を紹介します。

GraphQLの立ち位置

前回の記事でも紹介しましたが、GraphQLは管理用のデータベースアクセスインタフェースを担います。 社内で利用する管理用なので細かなアクセス権限管理が不要、ほぼすべてのデータを参照できる必要があるという特徴があります。 現時点でAurora MySQLには300程度のテーブルがあります。 GraphQLを通じてこの8割程度を参照できます。

200を超えるテーブルへのアクセスインタフェースを提供するのは気が遠くなる作業です。 PrismaなどRDBのスキーマから自動的にGraphQLサーバを運用するような仕組みも検討しました。 しかし複合主キーを持つテーブルがある、独自の行暗号化を行うテーブルがあるなどの理由で利用できませんでした。

そこでScala上でGraphQLサーバを構築することを考えました。 サーバ全体の実装にScalaを使っているため、DBアクセス、暗号レコード処理などで既存コードを再利用できる利点があります。 他方ScalaにはPrismaのようなワンストップフレームワークはありませんでした。 検索したなかでモデルが理解しやすかったSangriaを使って独自に構築することになりました。

GraphQL Schemaの要素と組み立て方

実際にkippのモデルのサブセットを使い、SangriaでGraphQLサーバを提供する方法を確認しましょう。 まず今後のコード例で利用するモデルを定義します。 実際にkippで使っているコードの一部を抽出して次のようなモデルを考えましょう。

type UserId = String
type CardId = String

final case class User(
  // Userの識別子
  userId: UserId,
  // 登録日時
  createdMs: Long,
  // 暗号化済み個人情報
  encryptedPersonalData: EncryptedMessage[PersonalData],
)

// 個人情報、要行単位暗号化
final case class PersonalData(
  // Userの氏名
  name: String
)

final case class PrepaidCard(
  // PrepaidCardの識別子、Primary key
  cardId: CardId,
  // PrepaidCardが属するUserのID
  userId: String,
  // 作成日時
  createdMs: Long,
  // PrepaidCardに割り当てらたPANの下4桁
  last4: String,
)

Userは複数枚のPrepaidCardを持てます。 kippのサービスで同時に有効なカードは1ユーザ1枚ですが、再発行やカード種類の変更の際には複数枚が関連づきます。 これらのモデルを取得するクエリは次のようになるでしょう。

{
  users {
    userId
    createdMs
    personalData {
      name
    }
    prepaidCards {
      cardId
      createdMs
      last4
    }
  }
  # PrepaidCardからUserを引くパターン
  prepaidCards {
    cardId
    last4
    user {
      personalData {
        name
      }
    }
  }
}

Sangriaでこのクエリを処理するために必要な部品を解説します。 まずusersprepaidCardsのフィールド定義が必要です。 フィールド定義は次のようになります。

Field("userType",
  UserGraphQLType,
  resolve = ctx => ctx.ctx.db.run(ctx.ctx.tables.users.result))

resolveはcontextを用いてデータを取得する方法を定義します。 この処理はDBを読み出していると受け取ってください。 UserGraphQLTypeは上で定義したcase class UserをSangriaのObjectTypeに写像したものです。 Sangriaにあらかじめ実装されたマクロをつかって次のように定義できます。

implicit lazy val UserGraphQLType: ObjectType[Ctx, User] =
  macros.derive.deriveObjectType

Userの定義を再確認するとencryptedPersonalDataというフィールドを持っています。 しかし取得したいのは復号済みのpersonalDataです。 deriveObjectTypeを修飾しマクロにフィールドを置き換えるよう指示します。

implicit lazy val UserGraphQLType: ObjectType[Ctx, User] =
  macros.derive.deriveObjectType(
    ReplaceField("encryptedPersonalData",
      Field(
        name = "personalData",
        fieldType = OptionType(implicitly[OutputType[PersonalData]]),
        resolve = ((ctx: Context[Ctx, User]) =>
          ctx.value.decryptPersonalData(ctx.ctx.crypto)
        )))

このコードを動かすためにさらに2つ修正が必要です。 1つはPersonalDataのOutputTypeの宣言、もう1つはPersonalDataの復号の実装です。 Userと同様にPersonalDataにもderiveObjectTypeを利用します。 ObjectType is-a OutputTypeの関係があります。

implicit lazy val PersonalDataGraphQLType: ObjectType[Ctx, PersonalData] =
  macros.derive.deriveObjectType

復号のためUserdecryptPersonalDataを追加します。 Cryptoは行レベル暗号を処理するモジュールです。 鍵などを持つでしょう。 decryptEncryptedMessage[T] => Option[T]の型を持ちます。

final case class User(...) {
  def decryptPersonalData(crypto: Crypto): Option[PersonalData] =
    crypto.decrypt(encryptedPersonalData)
}

またUserGraphQLTypeの定義のなかのctx.ctx.cryptoCtx型のcrypto: Cryptoフィールドを呼び出す処理です。 Ctx型はSchemaで出てくるresolveを実装するために必要なオブジェクトを持つ構造です。 cryptoのほかにデータベースへのアクセサなどを持つでしょう。 ここまでで以下のクエリが実行できるようになります。

{
  users {
    userId
    createdMs
    personalData {
      name
    }
  }
}

次にusersのなかでUserに関連づくPrepaidCardを取得できるようにしましょう。 SangriaはFetcherという仕組みを提供しています。

// オブジェクトとID (Primary key)の関係を定義
val relUserUserId =
  Relation[User, UserId]("User-userId", ((x: User) => Seq(x.userId)))
val relPrepaidCardUserId =
  Relation[PrepaidCard, UserId](
    "PrepaidCard-userId",
    ((x: PrepaidCard) => (x.userId)),
  )
implicit val hasIdPrepaidCard: HasId[PrepaidCard, CardId] =
  HasId[PrepaidCard, CardId]((x: PrepaidCard) => x.cardId)

// CardIdからPrepaidCardを取得するDB呼び出しを定義
val fetchPrepaidCard = ((ctx: Ctx, ids: Seq[CardId]) =>
  ctx.db.run(
    ctx.tables.prepaidCard.query.filter(x => x.cardId.inSet(ids)).result
  )
)
// UserIdからPrepaidCardを取得するDB呼び出しを定義
val fetcherPrepaidCardUserId = Fetcher.rel(
  fetchPrepaidCard,
  {(ctx: Ctx, arg: RelationIds[PrepaidCard]) =>
    val ids = arg(relPrepaidCardUserId)
    ctx.db.run(
      ctx.tables.prepaidCard.query.filter(x => x.userId.inSet(ids)).result
    )
  }
)

// User typeにprepaidCardsフィールドを追加
implicit lazy val UserGraphQLType: ObjectType[Ctx, User] =
  macros.derive.deriveObjectType(
    // 中略
    AddFields(Field(
      "prepaidCards",
      ListType(PrepaidCardGraphQLType),
      resolve = ((ctx) =>
        fetcherPrepaidCardUserId.deferRelSeq(relPrepaidCardUserId, ctx.value.userId)
      )
    ))
  )

PrepaidCardGraphQLTypeUserGraphQLTypeとおなじようにderiveObjectTypeで定義します。 fetcherPrepaidCardUserId.deferRelSeqの部分「PrepaidCardにとってuserIdは関連先」であり「1:Nで関連づく」ことを定義しています。 関連先とはforeign key側のようなニュアンスです。 逆にUserにとってuserIdはprimary keyなので、deferを使います。 また1:0か1:1であればdeferRelOptを使います。 このように宣言しわけることでGraphQLのスキーマ型をより正確なものにでき、利用者によく意図が伝わります。

このように一部のモデルで試したところ、Sangriaはやりたい事に対して比較的記述量の多い印象でした。 Sangriaで欲しい機能が実現できると確認しましたが、200超のテーブルの定義を書き下すのは非現実的ともわかりました。 新しいサービス展開に伴ってテーブルが更新、追加されたときGraphQLのメンテナンスコストが開発者に重くのしかかると予想されました。

自動化=最適化

Sangriaがうまく活用できるだろうと見通しがたったところでGraphQLサーバ部分を自動的に構築する方法を検討しました。 私達は自動化=最適化というポリシーを持っています。 自動化は言語やライブラリを用途にカスタマイズし、作業内容を最適化する行為という意味です。 「早すぎる最適化は悪」と言われるように、早すぎる自動化も悪です。 上の例のようにSangriaを直接使ってみて、簡単なケースで期待どおりに動くことを確認しました。 この時作成したコードは自動コード生成を実装するときのサンプルにもなります。

自動化=最適化の観点から、自動化した部分のカプセル化を目指しました。 実行コードのパフォーマンス最適化の場合、ボトルネック箇所を特定し、ブラックボックスとして扱えるよう切り離して最適化すべきです。 自動コード生成においては手書きコードが生成されたコードの中身を気にするべきではありません。 簡単なインタフェースを用意し、グルーコードは一箇所に集中させて生成の知識を持たずに扱えるのが理想です。

Sangriaの仕組みはこの目標に適します。 スキーマ宣言はSchema[Ctx, Unit]という型で簡潔に表現されます。 手書きコードはSchema型のみを意識すればよく、自動生成コードはSchema型を返せば中身はなんでもよいです。 まず自動生成の入力の与え方を検討し、次にSchemaの自動生成方法を検討することにしました。

最小レベルの表現

SangriaはオブジェクトやIDの関係をすべて明示する必要があります。 明示する必要があるのはやむ無しとしても、繰り返しを排し、記述を簡略化したいと感じます。 そのため最低限必要な要素を整理しました。

  1. GraphQLで参照したいテーブル
  2. テーブルの主キー
  3. テーブル同士の関連
  4. 復号して返すべき項目

「1.GraphQLで参照したいテーブル」は明示的に宣言する必要があります。 一部のトークンなどを扱うテーブルは管理者であっても見えてはならないからです。

「2.テーブルの主キー」はテーブルの宣言を解析して取得できますが、明示的に宣言することにしました。 テーブル定義に使っているSlickライブラリがテーブル定義のメソッドのいずれかに主キーが宣言されているという解析しづらい構造だったためです。 「1.参照したいテーブル」を宣言する際にそのテーブルの主キーをあわせて与えればよいので、明示しても煩雑にならないと考えました。

以上の2つは次のように宣言します。 なおtablesはすべてのテーブル定義(SlickのTableQuery)を束ねた構造体です。

// userテーブルはクエリ可能。主キーはuserId
id(tables.user)(_.userId)

「3. テーブル同士の関連」も明示的に宣言することにしました。 Foreign keyをつかっていれば自動化も可能でした。 しかしデータ投入の都合や1つのテーブルが複数のテーブルに従属する構造があることからForeign keyは使わないコーディング規約としています。 関連を簡潔に定義する次の構文を考えました。

table(tables.user).key(_.userId).has(
  many(tables.prepaidCard)(_.userId),
)

fluent interfaceを意識したDSLになっています。 manyのほかにzeroOrOneexactlyOneなどの関連が定義できます。

「4. 復号して返すべき項目」はConvention over Configurationでフィールド名に一貫性を持たせ、自動処理しました。 case classのencryptedXxxフィールドとdecryptXxxメソッドが定義されていたら前者のフィールドを後者のメソッド呼び出しで置換するというルールです。 結果的にEncryptedMessageの扱い全体が統一感あるものになり、コードの品質も良くなりました。

この表現ではさきほどの20行程度の記述がわずか3行で済むようになりました。

自動化

利用したい表現と出力したいコードがきまったら、その間の自動化を実装するだけです。 Scalaでコード生成する方法は複数あります。

  1. マクロはもっとも基本的なAST変換方法です。コンパイル時に与えられた記述を別のASTに置き換えます。式の位置に立つので、クラスを定義できないなどの制約があります。生成されたコードを直接確認できないので大規模になるとデバッグがとてつもなく大変です。
  2. マクロアノテーションは簡単に言うとクラスを定義できるマクロです。マクロがexpressionレベルで作用するのに対しマクロアノテーションは定義レベルで作用します。定義を書き換える性質上このマクロはtyper以前に実行されます。したがってこのマクロの中では型推論の恩恵が一切受けられません。上の例ではtables.userUser型と関連するものだとわかりません。型パラメータや型注釈で明示する必要があります。これも大変な手間です。生成コードを確認できない点も同じです。
  3. コンパイラプラグインはscalacに追加の処理ステップを設ける方法です。代表的な使用例はWartRemoverなどのlinterです。コンパイルされたすべてのASTを見られるので、ASTを見て新しくコードを作成する処理を実装できます。
  4. Scalaファイルを扱うScalaプログラムを作ることもできます。Scalaファイルを構文解析し、別のScalaファイルを出力するプログラムを実装します。コンパイラプラグインはコンパイルフェイズのなかで実行されるのに対し独立の処理として実行されます。

今回は生成するコードの規模がとても大きいことから4の独立したプログラムの方法を採用しました。 すべて試したのですが、この方法がいちばんデバッグしやすく、ビルドに使っているsbtのキャッシュとも相性がよかったです。 sbtがマルチプロジェクト構成になっていて、メインのプロジェクトをビルドした後に自動生成のタスクが実行されます。 出力されたコードはgeneratedというサブプロジェクトのsrcとなり、自動生成後にsbtでビルドされます。 この構成では生成されたコードもsbtや最終的な成果物から見ればただのScalaコードなので、コンパイルエラーやデバッグ出力が読みやすいです。

ScalaはマクロをはじめASTの取り扱いに長けた言語です。 標準ライブラリにインタプリタ実装、AST、Quasi-quoteなど必要なツールセットがすべて組み込まれています。 すこしコツを掴めば簡単にAST変換で言語内DSLが実装できます。

まずScalaファイルを解析するためIMain (interpreter main)を作成します。 jarなどから実行する場合は単に作成するだけでよいのですが、sbtで実行する場合2つ余分に考えるべきことがあります。 1つはsbtがclass loaderをLayeredClassLoaderに差し替えており、単純に作成するとScalaの標準ライブラリが見つからないという妙な状態になります。 次にIMainにメインのプロジェクトの型解析をやらせるにはメインのプロジェクトをclasspathに追加する必要があります。 メインのプロジェクトはすでにビルド済みで.classが作成されていますから、sbtのdependencyを辿って出力先ディレクトリをclasspathに組み込みます。 LayeredClassLoaderの面倒をみつつ以上の目標を達成するコードはこのようになります。 このコードは試行錯誤して書いているので、不要な部分を含む恐れがあります。 また最後のところでtyperフェイズまで実行して止まるようにしています。 型のついたASTだけあれば十分だからです。

def makeInterpreter: IMain = {
  val settings = new Settings
  val matcher = "file:(.*?scala-library.*?\\.jar)".r.unanchored
  @tailrec
  def findPath(loader: ClassLoader): String = {
    val description = loader.toString
    description match {
      case matcher(path)
          if scala.util.Try(Files.exists(Paths.get(URI.create("file:" + path))))
            .toOption.getOrElse(false) =>
        path
      case _ => findPath(loader.getParent)
    }
  }
  def findClassPaths(cl: ClassLoader): Option[URLClassLoader] = {
    cl match {
      case c: URLClassLoader => Some(c)
      case null              => None
      case other             => findClassPaths(other.getParent)
    }
  }
  settings.bootclasspath.value +=
    Environment.javaBootClassPath +
      File.pathSeparator +
      findPath(GraphQLPlugin.getClass.getClassLoader) +
      File.pathSeparator +
      findClassPaths(Thread.currentThread().getContextClassLoader)
        .map(_.getURLs
          .map(_.toString.replaceFirst("file:", ""))
          .mkString(File.pathSeparator))
        .getOrElse("")
  settings.stopAfter.value = List("typer")
  new IMain(
    settings,
    Some(Thread.currentThread().getContextClassLoader),
    settings,
    new ReplReporterImpl(settings),
  )
}

interpreterが作成できたらソースコードを詠み込みinterpreterに処理させます。 結果はCompilationUnitというASTのルートオブジェクトになるので、これを解析していきます。

val (result, run) = interpreter.compileSourcesKeepingRun(getSourceFile(file))
require(result, "Compilation error")
run.typerPhase.next
run.units.foreach(x => process(x.asInstanceOf[global.CompilationUnit]))

Scala ASTはscala-reflectTreeとして表現されます。 Tree解析の基本は次の形です。

tree.map {
  case q"<条件>" => // <処理>
}

Treeのtraverse系メソッドはVisitor pattern式にTree内の全要素を探索します。 条件の部分に対象としたいASTを指定します。

今回は次のように関連を宣言しました。 defClassid()などはすべて型だけ合わせるようになっていて、実装は空です。 最終的な成果物で呼び出されることはありません。

class GraphQLRelationship {
  def kipp = GraphQLHelper.defClass(
    Seq(
      id(tables.users)(_.userId)
    ),
    Seq(
      table(tables.user).key(_.userId).has(
        many(tables.prepaidCard)(_.userId),
      )
    ),
    Seq(
      // アクセス制限、本稿では割愛
    )
}

これにマッチする条件は次のとおりです。 可読性のため複数行にしています。

q"""
def $name(): $_ =
  $_.GraphQLHelper.defClass($_.apply[$_, $_, $_](
    $searChes,
    $relations,
    $restrictAccesses))
"""

展開結果は次です。

name = kipp
searches = Seq(id(tables.users)(_.userId))
relations = Seq(tables(...)...)
restrictAccesses = Seq()

あとは同様にsearchesにidなどマッチさせて宣言を解析し、ASTからRDBの関連の内部表現に変換します。

// matcherの例
q"id(tables.$table)($fn)"

最後に関連データから冒頭のSangriaの仕組みに準拠したコードを生成し、.scalaファイルとして保存します。

その他の機能

実際のGraphQLインタフェースでは絞り込みや権限管理が要求されます。 これらも自動生成の仕組に閉じ込めることで生成されたSchemaを他のプログラムでいじらなくていいようにしています。 絞り込みはsearchesに次のように記述できます。

many(tables.prepaidCard)(_.userId)
// => userId: "123" のようなargumentが作られます
range(tables.prepaidCard(_.createdMs)
// => createdMs: {lt: 1625762709, gt: 1625762709} のようなargumentが作られます

ほかにネストした検索条件、オプショナル値が定義されているかなど業務で頻出の条件が定義できます。

権限管理は管理画面を操作する人物(オペレータ)の職権に応じて表示可能なテーブルを制限します。 部長はオペレータの操作履歴を見られるが一般のオペレータは見られないなどのアクセスコントロールが可能です。

表に出てこない機能として、エラーを起こしかねない状況を発見することにも注力しています。 インデックスを張っていない項目で絞り込みを行うと性能が悪化します。 一度に数百万件を取得しようとするなども同様です。 インデックスの問題であればコンパイル時に、件数の問題はクエリ実行前に検出しエラーとします。 自動化したことで網羅性のある機械的チェックが可能になり、より信頼性の高いシステムが構築できました。

効果

前回の記事にもあったように、当初はgRPCで管理画面にあわせてクエリを実装していました。 今回の例のUserとPrepaidCardのように、画面に応じてさまざまな組み合わせでデータを返す必要があります。 ページングや絞り込み条件もそれぞれに定義されているので、1APIずつ手で記述していました。 工数がかかるのみならず変更を忌避する気持ちが強くなっていました。

GraphQLを導入したのは呼出側が関連や絞り込みを柔軟に記述できると期待しての事でした。 社外のエンジニアの友人と夕食を共にしているとき、導入しやすいbackend for frontendとしてレクチャーされた記憶があります。 可能な限り少ない工数で最大の効果を上げることを目標として、RDBと密結合したクエリインタフェースを実装しました。

結果はすばらしいものでした。 多くのメンテナンスしにくい管理画面用のクエリ実装をすべて消し去りコードサイズが縮小しました。 新しい条件やテーブルの追加も数分から数十分でGraphQLに反映でき、開発者体験が改善しました。

さらにデバッグ用にGraphiQLを管理画面につけておいたところ、開発者だけでなく運用チームもデバッグや集計に使ってくれるようになりました。 すばらしい化学反応です。 管理画面は実装者に明確なリクエストを出し実装を待つ必要がありますが、GraphiQLは独力でちょっと調べることができます。 デバッグのために見られる情報が増え、開発者にトラブルシューティングが依頼される頻度も減りました。 GraphiQLのスキーマ表示や補完が充実していて非開発者に比較的使いやすかったこともプラスに働きました。 GraphQLというメジャーなシステムを利用した恩恵です。 最近ではGraphQLを通じてBigQueryやSpreadsheetにデータを書き出し、集計やモニタリングを自動化するところまで進んでいます。

ひとつ欠点を挙げるならば、AST変換実装がメタレベルのScalaコードとなるためやや理解しづらく、いざ変換コードに手をいれる時、担当者が限定されがちです。 Scala ASTとたわむれたい方はぜひお力をお貸しください。 正社員だけでなく時短や業務委託で活躍されているエンジニアの方も多数いらっしゃいます。 お気軽にpeople@kipp-corp.comまでご連絡ください。