CSS の position: sticky を試してみる
Material UI の ListSubHeader
の sticky な振る舞いって、どう実装されているんだろうと覗いてみたら position: sticky
とだけ書かれていて「こんなプロパティをブラウザがサポートする時代になったんだな」ということを今更知ったので試してみます。
本文中は実際のコードの要点だけを紹介していますので、完全版のコードは以下のリポジトリをご覧ください。
今回は sticky なヘッダとフッタを持った要素が大量に並んでいるというデモを作成しました。
やったととしては、こんな感じの要素を大量に用意して
<div class="item"> <header>header</header> <div class="content"> content </div> <footer>footer</footer> </div>
こんな感じの CSS (装飾用途のプロパティは省いています)を書くと
.item header { position: sticky; top: 0; } .item footer { position: sticky; bottom: 0; }
上で挙げたような sticky なヘッダーやフッターが実に簡単に実装できます。
楽すぎますね。
現時点で対応ブラウザは Firefox と Chrome、それに -webkit-sticky
で Safari も対応しています(macOS)。
一般的になった UI 要素をブラウザがネイティブサポートするこの感じ、 box-shadow
や border-radius
が実装された頃を思い出しますね。ちょっとそれっぽいデザインにしようとすると、すぐにWebページが画像の集合体になってしまっていたあの頃に比べたら、今のウェブ開発は異次元と言っていいほどの至れり尽くせり具合ですね。
dataloader を使ってリクエストを取りまとめる React デモを作る
以前 GraphQL を利用していたときに、サーバサイドのSQLリクエストを減らすために使おうとしていたパッケージに dataloader というものがあります。
現在は GraphQL ではなく gRPC-Web を利用したアプリを書いているのですが、dataloader
は別に GraphQL に限ったものではなく、id 単体を指定するような複数のリクエストを、複数の id
を指定する単一のリクエストにまとめて実行するということをやってくれます*1。つまり dataloader
は
get****ById(id1)
x n → get****ByIds(id1, id2, ...)
という処理を行ってくれる中間層として機能してくれるものになります。
デフォルトではレスポンスに含まれる id
を利用して、リクエストの取りまとめや重複するキーへのリクエストの dedupe 処理を行ってくれます。
GraphQL のサーバサイドではスキーマの patching を行っていたのですが、GraphQL はレスポンスに id
を含めるかどうかが叩く側次第なためこの辺りの処理ががうまくいかずに頓挫しました。
話を戻して、今回は GraphQL ではなく、一般化したリモートリクエストをWebから行うようなAPIクライアントを対象にして、dataloader
の動作を以下のような単純なアプリを作って試していきたいと思います。
動作の詳しい仕組みは理解していませんが、とりあえず使ってみるところから始めます。
この記事で説明しているコードの、実際に動作するプロジェクトは以下の til リポジトリに置いてあります。
下準備
API クライアントのモックを定義する
今回デモで利用するAPIは以下のようなものです。
// api.ts // Data Types // ---------- export type IdType = number export interface User { id: IdType name: string favoriteThingIds: IdType[] } export interface Thing { id: IdType name: string } // Data Mock // --------- const thingList: Thing[] = [ { id: 1, name: "Raindrops" }, { id: 2, name: "Kittens" }, { id: 3, name: "Kettles" }, { id: 4, name: "Mittens" }, { id: 5, name: "Packages" } ] const userList: User[] = [ { id: 1, name: "Alice", favoriteThingIds: [1, 2, 3, 4, 5] }, { id: 2, name: "Bob", favoriteThingIds: [2, 4] }, { id: 3, name: "Carol", favoriteThingIds: [1, 3] } ] // API Mock // -------- const emulateNetworkDelay = <V>(value: V): Promise<V> => { return new Promise(resolve => setTimeout(() => resolve(value), 1000)) } export default { getUserById(id: IdType) { console.log(`API called: getUserById(${id})`) const user = userList.find(u => u.id === id) return emulateNetworkDelay(user) }, getUsersByIds(ids: IdType[]) { console.log(`API called: getUsersByIds(${ids})`) const users = ids.map(id => userList.find(u => u.id === id)) return emulateNetworkDelay(users) }, getThingById(id: IdType) { console.log(`API called: getThingById(${id})`) const thing = thingList.find(t => t.id === id) return emulateNetworkDelay(thing) }, getThingsByIds(ids: IdType[]) { console.log(`API called: getThingsByIds(${ids})`) const things = ids.map(id => thingList.find(t => t.id === id)) return emulateNetworkDelay(things) } }
データ型としては User
が Thing
への参照を id
のみで持っており、Thing
の内容を見たい場合は更にAPIを叩く必要がある、というAPI設計です。
ByIds
のクエリにより id
複数指定でデータを取得することができるという想定です。dataloader
の利用にはこういったAPIが存在することが前提となります。
しかしこの ByIds
系のクエリですが、実際に React アプリを書いた経験から言うと、意外と活かしづらいです。私が React を使う場合、コンポーネントがマウントされたタイミングでそのコンポーネントが必要とするリクエストを行う、という方針でコンポーネントを切り分け・実装することが多いです。なのでリクエストの効率化のために ByIds
でデータ取得の取りまとめを行おうとすると、ちょっと面倒くさく感じる場面が多いですね。
この辺の処理を React のデータフローだけで組もうとすると、実際にデータが必要なコンポーネントのより親階層の方で子コンポーネントに必要なリクエストを取りまとめて行う必要がありそうですが、それをやろうとすると描画コンポーネントと、データを取得するコンテナコンポーネントの間の仕様的な結合が強くなり、使い回しをしづらいコンポーネントになってしまうのではないかと思っています。
という事情もあり ByIds
的なAPI側が用意されているにも関わらず、実際には React での組みやすさによる制約が働いて「複数 id
指定使ってないです、すみません...」となることが今までよくありました。
APIクライアントに対して dataloader を噛ませたラッパーを用意する
さて、前置きが長くなりましたが、上記のAPIクライアントに対して、以下のような dataloader
を定義してAPIクライアントのラッパーを定義します。
// api-with-dataloader.ts import DataLoader from "dataloader" import api, { IdType } from "./api" // DataLoaders // ----------- const userLoader = new DataLoader((keys: IdType[]) => api.getUsersByIds(keys)) const thingLoader = new DataLoader((keys: IdType[]) => api.getThingsByIds(keys)) // Batching API Mock // ----------------- export default { getUserById(id: IdType) { return userLoader.load(id) }, getThingById(id: IdType) { return thingLoader.load(id) } }
User
と Thing
のそれぞれに dataloader
を仕込んだAPIクライアントのラッパーを定義します。ByIds
分の定義も loadMany
を利用すればできそうですが、今回のデモでは利用しないので省略します。
React コンポーネントを定義する
これらのAPIクライアントを利用するコンポーネントを以下のように組みます。
import React, { Component, useEffect, useState } from "react" import logo from "./logo.svg" import "./App.css" import api, { IdType, User, Thing } from "./api" import batchApi from "./api-with-dataloader" // ThingSummary // ------------ interface ThingSummaryProps { id: IdType batch?: boolean } function ThingSummary(props: ThingSummaryProps) { const { id, batch } = props const [thing, setThing] = useState<Thing | undefined>(undefined) useEffect(() => { if (batch) { batchApi.getThingById(id).then(setThing) } else { api.getThingById(id).then(setThing) } }, []) if (!thing) return <div>loading...</div> return ( <div> #{thing.id}: {thing.name} {batch && " (batched!)"} </div> ) } // UserSummary // ----------- interface UserSummaryProps { id: IdType batch?: boolean } function UserSummary(props: UserSummaryProps) { const { id, batch } = props const [user, setUser] = useState<User | undefined>(undefined) useEffect(() => { if (batch) { batchApi.getUserById(id).then(setUser) } else { api.getUserById(id).then(setUser) } }, []) if (!user) return <div>loading...</div> return ( <div> <h3> #{user.id}: {user.name}'s Favorite Things {batch && " (batched!)"} </h3> <ul> {user.favoriteThingIds.map(id => ( <ThingSummary key={id} id={id} batch={batch} /> ))} </ul> </div> ) } // Main Component // -------------- export default class App extends Component { render() { return ( <div className="App"> <header className="App-header"> <img src={logo} className="App-logo" alt="logo" /> <div> <UserSummary id={1} batch /> <UserSummary id={2} batch /> <UserSummary id={3} /> </div> </header> </div> ) } }
User
と Thing
それぞれに Summary
コンポーネントを定義して、それぞれのコンポーネントは必要なデータを自身がマウントされた際にリクエストを飛ばして取得する、という設計です。こういう処理が簡単に書けるので React Hook 便利ですね。
それぞれの Summary
コンポーネントには batch
オプションをプロパティとして定義しています。有効化された場合には素の api
の代わりに dataloader
を仕込んだ batchApi
が利用されます。batch
が指定された UserSummary
は自身のリクエストの他に子コンポーネントの ThingSummary
の batch
オプションも有効化するようにしています。
全体としては、id
が 1
と 2
のユーザ対しては batch
リクエストとして dataloader
を利用したAPIクライアントの方を利用し、3
のユーザに対しては元のAPIクライアントを利用する、という描画内容になっています。
実行する
実行結果をイメージするために、改めてデータの中身と描画部分のコードを載せておきます。
const thingList: Thing[] = [ { id: 1, name: "Raindrops" }, { id: 2, name: "Kittens" }, { id: 3, name: "Kettles" }, { id: 4, name: "Mittens" }, { id: 5, name: "Packages" } ] const userList: User[] = [ { id: 1, name: "Alice", favoriteThingIds: [1, 2, 3, 4, 5] }, { id: 2, name: "Bob", favoriteThingIds: [2, 4] }, { id: 3, name: "Carol", favoriteThingIds: [1, 3] } ]
<UserSummary id={1} batch /> <UserSummary id={2} batch /> <UserSummary id={3} />
もしこれらのデータが全て ById
によって取得された場合、叩かれるAPIの回数としては以下のようになりますね。
実際にアプリを実行してみると冒頭に貼った画像のように、以下のように描画されます。
「batched!」と描画されている箇所が batch
オプションを指定した箇所です。
問題なくデータは指定した id
の通りに取得されているようですね。
コンソールに出力されているのは dataloader
をかませていない、素のAPIクライアントの各メソッドが呼ばれたログです。
コンソールの行数だけ見ても、明らかにリクエスト数は減っていますね。実際、APIクライアントが叩かれた回数としては
getUserById
: 1 リクエスト( id:3
)getUsersByIds
: 1 リクエスト(id:1, 2
)- 直接コンポーネントからは呼んでいませんが
dataloader
経由で呼ばれていますね
- 直接コンポーネントからは呼んでいませんが
getThingById
: 2 リクエスト(id:1, 3
)getThingsByIds
: 1 リクエスト(id:1, 2, 3, 4, 5
)- こちらも同様です
となりました。確かに ById
リクエストが ByIds
リクエストにまとめられていることがわかります。
実際に動作することがつかめたので、使い慣れたあたりで、次は dataloader
の動作原理にも踏み込んでみたいですね。
今回は以上です。
*1:まとめられる単位はソースを軽く眺めたところ実行コンテキスト単位(?)な雰囲気を感じましたが、詳しくはまだ理解できていません
protoc-gen-xxxx のオプションを確認する方法を探す
protoc
を使いサーバ/クライアント実装の組み合わせや変換プロキシなどのエコシステムを色々と検証しているのですが、そのせいもあって手元にある分だけでも 7 つ程の protoc-gen-xxxx
プラグインがインストールされています。
$ protoc-gen- protoc-gen-dart protoc-gen-grpc-gateway protoc-gen-ts protoc-gen-go protoc-gen-grpc-web protoc-gen-govalidators protoc-gen-swagger
これらがあることで
protoc -I. \ --grpc-gateway_out=logtostderr=true:. \ my.proto
のようにデフォルトではサポートされていない --xxxx_out
オプションを使いコードを生成することができるのですが、そのオプションで実行される protoc-gen-xxxx
の更にそのオプションである --xxxx_out="<plugin-options>:out"
の種類がプラグインごとに様々だったり、確認方法が提供されているかもまちまちだったりするのでちょっと整理してみたいです。
protoc-gen-xxxx はコマンドとして実行できる
まず基本的なところの確認です。
protoc-gen-xxxx
を protoc
で使うためにはパスを通してコマンドとして実行できるようにしておく必要があります。
なのでそのプラグインが使えている状態であれば、protoc-gen-xxxx
は単体のコマンドとしても実行できます。
標準入力を受け取り動作するようなので適当に dummy-contents
と入力して Ctrl+d
で EOF を送ると「proto
として読めないよ」という旨のようなエラーが返されます。
$ protoc-gen-grpc-gateway dummy-contents F0922 13:33:41.840980 97540 main.go:45] failed to unmarshal code generator request: proto: can't skip unknown wire type 4
という、基本的なインタフェースとしては protoc
と protoc-gen-xxxx
は標準入出力を介して繋がっているだけなので、割とシンプルで分かりやすいです。
コマンドライン引数でオプションを渡してヘルプを見る
--xxxx_out="<plugin-options>:out"
として protoc
に渡しているオプションはコマンドラインからプラグインを実行する際にはコマンドライン引数として渡すことができるようです。
protoc-gen-grpc-gateway で試してみると以下のようにヘルプが表示されます。
$ protoc-gen-grpc-gateway -h Usage of protoc-gen-grpc-gateway: -allow_delete_body unless set, HTTP DELETE methods may not have a body -allow_repeated_fields_in_body body allows to use repeated field in body and `response_body` field of `google.api.http` annotation option : -logtostderr log to standard error instead of files : -v value log level for V logs
この -h
でヘルプが見られるのは protoc-gen-grpc-gateway がコマンドラインオプションの実装に flag というパッケージを使っているからです。
ついでに golang/glog というロギングパッケージのオプションも同様に flag で実装され、そのオプションも protoc-gen-grpc-gateway のオプションとして露出しているようなので、ログを標準入力に出す -logtostderr
や粒度を設定する -v
(verbose) オプションも利用できます。こういうオプションが備わっているプラグインだとデバッグなんかも楽になりますね。
と言ってもこの方法が使えるのは手元のプラグインの中で言えば protoc-gen-grpc-gateway 系だけみたいです。
--grpc-gateway_out=logtostderr=true,v=1:out
のように verbose レベルを指定できます。
諦めてコードを読む
オプションにヘルプが用意されているプラグインはいいですが、大部分のプラグインには用意されていないようなのでその場合はドキュメンテーション頼りか、コードを読んで対応する場合が多いです。自分が知らないだけでプラグインのオプションを確認する方法があるのかもしれませんが...
大抵 GitHub のリポジトリで option
や存在することが分かっているオプション名などのキーワードで検索すれば引っかかってきます。例えば protoc-gen-go の場合は ここ に見つかりました。
この辺は protoc
で色々生成しているうちに妙に探すのが小慣れてきた感じがします。この方法の難点は、探し出したオプションが将来的に消えることはないのだろうか、と心配になるところですね。
TypeScript の型定義を観点に gRPC-Web の実装ライブラリを比較する
gRPC-Web の実装を調べてみると、どうやら複数の実装があるみたいです。 それぞれ関連ライブラリの組み合わせが決まっており、きちんと区別しないと混乱しそうなのでまとめてみます。
比較のために作成したプロジェクトは以下に置いてあります。
gRPC-Web の実装を簡易比較
- grpc/grpc-web (star: 700)
- grpc のオーガニゼーション(以下本家)にある...と思ってこれを選ぶとまだ成熟しておらず後悔する
- 特に TypeScript の型定義は完全にサポートされていない
- アクセッサの戻り型が
{}
で潰されていたりする
- アクセッサの戻り型が
- improbable-eng/grpc-web (star: 1.7k)
- 本家よりも勢いがあって利用者も多そう
- TypeScript の型定義は本家よりも充実している
なので gRPC の本家本元のリポジトリにも grpc/grpc-web という実装はあるけれど、 improbable-eng/grpc-web の方が人気があり実装状況もマルという状況ですね。
以下、両者を細かく見ていきます。 比較の題材にした Protobuf 定義は以下の通りです。
syntax = "proto3"; package hello; service Greeting { rpc Hello(HelloRequest) returns (HelloResponse) {} } message HelloRequest { string message = 1; } message HelloResponse { string message = 1; Profile profile = 2; } message Profile { string name = 1; }
grpc/grpc-web
本家お膝元のリポジトリです。 しかしもう一つのリポジトリと比較すると実装に開きがあります。
- protoc プラグイン: protoc-gen-grpc-web
- web ランタイム: npm/grpc-web
protoc \ --js_out=import_style=commonjs:${OUT_DIR} \ --grpc-web_out=import_style=commonjs,mode=grpcwebtext:${OUT_DIR} \ hello.proto
のように生成します。 これで以下のようなファイルが生成されます。
out/grpc-web-by-grpc/proto ├── hello_grpc_web_pb.d.ts ├── hello_grpc_web_pb.js ├── hello_pb.d.ts └── hello_pb.js
上で書いた「TypeScript の型が {}
で潰される」とは以下のように Profile 型が返ってきてほしいところが {}
になっているところを指しています。
export class HelloResponse { constructor (); getMessage(): string; setMessage(a: string): void; getProfile(): {}; setProfile(a: {}): void; serializeBinary(): Uint8Array; static deserializeBinary: (bytes: {}) => HelloResponse; }
おそらくまだ実装途中なのだと思います。
toObject
の型が定義されていないのも物足りないところですね。
improbable-eng/grpc-web
本家よりも勢いのあるリポジトリです。
- protoc プラグイン: protoc-gen-ts (ts-protoc-gen)
- web ランタイム: npm/grpc-web-client
protoc \ --js_out="import_style=commonjs,binary:${OUT_DIR}" \ --ts_out="service=true:${OUT_DIR}" \ hello.proto
のように生成します。これで以下のようなファイルが生成されます。
out/grpc-web-by-improbable-eng/proto ├── hello_pb.d.ts ├── hello_pb.js ├── hello_pb_service.d.ts └── hello_pb_service.js
微妙にファイルの命名が違っていますが、hello_pb.js の型付けのために hello_pb.d.ts が生成されているのは同じですね。
ただこちらの方が Message
の型付け度合いは以下のように充実しています。
export class HelloResponse extends jspb.Message { getMessage(): string; setMessage(value: string): void; hasProfile(): boolean; clearProfile(): void; getProfile(): Profile | undefined; setProfile(value?: Profile): void; serializeBinary(): Uint8Array; toObject(includeInstance?: boolean): HelloResponse.AsObject; static toObject(includeInstance: boolean, msg: HelloResponse): HelloResponse.AsObject; static extensions: {[key: number]: jspb.ExtensionFieldInfo<jspb.Message>}; static extensionsBinary: {[key: number]: jspb.ExtensionFieldBinaryInfo<jspb.Message>}; static serializeBinaryToWriter(message: HelloResponse, writer: jspb.BinaryWriter): void; static deserializeBinary(bytes: Uint8Array): HelloResponse; static deserializeBinaryFromReader(message: HelloResponse, reader: jspb.BinaryReader): HelloResponse; }
toObject
にも型が設定されているのが嬉しいですね。現状で選ぶならこちら一択だと思います。
protoc のオプション js_out の提供元
余談的なものになりますが、ts-protoc-gen や protoc-gen-grpc-web を比較しているときに両者が共通して利用している --js_out が気になりました。
protoc
のデフォルトのオプションに含まれているので、元から含まれているものでしょうか。
となると JavaScript のコードジェネレータの実装も protoc に含まれているということなのでしょうか。
https://github.com/protocolbuffers/protobuf/tree/master/js がそれっぽいですね。
ただ gRPC のコードは含まれておらず単純にバイナリと JavaScript のデータの変換処理のみが行われているようです。 具体的には Protobuf の Message ごとに以下のメソッドを生やしているようです。
toObject
deserializeBinary
deserializeBinaryFromReader
serializeBinary
serializeBinaryToWriter
これ以外の処理は生成されないようなので完全にデータ変換のみの実装ですね。 なので protoc に含まれている JavaScript のコードジェネレータで生成されない gRPC 関係の service/server/client などの実装は ts-protoc-gen や protoc-gen-grpc-web などが独自に生成している、という理解をしました。
配列型のJSONから gRPC(protobuf) 用のモックデータを読み込む
gRPC のハンズオンがてら適当なサーバを書いてみて、ある程度ハンドラが充実してきたので今度は適当なデータをJSONで用意して読み込んでサーバで返すようにしよう、としたときに意外と大変だったという記録です。
動作するサーバコードは以下のリポジトリに置いてあります。
色々試したところそもそもやっていることの筋が悪いような気がしたのですが、一応動いたは動いたので記録として残しておきます。
サーバ・データ定義
サービス定義
サービス定義は以下のようなよくある ToDo アプリです。
syntax = "proto3"; import "google/protobuf/timestamp.proto"; service ReadOnlyTodo { rpc GetTodos(GetTodosRequest) returns (GetTodosResponse) {} } message Todo { int32 id = 1; string title = 2; google.protobuf.Timestamp deadline = 3; } message GetTodosRequest { } message GetTodosResponse { repeated Todo todos = 1; }
サーバ定義
サーバ定義は以下のようにしています。
type todoServer struct { todos []*pb.Todo mu sync.Mutex }
今回はこの todos
にJSONのデータをロードするための関数を生やそうと試行錯誤しています。ロードするJSONは以下のようなものです。
[ { "id": 1, "title": "First ToDo", "deadline": "2018-09-01T12:34:56.789Z" }, { "id": 2, "title": "Second ToDo", "deadline": "2018-09-01T12:34:56.789Z" } ]
配列形式で複数のエントリが表されています。[ptypes]
試したパターン
実際に試した順番からはちょうど逆転しますが成功したパターンからご紹介します。
成功したパターン
まずは成功したパターンです。
golang/protobuf#675 のコメントを参考にして書きました。
普段の json.Unmarshal
と比較するととても複雑なことをしているように見えます。
func (s *todoServer) loadTodos() { jsonBytes, _ := ioutil.ReadFile(dataFile) jsonString := string(jsonBytes) jsonDecoder := json.NewDecoder(strings.NewReader(jsonString)) // read open bracket if _, err := jsonDecoder.Token(); err != nil { log.Fatal(err) } for jsonDecoder.More() { todo := pb.Todo{} if err := jsonpb.UnmarshalNext(jsonDecoder, &todo); err != nil { log.Fatal(err) } s.todos = append(s.todos, &todo) } }
Unmarshal に標準の encoding/json ではなく protobuf/jsonpb というパッケージを利用しています。 叩いたところ無事データが返ってきました。
$ grpc_cli call localhost:10000 GetTodos "" connecting to localhost:10000 todos { id: 1 title: "First ToDo" deadline { seconds: 1535805296 nanos: 789000000 } } todos { id: 2 title: "Second ToDo" deadline { seconds: 1535805296 nanos: 789000000 } }
直感的に jsonpb を使った場合
いちいち UnmarshalNext
などせずに配列型を直接データの格納先に指定してやればいいのではと思い試してみました。
func (s *todoServer) loadTodos_byInstinctiveJSONPBUsage() { reader, _ := os.Open(dataFile) if err := jsonpb.Unmarshal(reader, &s.todos); err != nil { log.Fatal(err) } }
これを実行すると...というよりコンパイルの時点でエラーが出て
*[]*todo.Todo does not implement "github.com/golang/protobuf/proto".Message (missing ProtoMessage method)
と言われます。つまり pb.Todo
は Message
の interface を満たしているけれど []pb.Todo
には ProtoMessage
のメソッドが存在しないので jsonpb.Unmarshal
のシグネチャ違反ですよと言われているんですね。
標準の encoding/json パッケージを利用した場合
そもそも jsonpb を利用せずに json を利用したらどうなるかです。
func (s *todoServer) loadTodos_byStandardJSONPackage() { jsonBytes, _ := ioutil.ReadFile(dataFile) if err := json.Unmarshal(jsonBytes, &s.todos); err != nil { log.Fatal(err) } }
これを実行すると...
json: cannot unmarshal string into Go struct field Todo.deadline of type timestamp.Timestamp
と返ってきます。string
型を timestamp.Timestamp
型に Unmarshal できないと言っていますね。timestamp.Timestamp
型は Protocol Buffer の ptype (もしくは well-known type) です。
Go 自体そこまで詳しくはないのですが、Unmarshal の振る舞いを変えられたら json でもなんとかなる...んでしょうか。
Go の gRPC のシンプルな Interceptor を自作して理解を深める
Go の gPRC のシンプルな Interceptor を適当に書いたら割とすんなり動いたので基本的な部分をまとめていきます。
今回のサンプルは以下のリポジトリに置いてあります。
go-grpc-middleware にロガーなど様々なミドルウェア(= Interceptor)が用意されていますし、本番用途であればそれらメンテナンスや検証の行き届いたパッケージを使うべきだと思いますが、初見のパッケージのオプションを眺めるのも面倒なのでロガー程度ならスクラッチできないかなと思ってやってみたら意外と簡単でした。基本的に入出力さえ辻褄を合わせておけば何をやるのも自由そうです。
動機としてはもう一つ、grpc のフレームワーク的なインタフェースに興味があったというのもありました。既製品のミドルウェアを使う前に基本的なインタフェースだけでも押さえておくと、開発の自由度も上がるかもしれないという目論見です。
それでは以下、今回行ったことの簡単な解説です。
Interceptor を自作して grpc.Server に組み込んで実行する
Interceptor を定義する
今回はミドルウェアとしては定番のリクエストのログを出力する Interceptor を題材にしました。Interceptor について調べる前はやっつけでメソッドごとの各ハンドラに log.Printf
を突っ込んで回る作業をしていましたが、それらのコードも一掃できました。
Interceptor は func
として定義します。Interceptor には種類があり、今回実装したのは grpc.UnaryServerInterceptor という stream でない値を返す際に動作する Interceptor です。
grpc.UnaryServerInterceptor のシグネチャに従い、メソッド名・リクエスト・レスポンスを log
として表示する Interceptor は以下のようになりました。引数のシグネチャが長いですがミドルウェア的な処理としては必要な情報が並んでいるだけですね。
func loggingInterceptor(ctx context.Context, req interface{}, info *grpc.UnaryServerInfo, handler grpc.UnaryHandler) (resp interface{}, err error) { // ハンドラを実行する res, err := handler(ctx, req) // リクエスト処理のログを表示する(今回ミドルウェアでやりたかったこと ^-^) log.Println("My interceptor called!") log.Printf("%s: %v -> %v", info.FullMethod, req, res) // 実行結果とエラーを返す(返さないとサーバが結果を返さなくなる ^-^); return res, err }
特に難しいことはやっておらず、関数評価時に渡される req
や info
などの材料をもとに処理を行っているだけです。
一点気になったのが Interceptor 中でメソッドのハンドラを評価し、結果を返さなければならないという点です。そこまでミドルウェアというものを経験したこともないのですが、こういう形式が普通なんでしょうか。Node.js の Express とはお作法が微妙に違うなという感触を得ました。
ちなみに res
を nil
で置き換えたらサーバが空レスポンスを返すようになりました。
grpc.Server に設定する
Interceptor を ServerOption type に変換する grpc.UnaryInterceptor を通して Interceptor をサーバに設定します。
grpcServer := grpc.NewServer( grpc.UnaryInterceptor(loggingInterceptor), )
ここもそこまで難解な見た目にはなっていませんね。シンプルです。
実行
サーバを実行してリクエストを送ったところ、サーバのログが以下のように表示されました。
2018/09/13 22:20:39 gRPC server starts on localhost:10000 2018/09/13 22:20:41 My interceptor called! 2018/09/13 22:20:41 /hello.Greeting/Hello: name:"asa-taka" -> message:"Hello asa-taka, I am gentle-server"
...というこんな感じで grpc の Interceptor は自作するのも結構簡単なんだな、というお話でした。
余談
開発って
- その分野のベストプラクティス・定番のライブラリを調べる
- 調べるのが面倒だからとりあえず手前味噌で実装する
のバランスを探り探りで行なっていく作業だと思うのですが、技術検証フェーズでは後者を自由度高く行えると快適に思えることが多いです。ミドルウェアのインタフェースを知ることも、大いにそれに与するものだと思っております。