async-graphqlで独自の名前とフィールドを持つconnection/edgeを定義する

GraphQLで、適切にグラフ上のノード間の関係を示すために、connectionやedgeに独自の名前をつけて、独自のフィールドも追加したいことがある。

Explaining GraphQL Connections | Apollo GraphQL Blog

Instead think of them as the relationship between two nodes, and add fields that are appropriate for that relationship

async-graphqlでこれを実現するには、Connectionの型パラメータに独自の名前とフィールドを表す型を渡せばよい。

実装したいスキーマ

今回は次のスキーマを実装する。ユーザーと書籍がリソースとして存在して、ユーザーが書籍を「読了」したという関係性をUserReadBooksConnectionで表現する。UserReadBooksEdgereadAt(読了日時)を持つ。

type Book {
  id: Int!                                                        
  title: String!                                                  
  author: String!                                                 
}

type Query {
  books: [Book!]!
  user: User!
}

type User {
  """
  ユーザーが読了した書籍
  """
  readBooks: UserReadBooksConnection!
}

type UserReadBooksConnection {
  pageInfo: PageInfo!
  edges: [UserReadBooksEdge!]!
  nodes: [Book!]!
}

type UserReadBooksEdge {
  node: Book!
  cursor: String!

  """
  読了日時
  """
  readAt: String!
}

async-graphqlのConnection

async-graphqlのConnectionの定義は次のようになっている[^1]。

pub struct Connection<
    Cursor,
    Node,
    ConnectionFields = EmptyFields,
    EdgeFields = EmptyFields,
    Name = DefaultConnectionName,
    EdgeName = DefaultEdgeName,
    NodesField = EnableNodesField
>
where
    Cursor: CursorType + Send + Sync,
    Node: OutputType,
    ConnectionFields: ObjectType,
    EdgeFields: ObjectType,
    Name: ConnectionNameType,
    EdgeName: EdgeNameType,
    NodesField: NodesFieldSwitcherSealed,
{
  // ...
}

型パラメータのうち、今回着目するものの意味は次のとおり。

ConnectionFields, EdgeFields

connectionとedgeの独自フィールドの型。普通のフィールドと同じく、メソッドとしてリゾルバを持つObjectなどを渡せばよい。指定しないときはconnection::EmptyFieldsを渡す。

Name, EdgeName

ConnectionNameTypeEdgeNameTypeを実装した構造体を渡せばよい。指定しないときはconnection::DefaultConnectionNameを渡す。

独自のconnectionを定義

async-graphqlのConnectionに適切な型パラメータを渡して、独自の名前やフィールドを持つconnectionを定義する。

use async_graphql::{
    connection::{Connection, Edge, EmptyFields},
    types::connection::{ConnectionNameType, EdgeNameType},
    Object, OutputType, Result, SimpleObject,
};

// ...

// 独自のconnectionを定義する
// 型の記述が長くなるので別名をつける
type UserReadBooksConnection = Connection<
    i32,
    Book,
    EmptyFields,
    UserReadBooksEdgeFields,
    UserReadBooksConnectionName,
    UserReadBooksEdgeName,
>;

// 普通のフィールドと同じく、メソッドとしてリゾルバを持つObjectを実装
struct UserReadBooksEdgeFields;

#[Object]
impl UserReadBooksEdgeFields {
    /// 読了日時
    async fn read_at(&self) -> &'static str {
        "2024-01-01T12:34:56Z"
    }
}

// ConnectionNameTypeを実装した構造体で独自のconnection名を定義
struct UserReadBooksConnectionName;

impl ConnectionNameType for UserReadBooksConnectionName {
    fn type_name<T: OutputType>() -> String {
        "UserReadBooksConnection".to_string()
    }
}

// EdgeNameTypeを実装した構造体で独自のedge名を定義
struct UserReadBooksEdgeName;

impl EdgeNameType for UserReadBooksEdgeName {
    fn type_name<T: OutputType>() -> String {
        "UserReadBooksEdge".to_string()
    }
}

このconnectionを使って、フィールドUser.readBooksを次のように定義できる。

use async_graphql::{
    connection::{Connection, Edge, EmptyFields},
    types::connection::{ConnectionNameType, EdgeNameType},
    Object, OutputType, Result, SimpleObject,
};

// ...

/// 書籍
#[derive(SimpleObject)]
pub struct Book {
    id: i32,

    /// 書名
    title: String,

    /// 著者
    author: String,
}

// 書籍のダミーデータを作るファクトリ関数
fn get_books() -> Vec<Book> {
    let book1 = Book {
        id: 1,
        title: "Refactoring".to_string(),
        author: "Martin Fowler".to_string(),
    };

    let book2 = Book {
        id: 2,
        title: "Extreme Programming Explained".to_string(),
        author: "Kent Beck".to_string(),
    };

    vec![book1, book2]
}

struct User;

#[Object]
impl User {
    /// ユーザーが読了した書籍
    async fn read_books(&self) -> Result<UserReadBooksConnection> {
        let mut connection = UserReadBooksConnection::new(false, false);
        connection.edges.extend(
            get_books()
                .into_iter()
                .map(|book| Edge::with_additional_fields(book.id, book, UserReadBooksEdgeFields)),
        );

        Ok(connection)
    }
}

read_booksの中で、Edge::with_additional_fieldsを使ってUserReadBooksConnectionのインスタンスのedgesに独自フィールドを持つedgeを追加している。

結果

スキーマは独自に定義した名前のconnectionとedgeを提供しており、UserReadBooksEdge.readAtが追加されている。

スキーマにUserReadBooksConnectionとUserReadBooksEdgeとUserReadBooksEdge.readAtが追加されている
スキーマにUserReadBooksConnectionとUserReadBooksEdgeとUserReadBooksEdge.readAtが追加されている

GraphiQLでクエリすると次のようになる。edgeに追加された独自フィールドを取得できている。

GraphiQLでUser.readBooksをクエリすると独自のconnectionとedgeを取得できている
GraphiQLでUser.readBooksをクエリすると独自のconnectionとedgeを取得できている

定義する例外を減らしつつソフトウェアを設計する方法を"A Philosophy of Software Design"から学ぶ

会社の読書会で"A Philosophy of Software Design"を読んでいる。

自分の担当で第10章"Define Errors Out Of Existence"をしっかり読む機会があり、けっこう興味深い内容なので読書メモをブログにも書いておく。

章タイトルについて

章タイトル"Define Errors Out Of Existence"は直訳すると「エラーが存在しないものとして定義する」。例外やエラーはプログラム中で定義するものなので”define”の語を使っている様子。

こなれた感じにするには「エラーがもとから存在しないようにする」ぐらいが妥当そう。本文中では”defining away”(”define”を使いつつ消し去るニュアンスを出してる?)みたいな用例も出てくる。

この章の主張

著者のJohn先生による主張の構造は次のとおり。

  • エラーハンドリングのコードは特殊なケースについてのロジックを表している
  • 特殊なケースについてのコードはソフトウェアをより複雑にする
    • 6章の"General-Purpose Modules are Deeper"などでこの主張を支えている
  • よって、次の方法でエラーハンドリングする箇所をできるだけ減らすべきである
    • 例外的な状況をなくして通常のふるまいの一部になるようにセマンティクスを変える
    • 下位層のエラーとして隠す、もしくは上位層でまとめてエラーハンドリングする
    • そのままクラッシュさせる

各節のまとめ

10.1 Why exceptions add complexity

この本での例外 (exception) の定義は次のとおり。

I use the term exception to refer to any uncommon condition that alters the normal flow of control in a program.

プログラムにおける正常系フローを異常系フローに変えてしまう状況を例外と呼んでいる。

例外があると、無視すると別の例外が発生する可能性があるし、例外を受けて処理の中断をするとシステムの整合性を取りつつ、より上位にエラーを上げる必要があるので、コードが複雑になりやすい。また、例外処理のコードはボイラープレートが増えがちで読みにくい。

データ指向の分散システムにおける90%以上の障害がうまくエラーハンドリングできていないことによるものという研究もある。

所感

try-catchな例外処理が増えてくると読みにくいというのは誰しもが感じることがありそう。大域脱出するので処理が追いづらいというのもある。

10.2 Too many exceptions

プログラマはよかれと思って例外を定義しすぎる。

Programmers exacerbate the problems related to exception handling by defining unnecessary exceptions. Most programmers are taught that it’s important to detect and report errors;

著者のJohn先生の失敗談として、Tclunsetが紹介されている。ざっくりまとめると

  • unsetは変数を削除するためのコマンドで、存在しない変数の名前を渡すのは使う側のバグと考えてエラーを投げるようにした
  • 存在するかどうかに関わらず変数名のリストにある名前すべてをunsetに渡したいケースがあるが、存在しない変数が渡る場合に備えてエラーハンドリングを必ず書く必要があり、失敗したと感じている

例外もインタフェースの一部であり、上位層に伝播する性質もあるので、増やしすぎるとシステム全体の複雑度が上がる。

The exceptions thrown by a class are part of its interface; classes with lots of exceptions have complex interfaces, and they are shallower than classes with fewer exceptions.

…It can propagate up through several stack levels before being caught, so it affects not just the method’s caller, but potentially also higher-level callers …

例外を投げて使う側に対応を委譲するのは楽だが、システムの複雑さを軽減するためには例外をハンドリングする場所自体を減らしていく必要がある。

The best way to reduce the complexity damage caused by exception handling is to reduce the number of places where exceptions have to be handled.

所感

防御的プログラミングに対する反論的な章。手元のEffective Java第2版7章「メソッド」の「パラメータの正当性を検査する」には

もし、不正なパラメータ値がメソッドに渡されて、実行する前にメソッドがそのパラメータを検査すれば、適切な例外で持ってメソッドは速やかにきちんと失敗します

とある。こういうプラクティスが主流だった時期の反動はあるのだろうか。

10.3 Define errors out of existence

例外ハンドリングを減らすには、例外をハンドリングしなくて済むように(エラーがもとから存在しないように)APIを定義すればよい。

The best way to eliminate exception handling complexity is to define your APIs so that there are no exceptions to handle: define errors out of existence. This may seem sacrilegious, but it is very effective in practice.

先ほどのunsetの場合、未知の変数を渡したらなにもせずにreturnすればよかった。また、変数を削除するというよりは、削除した結果として変数が存在しなくなっていることを確認するというセマンティクスに変えるとよかった。すると、未知の変数名を渡してもエラーが発生しないというのが自然な挙動になる。

所感

この節は短いが、テストに出るとしたら(?)ここだと思う。APIを設計する時点で、例外を定義せずにすむようなセマンティクスにするのが大事。

10.4 Example: file deletion in Windows / 10.5 Example: Java substring method

これらの節では、Windows/Unixのファイル削除の対比や、JavaのStringクラスのsubstringメソッドを例に、エラーをなくす例を紹介している。

たとえば、Unixではファイル削除を遅延することで次の2種類のエラーをないものとしている。

  • ファイルが利用中でも削除操作がエラーを返さなくなっている
  • ファイルを削除するとき、すでにファイルをオープンしているプロセスで例外が起きなくなっている

また、Javaのsubstringは、指定したインデックスが文字列のインデックスのレンジからはみ出るとIndexOutOfBouondsExceptionが発生するので使いづらい(John先生の感想)。次のようなAPIにすればよい(John先生の提案)。

// Javaは次のコードでIndexOutOfBouondsExceptionを投げるが
// John先生は "ware" を返せるようにすればいいのではと言っている
  "software".substring(4, 8) // => "ware"
// ^^^^^^^^
// 01234567

// その他のerror-freeな例
"software".substring(-1, 3) // => "soft"
"software".substring(8, 9) // => ""
"software".substring(5, 3) // => ""

結果、APIがシンプルかつ多くの機能を持つ = 深いモジュールになる。

例外があるほうがバグをすぐに発見できるのではないかという反論がある。それによってバグを発見できると判断してJavaのsubstringは以上の機能になったかもしれないが、エラーを回避したり無視するコードを書く(もしくは書くのを忘れる)過程でソフトウェアが複雑になり、新たなバグを誘発する。

Overall, the best way to reduce bugs is to make software simpler.

所感

最近の言語だと、たとえばRustのstr::getは上記のJohn先生の提案と同じようなAPIであり、Optionを返す。また、スライスを使って部分文字列を得ることもできるが、こちらはメモリを意識しておりインデックスがはみ出るとpanicになる。

10.6 Mask exceptions

エラーをなくすのではなく隠すテクニックについて。例外的な状況に対してシステムの下位層で対処することで、上位層からは気にする必要がなくなる。

例としてはTCPのパケットロスのときの再送機構、NFSでサーバ内部にエラーを隠してアプリケーションはNFSサーバが復旧するまでハングする、といった話がある。

所感

手法自体は納得感あるものの、例として使っているNFSサーバの話の納得感があまりない…

10.7 Exception aggregation

エラーを単一の箇所でハンドリングする例外の集約について。個別の箇所でエラーハンドリングするのではなく、1つのハンドラを使って1箇所ですべてのエラーに対応する。

Webサーバで、URLに基づいてディスパッチャからハンドラにリクエストを割り当てるケースを考える。URLからパラメータを抽出するメソッド(ここではgetParameterという例)を使い、必要なパラメータがなければ例外を投げるとする。ふつうにディスパッチャから呼び出される各ハンドラでこの例外を対処するとコードが重複する。そこで、ディスパッチャに例外を集めて対処することで、エラー処理が1箇所にまとまる。

getParameterはパラメータ解析方法と人間が読みやすい形式のエラーについてだけ知っており、ディスパッチャはエラーをHTTPレスポンスにする方法だけ知っている、という責務の分担もできる。

所感

Webサーバの例があったが、このようなエラーハンドリングはWebアプリケーションフレームワークの機能で実現するのが現代という気がする。

10.8 Just crash?

例外処理の複雑さを減らすために、アプリケーションをクラッシュさせてしまうというテクニック。

例えば、Cでmallocを使うときにout of memory (OOM)はチェックしきれないし、メモリ不足は現代では起こりにくい。起こるとしたらバグの可能性が高いので、OOMをハンドリングする意義は薄く、そのままクラッシュさせるほうがよい。やるとしたらOOMのときプログラムをエラーメッセージとともに中断するckallocを定義して、それだけ使うようにするほうがまだよい。

所感

『「悪い方が良い」原則と僕の体験談』の3「プログラムの頑強さ」と近い話だと思った。とはいえ、Webサービスの会社としては、信頼できない入力が事故につながるWebアプリケーションではまた別の考え方が必要そうではあった。

10.9 Taking it too far

例外を消し去ったりモジュール内に隠したりするほうがいいといってもやり過ぎに注意。インタフェースが複雑になるが、重要な例外はちゃんとクライアント側に見せる必要がある。

全体の所感

エラーをエラーとして扱わずに済ませるような、特殊ケースをより汎用的な正常系に吸収させるセマンティクスを持つようにAPI定義するという考えかたは、これまで無意識下でやっていたこともあるものの、あらためて意識しておく価値があると思った。この章で紹介されている観点を持って各言語のライブラリのAPIがどのように定義されているかも見てみるとおもしろそう。

中途入社のソフトウェアエンジニアがWebサービス開発に参加するとき役立ったこと

この記事は一休.com Advent Calendar 2023 8日目の記事です。

2023-09-25に入社して2か月半が経ったので、既存のWebサービスの開発にソフトウェアエンジニアとして参加するにあたって役立ったことを書いておく。

Webサービスのソフトウェアエンジニアとしての転職活動で役立ったこと』の続編といえるかもしれない。

前提

  • レストラン予約のサービスの開発に参加した
    • 歴史が長い(2006〜)
  • Webアプリケーションを開発する
  • 技術スタックは転職前後で完全に変わった
    • 前: Rails, PHP, Nuxt, MySQLなど7年
    • 後: Rust, Next.js, Python, Microsoft系技術(SQL Serverなど)1

観点

知らないWebアプリケーションの開発に途中からJOINしたとき、どこから切り込むか?』というスライドでは

  • どのようなサービスか
  • どのようなアーキテクチャか
  • どのようにデータを保持するか
  • どのような開発環境か
  • どのようなコードか

という観点で、新たに参加したプロジェクトで開発を始めるために何が必要かを論じている。

加えて、次のような観点も結果的には必要だった。

  • どのように「未知の未知」を減らすか
  • どのようにチームは開発を進めているか

これらをもとに、今回は次の観点でやってみて役立ったことを挙げる。

  • どのようなサービスかを調べる
  • どのようにデータを保持するかを調べる
  • どのようなコードかを調べる
  • 「未知の未知」をできるだけ早く減らす
  • チームの開発体制に興味を持つ

どのようなサービスかを調べる

プロダクトには、解決すべき問題が属する業務ドメインがある。そこで、まずその業務についてざっくり知っておくと、自分が担当するプロダクト以外も含むサービス全体のアーキテクチャを把握するときや、仕事の会話でキーワードが出てきたときに理解を早められる。

また、インセプションデッキで得られる情報のように、ある程度高い抽象度でプロダクトが解決したいことを把握しておくと、自分のやっていることが結局なににつながるのかというのが腹落ちしやすい。

宿泊予約サービスもやっている会社なので、入社前は宿泊が絡む業界に関する本も読んだ。

航空業界との連携によって古くから基幹システムが導入されていたりとか、サイトコントローラというサービスで複数チャネルからの予約を管理する仕組みができあがっているというのがおもしろポイント。サイトコントローラは飲食店予約の業界でも存在する。また、部屋という総数が減らせない在庫を捌くためにダイナミックプライシングが発達しているというのも普通のECとちょっと違うところ。

一方、肝心の飲食店予約の業務知識については世の中にあんまり出回っていない。これについては、入社後にわからんとインターネットで発言していたらCTOに見つかり、結果として飲食店予約の取り扱いからお店で利用する台帳まで、さらに今後の事業戦略などいろいろと社内で教えていただけたので助かった。知りたい人は入社してください

どのようにデータを保持するかを調べる

システムがどのようにデータを持っているかは、これまでアプリが何度かリプレースされても同じデータ構造を保ってきたという点で重要。かつ、アプリから生成されるデータがプロダクト開発上早めに知っておくべきものたちなので、アプリからのデータの使い方とデータのライフサイクルを突き合わせて理解していく。

参加したサービスでは、店舗の方がオペレーションするための管理システムも提供している。その管理システムをテスト環境で操作しながらデータベースのドキュメントを読むことで、各画面で生成されるデータの構造とその意味についておおまかに把握していった。また、店舗の方に提供する網羅性の高いマニュアルがあるので、そのマニュアル上のガイドを読みつつ操作することで、自分で操作しているだけだと気づかない細かい仕様とそれに関連するデータベース上のデータについても理解を深められた。

この作業で各テーブルのデータについて理解を深めたうえで、アプリケーションのコードを読んでデータをどのように生成、参照、変更しているかもあわせて把握することで、データのライフサイクルの中盤から後半、つまり変更や(論理)削除についても理解を深めていった。

それなりに規模が大きいデータベースでまだまだ全体像を把握しているとは言えないものの、普段の開発に必要なデータについてはこの方法である程度目星がつけられるようになった。

どのようなコードかを調べる

前提で述べたとおり、今回の転職で技術スタックがすっかり変わっている。そんな中でもある程度早く成果を出したいので、とくに最初の段階ではコードベースで読むところを絞った。深くコードベースを理解するためのコードリーディングもまた重要だが、今回は割愛する。

まず、開発タスクを進めるにあたって変更する必要がある箇所のエントリポイントを探す。リクエストがどのように各モジュールを通ってコアのビジネスロジックにたどり着くかを読む。そのうえで、変更が必要な主要モジュールに限定して、それらは自分の言葉で説明できるぐらい理解を深めるようにした。また、同じ箇所を変更している過去のプルリクエストをgit blameで探して、最初のモチベーションを調べたりするのも理解につながりやすかった。

そのほか、コードベースの多くを書いた人が近くにいるなら、設計思想を聞くのがよい。これは次の「未知の未知」にもつながる。

「未知の未知」をできるだけ早く減らす

"A Philosophy of Software Design"の2.2節では"unknown unknowns"がソフトウェアの複雑さの中で一番厄介であり、減らす必要があるという話があった。

一方で、実際は「未知の未知」が初めのうちは数多くあるので、その解決は優先度高めでやる必要がある。

今回だと、初めて取り組んだ大きめのタスクを進めるには、5年以上前の開発の歴史を調べる必要があった。自分やチーム内で見ても進捗しないものだったので、チーム外のシステムの歴史に詳しいエンジニアの方にインタビューする会をささっと企画していろいろ伺うことで、ふつうに調べていたらわからないシステムの挙動に関する話が聞けた。結果、その後の開発方針の決定や進行が楽になった。

また、転職で技術スタックやコードベースが変わったことによって、現状はコードを書くうえで知らないことが多いと実感している。詳しい人からすると、私のプルリクエストはプロダクトの都合で必要な変更が足りない箇所や、もっと適切なメソッドやマクロを使える箇所があったりするかもしれない。そんななかでフィードバックサイクルを早めるために、一通りやりたいことをコードとして書いた時点で早めにドラフトのプルリクエストにして、作業の透明性を上げて早めにコメントをもらえるようにすることで、未知の未知を減らす機会を増やしている。

チームの開発体制に興味を持つ

エンジニアとして転職して開発に参加すると、どうしても技術的な課題解決で頭の中がいっぱいになる。その一方で、チームとしての開発体制がどうなっているかを見ておくと、結果として自分のタスクもスムーズに流せるようになるかもしれない。

まず、あらためて会議の話をちゃんと(!)聞いて、自分がわかっていないことやチーム内外の関心事をいかに把握するかが大事な気がしている。最近は出社したときのミーティングで(議事メモを取ったり何かを確認する必要がないときは)ラップトップを閉じて、能動的に聞く状況に追い込んだりしている。また、抑制的な傾向があるので、素朴な質問をちゃんとすることを最近は意識している。

そのうえで、チームの状況に合わせて、開発プロジェクトを進めるうえで手伝えるところは手伝うようにしている。たとえば、カンバンの運用、ストーリーの切り出し、計画づくりのための規模の相対見積りの導入、プロジェクト管理ツール運用のサポートなどを、アジャイルプラクティスの文脈を補足しつつやっている。開発ペースを予測可能にしてタスクのフロー効率を上げることで、結果として自分含め開発者の満足度を上げたい気持ちでやっている。また、フロー効率が向上すれば毎スプリントのインクリメントが生まれる確率が上がるので、ステークホルダーも嬉しいはず。

このあたりの領域でそれなりに動けるのは、アジャイル事業部がある会社の出身者や関係者が同僚に多くいた過去が影響しているのでラッキーだったと思う。

所感

オンボーディングにおけるアンチパターンの記事が話題だったので、最近オンボードした人間の視点での工夫を書いてみた。実際、オンボーディングの準備をする側も大変な現場が多いと思うので、双方とも楽になる方法を考えていきたい。他にいい方法があれば教えてください。