キャディ株式会社のブログ - TECH PLAY

TECH PLAY

キャディ株式会社

キャディ株式会社 の技術ブログ

239

キャディのバックエンドエンジニアをして働いている高藤です。 キャディではRustを使った API サーバを開発しています。今回はその開発の過程で導入した cargo workspace を使ったプロジェクト構成についてまとめました。 今回のアプリケーションについて Rustで記述 ドメイン 駆動設計を用いて設計をしており、 ドメイン 層を明確に分離している アプリケーションの役割はgRPCで API を提供したり、MessageQueueからくるメッセージの処理を行う 実装しているアプリケーションで使っている技術や設計手法などは弊社エンジニアが書いた別の記事もご参照下さい。 DDDのパターンをRustで表現する ~ Value Object編 ~ TypeScriptにおけるgRPC関連ライブラリの比較とプロダクト開発で採用した方法の紹介 workspaceを使うようになるまでの経緯 開発初期、 cargo new コマンドで生成されたプロジェクトを以下のような構造にして実装していました。 application_name ├─ app │ └─ main.rs ├─ src │ ├─ domain/ │ │ ├─ aaa.rs │ │ └─ ...etc │ ├─ usecase/ │ │ ├─ bbb.rs │ │ └─ ...etc │ └─ infrastructure/ │ ├─ grpc/ │ │ └─ ...etc │ └─ mq/ │ │ └─ ...etc ├─ Cargo.toml ドメイン 層などを ディレクト リを使い階層構造でmoduleを配置しています。処理をどこに記述すべきかを理解しやすくするためこのような構成にしていました。この構造でプロジェクトが進むにつれ、各 ディレクト リ内のmoduleは増え続けると共にビルド時間が増大し、開発の効率を悪化させる事象が発生しました。 cargo workspace の利用 上記の問題を解決するため、 cargo workspace という機能でプロジェクトを複数のcrateに分離しました。 workspaceを使うメリット crateを分割するメリットとしては保守性や再利用性の向上ももちろんありますが、今回のケースとしてはビルド時間を少しでも短縮することが当初の目的でした。 なぜならRustのビルドツール cargo では依存関係のない crate は並列に コンパイル する事が出来ます。 上記のケースでは infrastructure の中にあるコードは domain , usecase に依存しています。他方で infrastructure 内部の grpc , mq などの処理はお互いに依存はないため、分割することで コンパイル 速度を向上させることが可能です。 workspaceを使ったプロジェクト構成 application_name ├─ app │ ├─ src/main.rs │ └─ Cargo.toml ├─ domain │ ├─ src/...etc │ └─ Cargo.toml ├─ usecase │ ├─ src/...etc │ └─ Cargo.toml ├─ grpc │ ├─ src/...etc │ └─ Cargo.toml ├─ mq │ ├─ src/...etc │ └─ Cargo.toml ├─ Cargo.toml 上記の構成では5つの crate に分割しています。 workspaceの作り方 application_name/Cargo.toml を以下のように定義します。 [workspace] members = [ "app", "domain", "usecase", "grpc", "mq", ] workspace 配下に配置するcrateを上記の様に members として記述をします。 それぞれの crate の中には Cargo.toml を用意する必要があります。 なお、 members に記述のは path になるため、必ずしも同一階層に全ての crate を配置しなくても定義可能です。 例: ./infrastructure/grpc , ./infrastructure/mq のように定義することも可能。 workspace適用後の効果 今回のケースの場合、下記グラフの通り最終的に10分前後かかっていたビルド時間が、2分弱の時間で実行できるようになりました。 workspaceの使い方メモ workspace に関する詳細は各ドキュメント等を参考にしてください。簡単な説明となってしまいますが箇条書きでいくつか利用方法等をご紹介させてもらいます。 workspace の中では コンパイル 成果物が格納される target ディレクト リは workspace 直下に配置されます。(上記例だと application_name/target ) Cargo.lock も同様に workspace 直下に配置されます。これにより workspace 配下の crate が依存する crate のバージョンを保証しています。 workspace を利用しているときも通常のプロジェクトと同様に cargo コマンドでビルドを行うことが出来ます( cargo check , cargo build , cargo run ...etc) workspace 配下の crate にカレント ディレクト リを変更してビルドを行った場合その crate を対象にビルドができます。 カレント ディレクト リを変更したくない場合は --package オプションを使ってビルドも可能です( cargo check --package domain ) The Rust Programming Language ch14-03 The Cargo Book 最後に 私達が開発するアプリケーションは現在16 crateまで分割しています。正直まだ分離させられる余地もあり、成長と共にビルド時間が増えたり、保守観点から分離すべきタイミングで分けるべきだと考えています。 また、参考までにRust製のservice meshである linkerd2-proxy を確認すると55のcrateから構成されています。 このようにアプリケーションが成長し規模や複雑さに応じて簡単に workspace を使って分離できるのはかなり有用かと思っています。 ある程度の成長が予測されるアプリケーションなどは最初から workspace の構成を考えておくなどしておくと良いと思います。 参考: linkerd2-proxy
こんにちは、CADDi でフロントエンドエンジニアをしている桐生です。 少し前になりますが Deno 1.0 がリリース され話題になったかと思います。まだ記憶に新しい方も多いのではないでしょうか。タイムリーにも 隅田川.js #1(オンライン) にてLTをさせて頂く機会があったので、話題の Deno について発表してきました。 本ブログはこの発表内容をブログ化したものになります。(一部、可読性向上のため内容を改変しています) 目次 [toc] Deno とは Deno ざっくり Deno は Node.jsの作者である Ryan Dahl 氏が開発している TypeScript/JavaScript ランタイムです。 https://deno.land/ のトップによると V8, Rust, Tokio 上に構築された、TypeScript/JavaScript ランタイム デフォルトではファイルやネットワークへのアクセスが禁止されていてセキュア デフォルトでTypeScriptをサポート ビルトインでコードフォーマッタや各種ユーティリティを備えている といった特徴があります。 Node.js との違い V8 上の JavaScript ランタイムといえば Node.js です。Node.js があるにもかかわらず、なぜ新しい JavaScript ランタイムが必要なのでしょうか?背景には、Ryan Dahl氏の Node.js における設計ミスへの後悔 があります。興味のある方は見てみてください。 Comparison to Node.js によると npm を使わない。module への URL か file path を使う。 package.json を使わない。 require() を使わない。Es module を使う。 import * as log from " https://deno.land/std/log/mod.ts "; 全ての非同期アクションは Promise を返す。 file, network, environment への明示的な許可をが必要 Uncaught error で停止する といった違いがあります。 Deno で試したこと そんな Deno について、習うより慣れろということでいろいろと試してみました。 Getting Started をなぞる まずは https://deno.land/manual/getting_started に倣って、インストールと実行をやってみました。 $ brew install deno $ deno run https://deno.land/std/examples/welcome.ts Welcome to Deno 🦕 やったことはこれだけで非常に簡単でしたが、見事 Deno の第一歩を踏み出すことができました。 続いて HTTP Request を伴うモジュールの実行を試してみました。 $ deno run https://deno.land/std/examples/curl.ts https://example.com error: Uncaught PermissionDenied: network access to "https://example.com/", run again with the --allow-net flag at unwrapResponse ($deno$/ops/dispatch_json.ts:43:11) at Object.sendAsync ($deno$/ops/dispatch_json.ts:98:10) at async fetch ($deno$/web/fetch.ts:591:27) at async https://deno.land/std/examples/curl.ts:3:13 残念ながらエラーとなってしまいました。なぜでしょうか?冒頭でお伝えしたとおり、Deno はデフォルトで、ファイルやネットワークへのアクセスを禁止しているからです。この場合は --allow-net オプションを追加することで実行することができるようになりました。 $ deno run --allow-net https://deno.land/std/examples/curl.ts 他にもたくさんの --allow-xxx オプションがあり、用途に応じて追加することができます。 $ deno run -h OPTIONS: -A, --allow-all Allow all permissions --allow-env Allow environment access --allow-hrtime Allow high resolution time measurement --allow-net=<allow-net> Allow network access --allow-plugin Allow loading plugins --allow-read=<allow-read> Allow file system read access --allow-run Allow running subprocesses --allow-write=<allow-write> Allow file system write access ... 例えば複数追加する場合は、このような指定になります。 $ deno install \ --unstable \ --allow-net \ --allow-read \ --allow-write \ --allow-run \ -f -n das \ https://deno.land/x/deno_app_setuper/cli.ts 環境構築をする Getting Started のあと、環境構築を行うことにしました。私は普段 VSCode を使っているので、Deno 用の VSCode Extension をインストールすることにしました。(2020/5/19時点) そして、ワークスペースに .vscode/.settings.json を用意して Deno Extenstion を有効化しました。 { "deno.enable": true } 私が行った環境構築は以上で終わりです。 注意: 2020/6月現在、上記の extenstion は deprecated になっていました。代わりに以下が公式で出ているので、こちらをインストールしましょう。 これ以外にも shell autocomplete の設定や、Jetbrains や Vim など向けのプラグインも提供されているので、お好みでセットアップしましょう。 https://deno.land/manual/getting_started/setup_your_environment Style Guide を読む 基本的な実行と環境構築が終わり、コードを書ける状態になりました。しかし、どう書くのがベターか知っておきたいと思い、Style Guide を読むことにしました。 https://deno.land/manual/contributing/style_guide 一般 TypeScript を使う。 module という言葉を使う。library や package という言葉は使わない。 メタプログラミングは推奨しない。 依存は最小に。循環参照を避ける。 呼称のブレをなくし module で統一する、というのはいいなと思いました。 ファイル命名規則まわり ファイル名の区切りには underscore _ を使う。 ex) foo-bar.ts ではなく foo_bar.ts _foo.ts など underscore から始まるファイル名は、internal module なので import してはいけない index.ts / index.js を使わない。 もしエントリーポイントが必要なら mod.ts を使う。 ファイル名の単語の区切りにはハイフンではなくアンダースコアを使いましょうということで、私自身、普段ハイフンで区切ることが多いので、これは注意しておかなくてはと思いました。 またエントリーポイントの名前には index ではなく Rust に倣い mod を使おうというのも新鮮でした。 テストまわり Module が公開している機能にはテストを書く。foo.ts -> foo_test.ts Unit Test は明示的であるべき。 コードスタイルまわり module には copyright header を入れる。 TODO コメントには Github の Issue や author name を入れる。 公開機能には JSDoc を書く。 関数の必須引数は最大で2つまで。オプション引数はオブジェクトとして渡す。 オプション引数だけが唯一、”プレーン”オブジェクトである。必須引数はプレーンオブジェクトとは区別ができる必要がある。(Array, Map, Date, class MyThing) トップレベル関数には function を使う。Arrow function はクロージャーに限定する。 個人的に気になったのは下3つで、module を設計・実装するにあたって具体的な指針になると思いました。 Deno では関数のインターフェースを厳格に定めており、関数は必須パラメーターを最大2つまで指定できるとしています。さらに、必須でないその他の引数は全てオブジェクトに入れることとしています。このようにデザインすることで、オプションの位置が変更された場合にも、下位互換性を維持しつつ進化することができることのことです。 具体例を出しつつ見ていきましょう。 関数の必須引数は最大で2つまで。オプション引数はオブジェクトとして渡す。 // BAD: optional parameters not part of options object. export function resolve( hostname: string, family?: "ipv4" | "ipv6", timeout?: number ): IPAddress[] {} 上記のコードはどこが悪いのでしょうか? 引数 family , timeout はオプション引数ですが、オブジェクトとして提供されていません。 // GOOD. export interface ResolveOptions { family?: "ipv4" | "ipv6"; timeout?: number; } export function resolve( hostname: string, options: ResolveOptions = {} ): IPAddress[] {} 引数 family , timeout を含む ResolveOptions として提供するのがよいとされます。 オプション引数だけが唯一、”プレーン”オブジェクトである。必須引数はプレーンオブジェクトとは区別ができる必要がある。(Array, Map, Date, class MyThing) // BAD: `env` could be a regular Object export interface Environment { [key: string]: string; } export function runShellWithEnv( cmdline: string, env: Environment ): string {} 一見問題なさそうですが、 Enveronment は単なる key-value オブジェクトなので、実質的にオプションオブジェクトと同意ですが、必須オプションのように見えてしまいます。 // GOOD. export interface RunShellOptions { env: Environment; } export function runShellWithEnv( cmdline: string, options: RunShellOptions ): string {} RunShellOptions というオプションオブジェクトを定義し、その中に Enveronment を入れることで、オプションであることが自明となります。 これは陥りがちなポイントかもしれません。注意深く設計していく必要がありそうだと感じました。 トップレベル関数には function を使う。Arrow function はクロージャーに限定する。 // BAD export const foo = (): string => { return "bar"; }; // GOOD. export function foo(): string { return "bar"; } 確かに function を使ったほうが、関数であることが自明であったり、 function.name で名前を取得できたりといった利点があったりするので、よいルールだと感じました。 私は普段 React で functional component を Arrow function で書きまくっているので、気をつけたいポイントです。 Deno API を眺める 続いて、Deno のAPI を見てみようと思い眺めてみると、基本的に Web compatible に設計されているおかげで、おなじみのAPIがたくさんありました。API名を見ただけでどんな処理ができるのか大体のあたりがついたので、一旦おいておくことにしました。普段 Web をやっている者にとっては、学習コストがかなり抑えられるので、非常に大きなメリットではないかと思いました。 https://doc.deno.land/https/github.com/denoland/deno/releases/latest/download/lib.deno.d.ts Third Party Modules を使う そして最後に、いざプログラミングをしようと思ったときに、サードパーティモジュールは欠かせないということで、サードパーティモジュールはどう扱えば良いかを確認してみました。 https://deno.land/#third-party-modules Web 上のどこからでも import できる Github 独自の Web Server CDN pika.dev jspm.io etc... 冒頭で説明した通り、module の import には URL かファイルパスを指定可能なので、論理的には Web上のどこからでも import が可能です。 Deno 公式サービス https://deno.land/x これは Deno で動く ES Module をホストしているサービスです。試しに、ここにホストされている moment.js を使ってみることにしました。 // moment_sample.ts import { moment } from 'https://deno.land/x/moment/moment.ts'; console.log(moment.now()); このように moment への URL を指定して import し、現在時間を出力するサンプルプログラムを作成し、実行してみると・・・ $ deno run --reload moment_sample.ts Compile file:///Users/xxx/yyy/zzz/moment_sample.ts Download https://deno.land/x/moment/moment.ts Download https://deno.land/x/moment/vendor/moment.js 1589849822499 無事実行することができました。 確かに、URL をしっかりと指定すればサードパーティモジュールを使うことができるようです。 ただし、上記のプログラムには1点問題がありました。moment の型がなぜか any となっており、せっかく TypeScript で書いているのにコード補完が効かなかったのです。 module の型の指定 この問題を解消するには、型定義ファイルを使用すればよいと考え、Deno での型定義ファイルの適用方法を調べてみました。すると、 // @deno-types="" という pragma を指定すればよいことがわかりました。 https://deno.land/manual/getting_started/typescript#compiler-hint moment 用の型定義ファイルは deno.land にはホストされていなかったため、GitHub から直接 import することにしました。さらに、型定義があったとしても、deno.land の moment を使っている限りはなぜかコード補完が効かなかったため、こちらも GitHub のものを使うように変更しました。すると、ようやくコード補完が効くようになりました。 // @deno-types="https://raw.githubusercontent.com/moment/moment/develop/ts3.1-typings/moment.d.ts" import moment from "https://raw.githubusercontent.com/moment/moment/develop/dist/moment.js"; console.log(moment.now()); 単に module への URL を指定するだけで簡単に使えると思っていたのですが、実際に期待する動作を得るまでにかなり苦労しました。 Third Party Modules まとめ その後 moment 以外にも様々な module の import を試し、いろいろと試行錯誤した結果わかったことをまとめます。 Third Party Module を使う場合 CDN や GitHub などに公開されている module の URLを指定する。 ただし、import できる module には制限あり。 Node の Module Resolution に依存していないもの(外部 module を import していないもの、あるいは、import に 明示的なパスやURLが記述されているもの)は import 可能。そうでないものは Deno がパス解決できないため無理。 TypScriptの型のサポートを得たい場合 // deno-types="" で CDN や GitHub などにホストされている型定義ファイル の URLを指定する。 ただし、サポートする型定義ファイルには制限あり Node の Module Resolution に依存していないもの(外部の型定義 を import していないもの、あるいは、import に 明示的なパスやURLが記述されているもの)は import 可能。そうでないものは Deno がパス解決できないため無理。 ex) React の 型定義ファイル 内部では以下の参照が含まれているので Deno では扱えない。 import * as CSS from 'csstype'; // パス解決できない import * as PropTypes from 'prop-types'; // パス解決できない Deno は import に 明示的なパス指定を要求するため、npm package は(Node Module Resolution に依存しているので)使えない可能性が高い。 npm の @types に相当するものはないのか? あった! https://deno.land/x/types しかし現状は React の型定義ファイルのみ。。 deno.land に置いてある module は TypeScript としてホストされているので、型定義なしでも使えるはずでは? そんなこともなかった。型がついていなかったりする。少なくとも moment に型はついていなかった。 Deno が扱える module の正しい URL を見つけ出して指定するのが辛い。 pika.dev は Deno フレンドリー。検索性が高く探しやすい。 module を import すると一緒に型定義ファイルもダウンロードできる仕組みを提供している。 余談: TypeScript の Top level "for-await" が実装されていない // server.ts import { serve } from "https://deno.land/std@0.50.0/http/server.ts"; const s = serve({ port: 8000 }); for await (const req of s) { req.respond({ body: "Hello World\n" }); } 上記は 公式サイトに載っているサンプルコード ですが、このサンプルコードの bundle は成功するものの、bundle 後の run が失敗してしまいます。 $ deno bundle server.ts server.bundle.js $ deno run --allow-net server.bundle.js error: Uncaught SyntaxError: Unexpected reserved word for await (const req of s) { なぜだろうと思い調べてみると、Issue が挙がっていました。 Top-level for-await not working in bundles #4207 この Issue のコメントを読んでいくと原因が判明しました。なんと、そもそも TypeScript で Top Level "for-await" が実装されていない、というオチでした。。 Top Level "for await" not supported, but should be #37402 ということで、しばらくは Top Level "for-await" は使わないようにしましょう。 Deno 所感 Getting Started から Third Party Module を使ってみるところまでをやってみて感じたことは、 Install からサンプルを動かすまではあっという間。 API は Web フレンドリーでとっつきやすい。 Style Guide は目を通しておいたほうがいい。 Module の import はちょっとツライと思ったが pika.dev のおかげでだいぶ和らぎそう。 という感じでした。現時点では Node.js を置き換えるほどではありませんが、将来がとても楽しみです。今後の動向に注目していきたいと思います。 というわけで、Deno 知らなかった、興味はあるけど触ったことなかった、という方はこれを期にぜひトライしてみてください。 Let’S Try Deno!
前回の記事 でデザインの基本原則をご紹介しましたが、そちらで抜けていたもう1つ重要な概念をご紹介したいと思います。 SaaSなどの多機能で複雑な機能要件を抱えるサービスを、「わかりやすく作り上げる」にはデザイナーは何を基準にUI/UXをデザインしていけば良いでしょか。 今回は人が感じる「わかりやすさ」の指標となる<概念モデル>について自分なりにまとめてみたいと思います。デジタルプロダクトのUI/UXをデザインする上で意識しておくとユーザーにとってわかりやすく、また言語化しやすいデザインができると思います。 概念モデルとは 概念モデルとは人間が対象を知覚したときに無意識的にイメージするモデルです。 例えば私たちは蛇口を見たときハンドルを捻ると水が出ることを知っています。 なぜ私たちはハンドルを捻ると水が出るとわかるのでしょう? これは私たちが「ハンドルを捻ると水が出る仕組み」を無意識にイメージしているからです。 そして「掴んで回せそうなハンドル」や「水が出てきそうな蛇口の先端」はユーザーのイメージ化を助ける手がかりとなります。 また<概念モデル>はユーザーのイメージなので、正確である必要はありません。 水道から水を飲むのに川やダムから水がどのように家庭に届けられているかまで理解する必要はないのです。 イメージ化 蛇口のように機能が単純であり、物理的に存在する製品であれば、機器が示唆する構造をイメージ化し、<概念モデル>を頭の中に形成することは比較的かんたんに行えます。 しかし、デジタルプロダクトのように複雑で多機能な機器をイメージ化するのは一苦労です。 そこでデザイナーはイメージ化を容易にする手がかりを提供しなければなりません。 例えばGUI(グラフィックユーザーインタフェース)などはその手がかりの1つと言えるでしょう。 コンピュータの画面上にフォルダのアイコンがあります。 私たちはこのフォルダアイコンをクリックすると中身のファイルが表示されることを知っています。 これはデザイナーが「現実のフォルダと、その中に収納されている書類」というアナロジーをイメージ化の手がかりとして提供し、それによってフォルダの<概念モデル>が形成され私達はフォルダシステムを利用します。 では実際のフォルダシステムはどのような構造を持っているのでしょうか。 実際のフォルダシステムは階層的に情報を保持しているわけではなく、ファイルが記憶ディスク(HDやSSD)上のどの場所に保存されているかという位置情報を保持しているというような構造になっています。 参照:教養としてのコンピュータサイエンス講義 しかしユーザーはコンピュータの論理構造を理解しなくても<概念モデル>を自分の中に形成するのとでフォルダシステムを利用することが出来ます。 このように複雑な機器とユーザーがコミュニケーションできるよう<概念モデル>の形成を手助けするのがデザイナーとしての重要な仕事です。 概念モデルを形成するのに役立つのはGUIだけではありません。 遷移の設計やマイクロアニメーション・パターン化・普遍化など多くの要素があります。 デザイナーはプロダクトを通してユーザーが良い概念モデルを形成できるように適切な情報を提供しなければなりません。 ユーザーが概念モデルを形成していく過程を素早く、楽しく行えるようにUI/UXを設計することが大切です。
はじめに こんにちは、キャディでバックエンドエンジニアとして働いている kuwana-kb です。 キャディではバックエンドで Rust を採用しています。また、設計手法としてドメイン駆動設計(以下 DDD)を取り入れています。Rust と DDD 、それぞれの解説記事は今では珍しくありませんが、 Rust と DDD をかけ合わせた記事はまだあまり目にしません。 今回は、 Rust で DDD の実装パターンをどう表現するかをまとめたいと思います。DDD に登場する概念は色々とありますが、今回はそのうちの1つである Value Object に焦点をあてたいと思います。 ※ 本記事は、2020/04/28 に開催された「 下町.rs 」にて kuwana-kb が発表した内容を記事にしたものです。 目次 はじめに 目次 DDD とは DDD の実装パターンを Rust で書いてみた 実装パターンの紹介 DDD における Value Object とは まずはシンプルに実装してみる derive を使って実装を省略する 型による制約を与える Value Object にふるまいをもたせる 幽霊型を用いて Value Object の型を区別する まとめ 参考文献 DDD とは 前提知識として、軽くDDDについておさらいしましょう。DDDとは、 Domain-Driven Design(ドメイン駆動設計) の略です。アプリケーションの扱う業務領域に焦点をあてた設計手法であり、エリック・エヴァンスが提唱しました。 DDDといえば、「エリック・エヴァンスのドメイン駆動設計」と「実践ドメイン駆動設計」という2つの書籍が有名です。これらの書籍ですが、内容はDDDの原典としてよく言及される一方で、初心者には理解するのが少しむずかしいという側面もあります。私もその難しさにやられた一人でした。 そんな中で出会ったのが 「ドメイン駆動設計入門」(著: 成瀬 允宣氏) という書籍です。 この本は、DDD に登場するモデリングとパターンの用語うち、パターンを集中的に解説した入門書です。これ 1 冊で DDD の内容を網羅しているわけではありませんが、抽象的な DDD のパターンを具体的なコードとして学ぶことができます。サンプルコードも豊富です。C# で書かれていますが、 C# を知らなくても雰囲気で読めました。 DDD の実装パターンを Rust で書いてみた DDD の理解を深めるためには自分で書いてみるのが早い、ということ Rust で書いてみました。 コードは「ドメイン駆動設計入門」のサンプルコードをベースに Rust で書き直したものです。 kuwana-kb/ddd-in-rust 実装パターンの紹介 DDD における Value Object とは 実装パターンの紹介に入る前に、Value Object について整理します。 Value Objectには、以下のような特徴があります。 システム固有の値をオブジェクトとして定義したもの ex. 金銭や製品番号など プログラミング言語にはプリミティブな値が用意されているが、業務領域で使う値をオブジェクトとして定義することで、業務ルールに反した値を混入させない また、Value Object の性質として、以下が挙げられます。 値が等しいかどうか、他と比較できる(値の等価性) 状態が不変である http://bliki-ja.github.io/ValueObject/ この性質をコードにも反映したいと思います。 まずはシンプルに実装してみる まずは、シンプルに実装してみましょう。 今回は例として、 氏名 をコードで表現してみます。 氏名 は以下のような特徴を持っているとします。 氏名 は 姓 と 名 で構成される 姓 と 名 は個別に出力できるようにする 姓 と 名 はプリミティブな型とする 以下が実際のコードです。 // サンプルコード // https://github.com/kuwana-kb/ddd-in-rust/blob/master/chapter02_value_object/src/a1_simple_vo.rs #[derive(Clone, Debug)] pub struct FullName { first_name: String, last_name: String, } impl FullName { pub fn new(first_name: &str, last_name: &str) -> Self { Self { first_name: first_name.to_string(), last_name: last_name.to_string(), } } pub fn first_name(&self) -> String { self.first_name.clone() } pub fn last_name(&self) -> String { self.last_name.clone() } } // trait PartialEqは半同値関係の性質を表す // PartialEqを実装することで「==」演算子による比較が可能になる impl PartialEq for FullName { fn eq(&self, other: &Self) -> bool { self.first_name() == other.first_name() && self.last_name() == other.last_name() } } impl Eq for FullName {} #[test] fn test_equality_of_vo() { let taro_tanaka_1 = FullName::new("taro", "tanaka"); let taro_tanaka_2 = FullName::new("taro", "tanaka"); let jiro_suzuki = FullName::new("jiro", "suzuki"); // 値が同じVOの比較。一致する assert_eq!(taro_tanaka_1, taro_tanaka_2); // equal // 値が異なるVOの比較。一致しない assert_ne!(taro_tanaka_1, jiro_suzuki); // not equal } 今回は、必要と思われるメソッドや trait を愚直に書きました。値の等価性は、 PartialEq と Eq を実装することで表現しました。また、値の不変性は、 値を immutable にする(= mut な処理を実装しない)ことで表現しました。ひとまずこれで Value Object の性質をコードで表現することができましたね。 ※ 今回は FullName の各フィールドに対して getter を実装していますが、Value Object が getter を持つべきかしっかりと検討した方がよいと考えます。無闇に getter をはやすことは、意図しない値の使われ方を招く可能性があるためです。 derive を使って実装を省略する 先程の例で Value Object を表現できたわけですが、ひとつの Value Object を定義するのにいちいち実装を書くのは手間ですね。ということで、次は derive を用いて実装を省略してみます。 // サンプルコード // https://github.com/kuwana-kb/ddd-in-rust/blob/master/chapter02_value_object/src/a2_derive_vo.rs #[derive(Clone, Debug, Getters, PartialEq, Eq)] pub struct FullName { first_name: String, last_name: String, } impl FullName { fn new(first_name: &str, last_name: &str) -> Self { Self { first_name: first_name.to_string(), last_name: last_name.to_string(), } } } derive マクロによって、記述量を削減することができました。 具体的には、 PartialEq , Eq を derive で自動実装し、 getter を Getters という derive マクロで自動実装しました。 derive マクロは、 std crate 以外にも外部 crate で定義された便利なものがたくさんあります。以下に一部をご紹介します。 derive-getters フィールドのgetterを自動実装する derive マクロ 公開したくないフィールドは skip attribute で飛ばせる derive-new コンストラクタを自動実装する derive マクロ strum enum に対する Display , FromStr を自動実装する derive マクロ derive マクロは便利ですが、時と場合によって使い分ける必要があります。 例えば、先程紹介した derive-new 。このマクロは、コンストラクタを自動生成してくれる点で便利ですが、コンストラクタ内にvalidationをはさみたい時は自分で実装する必要があります。 型による制約を与える さて、これまで登場した例は Value Object の性質を満たしているものの、業務領域特有の値は特に持っていませんでした。今回は、業務領域特有の値を型として表現し、より実践で役立つ形にしたいと思います。 今回の例では、以下のような Value Object を表現してみます。 ビジネスの要求として、 姓 と 名 はアルファベットだけにしたい 具体的には、 FullName.first_name , FullName.last_name がアルファベットしか受け付けないようにする それでは、実際のコードをみてみましょう。 // サンプルコード // https://github.com/kuwana-kb/ddd-in-rust/blob/master/chapter02_value_object/src/a3_all_vo.rs /// 氏名 // このケースでは、プリミティブだったフィールドに対して、独自型(Name)を定義している // 独自型に対して制約を与えることで、「その型である = 制約を満たした値である」ことが保証される #[derive(Clone, Debug, Getters, PartialEq, Eq)] pub struct FullName { first_name: Name, last_name: Name, } impl FullName { pub fn new(first_name: Name, last_name: Name) -> Self { Self { first_name, last_name, } } } #[derive(Clone, Debug, PartialEq, Eq)] pub struct Name(String); impl FromStr for Name { type Err = String; fn from_str(s: &str) -> Result<Self, Self::Err> { let re = Regex::new(r#"^[a-zA-Z]+$"#).unwrap(); if re.is_match(s) { Ok(Name(s.to_string())) } else { bail!(MyError::type_error("許可されていない文字が使われています")) } } } #[test] fn show_full_name() { let first_name = "taro".parse().unwrap(); let last_name = "tanaka".parse().unwrap(); // この時点でfirst_name, last_nameは型のコンストラクタがもつ制約によりアルファベットであることが保証されている let full_name = FullName::new(first_name, last_name); println!("{:?}", full_name); // FullName { first_name: Name("taro"), last_name: Name("tanaka") } } #[test] fn test_parse_name() { let valid_name = "taro".parse::<Name>(); let invalid_name_with_num = "taro123".parse::<Name>(); let invalid_name_with_jpn = "太郎".parse::<Name>(); assert!(valid_name.is_ok()); // Ok() assert!(invalid_name_with_num.is_err()); // Err() assert!(invalid_name_with_jpn.is_err()); // Err() } 今回は、 氏名 を構成する 姓 と 名 に制約を設けました。具体的には、 姓 と 名 をプリミティブな型 String から、新しく独自に定義した型 Name に置き換えています。 この Name 型は、 Name::from_str() でregexによる値のチェックをし、不正な値を弾くような実装になっています。したがって、 Name インスタンスを生成できるということは、今回のビジネス要求である「アルファベットのみの値で構成された文字列である」を満たした値になっていることが保証されます。 Value Object にふるまいをもたせる Value Object は値としての意味だけでなく、ふるまいを持つことができます。 例えば、お金に対して加算のふるまいをもたせてみましょう。 今回実装するのは以下のような Value Object とします。 Money というValue Objectを定義する このValue Objectに、加算のふるまいを実装する 今回は、同じ通貨である場合のみ加算ができるとする それでは実際のコードをみてみましょう。 // サンプルコード // https://github.com/kuwana-kb/ddd-in-rust/blob/master/chapter02_value_object/src/a4_vo_with_behavior.rs // 振る舞いを持つVO // 具体的には通貨単位が一致した場合に限り加算が可能 // // このケースでは通貨単位をフィールドの一部として定義している // 通貨単位をフィールドではなく、型として表現するケースは`a5_vo_with_phantom`参照 #[derive(Clone, Debug, new, Eq, PartialEq)] struct Money { amount: Decimal, currency: String, } // Add traitは「+」演算子による加算を表現する impl Add for Money { type Output = Money; fn add(self, other: Money) -> Self::Output { // 通貨単位のチェック // 通貨単位が一致しない場合はpanicを起こす // traitのシグネチャ上、Result型として返せないのでこれは仕方ないはず... // その意味で、通貨単位を型として表現することでコンパイル時に検査できる方が嬉しいと思われる if self.currency != other.currency { panic!("Invalid currency") } let new_amount = self.amount + other.amount; Money::new(new_amount, self.currency) } } 今回は、お金の持つ性質として、加算のふるまいを Add trait で実装しました。 また、「同じ通貨である場合のみ加算ができる」という要求を満たすため、 Money.currency というフィールドを定義し、このフィールドを加算時にチェックするようにしています。 しかし、この実装には問題があります。 それは Money.currency のチェックを add() メソッド上でしている点です。 add() メソッドは返り値がtrait上で Self::Output と指定されており、 Result 型にできません。したがって、異なる通貨で加算した時のエラーハンドリングは panic!() せざるを得ないです。panic はアプリケーションを強制終了させてしまうため、本番運用のプロダクトではなるべく利用を避けたいです。 幽霊型を用いて Value Object の型を区別する さて、先程の実装だと異なる通貨単位同士を加算すると panic してしまうという問題がありました。そこで、通貨を値ではなく型として表現することで、この問題を解決したいと思います。 ここで登場するのが幽霊型(Phantom Type)です。 幽霊型について、Rustの公式のドキュメントをみてみましょう。 幽霊型(Phantom Type)とは実行時には存在しないけれども、コンパイル時に静的に型チェックされるような型のことです。 構造体などのデータ型は、ジェネリック型パラメータを一つ余分に持ち、それをマーカーとして使ったりコンパイル時の型検査に使ったりすることができます。このマーカーは実際の値を何も持たず、したがって実行時の挙動そのものにはいかなる影響ももたらしません。 https://doc.rust-jp.rs/rust-by-example-ja/generics/phantom.html この幽霊型をラベルのような形で型のパラメータとして用いることで、同じ型も別の型としてコンパイル時に区別することができるようになります。今回の例の Money<T> 型がその一例です。 // サンプルコード // https://github.com/kuwana-kb/ddd-in-rust/blob/master/chapter02_value_object/src/a5_vo_with_phantom.rs // 振る舞いを持つVO // 具体的には通貨単位が一致した場合に限り加算が可能 // // このケースでは通貨単位を型として表現している // Money<T>のTで通貨単位を表すようにする // ここで嬉しいのは、誤った通貨単位同士の加算をコンパイル時に検査できること // Tはただのラベルとして扱いたいだけだが消費しないと怒られるので、std::marker::PhantomDataを用いる // 参考: https://keens.github.io/blog/2018/12/15/rustdetsuyomenikatawotsukerupart_1__new_type_pattern/ #[derive(Clone, Debug, PartialEq, Eq)] pub struct Money<T> { amount: Decimal, currency: PhantomData<T>, } impl<T> Money<T> { fn new(amount: Decimal) -> Self { Self { amount, currency: PhantomData::<T>, } } } impl<T> Add for Money<T> { type Output = Money<T>; fn add(self, other: Money<T>) -> Self::Output { Self::new(self.amount + other.amount) } } #[derive(Clone, Debug, PartialEq, Eq)] pub enum JPY {} #[derive(Clone, Debug, PartialEq, Eq)] pub enum USD {} #[test] fn test_phantom_money() { let jpy_1 = Money::<JPY>::new(Decimal::new(1, 0)); let jpy_2 = Money::<JPY>::new(Decimal::new(2, 0)); let _usd = Money::<USD>::new(Decimal::new(3, 0)); let result = jpy_1 + jpy_2; // コンパイルOk assert_eq!(result, Money::<JPY>::new(Decimal::new(3, 0))); // let cannot_compile = jpy_1 + usd; //コンパイルエラー } 今回は、 PhantomData で通貨単位を型として表現しました。この実装によって、異なる通貨 = 異なる型となるため、異なる通貨の加算をコンパイル時にエラー検出できるようになりました。テストを見ていただくと、 jpy 同士の加算はコンパイルが通りますが、 jpy と usd の加算はコンパイルエラーになることがわかると思います。 幽霊型を用いることのメリットは、似たような型を複数作らなくても良くなる点にあります。 例えば、通貨に関する実装として、 MoneyUsd , MoneyJpy といった形で通貨ごとに Money の型を分けることもできます。しかし、この実装方法は同じような実装が通貨分できてしまうため、あまりうれしくありません。今後扱う通貨の単位が増えた場合を考えるとなおさらです。 幽霊型であれば、通貨の単位を enum で追加するだけです。 まとめ 今回は、Rust で Value Object をどう表現できるか、についてご紹介しました。 Rust の機能によって、Value Object を豊かに表現することができました。 derive マクロ、 trait によるふるまいの実装、型システムによるコンパイル時チェックなどなど... Rustの魅力が少しでも伝われば幸いです。 また、今回のサンプルコードの元となった「ドメイン駆動設計入門」は良書です。ぜひ一緒に DDD に入門しましょう! 参考文献 ドメイン駆動設計入門 ボトムアップでわかる!ドメイン駆動設計の基本 | Naruse Masanobu https://www.amazon.co.jp/dp/B082WXZVPC/ バリューオブジェクト | Martin Fowler's Bliki (ja) http://bliki-ja.github.io/Value Object/ DDD Propaganda | Naruse Masanobu https://speakerdeck.com/nrslib/ddd-propaganda Rust で強めに型をつける Part 1: New Type Pattern | κeen の Happy Hacκing Blog https://keens.github.io/blog/2018/12/15/rustdetsuyomenikatawotsukerupart_1__new_type_pattern/
キャディでは エンジニア積極採用中! キャディでは エンジニア積極採用中!!!!! ということで、 CTO 小橋の思いが詰まった The letter from CTO を公開しました! [siteorigin_widget class="WP_Widget_Custom_HTML"]<input type="hidden" value="{"instance":{"title":"","content":"<div style=\"margin: 30px 0;\">\n\t<script async class=\"speakerdeck-embed\" data-id=\"8db0fb0f70c440d39942dd37f1a9047f\" data-ratio=\"1.77777777777778\" src=\"\/\/speakerdeck.com\/assets\/embed.js\"><\/script>\n<\/div>\n\n<p>\n\t<a href=\"https:\/\/speakerdeck.com\/caddi_eng\/caddi-the-letter-from-cto-to-all-the-engineer-applicants\"\u3000target=\"_blank\">https:\/\/speakerdeck.com\/caddi_eng\/caddi-the-letter-from-cto-to-all-the-engineer-applicants<\/a>\n<\/p>\n","so_sidebar_emulator_id":"custom_html-132510001","option_name":"widget_custom_html"},"args":{"before_widget":"<div id=\"panel-1325-0-0-1\" class=\"so-panel widget widget_custom_html\" data-index=\"1\" data-style=\"{"background_image_attachment":false,"background_display":"tile"}\" >","after_widget":"<\/div>","before_title":"<h3 class=\"widget-title\">","after_title":"<\/h3>","widget_id":"widget-0-0-1"}}" />[/siteorigin_widget] 内容は、今まで候補者の方々とお話する中で頂いた疑問を解消するようなものにしました! 例えば・・・ ・ キャディってマッチングサービス? ・ 製造業系出身のエンジニアが多いんじゃないの? ・ Technology を何に使っているの? などなど。 キャディのTech組織の現状から今後の展望までを理解して頂ける内容になっていると思います!ぜひご覧ください!
はじめに この記事は社内勉強会で発表したtypes-for-units-of-measurementの続きである。 メタプログラミングがどのように役に立つのか、弊社で使われている型レベルの単位つき演算を例に説明していく。 本記事ではメタプログラミングを主眼においているため、実際にコンパイルできるコード例をたくさん書くことを心がけている。しかし、メタプログラミングをC++17で書くと膨大になるため、コード量削減のためC++20を使って書かれている。 量の次元について 丸投げも甚だしいが、量の次元に関しては Wikipeadia に書いてあることを一読すれば分かるので読んでいただきたい。 導入 次のようなミスはコードを書いていてありがちである: // calc volume of box double volume = x * y; // ^~~~~~ // correct code: x * y * z; 物理学をかじった経験がある者ならば、量の次元が間違っていると思うだろう。 本当は3つの辺の長さを掛け合わせるところを2辺だけを掛けてしまっている。 このようなミスをどうやって防ぐのか? コードレビューで防ぐのか、はたまたユニットテストで防ぐのか。 本記事では3つ目の方法、「型レベルで防ぐ」方法について解説をしていく。 いわゆる「単位付き(次元付き)演算」はライブラリとして割と各言語に存在しており、謎の需要があるものと思われる(その割に真面目に使われているという話はあまりきいたことがない)。 弊社で使われているのは uom というクレートである。 しかしながら、Rustでメタプログラミングをするのはつらすぎるため本記事では通してC++で説明する (なぜRustはつらくてC++だとマシであるかについては後述する)。 基本的アイデア 厳密に型を区別したいという場合に使う技法といえば、幽霊型である。 実際に値を持たない、ただ次元を区別するためだけの型(幽霊型)を用意する。 namespace units { // 次元のための幽霊型 struct length{}; struct volume{}; } template <class Q, class UnderlyingType> struct quantity { UnderlyingType value; }; int main() { quantity<units::length, double> x={1}, y={2}, z={3}; quantity<units::volume, double> volume = x * y * z; } ここで問題になるのは、 quantity<units::length, double> を3回乗算した結果をどうやって quantity<units::volume, double> にするのかという事案である。 量というのは基本量から生成される有限生成群であり、要するに無限に単位を組み立てることができる。 実際に使われる次元だけでも100以上はあり、いちいち変換を定義するのはナンセンスである。 一番簡単な方法 ある量体系における任意の量$[Q]$はベクトルの基底(量体系における基本量)$d_i$を用いて [ Q ] = a_1 d_1 + a_2 d_2 + a_3 d_3 + ... + a_n d_n \\ (i = 1, 2, 3, ..., n) \\ という基底の元の有限線型結合として表すことができるベクトルである。 このことから、MKS単位を例にし \begin{aligned} d_1 &= length \\ d_2 &= mass \\ d_3 &= time \end{aligned} とすると namespace units { template <int Length, int Mass, int Time> struct MKS {}; using length = MKS<1,0,0>; using area = MKS<2,0,0>; using volume = MKS<3,0,0>; template <class, class> struct add; template <int L1, int L2, int M1, int M2, int T1, int T2> struct add<MKS<L1, M1, T1>, MKS<L2, M2, T2>> { using type = MKS<L1+L2, M1+M2, T1+T2>; }; template <class T, class U> using add_t = add<T, U>::type; } template <class Q, class UnderlyingType> struct quantity { UnderlyingType value; }; #include <concepts> template <class D1, class T1, class D2, class T2> requires std::common_with <T1, T2> auto operator*(quantity<D1, T1> lhs, quantity<D2, T2> rhs) -> quantity<units::add_t<D1, D2>, std::common_type_t<T1, T2>> { return { lhs.value * rhs.value }; } int main() { quantity<units::length, double> x={1}, y={2}, z={3}; quantity<units::volume, double> volume = x * y * z; } のように書くことができる。 異なる量の掛け算においては、それぞれの次元標数を足し合わせ、新しい型を作ればいいのである。 割り算に関しては逆数を定義しておいて掛け算に変換すればよい。 よく使う量の次元に関しては上記のコードのようにエイリアスを定義しておけばユーザーエクスペリエンスに関しても隙がない。 有理数の次元標数 量体系がベクトルであると述べた際、係数が何であるかということについて言及せずになんとなく整数ということにしてしまっていた。 よく考えると、分数の場合もある、べき乗根だ。 次元のべき乗根にも対応する。 そうすると、コンパイル時に有理数を扱う必要がある。 ゼロから構成するとなると 型レベル整数を構成する 型レベル整数に演算を実装して体を構成する 型レベル整数を2つ使って型レベル有理数を構成する という壮大な作業が必要になる。 明らかにやる気にならない。 C++の利点をここでひとつ明らかにしよう。 C++では整数リテラルを型に直接埋め込む事ができる。 この言語機能を利用して、 標準ライブラリ に型レベル有理数が存在している。 サードパーティのライブラリすら必要ないのである! 次のように改良すればよい。 #include <ratio> namespace units { template < class Length = std::ratio<0>, class Mass = std::ratio<0>, class Time = std::ratio<0>> struct MKS; template <int N1, int D1, int N2, int D2, int N3, int D3> struct MKS< std::ratio<N1, D1> // Length , std::ratio<N2, D2> // Mass , std::ratio<N3, D3> // Time > {}; using length = MKS<std::ratio<1>>; using area = MKS<std::ratio<2>>; using volume = MKS<std::ratio<3>>; template <class> struct sqrt; template <class L, class M, class T> struct sqrt<MKS<L, M, T>> { using type = MKS<std::ratio_divide<L, std::ratio<2>>, std::ratio_divide<M, std::ratio<2>>, std::ratio_divide<T, std::ratio<2>>>; }; template <class T> using sqrt_t = sqrt<T>::type; } template <class Q, class UnderlyingType> struct quantity { UnderlyingType value; }; #include <concepts> #include <cmath> template <class D, class T> requires requires (T x) { std::sqrt(x); } auto sqrt(quantity<D, T> x) -> quantity<units::sqrt_t<D>, decltype(std::sqrt(std::declval<T>()))> { return { std::sqrt(x.value) }; } int main() { quantity<units::length, double> x = {1}; quantity sqrt_x = sqrt(x); } これだけでも十分実用性がある用に見えるが、実際にはそうでもない。 次回、上級編 実際に用いられているライブラリの設計がどうなっているのかを紹介し、なぜそのような設計になったのかを考察する。 そして、それを可能にするメタプログラミング技法について解説する。
陰関数による形状表現 (1) のつづき 前回は円領域を例にとって、 パラメトリック 表現と陰関数表現を比較しました。そこで分かった陰関数による形状表現の利点は「集合演算がメチャ簡単!」でした。 陰関数表現にはもう一つ重要な利点があるので、それを紹介していきましょう。今回は多角形領域を例にとってみます。 多角形領域を頂点列で表すと起きる問題 しばらく陰関数のことは忘れて、このような多角形領域を n 個の頂点座標で表すことを考えてみます。つまり上図の五角形の領域を、半時計回りに並んだ5つの頂点座標で表します。この多角形領域を下記のように $P(\cdots)$ という形式で表記することにします。 P\left(p_0, p_1, p_2, p_3, p_4 \right), \quad p_i=(x_i, y_i)\in R^2 重要なポイントが2つあります。 $P(\cdots)$ は多角形の輪郭線を表しているのではなく、塗りつぶされた多角形領域を意味しているとします。 頂点列は反時計回りに並んでいるものとします。(もし時計回りに並んでいたら、その領域は多角形の内側ではなく外側の領域を表しているとします。あるいは、多角形の面にオモテとウラがあるとして、時計回りに並んでいるときは多角形が裏返っているとみなす流儀もあります。いずれにせよ、このようにループの向きに意味をもたせるのはよく用いられる手法です。) この表現、一見何の問題もないように思えて、実は面倒なことが起きます。頂点列が次の図のように並んでいたらどうなるでしょう。 このように多角形に自己交差があると、ループの向きが時計回りか反時計回りか定まりませんし、領域が単純には定まらなくなってしまいます。自己交差の交点で領域が2つに分割するとか、分割して小さい方の領域は無視するとか、考えようはあるかもしれませんが、面倒な処理が増えることは間違いありません。 このような面倒事が、陰関数表現では生じないのです。 多角形領域を陰関数で表す では陰関数で多角形領域を表現する方法を考えてみましょう。まずは簡単な例として、次のような一辺の長さが 1 の正方形を陰関数で表現してみます。 S = \{(x,y) \mid 0\le x\le 1,\ 0\le y\le 1\} 簡単に復習しておくと、陰関数による形状表現では、陰関数 $f : R 2 \mapsto R $ によって図形を次のように定義するのでした。 D(f) = \{ (x, y) \mid f(x, y) \le 0 \} では次の陰関数で表現される図形(領域)は分かりますか? f_1(x, y) = -x 答えは、$x\ge 0$ の半空間です。 同様に、次の陰関数も考えてみましょう。 f_2(x, y) = x - 1 こちらは $x \le 1$ の半空間になりますね。 では、$0\le x \le 1$ の領域はどのように表現できるでしょうか。これは $f_1$ による半空間と $f_2$ による半空間の集合演算で表されます。 D(f_1)\cap D(f_2) = D(\max(f_1, f_2)) $y$ 方向についても同様に同様に考えることが出来ます。従って、従って正方形領域 $S$ は次のように表されることになります。 次の4つの陰関数を用意すると、 \begin{array}{lll} f_1(x, y) &=& -x \\ f_2(x, y) &=& x - 1 \\ f_3(x, y) &=& -y \\ f_4(x, y) &=& y - 1 \end{array} 正方形 $S$ は次式となります。 \begin{array}{ll} S &= D(f_1) \cap D(f_2) \cap D(f_3) \cap D(f_4) \\ &= D(\max(f_1, f_2, f_3, f_4)) \end{array} 同様のアプローチで、任意の凸多角形領域が表現できることが、容易に推測できますね。 凸でない多角形領域についても、集合演算で作り出すことが出来ます。2つの重なり合う凸多角形の和集合や差集合を想像してみれば、凸でない多角形領域が作り出せることはすぐに分かるでしょう。 さて、陰関数表現のメリットの話に戻りましょう。 頂点列で多角形を表す方法では、自己交差が存在すると面倒なことになるのでした。陰関数による表現でも同様の面倒事が生じうるかどうか、考えてみてください。 そう、陰関数表現では、どんな関数を与えても図形が定義 不能 になってしまうことはありません。この ロバスト な性質は、次の2つの観点で大変好ましいものです。 演算誤差や近似誤差に対して ロバスト である。 3Dで物体形状を表現したとき、実体化が不可能なあり得ないデータが生じ得ない。 前者の誤差の問題は、実務者にとっては非常に重要でクリティカルな問題なのですが、どうしても地味な議論になってしまうのでここで深入りするのは控えます。 ここでは後者の、3D の形状表現への応用例を「チラ見せ」して、この記事を終えることにしましょう。 3Dへの応用例 陰関数を3Dに拡張 陰関数表現は、容易に3Dに拡張可能です。$f(x, y, z)$ という陰関数 $f:R 3 \mapsto R$ によって、3D形状を次のように定義します。 D(f) = \{(x, y, z)\in R^3 \mid f(x, y, z) \le 0 \} 集合演算も、今までの2Dの議論と同様の式で定義できます。 三角形メッシュ 一方、3Dにおいて「頂点列による多角形」に相当するものは、三角形メッシュです。こういうやつですね。 三角形メッシュは、「頂点列による多角形」と同様の問題(自己交差)以外にも、面倒な問題が起こり得ます。その問題とは、主に次の3つです。 自己交差(多角形と同様の問題) 穴 非 多様体 (ここでは詳細は説明しません) これらは何が問題なのでしょう。 3Dプリンタ という機械をご存知でしょうか。三角形メッシュのデータから物体を造形する装置です。その 3Dプリンタ に、上記の問題を含むメッシュデータを入力した場合を想像してみてください。 3Dプリンタ は正常に動くでしょうか?意図したとおりの形状が造形されるでしょうか?答えはNoです。 もし物体の形状をメッシュではなく陰関数によって表現すれば、このような問題は決して生じません。この著しい特性の応用例として、下記に紹介する Mesh Reconstruction が挙げられます。 Mesh Reconstruction 3Dスキャナーとか3次元測定器などと呼ばれる機械があります。この機械は、物体の3次元形状を計測して、大量の測定点の集まり=点群(Point Cloud)を出力します。こういうやつです。 この計測点群からメッシュを生成したいというニーズがあります。そのような処理を Mesh Reconstruction と呼びます。 Mesh Reconstruction の アルゴリズム には、大きく分けて2系統のアプローチが存在しています。1つは、計測点群で得られた点と点を三角形で結んで、メッシュを構築するアプローチです。当然ながら先述したような問題を含まないメッシュが生成されることが望ましいのですが、計測点群は必ず誤差やノイズを含むものですし、このアプローチでは生成されるメッシュが問題を含まないことを保証するのは本質的に難しいといえます。 2つ目のアプローチが、陰関数を利用したものです。計測点群から直接メッシュを生成するのではなく、いったん陰関数による形状表現を経由して、間接的にメッシュを生成します。陰関数を経由することによって、不正なメッシュが生成されないことを保証できるのです。 なお、蛇足ながら補足しておくと、Mesh Reconstruction の手法として必ずしも陰関数の方が優れているとは言い切れません。陰関数を経由すると、元々の計測点群に含まれている計測誤差に加えて、陰関数による近似誤差が乗ってしまいます。このため、測定の用途では陰関数による手法はあまり好まれないようです。一方、3Dスキャンの用途では陰関数が広く利用されています。 さて、次は… 次に何を書くかまでは決めてないので、どうなるかは分かりませんけども。 最後にチラッと、「陰関数を経由してメッシュを生成する」といった話題を出しました。物体形状を陰関数で定義しても、最終的にはメッシュに変換する処理が必要になることが多いのですが、このあたりに陰関数の厄介さがあります。その辺を紹介できるといいかな、と思いつつ。
UI/UXデザイナーのモリです。 今回はプロダクトのUI/UXをデザインする時に、僕が常に心がけている「3つの基本原理」を、普段愛用しているセブンカフェのコーヒーメーカーを例に紹介したいと思います。 ※例に出しているコーヒーメーカーですが、実際はブランドや様々な体験を考慮して制作されたプロダクトです。今回はインターフェースのレイアウトやスタイルみにフォーカスした提案として参照していただければ幸いです。 3つの基本原則とは 僕がプロダクトのUI/UXを考慮するに当たって常に意識してる基本原則とは「シグニファイア・対応づけ・フィードバック」の3つです。 因みにこれは「 誰のためのデザイン 」という本に登場する「7つの基本原理」から、特に重要だと思うもの3つを抜粋しています。ご興味ある方はぜひ一度手にとってみてください。 それではそれぞれについて解説していきます。 シグニファイア シグニファイアとは 人が対象物とどうインタラクションするかを指し示す手がかり のことです。 ここでコーヒーメーカーで考えてみましょう。 まずコーヒーメーカーの利用者のゴールはなんでしょう?利用者のゴールは目的のコーヒー(ラテかドリップか、アイスかホットか等)が購入したカップに注がれることですね。 このゴールに向かうための手がかりがシグニファイアです。「ヘンゼルとグレーテル」のパンくずみたいなものです。 では実際のコーヒーメーカーを見てみましょう。 ちょっとわかりずらいので抽象化したのもをイラストにしてみました。 このコーヒーメーカーを利用する上でパッと見て取れるシグニファイアは以下の箇所です。 カップを置く場所 ボタンを押すとコーヒーが出てくるという操作 「カップ」を指定の場所におき「ボタン」を押せばコーヒーが出てくるというのは明確です。これは「カップを置く」という行為と「ボタンを押す」という行為のシグニファイアが適切ということです。 逆にアイスとホットの選択、コーヒーのサイズ選択、これはちょっとわかりづらいですね、 ではシグニファイアを適切にデザインし直してみましょう。 変更後 アイスとホットはよりシグニファイアが強くなるようにテキストと合わせて色でも表現しました。 またボタンに表記してあるR(レギュラー)とL(ラージ)を一瞬で理解するには熟練の技が必要でしょう。そこでカップのアイコンにして大きさを表現してみました。 このようなインタラクションの手がかりをシグニファイアと呼びます。 対応づけ 対応づけとは 操作すべき対象との関係 です。 水道の蛇口とバルブのように操作すべき対象との関係が明白であることが「良い対応づけ」の条件です。 コーヒーマシンはどうでしょう? カフェラテとドリップコーヒーの対応づけは良さそうです、それぞれの排出口の上部にそれぞれのボタンがあります。 アイスとホットはどうでしょう?これは少し対応づけが弱いようですね、 アイスとホットは並列の関係ですが配置としては上下の関係になっています。 逆にカップサイズの対応づけは「普通・大きい」と序列がありますが並列(左から右という弱い順列とも言えます)に並んでいます。 ではこれを上記の概念モデルに照らし合わせて適切な対応づけの関係に配置し直してみましょう。 アイスとホットを並列に、サイズの「普通」と「大きい」を上下の関係に配置し直しました。 またこの対応づけにしたことで、「普通」のボタンを小さくするデザインが可能になりました。また、これによってシグニファイアを強くすることもできました。 フィードバック フィードバックとは 要求したことに対してシステムが動いているかどうか知らせる手段 です。 例えばエレベーターのボタンを押した時にボタンにランプが点灯するのも、エレベーターが何階に留まっているか知らせるのもフィードバックです。 コーヒーメーカーのフィードバックはどうでしょう? まずは自分の選択したサイズと種類が摘出されているか、そして摘出はいつ終わるのか この2点が重要なフィードバックだと思われます。 この2点がわかるようなフィードバックを加えてみましょう。 2箇所にフィードバックシグナルを加えました。 自分が押したボタンの背景の色が変わる 押したボタンの下部にプログレスバーを配置し、コーヒーの摘出が完了するまでの進捗を可視化 今どの種類のどのサイズのコーヒーがどのくらいの進捗であるかがわかりやすくなりました。また対応づけとシグニファイアを適切にすることで適切なフィードバックが送れるようになったと思います。 まとめ 僕が普段デザインしているデジタルプロダクであれコーヒマシンのようなリアルプロダクトであれ基本は同じです。 皆さんもプロダクトのUIに何か違和感があったり、どこか使いづらいんだけどどこを直したら良いかわからない!なんてことがあると思います。 そんな時はこの基本原則に照らし合わせて考えてみると改善点が明確に浮かび上がってくるので試してみてください!
はじめに Orbって何? どうやって使うの? 調べて選ぶ ドキュメントを読む Usage Examples JobとCommandの違い JobとCommandの呼び出し方 Executors リソースクラスとは Orb Source 実装する 最後に はじめに キャディでバックエンドエンジニア兼DevOpsエンジニアをやっている山下です。 今回取りあげたいのは CircleCIのOrb という機能です。 現在、開発者は、開発する際、息をするぐらい自然にOSSライブラリーを使って開発を進めますよね。 インフラに関しても、TerraformやAnsibleなどではモジュールが公開されていたりと、 少しずつですがOSSの考えが普及してきているように思います。 ただ、その中間に位置する、CI/CDの部分になると、技術スタックや開発の流れに個別性が強いからか、 あまりOSS化・共通化が進んでこなかったように思います。 そんな中、CircleCIがOrbという機能をリリースしています。 最近では、Facebook広告などで、Orbの宣伝をするほど、CircleCIとしても力を入れている機能です。 https://www.youtube.com/watch?v=B5-UWDnvKRo Orbって何? 早速、Orbについて説明すると、 Orbは簡単にいうと、CircleCIの設定を共通化するためのパッケージになります。 そして、そのパッケージを、npmやDocker Hubのようにクラウドで管理できるように、 CircleCIが レジストリー を保持しています これによって、CircleCIでCIを構築する際は、このレジストリーから必要なパッケージを選んで使用するだけで、 簡単に複雑な設定を組み込むことができるようになります。 どうやって使うの? 使い方はシンプルで、CircleCIの設定ファイルに必要なパッケージを 下記のようにインポートするだけで使用することができます。 version: 2.1 orbs: slack: circleci/slack@0.1.0 heroku: circleci/heroku@0.0.1 ただ、この状態は、コードにライブラリーをインポートしただけの状態ですので、 実際に使用するためには、定義を書いていく必要があります。 そして、使い方の詳細は各Orbによって異なるため、Orb毎のドキュメントを読む必要があります(ライブラリーと同じですね) CIでよく行う設定として、Slack通知が上げられると思うので、 今回はSlackを例にOrbを使った設定までの流れを説明していきたいと思います。 ステップとしては三つです 調べて選ぶ ドキュメントを読む 実装する 調べて選ぶ まずやるのが、 レジストリー での検索です この時、目安となるのがタグの表示です Certified と記されているものが、CircleCIが公式で提供しているもので、 Partner と記されているものが、CircleCIと パートナープログラム を締結した会社によるものです 他にも会社やコミュニティ、個人などが公開したOrbが多数存在します。 (タブを切り替えることで確認することが可能です) 基本的に、公式かパートナーのものを選びましょう。 ドキュメントを読む ドキュメント読めっていうなら、この記事読まんわい、という感じかと思いますが、 結局、最後に頼りになるのはドキュメントなので、読み方をご紹介出来ればと思います。 Slackだと こちら が公式で提供しているドキュメントになります。 公式のドキュメントの構成は大体下記の5つで、他のOrbも項目としては一緒で、構成に応じて、一部欠けていることがあります。 (これは、ドキュメントが自動生成されるためで、定義していない項目は表示されないからです) Usage Examples Jobs Commands Executors Orb Source Usage Examples 名前の通り使用例になります。 その後に書かれているJobやCommandの使用例が書いてあります。 簡単な使い方であれば、こちらを参考に実装だけで十分ですが、 細かなパラメーターやその説明を読みたい場合は、その後の二つを読みます。 JobとCommandの違い 初めて読むと混乱するのが、JobとCommandの違いです JobはWorkflowの項目で他の自分で設定したジョブと同じように実装します orbs: slack: circleci/slack@x.y.z version: 2.1 workflows: your-workflow: jobs: - your-original-job - slack/approval-notification: message: Pending approval webhook: webhook Jobは簡単な設定をするだけで使えるため大変便利ですが、細かな設定をジョブの途中に差し込みたい場合は不便です。 その時に使うのがCommandです。 Commandは、Jobの設定を行う際に使用します。 steps以下に、他に設定したい項目と合わせて設定し、自分の使い方にあったジョブを作成することができます。 jobs: build: docker: - image: <docker image> steps: - your-original-step - slack/notify: channel: CHANNELID color: '#42e2f4' mentions: 'USERID1,USERID2,' message: This is a custom message notification webhook: webhook orbs: slack: circleci/slack@x.y.z version: 2.1 workflows: your-workflow: jobs: - build JobとCommandの呼び出し方 呼び出し方としては、JobもCommandも一緒で 最初に下記のようにインポートした際のキーを使って、 slack/${JOB_NAME} や slack/${COMMAND_NAME} とすれば使用可能です orbs: slack: circleci/slack@x.y.z この キーは自由に設定することができる ため、下記のように設定したら、 my_slack/${JOB_NAME} という風に使用します このように、 Orb名ではなく自分が設定したキー名で指定する ことを忘れないでください orbs: my_slack: circleci/slack@x.y.z Executors Executorsは実行時に使用するDocker Imageを指します。 その他パラメーターを設定できることもあり、imageのtagや言語バージョン、executorのリソースクラスの変更などが設定出来るようにしていることが多いです。 (サンプルに gcp-cliのOrbのリンク を載せておきます) 使い方としては、何かと使う機会の多いリソースサイズだと下記のように設定します orbs: slack: circleci/slack@x.y.z version: 2.1 workflows: your-workflow: jobs: - slack/approval-notification: executor: name: slack/alpine resource_class: large message: Pending approval webhook: webhook リソースクラスとは リソースクラス とは 名前の通り、実行環境のリソースを定義するものです。 何だか、CIでのビルド時間が長くなってきたな、という場合などに使用します。 実は、20CPU, 40GB RAMまで拡張することが可能だったりします。 (ただ、お財布はオートスケーリング出来ないので注意して使いましょう) Orb Source 最後に、Orb Sourceですが、これは名前の通り、Orbのソースコードになります。 内部で何を実装しているか、詳細を知りたい時に使用します。 ドキュメントが少ない時に使用すると便利です。 ここら辺も他の言語のライブラリーなどと同じですね。 ただ、Orbの場合、あまりコードが長くなることが少ないので、全部読んでもそこまで時間は掛からないです。 因みに、何だか、ぐちゃっとしてて読みにくいなぁ、という印象を受けられるかと思いますが、 これは、CirlceCIのOrbをファイル分割して実装しても、レジストリーに公開する時にBundleする必要があるためです。 そのため、大規模なOrbの場合は、Githubレポジトリーを探して読むと良いです。 (そう、Gitレポジトリーへのリンクを定義出来ないのです。対応してくださいCircleCIさん >_<) 後、実は非常に役立つタイミングとしてはオリジナルのOrbを実装する時に、どんな実装方法をすると良いのかの参考になります (案外、地道に書いているな、ということも多いですが) 実装する お気付きかと思いますが、ドキュメントが読めれば、 後はただ自分が使いたいOrbを複数持ってきて、自分好みに実装していくだけです! 良いOrbライフを! 最後に キャディでは、このOrbを自分たちで実装して、最適なフローを組織全体に即座に展開できるようにしています。 実は、この記事では、そうした独自Orbの実装方法や展開・運用までを書こうと思って書き始めたのですが、 書き始めるとあっという間に膨張してしまったので、一旦、こちらで終えて次の記事に書きたいと思います。 キャディでは、 言語や設計で最新のやり方を取り入れている だけでなく、インフラ領域においても積極的に新しい取り組みを行っております。 絶賛 SREポジションでも募集中 ですので、少しでも興味を持った方は、お話に聞きに来て頂ければ幸いです!
はじめに テクノロジー本部バックエンド開発グループの狭間です。所属はバックエンドですが、フロントエンドやインフラなど色んなことをやらせてもらってます。 今回はファイルの処理について書こうと思います。それなりに大きなファイルをオンラインで処理しようとするとタイムアウトや負荷の問題もあり、バックグラウンドで実行したいとは思うのですが、インフラの準備等を考えると結構手間だったりします。そういった手間をマネージドサービスの組み合わせで省けそうというのが今回の記事になります。 今回はFirebaseにホスティングされたフロントエンドからCloud Storageにファイルをアップロードし、その結果をCloud Firestoreに保存し、UIに表示するアプリケーションをサンプルとして実装してみました。 Firebaseの設定 プロジェクトの作成などFirebaseに関する操作は公式のドキュメント等にまとまっていると思うので、ここでは何を設定したかレベルの記述に留めます。 今回フロントエンドはReactを使うので、Webアプリを作成しました。データベースはCloud Firestoreを使用しました。認証はなしでもよかったのですが、後々使うこともあるかと思い、Googleの認証を設定しました。 認証を使用するのでデータベースは認証ユーザーのみ読み書きできる設定にしました(もっと細かく設定できますが、サンプルなので今回はこれで)。公式のままですが、下記のようなルールを設定してあります。 service cloud.firestore { match /databases/{database}/documents { match /{document=**} { allow read, write: if request.auth.uid != null; } } } クライアントの実装 今回フロントエンドはReactで実装しました。Firebase関連の操作には React Firebase Hooks を使いました。 まずはCreate React Appでアプリケーションを作成します。TypeScriptを使いたかったので今回は下記のコマンドで作成しました。 npx create-react-app my-app --template typescript 雛形ができたらまずはFirebaseの初期化処理を追加します。 index.tsxに下記初期化処理を追加しました。Firebaseの設定値はprocess.envから取得するようにしました。 import React from 'react'; import ReactDOM from 'react-dom'; import './index.css'; import App from './App'; import * as serviceWorker from './serviceWorker'; import * as firebase from 'firebase/app'; const firebaseConfig = { apiKey: process.env.REACT_APP_API_KEY, authDomain: `${process.env.REACT_APP_PROJECT_ID}.firebaseapp.com`, databaseURL: `${process.env.REACT_APP_PROJECT_ID}.firebaseio.com`, projectId: process.env.REACT_APP_PROJECT_ID, storageBucket: `${process.env.REACT_APP_PROJECT_ID}.appspot.com`, messagingSenderId: process.env.REACT_APP_MESSAGING_SENDER_ID } // Initialize Firebase firebase.initializeApp(firebaseConfig); ReactDOM.render(<App />, document.getElementById('root')); serviceWorker.unregister(); 認証まわりの処理 これで初期化できたので、次は認証の処理を追加します。 実際に使うとなったら認証がないと困るのでまずは認証を実装しました。 React Firebase Hooksのサンプル と Firebaseの公式ドキュメント を参考に作ってあります。 import React from "react"; import "./App.css"; import firebase from "firebase"; import { useAuthState } from "react-firebase-hooks/auth"; import ExampleDataViewer from "./ExampleDataViewer"; const App: React.FC = () => { const [user, initialising] = useAuthState(firebase.auth()); const login = async () => { const provider = new firebase.auth.GoogleAuthProvider(); provider.addScope("https://www.googleapis.com/auth/contacts.readonly"); await firebase.auth().signInWithPopup(provider); }; const logout = () => { firebase.auth().signOut(); }; if (initialising) { return ( <div> <p>Initialising User...</p> </div> ); } if (user) { return ( <div> <p>Current User: {user.email}</p> {/* ↓データを表示するコンポーネント。詳細は後述。 */} <ExampleDataViewer user={user.uid} /> </div> ); } return <button onClick={login}>Log in</button>; }; export default App; データ表示部分の実装 次にデータを表示するコンポーネントを実装します。 まず var storageRef = firebase.storage().ref(); ファイルアップデート用のStorageオブジェクトを取得します。このオブジェエクトを利用してCloud Storageにアップロードします。 次にCloud Firestoreのデータへの参照をHooks経由で取得します。 データの同期はライブラリ側がやってくれるので、サーバーのデータが書き換わるとクライント側のデータも更新されます。 const [values, loading, error] = useCollectionData<Data>( firebase.firestore().collection("example"), { idField: "id", snapshotListenOptions: { includeMetadataChanges: true } } ); データをファイル単位に識別したいので、ファイルアップデート時のファイル名をUUIDにして、それをデータベースとキーになるようにしています。 const fileId = uuidv4(); const file = e.target.files[0]; const child = storageRef.child(fileId); await child.put(file); firebase .firestore() .collection("example") .doc(fileId) .set({ id: fileId, userId: user, fileName: file.name }); データを表示するコンポーネントの全体は下記のようになりました。 import React from "react"; import firebase from "firebase"; import { useCollectionData } from "react-firebase-hooks/firestore"; import { v4 as uuidv4 } from "uuid"; type Data = { id: string; userId: string; fileName: string; data: string[]; }; type Props = { user: string; }; const ExampleDataViewer: React.FC<Props> = ({ user }) => { var storageRef = firebase.storage().ref(); const [values, loading, error] = useCollectionData<Data>( firebase.firestore().collection("example"), { idField: "id", snapshotListenOptions: { includeMetadataChanges: true } } ); if (loading) { return <div>Loading...</div>; } if (error) { return <div>{`Error: ${error.message}`}</div>; } return ( <> {values && ( <ul> {values.map(value => ( <li key={value.fileName}> {value.id} {value.fileName} <ul> {value.data && (value.data.map((data, idx) => ( <li key={idx}>{data}</li> )))} </ul> </li> ))} </ul> )} <div> <input type="file" onChange={async e => { if (e.target.files?.length) { const fileId = uuidv4(); const file = e.target.files[0]; const child = storageRef.child(fileId); await child.put(file); firebase .firestore() .collection("example") .doc(fileId) .set({ id: fileId, userId: user, fileName: file.name }); } }} /> </div> </> ); }; export default ExampleDataViewer; サーバーサイドの実装 サーバーサイドは Google Cloud Storage トリガー を使って起動します。Cloud Functionsを起動するトリガーは4種類あるのですが、今回はオブジェクトのファイナライズ(オブジェクトの作成完了)を使用します。(ファイナライズ以外のトリガーは こちら ) トリガーの種類はデプロイ時に指定します。 まずは動かしてみる 今回は一番簡単そうだったNode.jsを使うことにします。 まずは公式の サンプル を動かしてみます。 特定のディレクトリにindex.jsを作り、そのディレクトリで下記の実行するだけでデプロイできます。 gcloud functions deploy helloGCSGeneric --runtime nodejs8 --trigger-resource <<プロジェクトID>>.appspot.com --trigger-event google.storage.object.finalize デプロイが終わったらフロントエンドを yarn start で起動しファイルをアップロードしてみると、デプロイしたサーバーサイドの処理が起動されます。 Firebaseの管理画面の 開発>Functions>ログ のメニューからログを確認できます。そこで console.log で出力されたファイル名等を確認できると思います。 次にファイルをCloud Storageから取得する処理を実装します。 まず firebase-admin を yarn add します。Cloud Functionsではデプロイ時に依存関係を 解決してくれるようなので 、通常のNode.jsのアプリと同じく yarn add していくだけでOKです。 ファイルを読む処理ですが、まず firebase-admin を初期化しファイルをダウンロードします。Cloud Functionsでは /tmp にのみ書き込み権限が付与されているので、 /tmp 配下にファイルを保存します。 const admin = require('firebase-admin'); admin.initializeApp(); const filePath = `/tmp/${file.name}`; await admin.storage() .bucket(file.bucket) .file(file.name) .download({ 'destination': filePath }); 保存してしまえばあとは普通に読み込むだけです。今回はテキストファイルが送信される前提で1行ずつ読むようにしました。 const stream = fs.createReadStream(filePath, 'utf8'); const reader = readline.createInterface({ input: stream }); const fileRecords = []; reader.on('line', (data) => { fileRecords.push(data); }); 読み込んだ内容をFirestoreに書き戻して完了です。 ファイル名とレコードのキーを同じにしたあるので、 admin.firestore().collection('example').doc(file.name).get(); で更新対象のレコードを取得しています。 const record = await admin.firestore().collection('example').doc(file.name).get(); console.log(record); const newValue = { ...record.data(), data: fileRecords }; 上記の処理を全部つなげるとこんな感じになります。 フロントエンドがFirestoreの内容を常に同期しているので、バックエンドから更新すればフロントエンドの表示内容も更新されます。 const admin = require('firebase-admin'); const fs = require('fs'); const readline = require("readline"); exports.readBucketFile = async (data, context) => { const file = data; admin.initializeApp(); console.log('app initialized.'); const filePath = `/tmp/${file.name}`; await admin.storage() .bucket(file.bucket) .file(file.name) .download({ 'destination': filePath }); console.log('file downloaded'); const stream = fs.createReadStream(filePath, 'utf8'); const reader = readline.createInterface({ input: stream }); const fileRecords = []; reader.on('line', (data) => { fileRecords.push(data); }); console.log(`file read ${fileRecords}`); }; まとめ FirebaseとCloud Functionsを組み合わせることで、これだけ簡単にファイル処理を実現することができました。関数の呼び出しが複数回行われることを許容できない処理など、これだけでは実現できないものもありますが、そういった制約がない場合においては有効な方法ではないかと思います。 また一つの処理を複数に分割できる場合(今回のようなテキストファイルの処理で行ごとに並列に動かせる場合など)においては Google Cloud Pub/Sub トリガー を使って並列に処理させることも可能なので、色々応用させることもできそうです。 早くリリースしてスピーディーにPDCAをまわせるということはエンジニアにとって重要なことだと思うので、こういった手法も取り入れながらより価値を出せるようになっていきたいと改めて思いました。
はじめまして。 むらみん です。CADDi に入って最初に着手したコードを書く仕事で色々とハマったのでまとめておこうかと思います。 コンテキスト CADDi では、顧客から受注した製品の製作フロー管理 (サプライチェーン管理といいます) を営業系 SaaS を用いて行っています。その中で、実際に部品の加工を行っていただくサプライパートナーへの発注は、SaaS の画面上に設置したリンクから Firebase 上にホストしたウェブアプリと Firebase Functions を通じて帳票発行を行う外部システムの API を呼び出すことで実現しています。 この Firebase Function で実現している API は、TypeScript で書かれた Express を用いた一般的な REST API です。 きっかけとなった開発案件 本来は顧客との営業活動管理を目的とした SaaS をサプライチェーン管理に利用することで、CADDi のオペレーションは様々なペインを抱えるようになりました。また、キャディがより強固な製造業のマーケットプレイスを構築するための技術的な基盤が必要だよね、という攻め寄りのニーズも生まれてきました。 (本稿では詳しく触れませんので、気になる方はぜひ カジュアル面談 へ!) そのために CADDi のソフトウェアエンジニアたちが現在取り組んでいるのが、サプライチェーン管理を行うシステムの内製化です。今回私が担当したのは、新システムが発注書帳票を発行する際に利用する Firebase 上の API の実装です。これは、旧フローでは現行利用している SaaS 上の発注情報 id が API の引数になっており、既存 API のシグニチャでは新内製システムが利用することが出来ません。そのため、従前のエンドポイントと同様の挙動を行う、異なるシグニチャを持ったエンドポイントを開発するというものでした。 どのような「技術的負債」があったか ここまでで紹介した Firebase 上に構築された SaaS の機能を補完するシステムは、過去に CTO が「突貫工事」(本人談) で作ったものでした。 私が開発に着手しようとしたときに目についただけでも、以下のような点は課題だなと感じました。 必須項目確認 null チェック、不正な id の確認などが処理の中で各所に散らばっており、どのようなケースでどのようなエラーが返るかわからなかった。 API の機能テストがなかった。 (ユーティリティなどのユニットテストはあった) 帳票システムの API を叩く部分はファサードオブジェクトになっていたが、テスト時にフェイクオブジェクトを差し込みやすいようにするなどを考慮した設計にはなっていなかった。 どう取り組むことにしたか こういった状況を踏まえて、以下の方針で新しいエンドポイントを実装してゆくことを決めました。 既存の外部 API ファサードは流用 Express に与える handler から新しく書く 項目チェックはなるべく宣言的な書き方で、1箇所にまとめる方法を模索する なるべく End to End でテストをする 外部 API ファサードはモックして、想定通りの API 呼び出しをしているかどうかを検証できるようにする REST API コールを実際に行って返り値が正しく得られるかを確認するようにする。 (そもそも express に渡す handler 単体でテストするほうが大変そうだった) 最終的にどうなったかのサンプルコード 実際のコードはお見せできないのですが、今回の取り組みを盛り込んだサンプルコードを用意してみたので、気になる方はそちらも読んでいただきながら、以降の解説を書いていきたいなと思います。 https://github.com/caddijp/firebase-functions-testing-sample 具体的なソリューション express-validator の導入 express の middleware として差し込むだけで宣言的なバリデーションが利用できるということで、ほぼ一択の選択肢と思い導入しました。 現実的な問題として、単純な必須チェックや値の境界値の確認の域を越えて、より業務的なバリデーションや相関バリデーションを導入しようとすると厳しい気もしますが、現状では間に合っているので、困ったらそのときに考えようかと思っています。 しっかりテストするためのフレームワークを揃える jest はユニットテストを書くためにすでに利用されていたので、最低限のテスティングフレームワークとしては問題なしと判断。それ以外のライブラリとして、 power-assert , supertest , firebase-functions-test への依存を追加することにしました。 power-assert に関しては説明は多く要らないかと思いますが、 assert(A === B) と書くだけで、テストが失敗したときに豊富な情報が得られるようにしてくれるアサーションライブラリです。テスティングフレームワークの用意する expect スタイルや should スタイルのアサーション DSL に精通するコストを払わずに充分な情報量を得られるメリットはやはり捨てがたいですね。 supertest は http 通信を行うテストの実行を容易にしてくれるライブラリですね。宣言的でシンプルに記述ができる点が良いと思い採用しました。 firebase-functions-test はその名の通りで firebase functions のテストを書くためのライブラリです。firebase に設定する configuration に、帳票タイトル名のプレフィクスなどが設定されているので、テスト用にスタブ値を設定する必要がありました。 jest によるモック の導入 Scala や Java に親しく JavaScript に疎いエンジニアとしては、jest でモジュールのパスを指定すればモックを差し込むことができるという jest のモック機能はとても斬新でした。何でも出来てしまうので使い方を間違えないようにする必要はありますが、なるほど JavaScript 界隈では DI の機構などもそこまで必要ないのかもな、と思った瞬間でした。 何に苦労したか bodyParser が動かない express では body に JSON を受け取ったときに、JSON を JavaScript Object にパースするミドルウェアを差し込む必要がありますが、 firebase ではリクエストの Content-Type に応じて自動でこの処理を差し込んでくれる機能 があります。が、テストコード内でリクエストを打っても、 req.body が undefined にしかならない…。 Firebase SDK のコードを読めるだけ読んで見たのですが、この bodyParser の処理を差し込んでいる場所が見当たらず、firebase のプラットフォームに載せないと動かないパーツなのかな?と推測せざるを得ませんでした。(教えて詳しい人!!) 解決策としては、 普通に express で bodyParser を挟み込んでも問題ないので、あえて明確に追加する ことで妥協することにしました。 mock の書き方が難しい! jest での mock は一通り function mock も ES6 Class Mock もやりたいことが意図通りに動くところまで持っていきましたが、 書き方は結構煩雑で難しい なぁと感じました。これは慣れていくしか無いんですかね。もし、もっと簡素な書き方があったら知りたい! 改めて、JavaScript にはクラスという概念はなくオブジェクトがすべてのオブジェクト指向言語なんだなぁと実感しました。 express-validator もうちょっと小さくテストできないのかな? express-validator のテストは、 バリデーションに引っかかるリクエストをエンドポイントから流して、レスポンスを検証するという形で実施しました。 これでも特段問題は無いのですが、バリデーションの箇所だけコンポーネントを切り出してテストすればもうちょっとコンパクトになるのかな?というのは検討しつつ断念。express のミドルウェアのテストをするために Request や Response のフェイクオブジェクトを用意することのほうが大変そうだったので、その手法は取らないことにしました。 なるべく小さくテストすることにこだわりすぎるのも良くないので、今はこれでも良いかとは思っていますが、もう少し良い方法があったら知りたいところです。 感想 私自身はTypeScript を書くこと自体には4年ほどブランクがあり、Firebase に至っては利用歴は皆無だったのですが、調べ物にちょっと時間をかけてしまったものの問題なく開発ができたので良かったです。このラーニングも組織のスキルの底上げを図る投資だと思えるのは良いチームだな、というも思いました。 今回手を入れたコードは CTO による「突貫工事」(本人談)であり、彼には事あるごとに「こんなレガシーコードを触らせてしまって申し訳ないです」と謝られていたのですが、実際に触ってみると可読性は十分あって、手を入れるのもさほど大変ではなかったのが正直な感想です。アキさん流石!という気持ちです。 宣伝 そんな CADDi は エンジニアを募集しています 。興味をお持ちいただけたらぜひご連絡ください。
半径 $r$ の円の式を考えましょう。 パラメトリック 表現はこうです。 \left(\begin{array}{c}x(t) \\ y(t) \end{array}\right) = r\left(\begin{array}{c} \cos(t) \\ \sin(t) \end{array}\right) 対して、陰関数表現はこうです。 f(x, y) = \sqrt{x^2 + y^2} - r = 0 大体分かりましたね? 大丈夫です、私も大体しか分かっていません。 関数 $f(x, y)$ に注目していきましょう。これは「原点を中心とし半径 $r$ の円からの符号付き距離」を表す関数です。 $(x, y)$ に円周上の点を入れると $f(x, y) = 0$、つまり円からの距離はゼロです。 $(x, y)$ に円の外側の点を入れると $f(x, y) > 0$ で、$f(x, y)$ は円からの(正の)距離です。 $(x, y)$ に円の内側の点を入れると $f(x, y) < 0$ で、$f(x, y)$ は円からの負の距離です。 はい、これで完全に理解()しました。$f(x, y)$ は円の外側は正、円の内側は負、絶対値は円までの距離を返す関数です。これを「符号付き距離(signed distance)」と言います。 $f(x, y) = 0$ が円周を表しているとも考えられるし、$f(x, y) \le 0$ が(中身の詰まった)円領域を表しているとも考えられます。ここでは後者の捉え方が重要です。円領域を集合の記法で書き表してみましょう。 A = \{ (x, y)\in R^2 \mid f(x, y) \le 0 \} 円領域が集合の記法で書けました。素晴らしいですね。 しかしこの表記だと、領域 $A$ が関数 $f$ から導出されるという雰囲気が伝わってきません。ちょっと改良して次のように書くことにしましょう。 D(f) = \{ (x, y)\in R^2 \mid f(x, y) \le 0 \} ここでは円を例に取っていますが、この形状表現は円に限定されません。関数 $f: R 2 \rightarrow R$ を $D$ に渡すことにより様々な図形(領域)を定義することができます。 さて、集合の記法で書くと、集合演算をしたくなりますね? というわけで、もうひとつ別の円領域を作って集合演算をしてみます。中心座標 $(c_x, c_y)$、半径 $\tilde{r}$ の円領域を作ることとし、次の陰関数を定義します。 g(x,y) = \sqrt{(x-c_x)^2 + (y-c_y)^2} - \tilde{r} すると新しい円領域は $D(g)$ と表記できます。では2つの円領域 $D(f)$ と $D(g)$ の集合演算を考えましょう。 2つの円が重なっている領域は次式となります。 D(f)\cap D(g) = \{(x, y) \mid \max(f(x,y), g(x, y)) \le 0 \} = D(\max(f, g)) \\ 2つの円領域を合わせた領域は次式となります。 D(f)\cup D(g) = \{(x, y) \mid \min(f(x,y), g(x, y)) \le 0 \} = D(\min(f, g)) \\ ただし、$\max(f, g)$、$\min(f, g)$ は次式で定義されるものとしています。 \max(f, g)(x, y) = \max(f(x, y), g(x, y)) \\ \min(f, g)(x, y) = \min(f(x, y), g(x, y)) 非常に簡単に集合演算が表現できました。ここでは円領域を例に図示していますが、円以外の形状でも集合演算は同様に定義できます。 パラメトリック 表現ではこうはいきません。同じ演算を パラメトリック 表現された2つの円で計算することを少し想像してみてください。2つの円の交点計算をし、交点が2つある場合と接する場合と交わらない場合に場合分けをし、…あーめんどくさい! もう分かりましたね。陰関数による形状表現の利点はズバリ、 「集合演算がめちゃ簡単!」 です。 実はもうひとつ利点があるので、次回はそれを説明することにしましょう。こっちのほうが重要かもしれません。とっても簡単でほとんど自明なことなのですが、もったいぶって次に回すことにします。 欠点はないのでしょうか。もちろんあります。光あるところには必ず陰があるのです、陰関数だけに(あ、今読者が減りました)。それについても追々説明することにしましょう。 さしあたって簡単のために2Dの図形で説明していますが、そのうち3Dにも拡張していきましょう。 (つづく)
対象読者さんはどのような方ですか? FFT ( 高速フーリエ変換 )の定義を知っているものの、その実装が難しそうだと感じて困っている方々です。逆に原理や有用性、理論的な子細にご興味のある方のご期待には応えられないと思います。 目標 FFT に苦手意識のあった方が、最低限動くコードを書くだけなら簡単かも? と感じてくださるまでになれたら、私はとっても嬉しいです。 離散 フーリエ変換 とは 定義は ウィキペディア にあります。(責任放棄) wikipedia: 離散フーリエ変換 今回採用する定義 最速で実装までたどり着きたいですから、理論的なところはスキップです。 $N = 2 ^ n$ としましょう。$N$ 次 多項式 を入れると $N$ 次 多項式 を返してくれる何かが フーリエ変換 です。 多項式 と言いましたが、コンピュータープログラムですから、係数を並べたものだと思ってくださると嬉しいです。 複素係数 $N$ 次 多項式 $f$ に対して、その フーリエ変換 $\mathcal F (f) = \widehat f$ は次のようになります。 \widehat f (q) = \sum _ { i = 0 } ^ { N - 1 } f( \zeta ^ i ) q ^ i いきなり数式でつらいですね。しかし大丈夫です。 $\widehat f$ は関数なのですが、関数だとではなく係数の並びだと思いましょう。定数項は $f ( 1 )$ です。次は $f ( \zeta )$、その次が $f( \zeta ^ 2 )$ です。 そうです、私たちの知りたいのは 多項式 ではなく、$f$ に $\zeta$ の冪を代入したときの値の一覧表です。 素直な方法 for-loop を知っていますか? 私は知っています。天才ですね。もっと褒めてくださっても良いんですよ? ところでこの章は、定義を確認するためのものですから、先程の章で十分に理解できた方は読み飛ばしてくださっても構いません。 さて、疑似コードです。 配列 $A$ を入力として受け取ります。 同じ長さの配列 $B$ を作って 0 で初期化です。 $i$ を $0$ から $N$ まで繰り返しです。( $B _ i$ を計算しましょう。) $j$ を $0$ から $N$ まで繰り返しです。( $j$ 次の項を足したいです。) $B _ i$ に $A _ j \zeta ^ {i j}$ を足します。 $B$ の出来上がりです。 C# で書くと、次のようになります。 static Complex[] FourierTransformation(Complex[] a) { int N = a.Length; Complex[] b = new Complex[N]; double circle = 2 * Math.PI; for(int i = 0; i < N; i++) { for(int j = 0; j < N; j++) { b[i] += a[j] * Complex.Exp(new Complex(0, i * j * circle / N)); } } return b; } フーリエ変換 デバックのコツ フーリエ 逆変換を知っていますか? 私は知っています(満面の笑み) $\zeta$ の代わりに逆数 $\zeta ^ {-1}$ を使いましょう。この変換をするとあら不思議、定数倍を除いて元に戻ってしまいます。(ちなみになのですが、標本点の数 $ N $ が 多項式 の次数よりも少ないと、うまく行きませんから、注意です。) もう少し正確なお話をすると、逆変換は次のようになっています。 \frac 1 N \sum _ { i = 0 } ^ { N - 1 } f( \zeta ^ { - i } ) q ^ i 普通の変換と見比べてみましょう。$\zeta$ がその逆数にすり替わっているところと、正規化係数 $1 / N$ が掛っているところを除けば、同じです。 逆変換は、先程の実装と殆ど同じですから、同じ関数を使いましょう。第二引数に、逆変換かどうかを入れましょう。次のように変更です。するとコードは次のようになります。 static Complex[] FourierTransformation(Complex[] a, bool reverse) { int N = a.Length; Complex[] b = new Complex[N]; double circle = (reverse ? -1 : +1) * 2 * Math.PI; for(int i = 0; i < N; i++) { for(int j = 0; j < N; j++) { b[i] += a[j] * Complex.Exp(new Complex(0, i * j * circle / N)); } } return b; } これで逆変換の完成です。 これがあれば適当な配列を作って、 フーリエ変換 をして逆変換をして、戻るかどうかを確認をすると、実装に誤りがるかどうかがすぐにわかります。 実装の前に、数学のお時間です $f$ の係数列 $( a _ i ) _ { i = 0 } ^ { N - 1 }$ を用いて、$f$ の $\zeta ^ j$ における値を求めましょう。これこそが知りたいことでした。 f( \zeta ^ j ) = \sum _ { i = 0 } ^ { N - 1 } a _ i \zeta ^ { i j } 突然のお知らせなのですが、添え字の偶奇で分解です。するとあら不思議、奇数パートが $\zeta ^ i$ で割れますね。 f( \zeta ^ j ) = \sum _ { i = 0 } ^ { N / 2 - 1 } a _ { 2 i } \zeta ^ { 2 i j } + \sum _ { i = 0 } ^ { N / 2 - 1 } a _ { 2 i + 1 } \zeta ^ { ( 2 i + 1) j } \\ = \sum _ { i = 0 } ^ { N / 2 - 1 } a _ { 2 i } \zeta ^ { 2 i j } + \zeta ^ j \sum _ { i = 0 } ^ { N / 2 - 1 } a _ { 2 i + 1 } \zeta ^ { 2 i j } こちらの数式をよく眺めてみます。前半も後半も フーリエ変換 ではありませんか!! 嬉しいですね。 というわけで、偶数次の係数ばかりを束ねた 多項式 を $g$、奇数次の係数ばかりを束ねた 多項式 を $h$ と書きましょう。ただし単項式パートは、長いですから、畳んでおきましょう。収納上手です。形式的には次です。 g( q ) = \sum _ { i = 0 } ^ { N / 2 - 1 } a _ { 2 i } q ^ i, \quad h( q ) = \sum _ { i = 0 } ^ { N / 2 - 1 } a _ { 2 i + 1 } q ^ i せっかく定義したのですから、使ってさしあげましょう。先ほど書いた $ f ( \zeta ^ i ) $ の計算式に、代入です。 f ( \zeta ^ i ) = g ( \zeta ^ { 2 i } ) + \zeta ^ i \cdot h( \zeta ^ { 2 i } ) 実装のお時間です。 先ほどの式を素直に実装すればよいです。 疑似コードです。 配列 $A$ を入力として受け取ります。 長さが $1$ ならばこのままお返しすればよいですね。 同じ長さの配列 $ FFT (A)$ を用意です。 $A$ の偶数番目だけを並べた配列を $B$、奇数番目だけを並べた配列を $C$ としましょう。 再帰呼び出し 発動です! $B, C$ の フーリエ変換 $ FFT (B), FFT (C)$ を計算していただきましょう。 マージ開始です。$i$ を $0$ から $N$ まで繰り返しです。( $ FFT (A)_i$ を計算しましょう。) 代入です。 $ FFT (A) _ i \leftarrow FFT (B) _ i + \zeta ^ i FFT (C) _ i $ です。(あらあら、添字が範囲を超えてしまします。) さて、この通りに実装をしましょう。 疑似コードでは $A$ と $ FFT (A)$ のように 2 つの配列を用意してしまいましたが、兼用で良いです。 それと注意なのですが、$B$ や $C$ は長さが半分です。そのままイテレートをしたらはみ出してしまいますね。 しかし大丈夫です、思い出してください。 フーリエ変換 というものは、$1$ の冪根の累乗を代入して並べたものです。 はみ出してしまっても、そのまま初めに戻ってくれば良いです。 こちらが C# による ソースコード です。たったの 16 行、嬉しいですね。 static void FFT(ref Complex[] a) { int N = a.Length; if (N == 1) return; Complex[] b = new Complex[N / 2]; Complex[] c = new Complex[N / 2]; for (int i = 0; i < N; i++) { if (i % 2 == 0) b[i / 2] = a[i]; if (i % 2 == 1) c[i / 2] = a[i]; } FFT(b); FFT(c); double circle = 2 * Math.PI; for (int i = 0; i < N; i++) { a[i] = b[i % (N / 2)] + c[i % (N / 2)] * Complex.Exp(new Complex(0, circle * i / N)); } } デバックだいすきクラブのみなさんのために、reverse 付きバージョンも乗せておきますね。 static void FFT(ref Complex[] a, bool reverse){ int N = a.Length; if (N == 1) return; Complex[] b = new Complex[N / 2]; Complex[] c = new Complex[N / 2]; for (int i = 0; i < N; i++) { if (i % 2 == 0) b[i / 2] = a[i]; if (i % 2 == 1) c[i / 2] = a[i]; } FFT(b, reverse); FFT(c, reverse); double circle = (reverse ? -1 : +1) * 2 * Math.PI; for (int i = 0; i < N; i++) { a[i] = b[i % (N / 2)] + c[i % (N / 2)] * Complex.Exp(new Complex(0, circle * i / N)); } } この記事でお話できなかったこと 多項式 の積が高速に計算できます。ご興味のある方はぜひとも調べてみてください。 また実は FFT は 再帰 を使わずにキレイに書くことが出来ます。計算量オーダーこそ同じなのですが、そちらのほうが速いです。 FFT で検索して見つかる解説記事では、そういうものを紹介していることが多いのですが、今回は「最速で実装にたどり着く」をテーマに据えたくて、あえて省略させていただきました。もしご興味のお有りの方が多数いらしましたら、続きの記事でご紹介できるかもしれません。
Overview 最低限のtoolchainでWebAssemblyを活用してみました。 cargo web , wasm pack , wasm-bindgen 等色々と便利なツールがありますが、あえて使わずに全部自分でゴリゴリ。便利なツール使う前に苦しさを自分で実感しないと、ツールの仕様でハマった時に自分で解決出来なくなるのではないかと思いついつい低レイヤーに手を出してしまったお話。 Today's toolchain rustc: Rustの コンパイラ , stableでも使えます。ローカルでは1.40使っています。 google - chrome : ブラウザー , 最近のバージョンなら何でもOK(厳密にはこちらを参照: https://caniuse.com/#feat=wasm) 以上となります。 cargo も使わず、本当の最低限でwasmを書いてみましょう。 Setup Install latest stable rust Install the wasm target $ rustup target add wasm32-unknown-unknown Hello world 超簡単な足し算を実装。実際 ブラウザー 上で動くWebAssemblyに関してはライブラリー関数しか用意できない。今回は本当の最低限のバイナリーを作りたいので、libstd無しで書くため組込の開発に似ていますね。。。適当にパニックも無限ループで対応 #![no_main] // this file does not contain a main function #![no_std] // we will not be using libstd #[no_mangle] // we do not want to mangle the symbol when exporting pub extern fn add(a: i32, b: i32) -> i32 { a + b } // we need to specify the panic handler because we are not using libstd use core::panic::PanicInfo; #[panic_handler] fn panic(_panic: &PanicInfo<'_>) -> !{ loop {} } $ rustc --target wasm32-unknown-unknown ./example1.rs $ stat --printf="%s\n" ./example1.wasm 651450 あれ?650kB? 確かに コンパイル は上手くいったが、謎に巨大なバイナリーはかれてしまいました。絶対何かおかしいが、これ以上低レイヤーに向かう前に軽くWebAssemblyのバイナリーフォーマットの説明をさせて下さい。 WebAssembly binary format 規格は こちら に綺麗に説明されているので省略しますが、ざっくりとセクションが12種類程あって、その一つが関数を定義するためのセクション(Function section)。 Rust Tokyo で使ったスライドを引用していますが以下のような形で、外部からインポートした関数(Import section)、外部にエキスポートする関数(Export section)、そして実際のコード(Code section)、に分かれている。その他、型を定義するType sectionや、メ モリー 領域を定義するMemory sectionがあったりする。文字列(&strとか)はData sectionに入る。 Hello world の分析 $ wasm-objdump -h ./example1.wasm [...省略...] Type start=0x0000000a end=0x00000025 (size=0x0000001b) count: 5 Function start=0x00000027 end=0x0000002e (size=0x00000007) count: 6 Table start=0x00000030 end=0x00000035 (size=0x00000005) count: 1 Memory start=0x00000037 end=0x0000003a (size=0x00000003) count: 1 Global start=0x0000003c end=0x00000055 (size=0x00000019) count: 3 Export start=0x00000057 end=0x00000082 (size=0x0000002b) count: 4 Elem start=0x00000084 end=0x0000008c (size=0x00000008) count: 1 Code start=0x0000008f end=0x000001a7 (size=0x00000118) count: 6 Data start=0x000001a9 end=0x000001fe (size=0x00000055) count: 1 Custom start=0x00000202 end=0x0002a1d3 (size=0x00029fd1) ".debug_info" [...省略...] デバッグ っぽいデータが色々あるので、その辺は吹き飛ばせるだろう。本来はFunctionセクションに1つしか関数が無いはずなのに、何故か6つもある。何が入っているのだろう。。。 $ wasm-objdump -x ./example1.wasm [...省略...] Function[6]: - func[0] sig=0 <add> - func[1] sig=1 <rust_begin_unwind> - func[2] sig=1 <_ZN4core3ptr18real_drop_in_place17h812c5b87254dd4a7E> - func[3] sig=2 <_ZN4core9panicking5panic17hb5daa85c7c72fc62E> - func[4] sig=3 <_ZN4core9panicking9panic_fmt17hdeb7979ab6591473E> - func[5] sig=4 <_ZN36_$LT$T$u20$as$u20$core..any..Any$GT$7type_id17hb5877568404f30deE> Table[1]: - table[0] type=funcref initial=3 max=3 Memory[1]: - memory[0] pages: initial=17 Global[3]: - global[0] i32 mutable=1 - init i32=1048576 - global[1] i32 mutable=0 <__data_end> - init i32=1048652 [...省略...] 使わない関数が色々と出てくるので、一旦rustc opt-level=1で コンパイル してみましょう。ちなみに、 func[0] は #[no_mangle] 指定したので関数名がただの <add> になっています。mangleしたままの add_one() 関数を入れてobjdumpするとこんな事になります: Function[7]: - func[0] sig=0 <add> - func[1] sig=1 <_ZN8example17add_one17h8f1b54fb7de5b457E> - func[2] sig=2 <rust_begin_unwind> - func[3] sig=2 <_ZN4core3ptr18real_drop_in_place17h812c5b87254dd4a7E> $ rustc -C opt-level=1 --target wasm32-unknown-unknown ./example1.rs $ stat --printf="%s\n" ./example1.wasm 131 $ wasm-objdump -d ./example1.wasm [...省略...] 00006d func[0] <add>: 00006e: 20 01 | local.get 1 000070: 20 00 | local.get 0 000072: 6a | i32.add 000073: 0b | end 131バイト!圧倒的に小さくなりましたね。分かりやすいですね。ローカル関数はスタックにpushして、i32.add instruction呼べば完了。実はwasmの設計は割とシンプルなstack machineに似た構造になっている。 Call wasm Rust function from JavaScript さて、早速WebAssemblyの add 関数 コンパイル 出来たのでウェブに埋め込んでみた。Rustからエキスポートされた関数を直接JSから呼べるので使いやすい。 <html><body><script> WebAssembly.instantiateStreaming( fetch('example1.wasm'), {}) .then(wasm => { var add_func = wasm.instance.exports.add; // use the exported 'add' function var result = add_func(10, 20); console.log(result); }); </script></body></html> Call JavaScript from wasm Rust function もちろん逆もしたいですよね。。。RustからJSの関数呼べないと。これには、WebAssemblyにJSの関数をインポートする必要がある。Rustからすると FFI 的な扱いで、そのような関数がインポート出来るという前提で書ける。もちろん、JSの関数は unsafe 扱いです! #![no_main] #![no_std] #[link(wasm_import_module = "imports")] extern { fn console_log(x: i32); } #[no_mangle] pub extern fn sayFive() { unsafe { console_log(5); } } use core::panic::PanicInfo; #[panic_handler] fn panic(_panic: &PanicInfo<'_>) -> !{ loop {} } 普段Cのライブラリとかとつなぎこみ時はlinkerいじりをしますが、WebAssemblyの場合はJSからWebAssemblyを初期化するときの引数として渡します。 <html><body><script> WebAssembly.instantiateStreaming( fetch('example2.wasm'), { imports: { console_log: (x) => console.log(x), } }) .then(wasm => { var add_func = wasm.instance.exports.add; var result = add_func(10, 20); console.log(result); }); </script></body></html> 最後に 以外と簡単にwasmの関数が作れたが、本気で使うにはもちろんオブジェクトを渡す必要があり、linear memoryを活用しないといけなく、ここまで簡単にはいかないんですよね。 wasm_bindgen を活用すれば大分楽になるんですが、wasmは相当デバッギングが難しいのは現状です。そこまでDWARF詳しくないのでこれから勉強してみたいんですが、なんか根本的にWASMのアドレスの考え方とLLDBと相性が悪いらしい。 erikmcclureさんのブログ参照
はじめに はじめまして、キャディでバックエンドエンジニアとして働いている高藤です。 キャディではRustを使ったバックエンド API を実装しています。業務ではgRPCサーバを実装していますが、今回はRustを利用した簡単なWebアプリケーションを作成し意外と簡単に API サーバが作れる事を紹介させていただきます。 今回はまだRustを触ったことない方でも記事を読み、ちょっとRustやってみようかなと思ってもらえたら幸いです。 前提 Rustの言語仕様など基本的な説明は省略させていただきます。Rust未経験であれば、是非公式のドキュメントを読んでください。 https://doc.rust-lang.org/book/ 有志による日本語訳 https://doc.rust-jp.rs/ 作るもの 今回はまず単純にHTTP Requestをすると JSON を返すサーバを実装を行います。 環境 ❯ rustc --version rustc 1.41.0 (5e1a79984 2020-01-27) プロジェクトを作成する ❯ cargo new sample-web-app Created binary (application) `sample-web-app` package ❯ cd sample-web-app 依存するcrateの定義 今回のサンプルには warp という crate を使って実装を行います。 warp は Github の冒頭に A super-easy と明記されているようにRustを触ったばかりでも比較的導入が楽だと思っています。 https://github.com/seanmonstar/warp まずは依存関係を定義します。 sample-web-app/Cargo.toml [package] name = "sample_web_app" version = "0.1.0" authors = ["nrskt <norisuke_takafuji@caddi.jp>"] edition = "2018" # See more keys and their definitions at https://doc.rust-lang.org/cargo/reference/manifest.html [dependencies] tokio = { version = "0.2", features = ["macros"] } warp = "0.2" [dependencies] 配下に2行追加しました。1つは今回メインとなる warp ,もう1つは warp が依存する tokio という crate です。 まずは Github のREADMEどおりに実装 sample-web-app/src/main.rs // 今回のサンプルが必要とする`warp.Filter` traitをimportします。 use warp::Filter; // 今回tokioのランタイムを利用する // 非同期ランタイムの上で実行されるためmain関数はasyncをつけて定義します #[tokio::main] async fn main() { // GET /hello/warp => 200 OK with body "Hello, warp!" let hello = warp::path!("hello" / String).map(|name| format!("Hello, {}!", name)); // Serverの起動 warp::serve(hello).run(([127, 0, 0, 1], 3030)).await; } 処理内容 warp::path!("hello" / String) の箇所で URL パスを定義し、 /hello/ 以下を String 型で受け取ることを宣言します。 map(|name| format!("Hello, {}!", name)) の箇所で前述のURLからString型で受け取った値と format! する処理をつなぐように宣言しています。 起動してみる ❯ cargo run ❯ curl localhost:3030/hello/nrskt Hello, nrskt! URLの末尾にある文字列を利用したResponseが返る事を確認できました。 Filter を理解する 今回利用している warp は Filter traitを実装したFilterと呼ばれる部品を組み合わせて1つの処理を作り上げる仕組みとなっています。 これらの Filter を使っていくつかサンプルを作ってみます。 #[tokio::main] async fn main() { let hello = hello().and(name()).and_then(greet_handler); warp::serve(hello).run(([127, 0, 0, 1], 3030)).await; } fn hello() -> warp::filters::BoxedFilter<()> { warp::path("hello").boxed() } fn name() -> warp::filters::BoxedFilter<(String,)> { warp::path::param().boxed() } async fn greet_handler(name: String) -> Result<impl Reply, Rejection> { let reply = format!("hello {}", name); Ok(warp::reply::html(reply)) } 先程の path! マクロで表現していた path の処理を、 hello() , name() Filterに分解し、組み合わせられる部品としました。 また最終的に処理を行うhandlerも関数をして表す事が可能です。 上記の例ではあまりメリットはありませんが、複雑な処理を小さく分解された部品を組み合わせて組み立てる仕組みが強く意識されています。 型安全 先程の例で 名前 を受け取る部分では String 型のパラメータを受け取るように処理を書いていました( fn name() -> warp::filters::BoxedFilter<(String,)> )。 このままだとどのような文字列が来ても処理を進めることが出来てしまうためhandler内で受け取った値が想定している値かValidationをする必要が発生します。 Rustでは独自の型を定義することが容易にできるため、名前を表す型を用意し、意図しない値がそもそもhandlerに渡ることを防ぐ事が出来ます。 ここでは例として名前の仕様を以下のように定義してみました。 [A-Za-z]の文字種を使い、10文字以内で表される 型の定義 /// 名前を表す型の定義 #[derive(Clone, Debug)] struct Name(String); impl Name { /// 値のチェックを行った上でNameを作成する /// 今回はサンプルのため作成の失敗をString型で表現している pub fn new(name: &str) -> Result<Self, String> { let size = name.chars().count(); if size < 1 || size > 10 { return Err("名前は10文字以内です".to_string()); } if name.chars().any(|c| !c.is_ascii_alphabetic()) { return Err("名前が使用できる文字種はA-Z, a-zです".to_string()); } Ok(Name(name.to_string())) } } /// 文字列からの変換を表す /// このtraitの実装をwarp::path::params()関数が要求する impl std::str::FromStr for Name { type Err = String; fn from_str(s: &str) -> Result<Self, Self::Err> { Name::new(s) } } /// handlerでformatを行うために要求される impl std::fmt::Display for Name { fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { write!(f, "{}", self.0) } } #[test] fn test_name() { let ok_value = "Nrskt"; assert!(Name::new(ok_value).is_ok()); let ok_value = "N"; assert!(Name::new(ok_value).is_ok()); let ok_value = "NrsktNrskt"; assert!(Name::new(ok_value).is_ok()); let ng_value = "0"; assert!(Name::new(ng_value).is_err()); let ng_value = ""; assert!(Name::new(ng_value).is_err()); let ng_value = "NrsktNrsktN"; assert!(Name::new(ng_value).is_err()); } これで新しく Name 型の定義が終わりました。 先程のコードを修正します。 fn name() -> warp::filters::BoxedFilter<(Name,)> { warp::path::param().boxed() } async fn greet_handler(name: Name) -> Result<impl Reply, Rejection> { let reply = format!("hello {}", name); Ok(warp::reply::html(reply)) } Pathのパラメータを受け取る部分の戻り値の型を String -> Name に変更します。 greet_handler の引数の型を String -> Name に変更します これによりパラメータ部分から受け取った値が Name 型の範囲になることが保証されます。 ❯ curl -D - localhost:3030/hello/0 HTTP/1.1 404 Not Found 上記の例のように Name 型で利用できない文字種が使われた際にエラーを返すようになりました。 Userを取得,保存する API を書いてみる ここからはもう少し実用的な例 としてユーザの取得と保存を行う API を実装します。 今回はRESTでよく使われる JSON を利用してRequest値とResponse値を表します。 なお、データの保存については HashMap を利用して実装を行います。 (メモリ上にデータが残るためサーバを停止するとデータは消えます。) 最終的にサンプルコードは以下の リポジトリ に公開しているので併せて確認をして下さい。 https://github.com/nrskt/sample-web-app 依存関係の修正 JSON を扱うため依存するcrateを追加するため Cargo.toml の dependencies に以下を追加します。 serde = { version ="1.0.104", features = ["derive"] } [package] name = "sample_web_app" version = "0.1.0" authors = ["nrskt <norisuke_takafuji@caddi.jp>"] edition = "2018" # See more keys and their definitions at https://doc.rust-lang.org/cargo/reference/manifest.html [dependencies] tokio = { version = "0.2", features = ["macros"] } warp = "0.2.1" serde = { version ="1.0.104", features = ["derive"] } Userの定義 models.rs #[derive(Clone, Debug)] struct User { id: u64, name: Name, } このUser型は JSON として入出力できなければならないため、 Serialize , Deserialize の特性を導出します。 まずUser型の構成要素である Name 型に Serialize , Deserialize の実装を行います。 models.rs // Serializeを追加 #[derive(Clone, Debug, Serialize)] struct Name(String); // Deserializeの実装を行う impl<'de> de::Deserialize<'de> for Name { fn deserialize<D>(deserializer: D) -> Result<Self, D::Error> where D: de::Deserializer<'de>, { let s = String::deserialize(deserializer)?; Name::new(&s).map_err(de::Error::custom) } } #[derive] で Deserialize を自動導出しなかったのは、型の制約が記述されている Name::new() を呼び出す必要があったためです。 #[derive(Deserialize)] としてしまうとどのような文字列でも Name 型に変換できてしまうためこのような実装としています。 同様に User 型に対して Serialize , Deserialize の実装を行います。 models.rs #[derive(Clone, Debug, Serialize, Deserialize)] struct User { id: u64, name: Name, } Database(HashMap)の定義 今回のサンプルではUserの情報を HashMap に残すように実装します。併せてDBの初期化を行う関数 init_db を定義します。 db.rs use std::collections::HashMap; use std::sync::Arc; use tokio::sync::Mutex; use crate::User; pub type Database = Arc<Mutex<HashMap<u64, User>>>; pub fn init_db() -> Database { Arc::new(Mutex::new(HashMap::new())) } Handlerの実装 3つのHandlerを実装します。 ユーザを全件取得する処理 ユーザIdを指定して特定のユーザを取得する処理 ユーザを新規登録、更新する処理 handlers.rs use warp::{Rejection, Reply}; use crate::{Database, User}; pub async fn list_users_handler(db: Database) -> Result<impl Reply, Rejection> { let db = db.lock().await; let users = db .clone() .into_iter() .map(|(_, v)| v) .collect::<Vec<User>>(); Ok(warp::reply::json(&users)) } pub async fn get_user_handler(db: Database, id: u64) -> Result<impl Reply, Rejection> { let db = db.lock().await; let user = db.get(&id); match user { None => Err(warp::reject::not_found()), Some(u) => Ok(warp::reply::json(&u)), } } pub async fn put_user_handler(db: Database, id: u64, user: User) -> Result<impl Reply, Rejection> { if id != user.id() { return Ok(warp::reply::with_status( warp::reply::json(&()), warp::http::StatusCode::BAD_REQUEST, )); } let mut db = db.lock().await; db.insert(user.id(), user.clone()); Ok(warp::reply::with_status( warp::reply::json(&user), warp::http::StatusCode::OK, )) } Reply を作成する際に warp::reply::json 関数を使っています。 pub fn json<T>(val: &T) -> Json where T: Serialize, 型定義の示すとおり、引数の型 T が serde::Serialize を実装していれば与えた T 型の値を JSON に変換した Reply を作成する関数です。 今回の実装では JSON での入出力を行うために利用しています。 Filterの定義 続いてFilterの定義を行います。 今回は各Handlerへのルーティングを表すFIlterを用意し、作成した3つのFilterをまとめた users_api というFilterを定義しました。 filters.rs use warp::{Filter, Rejection, Reply}; use crate::{get_user_handler, list_users_handler, put_user_handler, Database}; /// 最終的に公開するFilter /// 用意した部品を組み合わせて表現する pub fn users_api(db: Database) -> impl Filter<Extract = impl Reply, Error = Rejection> + Clone { get_user(db.clone()).or(list(db.clone())).or(put_user(db)) } /// Path "users" を表す部品 fn users() -> warp::filters::BoxedFilter<()> { warp::path("users").boxed() } /// PathからUserIdを取り出す部品 fn user_id() -> warp::filters::BoxedFilter<(u64,)> { warp::path::param().boxed() } /// list_users_handlerを呼び出すための部品 fn list(db: Database) -> impl Filter<Extract = impl Reply, Error = Rejection> + Clone { users() .and(warp::get()) // HTTP GETメソッドを指定 .and_then(move || list_users_handler(db.clone())) // Handlerを呼び出す } /// get_user_handlerを呼び出すための部品 fn get_user(db: Database) -> impl Filter<Extract = impl Reply, Error = Rejection> + Clone { users() .and(user_id()) // User IdをPathから取得 .and(warp::get()) // HTTP GETメソッドを指定 .and_then(move |id| get_user_handler(db.clone(), id)) // Handlerを呼び出す } /// put_user_handlerを呼び出すための部品 fn put_user(db: Database) -> impl Filter<Extract = impl Reply, Error = Rejection> + Clone { users() .and(user_id()) // User IdをPathから取得 .and(warp::put()) // HTTP PUTメソッドを指定 .and(warp::body::json()) // Request Bodyに含まれたJSONを取り出しUser型へ変換 .and_then(move |id, body| put_user_handler(db.clone(), id, body)) // Handlerを呼び出す } かなりややこしい型になりますが、やっている処理自体は Path のマッチ、 id を取り出す、Request Bodyから JSON を取り出す事を行っています。 warp::body::json() 関数はRequest Bodyに含まれる JSON から Deserialize を実装した特定の型への変換を行っています。どの型へ変換するかの指定を行う必要があります。 型推論 が正しく動かない場合は warp::body::json::<User>() のように User 型への変換を明示する必要があります。 今回の例では put_user_handler の引数で明示的に User 型を要求しているため省略して記述が可能です。 main関数の実装 最後に実装した部品をmain関数にまとめます。 main.rs use sample_web_app::{init_db, users_api}; #[tokio::main] async fn main() { // Database(HashMap)の初期化 let database = init_db(); // users_api filterにdatabaseを代入してサーバを起動 warp::serve(users_api(database)) .run(([127, 0, 0, 1], 3030)) .await; } 動作確認 実際に cargo run でサーバを起動して、いくつかテストを行います。 何も登録されていないことを確認する ❯ curl localhost:3030/users [] ユーザの登録 ❯ curl -X PUT -H 'Content-Type:application/json' -D - localhost:3030/users/1 -d '{"id": 1, "name": "nrskt"}' HTTP/1.1 200 OK content-type: application/json content-length: 23 date: Mon, 24 Feb 2020 09:10:20 GMT {"id":1,"name":"nrskt"} ❯ curl -X PUT -H 'Content-Type:application/json' -D - localhost:3030/users/2 -d '{"id": 2, "name": "neko"}' HTTP/1.1 200 OK content-type: application/json content-length: 22 date: Mon, 24 Feb 2020 09:12:48 GMT {"id":2,"name":"neko"} 登録ユーザの取得 ❯ curl -D - localhost:3030/users HTTP/1.1 200 OK content-type: application/json content-length: 48 date: Mon, 24 Feb 2020 09:14:03 GMT [{"id":1,"name":"nrskt"},{"id":2,"name":"neko"}] 登録した全ユーザを取得することが確認できました。 IDを指定したユーザの取得 ❯ curl -D - localhost:3030/users/1 HTTP/1.1 200 OK content-type: application/json content-length: 23 date: Mon, 24 Feb 2020 09:19:22 GMT {"id":1,"name":"nrskt"} 指定したIDのユーザを取得することを確認できました。 誤ったデータの登録 ❯ curl -X PUT -H 'Content-Type:application/json' -D - localhost:3030/users/3 -d '{"id": 2, "name": 1}' HTTP/1.1 400 Bad Request content-type: text/plain; charset=utf-8 content-length: 96 date: Mon, 24 Feb 2020 09:20:52 GMT Request body deserialize error: invalid type: integer `1`, expected a string at line 1 column 20 文字列を期待している部分に数値型を入れた場合、正しく 400 Bad Request が返る事を確認できました。 ❯ curl -X PUT -H 'Content-Type:application/json' -D - localhost:3030/users/3 -d '{"id": 2, "name": "0"}' HTTP/1.1 400 Bad Request content-type: text/plain; charset=utf-8 content-length: 102 date: Mon, 24 Feb 2020 09:21:33 GMT Request body deserialize error: 名前が使用できる文字種はA-Z, a-zです at line 1 column 22 Name 型の範囲外の値が指定された場合も正しく 400 Bad Request が返る事を確認できました。 まとめ 簡単な説明となってしまいましたが、 warp を利用してRustでWebアプリケーションを実装する例を紹介させていただきました。もちろん warp 以外にも様々なライブラリ、 フレームワーク が存在するので、そちらも試していただければと思います。
こんにちは、CADDi でフロントエンドエンジニアをしている桐生です。 弊社では バックエンドとの通信に GraphQL を採用し、そのクライアントライブラリとして Apollo Client を使用しています。 今回は Apollo Client と immer を使った Tips を紹介したいと思います。 [toc] 前提 apollo-client@2.6.4 immer@5.3.4 先に結論 assumeImmutableResults: true を設定して、パフォーマンスアップを図ろう。 freezeResults: true を設定して、キャッシュの mutable change を禁止しよう。 キャッシュ更新は immer を使って簡潔に直感的に実装しよう。 Apollo Client のキャッシュとは Apollo Client の便利な仕組みの1つにキャッシュ機構 InMemoryCache があります。これは簡単に言えば 正規化されたデータが格納された Redux state のようなものです。state の正規化については Redux 公式ドキュメント https://redux.js.org/recipes/structuring-reducers/normalizing-state-shape/ にも記載されているので参考にしてください。 キャッシュの更新方法 Apollo Client のキャッシュへのアクセス方法としては https://www.apollographql.com/docs/react/caching/cache-interaction/ に記載されている4つのメソッドがあります。 readQuery readFragment writeQuery writeFragment 使い方はそれぞれ以下のようになります。 1. readQuery x writeQuery を使った更新 // query の定義 const query = gql` query TodoList { todos { id text completed } } `; // キャッシュからの読み出し const { todos } = client.readQuery({ query }); // データの更新 const newTodo = { id: '4', text: 'Todo 4', completed: false, __typename: 'Todo', }; const newTodos = [...todos, newTodo]; // キャッシュへの書き込み client.writeQuery({ query, data: { todos: newTodos } }); 2. readFragment x writeFragment を使った更新 const id = '3'; // fragment の定義 const fragment = gql` fragment todo on Todo { id text completed } `; // キャッシュからの読み出し const todo = client.readFragment({ id, fragment }); // データの更新 const completedTodo = { ...todo, completed: true }; // キャッシュへの書き込み client.writeFragment({ id, fragment, data: completedTodo }); mutable なキャッシュ更新も可能だが、Apollo Client 3からは非推奨に 上記のデータ更新は immutable に行っていますが、実は mutable にも更新を行うことができます。厳密には readQuery/readFragment の戻り値はスナップショットなので、直接変更しても writeQuery/writeFragment を行わない限りキャッシュデータに変更が入るようなことはありません。 したがって、現状では以下のように記述することも可能です。そして、こちらの方が実装が直感的でわかりやすいのが正直なところです。 1. readQuery x writeQuery を使った更新(mutable) ... // キャッシュからの読み出し const { todos } = client.readQuery({ query }); // データの更新(mutable) todos.push({ id: '4', text: 'Todo 4', completed: false, __typename: 'Todo', }); // キャッシュへの書き込み client.writeQuery({ query, data: { todos } }); 2. readFragment x writeFragment を使った更新(mutable) ... // キャッシュからの読み出し const todo = client.readFragment({ id, fragment }); // データの更新 todo.completed = true; // キャッシュへの書き込み client.writeFragment({ id, fragment, data: todo }); ただし https://blog.apollographql.com/whats-new-in-apollo-client-2-6-b3acf28ecad1 で言及されているように、パフォーマンス最適化のために、Apollo Client 3 からはキャッシュデータそのものを返すようになるため、上記のような mutable な変更を行うと問題になります。そこで Apollo Client 3 への布石として Apollo Client 2.6 では2つのオプションが追加されました。 assumeImmutableResults : アプリケーションコードがキャッシュを mutable に変更しないと確信している場合、この仮定をApolloClientコンストラクターに伝えることで、パフォーマンスの大幅な改善を実現できる。 freezeResults : devモードですべてのキャッシュ結果を凍結し、mutableな変更をできないようにする。 この2つのオプションは Apollo Client 3 ではデフォルトで true に設定される予定です。そのため今のうちからこれらのオプションを有効にして immutable なキャッシュ更新を強制しておくのがベターと言えます。 immer で immutable なキャッシュ更新を ようやく immer の出番です。 つまるところ、Redux のようにキャッシュは immutable に更新すべし、ということなんですが、ただそうなると、やはり Redux と同じで spread hell という問題に悩まされることになります。 これを解消してくれるのが immer です。最近では redux-toolkit にも採用されており、immutable な更新を mutable 的に直感的に記述することが可能になっています。 1. readQuery x writeQuery を使った更新 with immer import { produce } from 'immer'; ... // キャッシュからの読み出し const { todos } = client.readQuery({ query }); // データの更新 with immer const newTodos = produce(todos, draft => { draft.push({ id: '4', text: 'Todo 4', completed: false, __typename: 'Todo', }); }); // キャッシュへの書き込み client.writeQuery({ query, data: { todos: newTodos } }); 2. readFragment x writeFragment を使った更新 with immer import { produce } from 'immer'; ... // キャッシュからの読み出し const todo = client.readFragment({ id, fragment }); // データの更新 with immer const newTodo = produce(todo, draft => draft.completed = true); // キャッシュへの書き込み client.writeFragment({ id, fragment, data: newTodo }); この例はデータ構造が単純なので、そこまでの恩恵は得られませんが、より複雑な構造をもつデータに対しては効力を発揮するでしょう。 ぜひ immer を使ってみてください。
1. はじめに こんばんは、キャディでバックエンドエンジニアをしている kuwana-kb( @kuwana_kb_ )と申します。 キャディでは Rust を用いたプロダクト開発をしています。Rust は安全性・速度・並行性に秀でた言語ですが、まだ国内での採用事例は少なくWeb アプリケーションの開発事例もあまり見受けられません。この事実は採用にも影響していて、はじめから Rust ができる人を採用するのはなかなか厳しいのが現状です。そこで弊社では Rust 未経験の人でも Rust を使って開発できるようにオンボーディングを工夫しています。私自身も会社に入るまで Rust を使ったことがなかったのですが、おかげさまで今では毎日 Rust で開発をしています。 今回はキャディのバックエンドチームの Rust オンボーディングについてお話したいと思います。 [toc] 2. 簡単な自己紹介 まず、オンボーディングを受けた私のバックグラウンドを軽くご紹介します。 私は2019年8月にキャディにジョインしました。入社当時のキャリアとしては、Webディレクターを3年、エンジニアを1年ほど経験していて、それまでは Go を用いたバックエンドの API の実装、クラウドインフラの構築をしていました。Rust については全く経験がなく、せいぜい名前を聞いたことがある程度でした。 現在はキャディでバックエンドエンジニアとして Rust を用いた API の実装や GCP を中心としたインフラの構築をしています。 3. Rust のオンボーディング ① 「The Book」 で基礎を学ぶ 「The Rust Programming Language」 Rust を学ぶ上ではじめに手をつけたのが「The Book」です。The Book とは 「The Rust Programming Language(TRPL)」の通称で、Rustプロジェクトが公式でメンテナンスしている入門書です。本書はネット上で無料で公開されています。また、PDF に出力することもできるので電子書籍として持ち歩くことも可能です。本書には Rust の基本的なことが詰まっていまして、入門に最適な書籍です。 ちなみに弊社の Rustacean に入門で使った書籍のアンケートをとってみました。The Book が一番人気で、オライリー本や「実践Rust入門(通称:自転車本)」を使った人もいますね。書籍は使っていないという猛者もいましたw また、アンケートには公式の docs や使いたい crate の example を読んで勉強した、というコメントもありました。 ② 雛形ソースコードでプロダクトの実装を理解する さて、「The Book」で基礎を身に着けたら、少しずつ業務に入っていきます。 私のチームの場合、業務アプリケーションに慣れていくための手段として、 業務アプリケーションの雛形を用意しています。雛形がどういったものかというと、以下の役割を満たす必要最小限のソースコードになります。 業務アプリケーションで扱う crate(Rustにおけるパッケージ)に慣れる 業務アプリケーションのアーキテクチャを理解する Rust オンボーディングの観点でいうと1つめが重要です。TRPL では基礎的な crate しか使わないため、業務で扱う crate の学習が別途必要になってきます。一例を挙げると、ORM のdiesel、 grpc サーバーの tower-grpc 、 シリアライズ・デシリアライズの serde などです。いきなりプロダクトのソースコードを読むと知らない crate や記法の多さに困惑する可能性があるため、この雛形でワンクッション置く形にしています。 私のチームでは以下のような構成のソースコードを使用しています。 雛形が読めるようになったらいよいよAPIの実装です。 src/ ├ domain/ │ └user.rs ├ usecase/ │ ├ input/ │ │ └get_user_input.rs │ ├ input.rs │ └ get_user.rs ├ infrastructure/ │ ├ grpc/ │ │ ├ convert/ │ │ │ └ get_user.rs │ │ ├ covert.rs │ │ └ server.rs │ ├ grpc.rs │ └ postgres.rs │ ├ domain.rs ├ usecase.rs ├ infrastructure.rs ├ error.rs └ lib.rs ③ API を自分で書く いよいよ業務アプリケーションの実装に着手します。最初は API を一本作ることを目標にしました。 ここまでくると、「The Book」と雛形ソースコードの読み込みで実装に必要な知識はある程度身についています。多少詰まることもあるので最初の方はペアプロで実装をすすめていきます。また、レイヤードアーキテクチャのレイヤー単位で PR を作成し、段階的に独り立ちしていきます。 API 一本をひとりで作れるようになればオンボーディングは終了です。「The Book」の読み込みから API の実装に至るまで、オンボーディングの期間としては、おおよそ3週間くらいでした。 4. Rust で躓いた点 ここではRustを学ぶ上で私が躓いた点を共有します。 Trait Trait は抽象型の一種で、他言語でいうInterfaceや型クラスのようなものです。Rust入門当初の私は、 parse() や try_into() といったメソッドがなぜ意図した型に変換できるのか、仕組みがよくわかっていませんでした。 これは FromStr , TryFrom といったTraitと型推論の理解が浅かったことが原因のように思います。上述の FromStr や TryFrom といった基礎的な Trait の実装を実際に自分の手で書いてみるのが理解につながると感じました。 Option と Result 型 Option と Result はそれぞれ「存在しないかもしれない」、「失敗するかもしれない」という文脈を表す型です。これまで自分が触ってきた言語にはない概念だったので理解に苦労しました。また、付随するメソッドも多いのでケースによってうまく使い分けることが難しかったり、メソッドの存在を知らずに車輪の再発明をしてしまうことがありました。 こちらは実装に迷ったら Option や Result のdocsを見て、少しずつ使い方に慣れていくのがよいと思います。 特殊な構文 エラー変換の糖衣構文である ? や アノテーション ::<>(turbofish) など、初見では理解しづらく詰まりました。こういった記号ってネット上で検索しづらいので、わかる人にすぐ聞ける環境だったのはよかったと思います。 所有権システム Rustの大きな特徴の1つである所有権システムですが、難しいです。今でも詰まります。概念の理解も必要ですが、ルールも結構複雑です。まずは、 clone() 等を使って動かすことを優先し、慣れてきたら少しずつ理解していくという形でもよいかな、と個人的には思ってます。 今振り返ってみると、Rust特有の難しさは所有権システムだけなのではないでしょうか。構文への慣れはどの言語でもありますし、 Trait や Option , Result なんかは Haskell や Scala で似たような概念があります。当たり前かもしれませんが、Rust に対して感じる難易度は、それまで経験してきた言語によって左右されるように思います。 5. Rust の学習に効果的だったこと コンパイラ駆動開発 Rustのコンパイラはとても優秀です。コンパイルが通らないコードを書いた時、 cargo check コマンドで誤っている点を優しく教えてくれます。また、 cargo clippy コマンドによるlintによって、冗長な記述やより最適な関数の提案をしてくれます。 例として上記のスクリーンショットでは、変数が使われていないことの警告と変数を可変にすべきというエラーが表示されています。開発者はコンパイラとの対話を通じて Rust のコードが書けるようになっていくでしょう。 Rust Playgroundを活用する 「Rust Playground」 Rust Playground とは、 Rust をWebブラウザ上で気軽に使えるサイトです。Go の Playground と同じようなものです。関数や derive の挙動などで不明な点ががあれば、とりあえず Rust Playground で動かしてみることで簡単に動作確認ができます。また、 Rust Playground でサンプルコード書いてURLを共有することもできるのでレビューでもたまに使います。 ペアプロでRustのコーディングを学ぶ Rust に限った話ではないですが、やはりペアプロは有効です。少しでも気になった点はその場で解決できる点がよいです。また、レビューでは伝えづらい Rust コーディングのコツなんかも学べると思います。例えば、実装途中の関数は unimplemented() でコンパイルを通すようにする、とかは一緒にコーディングしないと得づらい知見なんじゃないでしょうか。ちなみに私のチームではペアプロ時に VS codeの LiveShare 機能を使っています。それぞれのモニターで共通のソースコードを編集できて便利です。 日報で非同期に解決 入社して1ヶ月間は毎日日報を書いていました。この日報では、その日詰まったことを書いてわかる人がそれに回答する、という運用をしていました。日報というとちょっとめんどくさい雰囲気がするかもしれませんが、以下の点で良かったです。 疑問を非同期的に解決できる 疑問をログとして残せるので後から入ったメンバーが参考にできる 非同期に解決できて過去ログを遡れればよいので、日報という形ではなく疑問解消用のslackチャンネルを作るといった運用でもいいかもしれません。 まとめ 以上がキャディのバックエンドチームにおける Rust オンボーディングでした。 Rust を始めたい方や Rust の導入を検討している方の参考になれば幸いです。
目次 [toc] はじめに キャディでバックエンドエンジニアとCI/CDやIaC、自動テストなどDevOps的な仕事を兼務している山下です。 k8sを実際にサービスの運用に使おうとすると確実にぶつかる壁があります。それは構成管理です。 具体的にいうと、基本は設定を共通化しつつ、環境に応じて一部だけを差し替えて管理しようとするとk8sだけでは運用が難しくなってきます。 そうした課題を解決するのに、最も使われているツールは Helm です。 (Helmは構成管理だけでなく、パッケージマネージャーとしての機能もあり、より広範な存在ですが) しかし、キャディでは、 Kustomize というツールを使用し、構成管理を行なっています。 その理由やメリット、使い方を簡単に紹介出来ればと思います。 Kustomizeを選定した理由 HelmではなくKustomizeを選定した理由は大きく4つ存在します。 それは、学習コスト、移行コスト、導入コストの三つが低いことと、GitOpsとの親和性が高いことです。 学習コストが低い HelmはGoのテンプレートを元にした、独自記法が多く存在します。 勿論、その分多くのことを実現でき、高いレベルの共通化を行うことが出来ます。 こうした機能はミドルウェアなどのように、構築がある程度複雑でかつ、幅広いサービスで組み込んで使ってもらうためには非常に有用なツールだと思っています。 弊社でも、RabbitMQやElasticSearchなどのミドルウェアを使っていますが、それらを展開するシーンではHelmを使用しています。 (特にHA構成になるように自力で設定しようとすると大変なコストが掛かりますが、Helmなら一発で展開できます。) ただ、今回は、ミドルウェアのように幅広い人にツール的に使ってもらうものではなく、自社サービスであり、 サービスのことを理解した同じ会社のメンバーだけに限定して運用することから、シンプルで理解が容易なものを探していました。 その点、Kustomizeは独自の記法がほぼなく、基本的には、k8s本来の記法を使用して構成管理を行うことが出来ます。 移行コストが低い 上記と関連するのですが、k8sは非常に普及してきているとはいえ、まだまだ進化が非常に早いツールです。 そのため、その周辺に存在するツール群は更に変遷が早いと考えらます。折角、様々な記法に慣れても、あっという間にデファクトが変わることはあり得ます。 (例えば、構成管理繋がりですと、ChefやPuppetは一時期盛り上がりましたが、Ansibleが出てから下火になっていき、Dockerが来てから話題にならなくなっています) その点、Kustomizeはk8sのmanifestと同じ記法を使用するため、Kustomizeが廃れたとしても、過去の資産が無駄にならず、移行が容易に出来ると考えています。 これにより、例え会社やサービスが大きくなったタイミングでも、柔軟にベストプラクティスを追求していけると考えています。 導入コストが低い 既に述べましたが、Kustomizeは独自記法がないため、覚えることが少なく、多くの人が簡単に利用できるため、組織的な導入が早いです。 これは、現状、インフラ専任者をおかずに、バックエンドエンジニアがインフラも一緒に見て、開発からリリースまでの責任を持つようにしている キャディにとっては非常に重要な用件でした。 実際、既に私以外にも4人のエンジニアがk8sとKustomizeを利用して、インフラコードを変更してリリース作業を行なっていますが、 それも追加の学習コストが低いことが良い影響を及ぼしています。 次に、ローカルで試す際やパイプラインに組み込む時も、kubectlコマンド自体に kustomizeコマンドが同梱 されているため、余計なインストールが不要です。 同梱とはどういうことかというと、kubctlをインストールしたタイミングで、既にKustomizeが使えるようになっているということで、 具体的には、 kubectl kustomize や kubectl apply -k といったコマンドを利用することで、Kustomizeを利用したmanifest群をデプロイすることが出来ます。 (ただし、ここにハマりどころがあり、あとで解説します) GitOpsとの親和性が高い こちらの内容は、GitOps自体の説明だけで一記事分を軽く超え、関連する領域も広いため、 別記事で改めて紹介させてください Kustomizeの使い方 Kustomizeはどうやって環境毎の差分を吸収しているのか Kustomizeの環境差分を適用する方法は非常にシンプルで、 変更元のファイルと変更先のファイルの用意し、それらの差分がある部分だけを上書きを行うというものです。 具体的には、下記のファイルのAPI_DOMAINの部分を変更したいとします。 apiVersion: v1 kind: ConfigMap metadata: name: sample-app-config data: API_DOMAIN: localhost API_PROTO: http API_PORT: "4000" その際は、下記のように、どのファイルかが分かるようにapiVersion,kind,metadataの部分を同じ値を入れた上で、変更部分を書いていきます。 (ただし、マップであればいいのですが、配列の値を変えたい場合だけは少し特殊であとで説明いたします) apiVersion: v1 kind: ConfigMap metadata: name: sample-app-config data: API_DOMAIN: sample-domain そうすると、下記のように、API_DOMAINだけが書き換わったmanifestに書き換わります。 apiVersion: v1 kind: ConfigMap metadata: name: sample-app-config data: API_DOMAIN: sample-domain API_PROTO: http API_PORT: "4000" ただ、勿論、そのファイルを置いているだけで書き換わるということはありません。 その時に登場するのが、Kustomizeが唯一持つ独自記法であるkustomization.yamlという設定ファイルです。 Kustomization.yamlの記法 Sampleと概要 まずはサンプルはこちらになります。 apiVersion: kustomize.config.k8s.io/v1beta1 kind: Kustomization resources: - ../../base patchesStrategicMerge: - ./services/sample_micro_service_A/be/config.yaml - ./services/sample_micro_service_B/be/config.yaml patchesJson6902: - path: ./ingress/ingress_patch.yaml target: group: extensions kind: Ingress name: sample_ingress version: v1beta1 images: - name: sample-app newName: gcr.io/bucket-name/image-name newTag: 91c8cee4b05a0ab1642d63198969fac9df5f62ae resources これは、どのファイルをデプロイや更新の対象にするかを決めるための設定です。 ファイルを指定するとそのファイルを管理対象とし、 ディレクトリーを指定すると、その配下のkustomization.yamlのresourcesを読みます。 patchesStrategicMerge この項目が、最初のサンプルで説明した、Diffをとって更新をかけるために必要な設定項目です。 設定の仕方としてはシンプルで、変更先のファイルをresourcesに設定し、更新したい値が入ったファイルを このpatchesStrategicMergeに設定します。 この時注意が必要なのは、この項目ではディレクトリーを指定出来ず、ファイルのみが設定可能なことです。 patchesJson6902 少し上記で書きましたが、書き換えたい値が配列の場合に利用するのが、この設定です。 ファイルではなく、JsonPathを使用して一部の値だけをピンポイントで変換します。 ちなみに、変わった名前だな、と思われると思いますが、 これは RPFの6902の仕様 を元にした機能なためです。 サンプルとしては、まずの元のファイルが下記だとすると、 spec: rules: - host: HOST_NAME http: paths: ... patchファイルは下記の様になります - op: replace path: /spec/rules/0/host value: dev.caddi.com - op: replace path: /spec/tls/0/hosts/0 value: dev.caddi.com pathで対象のプロパティまでのパスを指定し、valueに変更したい値をセットします。 patchJson6902の詳しい使い方 どのようなディレクトリー構成にすればいいのか? kutomizeは template-free way to customize application configuration と謳っているだけあり、 何かこうしなければいけないという構成がある訳ではありません。 ただ、大まかに base と overlays というディレクトリーに分け、 共通項目はbaseに、上書きを行いたいものはoverlaysにいれ、overlays以下に環境毎のディレクトリーと kustomization.yamlが存在しているというのが、一般的な構成のようです。 とはいっても、私自身、そこから先を決めるのが困るんだよな、と思って結構頭を悩ましたので、 改善中の状態ではありますが、現状のキャディのインフラの構成を公開致します。参考にして頂ければ幸いです。 . ├── README.md ├── base │   ├── ingress │   │   ├── configmap.yaml │   │   ├── ingress.yaml │   │   └── kustomization.yaml │   ├── kustomization.yaml │   ├── middleware │   │   ├── README.md │   │   └── charts_config.json │   ├── secrets │   │   ├── kustomization.yaml │   │   └── tls-secret.yaml │   └── services │   ├── kustomization.yaml │   ├── sample_micro_service_A │   │   ├── be │   │   │   ├── config.yaml │   │   │   ├── deployment.yaml │   │   │   ├── service.yaml │   │   │   └── subscriber-config.yaml │   │   ├── bff │   │   │   ├── config.yaml │   │   │   ├── deployment.yaml │   │   │   └── service.yaml │   │   ├── kustomization.yaml │   │   └── ui │   │   ├── config.yaml │   │   ├── deployment.yaml │   │   └── service.yaml │   └── service_micro_service_B │   ├── be │   │   ├── deployment.yaml │   │   └── service.yaml │   ├── bff │   │   ├── config.yaml │   │   ├── deployment.yaml │   │   └── service.yaml │   ├── kustomization.yaml │   └── ui │   ├── config.yaml │   ├── deployment.yaml │   └── service.yaml └── overlays ├── dev │   ├── ingress │   │   └── ingress_patch.yaml │   ├── kustomization.yaml │   ├── middleware │   │   ├── elasticsearch │   │   │   └── values.yaml │   │   └── rabbitmq-ha │   │   └── values.yaml │   ├── secrets │   │   ├── kustomization.yaml │   │   ├── sample_micro_service_A │   │   │   ├── be │   │   │   │   ├── cloudsql-secret.yaml │   │   │   │   ├── publisher-secret.yaml │   │   │   │   └── subscriber-secret.yaml │   │   │   └── kustomization.yaml │   │   └── sample_micro_service_B │   │   ├── be │   │   │   ├── be-secret.yaml │   │   │   └── cloudsql-secret.yaml │   │   └── kustomization.yaml │   └── services │   ├── sample_micro_service_A │   │   ├── be │   │   │   └── deployment_patch.yaml │   │   ├── bff │   │   │   └── config.yaml │   │   └── ui │   │   └── config.yaml │   └── sample_micro_service_B │   ├── be │   │   └── deployment_patch.yaml │   ├── bff │   │   └── config.yaml │   └── ui │   └── config.yaml ├── local_sample ├── prod ├── stg └── test コマンド群 まず、導入理由で、ツールをインストールがしなくてよく導入コストが低いと書いておきながら、 恐縮なのですが、Kustomizeで実際にインフラコードを書いたり、より踏み込んだ機能を使いたい場合は、 kustomize コマンドのインストールが必要になってきます。macであればbrewでインストール可能です。 ただ、Kustomizeには様々なコマンドがありますが、よく利用するコマンドは下記の二つです Build kutomize build は、kustomization.yamlのresourcesで指定したファイルをbundleし、 overlaysで設定したファイルで値を更新したものを出力します。 これによって、manifestやkustomization.yamlを書いて、動作確認をする際に、 ちゃんと値が意図通り更新しているかを確かめたりする際に非常に有用ですし、 ファイルが一ファイルにまとまるため、取り回しが用意になります。 (デプロイする時も、buildの結果をファイルとして書き出し、  それをkubectl apply -f で指定すると、build結果と必ず同じものを参照するようになるので、変なところでハマることが少なくなります。) 実行するときは、kustomization.yamlがあるディレクトリーを指定してください。 Edit 記事の終盤ですが、実は、 このEdit機能こそがKustomizeで最も重要な機能 だと私は考えています。 そして、これがGitOpsの親和性が高いというメリットに繋がります。 では、このEdit機能、めちゃくちゃ高機能なものかというと実は異なり、 kustomization.yamlのimagesで指定したimageのtagやdigestというhash値を書き換えるというシンプルな機能になります。 具体的な使い方は下記となります。 変更対象 元の情報が下記だとすると(kustomization.yaml) images: - name: base-sample-image-name newName: new-sample-image-name digest: hash value newTag: tag name tagの変更の場合 対象のkustomization.yamlの場所までcdした上で 、 下記コマンドを実行することでimageのtagを変更出来ます、 kustomize edit set image image_name:tag_name 具体的には、下記の様なコマンドになります。 kustomize edit set image base-sample-image-name:v1.2.3 digestの変更 tagと同様に 対象のkustomization.yamlの場所までcdした上で 、 下記コマンドを実行することでimageのdigestを変更出来ます。 kustomize edit set image base_image_name=new_image_name@digest 具体的には、下記の様なコマンドになります。 kustomize edit set image base-sample-image-name=gcr.io/caddi/new-sample-image-name@sha256:bcfda0cb68ebe4e2a6d8157066623147bbe00aa80b664426443d9c60409551eb 何が嬉しいのか この機能を利用することで、対象のアプリケーションのbuild後に合わせて インフラコードの該当のイメージ情報をeditするだけでアプリケーションのデプロイを完了することができ、簡単にGitOpsを実現することができます。 (GitOpsの詳細な内容は別記事でご紹介致します。) Tips Helmとの共存 基本的に、ミドルウェアなど、決まった構成のものはHelmを利用するのが適切ですが、 Kustomizeだけで完結させたい場合は、Helmのtemplate機能を使うとKustomizeに寄せることも出来ます。 Helmには作られているChart(Helmの設定ファイル)からk8sがそのまま扱えるファイルであるmanifestを生成するtemplate機能があります。 このtemplate機能を利用して、manifestを生成し、一部必要なものだけをkustomizationで上書きするようにすれば、Helmと併用して使用することが出来ます。 helm template stable/nginx-ingress > nginx-ingress-manifest.yaml Pluginの作成 Kustomizeの基本であるシンプルさから離れてしまいますが、より高いレベルの共通化や、自動化を行う時には Plugin を利用するのも一つ手です。 Shellもしくは、Golangを用いて、追加の処理を実装することが出来ます。 まとめ 以上、駆け足でしたが、KustomizeとHelmの違いとKustomizeを選んだ理由、簡単な使い方をご紹介させて頂きました。 現在、ツール選定を行なっている方の参考になれば、非常に嬉しい限りです。 参考資料 公式スライド資料 公式サンプル
こんにちは、テクノロジー本部バックエンド開発グループの山田です。 弊社のプロダクト開発では、以下の図のように フロントエンド <-> BFF <-> バックエンド の構成をとっており、Node.js上で稼働している BFF と、Rustで作成している バックエンド の間を gRPC で通信しています。 そこで今回は、 TypeScriptにおけるgRPCの関連ライブラリ について、以下を紹介していきます。 【1】 公式チュートリアルに沿った2種類の実装サンプルに、アプリケーション開発中に認証や分散トレーシング等で利用するMetadataの実装を追加したコード 【2】 2種類の方法をライブラリの実装も見つつ比較 【3】 直近の開発で採用している方法の紹介 お急ぎの方は下部にまとめを記述しているのでそちらを参照ください。 また、説明の都合で記事中のサンプルコードは一部を抜粋して記述していくため、完全なサンプルコードは↓のリポジトリを参照ください。 GitHub - kei711/ts-grpc-example ※ライブラリ使用方法の比較にフォーカスするため、gRPC自体とNode.jsやTypeScriptに関しては説明を省略します ※gRPC-Webについては、採用を決めたときに記事にできたらと思っています [toc] 事前準備 Node.js v12.xとyarnがインストールされている環境を前提に進めていきます。 npmを使う方は適宜読み替えてください。 TypeScript等の開発に必要なライブラリとgRPCをインストール yarn add -D typescript @types/node ts-node yarn add grpc サービスの定義 gRPCで通信するためにはサービスを定義する必要があります。 今回はサーバー・クライアント間でPingとPongのメッセージをやり取りするサービスを定義します。 pingpong.proto syntax = "proto3"; package pingpong; service PingPong { rpc SendPing (Ping) returns (Pong) {} } message Ping { string type = 1; string payload = 2; } message Pong { string payload = 1; } 【1】 gRPC Getting Started 公式のチュートリアル でも紹介されている、以下2パターンの実装をしてみます。 ① protocコマンドにより静的コードを生成する方法(以下公式に合わせて static_codegen と記述する) ② プログラム実行時に.protoファイルを直接読み込む方法(以下公式に合わせて dynamic_codegen と記述する) ① static_codegen protocコマンドにより静的コードを生成する方法です。 必要なライブラリをインストールして、gRPCのサーバー・クライアントの処理を書いていきます。 yarn add google-protobuf yarn add -D @types/google-protobuf grpc-tools grpc_tools_node_protoc_ts コード生成時のコマンドが大変なので、 grpc_tools_node_protoc_tsのREADME を参考にshellを用意して実行します。 static/proto_generate.sh "`yarn bin`"/grpc_tools_node_protoc \ --plugin=protoc-gen-grpc="`yarn bin`"/grpc_tools_node_protoc_plugin \ --js_out=import_style=commonjs,binary:./generated \ --grpc_out=./generated \ -I ../../ ../../pingpong.proto "`yarn bin`"/grpc_tools_node_protoc \ --plugin=protoc-gen-ts="`yarn bin`"/protoc-gen-ts \ --ts_out=./generated \ -I ../../ ../../pingpong.proto 生成されたコードをもとにサーバー・クライアントを実装 (抜粋) static/server.ts const server = new grpc.Server(); server.addService<IPingPongServer>(PingPongService, { sendPing: (call, callback) => { let pong = new Pong(); pong.setPayload('pong'); callback(null, pong); }, }); server.bind('0.0.0.0:50051', grpc.ServerCredentials.createInsecure()); server.start(); static/client.ts const meta = new grpc.Metadata(); meta.set('identifier', 'test@example.com'); const client = new PingPongClient('localhost:50051', grpc.credentials.createInsecure()); const ping = new Ping(); ping.setType('sample'); ping.setPayload('ping'); client.sendPing(ping, meta, (error, value) => { client.close(); }); ここまでがprotocコマンドにより生成された静的コードを利用したgRPCサーバー・クライアントの実装でした。 次はprotoファイルを動的に読み込んでgRPCサーバー・クライアントを実装する方法です。 ② dynamic_codegen プログラム実行時に.protoファイルを直接読み込み、動的に処理が追加される実装方法です。 必要なライブラリをインストールして、gRPCのサーバー・クライアントの処理を書いていきます。 yarn add @grpc/proto-loader static_codegenとは異なり、実行時に動的にメソッド定義されるため、TypeScriptの型チェックの恩恵を得るためには自分で記述する必要があるため記述していきます。 export interface PingPongServer { sendPing(call: grpc.ServerUnaryCall<Ping>, callback: grpc.sendUnaryData<Pong>): void; } export interface PingPongClient extends grpc.Client { sendPing(call: Ping, metadata: grpc.Metadata, callback: grpc.sendUnaryData<Pong>): void; } export interface Ping { type: string; payload: string; } export interface Pong { payload: string; } このようにgrpcライブラリの値を使いながら定義しておくと、正しく型チェックできるようになります。 作成した型定義を使いつつサーバー・クライアントを実装 (抜粋) dynamic/server.ts const PROTO_PATH = path.resolve(__dirname, '../../pingpong.proto'); const packageDefinition = protoLoader.loadSync(PROTO_PATH, options); const packageObject = grpc.loadPackageDefinition(packageDefinition).pingpong; const server = new grpc.Server(); server.addService<PingPongServer>(packageObject['PingPong'].service, { sendPing: (call, callback) => { callback(null, { payload: 'pong' }); }, }); server.bind('0.0.0.0:50051', grpc.ServerCredentials.createInsecure()); server.start(); dynamic/client.ts const PROTO_PATH = path.resolve(__dirname, '../../pingpong.proto'); const packageDefinition = protoLoader.loadSync(PROTO_PATH, options); const packageObject = grpc.loadPackageDefinition(packageDefinition).pingpong; const meta = new grpc.Metadata(); meta.set('identifier', 'test@example.com'); const client: PingPongClient = new packageObject['PingPong']('localhost:50051', grpc.credentials.createInsecure()); client.sendPing({ type: 'sample', payload: 'ping' }, meta, (error, value) => { client.close(); }); 以上がprotoファイルを動的に読み込んでgRPCサーバー・クライアントを実装する方法です。 ここまでで、公式チュートリアルに沿って2種類の実装方法をサンプルとともに紹介してきました。 次はこれらの実装方法を比較し、より開発体験の良い方法を模索していきます。 【2】 各実装の比較 static_codegenとdynamic_codegenのいずれにしても実現できることは変わりません。 しかし、Protocol Buffers向けにSerialize/Deserializeの実装を行っている処理が以下のように依存する実装ライブラリが異なるため、記述の仕方も異なっています。 static_codegenのライブラリ依存関係 grpc + grpc_tools_/node_protoc_ts + google-protobuf dynamic_codegenのライブラリ依存関係 grpc + @grpc/proto-loader + protobuf.js これから、この差分を見て比較していきたいところですが、複数の観点から比較するためにも、もう少し深堀りして判断材料を増やしたいとおもいます。 各実装のライブラリ内実装 次の2点は、ライブラリの実装を見ていないと気づきにくい箇所でもあったので紹介していきます。 ① static_codegenで型定義から隠されている実装 ② dynamic_codegenで追加されるメソッド・処理 ① static_codegenで型定義から隠されている実装 まずstatic_codegenに関してです。 生成された型定義を見ていると一見setterによる値指定しかできないように見えますが、JavaScriptのコードを見てみるとコンストラクタからも値を指定することが可能になっています。 static/generated/pingpong_pb.js proto.pingpong.Ping = function(opt_data) { jspb.Message.initialize(this, opt_data, 0, -1, null, null); }; コンストラクタとして実行される google-protobuf/jspb.Message.initialize の実装を見てみると、内部で使用しているjspb.Message.arrayに第2引数の配列を直接代入するようになっています。 また、関連する jspb.Message.setFieldの処理 を見ていると、どうやらprotoの定義上のナンバリングと配列の数値インデックスを一致させる必要がありそうです。 一見コンストラクタで値を注入できて便利そうに見えましたが、運用していくにつれてprotoのナンバリングは歯抜けになったり、予約されていたりするので、配列のインデックスを一致させるのが難しくなっていきそうです。 関連コードを読み切れていないので想像ですが、カジュアルに使うと分かりにくいバグの原因になるのと、順序の制約もつけにくいため、型として公開されていないのではないか、と思っています。 (Tupleを使えば解決できそうでもありますが、一旦考えないこととします) ② dynamic_codegenで追加されるメソッド・処理 ここからはstatic_codegenと比較するためにdynamic_codegenで、以下の2つの処理がどのような形で動的に追加されているのか、ライブラリの実装を追っていきたいと思います。 Serialize/Deserializeの処理 UnaryRequestとして単一のリクエストを実行する処理 Serialize/Deserializeの処理 @grpc/proto-loader でprotoを読み込んだ際に、以下のようにprotobuf.jsによるJSONを利用した実装がオブジェクトに追加されます。 これによりsetter/getterの実装をしなくてもメッセージの作成が行えます。 @grpc/proto-loader/index.js function createDeserializer(cls, options) { return function deserialize(argBuf) { // protobuf.jsのtoObject()とdecode()が利用される return cls.toObject(cls.decode(argBuf), options); }; } function createSerializer(cls) { return function serialize(arg) { // protobuf.jsのfromObject()とencode()が利用される var message = cls.fromObject(arg); return cls.encode(message).finish(); }; } UnaryRequestとして単一のリクエストを実行する処理 protoロード時にRPCとして追加されるメソッドの実態を確認していきます。 @grpc/proto-loader で読み込み時に呼ばれている grpc.loadPackageDefintion をもとに、 grpc/src/client.js の定義を以下の順にたどっていくと処理が見えてきます。 client.makeClientConstructor protoの定義をもとにメソッド等を生成している Client.prototype.makeUnaryRequest 第3引数までは内部で生成された値が利用されている 第4引数以降の argument, metadata, options, callback が動的生成されているRPCのメソッドが受け付けるものとして利用されている 結果として、動的に生成されるRPCのメソッドが受け付ける引数は以下3パターンであることがわかります。 rpcName(argument, callback); rpcName(argument, metadata, callback); rpcName(argument, metadata, options, callback); static_codegenとdynamic_codegenの比較 以上のライブラリ実装をもとに比較してみると以下のことがわかりました。 static_codegen Serialize/Deserializeの処理は google-protobuf の jspb.Message を利用 値の指定は、Messageごとに生成されるsetterを使用する コンストラクタからも指定できるが使いにくい dynamic_codegen Serialize/Deserializeの処理は protobuf.js の Message を利用 値の指定は、Messageのコンストラクタから値を渡す or 直接指定する 内部的にJSONからMessageを作成する protobuf.js の fromObject が使われる ただし、値がすべて入ることが保証できないので、 protobuf.jsのREADME に記載があるようにverifyメソッドでチェックする必要がある 【3】 直近の開発で採用している方法の紹介 さて、ここからは直近の開発で採用した方法について、紹介します。 開発時の悩みと検討したこと BFFにgRPCのクライアントを実装するなかで、以下の要件が見えてきました。 フロントとはGraphQL、バックエンドとはgRPCで通信を行うため、型変換を大量に行う必要がある モデルごとにsetter/getterを書くのは非常につらいので楽をしたい オブジェクト数が増えたときを考えると、できるだけ高速であるといい 入力値等はTypeScriptの型で縛りたいが、すべてのRPCの型定義するのは非常に大変なので行いたくない 上記までの比較から、dynamic_codegenであれば、 1. はJSONをDTOとして扱うことで解消でき、 2. についてもprotobuf.jsのほうが早いとのベンチマークが protobuf.jsのREADME.md に掲示されているため、だいぶフィットしそうなことがわかりました。 問題は 3. の型で縛るという点です。 ライブラリの導入だけではどうしようもないので、この問題を解決していきます。 dynamic_codegenのモデルへの型づけで楽をしたい 最初のdynamic_codegenの実装方法でだと、TypeScriptの型チェックを行うために自分でtypeの定義をしていかなければいけませんでした。 ただ、これを毎回行うのは非常に面倒なので、もっと楽できる方法はないかと考えました。 まず、モデルの型については、protobuf.jsが pbjs / pbts というCLIを提供してくれています。 こちらもstatic_codegenと同様に毎回指定するのが大変なので protobuf.js/README を参考にshellを用意して実行することにします。 dynamic_with_protobufjs/proto_generate.sh "`yarn bin`"/pbjs \ --target static-module \ --no-encode \ --no-decode \ --path ../../ \ --out ./generated/index.js \ ../../pingpong.proto "`yarn bin`"/pbts \ --out ./generated/index.d.ts \ ./generated/index.js このshellを実行することで、一通りの型定義を自動生成してくれます。 しかし、残念なことにprotobuf.jsの pbts で生成される型は、gRPCには対応していません。 GitHubのissueでも議論されていましたが、gRPCはGoogleが提唱したProtocol Buffersを利用した規格であり、Protocol Buffers向けのライブラリであるprotobuf.jsには現状だと取り込まれることが無いためです。 gRPCに合うように、pbtsで生成された型を拡張する 実装方法の比較をする際に深堀りをした @grpc/proto-loader では、protobuf.jsの一部を利用しつつgrpcライブラリに値を渡すことで、metadetaやoptionsの指定ができるようになっていました。 そこで、protobuf.jsの pbts で生成された型を活用し、metadataやoptionsの指定ができるように型の拡張を行う定義をしていきます。 dynamic_with_protobufjs/types.ts import * as grpc from 'grpc'; import * as protobuf from 'protobufjs'; type FilteredKeys<T, U> = { [P in keyof T]: T[P] extends U ? P : never; }[keyof T]; type ProtobufFn = (request: {}) => PromiseLike<{}>; type RequestArgType<T extends ProtobufFn> = T extends (request: infer U) => PromiseLike<any> ? U : never; type ResponseType<T extends ProtobufFn> = T extends (request) => PromiseLike<infer U> ? U : never; export type GrpcServer<T extends protobuf.rpc.Service> = { [K in FilteredKeys<T, ProtobufFn>]: grpc.handleUnaryCall< RequestArgType<T[K]>, ResponseType<T[K]> >; }; export type GrpcClient<T extends protobuf.rpc.Service> = grpc.Client & { [K in FilteredKeys<T, ProtobufFn>]: ( req: RequestArgType<T[K]>, metadata: grpc.Metadata, callback: grpc.requestCallback<ResponseType<T[K]>>, ) => void; }; この定義した型と pbjs / pbts で作成される型を活用してサーバー・クライアントのコードを書いてみます。 dynamic_with_protobufjs/server.ts const PROTO_PATH = path.resolve(__dirname, '../../pingpong.proto'); const packageDefinition = protoLoader.loadSync(PROTO_PATH, options); const packageObject = grpc.loadPackageDefinition(packageDefinition).pingpong; const server = new grpc.Server(); server.addService<GrpcServer<pingpong.PingPong>>(packageObject['PingPong'].service, { sendPing(call, callback): void { callback(null, new pingpong.Pong({ payload: 'pong' })); }, }); server.bind('0.0.0.0:50051', grpc.ServerCredentials.createInsecure()); server.start(); dynamic_with_protobufjs/client.ts const PROTO_PATH = path.resolve(__dirname, '../../pingpong.proto'); const packageDefinition = protoLoader.loadSync(PROTO_PATH, options); const packageObject = grpc.loadPackageDefinition(packageDefinition).pingpong; const meta = new grpc.Metadata(); meta.set('identifier', 'test@example.com'); const client: GrpcClient<pingpong.PingPong> = new packageObject['PingPong']('localhost:50051', grpc.credentials.createInsecure()); client.sendPing(new pingpong.Ping({ type: 'sample', payload: 'ping' }), meta, (error, value) => { client.close(); }); 上記のようにprotobuf.jsで生成された型をさらに拡張することにより、メンテ不要で サービス定義が増えてもmetadataの指定を行える型 が出来上がり、モデルについても コマンドで生成された型で保護 されるコードになりました。 まとめ TypeScriptでgRPCの実装をする方法はstatic_codegenとdynamic_codegenの2種類ある dynamic_codegenのほうが protobuf.js のSerialize/Deserializeにより、JSONを使ってmessageのインスタンスを作成できるため、型変換に柔軟に対応できる 今回作成した dynamic_with_protobufjs/types.ts の GrpcServer と GrpcClient 、protobuf.jsの pbts で生成される型を組み合わせることでgRPCに対応した型を楽に指定できる 最後に いかがでしたでしょうか。 BFFの開発をする中でGraphQLとgRPCの型変換に悩まされた結果、最後のprotobuf.jsを活用しつつ独自の型を定義する形が一つの答えかなと考えています。 もしより良いアイデア・実装方法がありましたら、より洗練させられると良いなと思っていますので、ぜひご連絡ください。