TECH PLAY

キャディ株式会社

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

93

こんにちは。Platform チームの飯迫 ( @minato128 )です。 CADDi ではこれまで Hosted Redash(app.redash.io) を利用していたのですが、残念ながら 2021/11/30 に End of Life になるので、10 月末に Self-Hosted Redash 環境を構築して移行しました。今回はそのときやったことを紹介します。 移行の流れ 新しい Redash 環境を v10 で構築する 公式の移行ツールを利用してデータ移行する 監視を追加する 新しい Redash 環境を v10 で構築する まず、 移行ツールは移行先として v10 を前提としている ので、新しい環境は v10 である必要があります。ちなみに、 v10 は 10/2 にリリースされた現時点の最新版 です。 v10 であればどんな方法で構築しても問題ないのですが、今回は社内用 GKE Cluster に入れることにしました。監視もリソースも集約でき、Helm Chart を使うことで初期導入コストも低そうだったからです。 移行対象 GKE Cluster は、このような構成となっており、 基幹システム(社内システム)のほとんどをマルチテナントで運用している ArgoCD で GitOps している Secret は External Secrets + GCP Secret Manager で管理している Datadog で監視、ログを参照できるようにしている 次のように進めていきました。 (1) Terraform に必要な GCP リソースを追加してデプロイ GKE に新しい Node pool の追加 基幹システムに出来る限り影響を与えないようにしたかったので、いったん Node pool ごと分離 移行後のリソース状況をみながら見直す想定 CloudSQL( PostgreSQL ) の追加 DB の管理はできるだけしたくなかったため バックアップもおまかせ (2) Redash Helm Chart の ArgoCD Application Manifest を追加してデプロイ 現在の Chart version は 2.3.1 で Redash version は v8 となっています。ほしいのは v10 だったため、まず v10 に対応する PR を作成しました。(Redash ほど有名なツールの Chart が最新バージョンに対応してないとは思っておらず、これは想定外の作業でした) https://github.com/getredash/contrib-helm-chart/pull/102 追加したファイルの一覧はこれらで、 ├── applications │   ├── redash-assets │      └── overlays │      └── prod │      ├── kustomization.yaml │      ├── redash-postgres-secret.yaml │      └── redash-secret.yaml ├── argocd │   ├── overlays │      ├── prod │         ├── pj-name │            ├── helm-redash.yaml 以下が helm-redash.yaml のイメージです。(実際のものではなくいろいろ端折っています) apiVersion : argoproj.io/v1alpha1 kind : Application metadata : name : redash namespace : argocd finalizers : - resources-finalizer.argocd.argoproj.io spec : project : project-name source : repoURL : "https://minato128.com/contrib-helm-chart/" chart : redash targetRevision : 2.4.1 helm : valueFiles : - values.yaml values : |- # env -- Redash global envrionment variables - applied to both server and worker containers. env : PYTHONUNBUFFERED : 0 REDASH_RATELIMIT_ENABLED : "false" REDASH_MAIL_SERVER : "smtp.sendgrid.net" REDASH_MAIL_PORT : "587" REDASH_MAIL_USE_TLS : "true" REDASH_MAIL_USERNAME : "apikey" REDASH_MAIL_DEFAULT_SENDER : "redash@caddi.jp" ## Redash application configuration redash : # -- REQUIRED `REDASH_SECRET_KEY` value. Secret key used for data encryption. Stored as a Secret value. # helm template 通らないので入れているが、実際はexistingSecretが採用される secretKey : "DUMMY" # -- `REDASH_STATSD_HOST` value. # @default -- 127.0.0.1 statsdHost : "datadog-statsd-service.datadog" # -- `REDASH_GOOGLE_CLIENT_ID` value. googleClientId : "XXXXXXXXXX" # -- REQUIRED `REDASH_COOKIE_SECRET` value. Stored as a Secret value. # helm template 通らないので入れているが、実際はexistingSecretが採用される cookieSecret : "DUMMY" # redash.existingSecret -- Name of existing secret to use instead of either the values above ## This secret must contain keys matching the items marked "Stored as a Secret value" above. existingSecret : "redash-secret" # we dont use ingress ingress : # ingress.enabled -- Enable ingress controller resource enabled : false # CloudSQL # externalPostgreSQLSecret -- Read external PostgreSQL configuration from a secret. This should point at a secret file with a single key which specifyies the connection string. externalPostgreSQLSecret : name : redash-postgres-secret key : connectionString ## we dont use this postgresql postgresql : # postgresql.enabled -- Whether to deploy a PostgreSQL server to satisfy the applications database requirements. To use an external PostgreSQL set this to false and configure the externalPostgreSQL parameter. enabled : false # helm template 通らないので入れているが、実際はexternalPostgreSQLSecretが採用される postgresqlPassword : "DUMMY" ## Configuration values for the redis dependency. This Redis instance is used by default for caching and temporary storage [ref](https://github.com/kubernetes/charts/blob/master/stable/redis/README.md) redis : # redis.enabled -- Whether to deploy a Redis server to satisfy the applications database requirements. To use an external Redis set this to false and configure the externalRedis parameter. enabled : true destination : server : "https://kubernetes.default.svc" namespace : redash syncPolicy : syncOptions : - CreateNamespace= true v10 対応の PR はまだマージできていないので、fork した Repository の Package を参照している v10 の スキーマ に対応した DB で運用していれば、あとで Chart を入れ替えても問題ない 移行のブロック要因にはならない 移行データが多いと API 制限に当たってしまうので REDASH_RATELIMIT_ENABLED を false にする Secret は existingSecret と externalPostgreSQLSecret で指定する redash-assets という名前で、ArgoCD Application を追加して External Secrets をデプロイ REQUIRED な設定には、実際には使わなくても空でない値を入れておかないと helm template コマンドが通らない Redash は Statsd に対応しているが、現在の Chart では REDASH_STATSD_HOST に status.hostIP を指定できないので、Proxy Service を作って指定している 公式の移行ツールを利用してデータ移行する 概要は @ariarijp さんのスライドがわかりやすいです。 https://speakerdeck.com/ariarijp/you-should-know-about-hosted-redash-eol-and-redash-migrate 基本的には 公式ドキュメント に書いてある通りにやっただけです。 redash-toolbelt をインストールする redash-migrate init 移行に必要な設定が meta.json として生成される redash-migrate --help の結果のコマンドを上から順に実行していく データに依存関係があるため 移行の from/to 情報が meta.json に蓄積される こちらが実行後の meta.json の一部抜粋で、データタイプごとに移行元と移行先の id が状態管理されていることがわかります。 " queries ": { " 233300 ": 1 , " 233440 ": 2 , " 234065 ": 3 , } 以下がポイントです。 redash-migrate は最新バージョンを使う 初期バージョンだと group の重複など致命的なバグがある 現時点でも細かいバグは残っている https://github.com/getredash/redash-toolbelt/issues セキュリティ上の都合で、Data Source の Secret は移行されない Redash UI 上で再設定が必要 冪等性があり何度でも実行可能 リソースの id 単位で移行するため、移行済みリソースを origin 側で変更しても再同期はできない Origin 側も Destination 側も Redash 本体の Web API でアクセスする 接続周りに気を使う必要がない 移行量が多い場合は、 Destination 側の API Limit を変えておく 環境変数 REDASH_RATELIMIT_ENABLED 監視を追加する モニタリングのためにこれらを追加しました。 Datadog Logs の Pipeline 追加 一部 Parse しづらいログがあるので手動で Parser を書いてレベル(status)を正しく Remap する Datadog Monitor の追加 GKE や CloudSQL は既存の監視を利用 Terraform で管理しており、Module を参照 Error log 数 Synthetics(外形監視) https ://[REDASH_HOST]/ ping を監視 Queue 監視 前提として、Redash v10 は RQ(Redis Queue) を利用しており、job(message)を処理できているか監視する必要がある 個人的に Queue は Message Age で監視する派ですが、既存 metrics にはないので、message がたまり続けてないか観測することにした redash.rq.jobs.created が enqueue 時、 redash.rq.jobs.started が処理開始時に 記録されている ので、 created - started が n (正の整数)より大きい状態が一定期間続くと worker が message をさばけていないことになる n は traffic や worker count に依るが 0 に近いほうがよい Datadog Dashboard の追加 俯瞰して状況把握できるように まとめとポイント 新しい環境は v10 で作る 移行ツールが v10 しかサポートしていないため Redash Helm Chart は現時点では Redash v8 データ移行は、公式の移行ツールで簡単にできる 移行データに依存関係があるので、help コマンドの順番通りに実行していく セキュリティ上の都合で、Data Source の Secret は移行されないので UI 上で再設定が必要 冪等性があり何度でも実行可能 Origin 側も Destination 側も Redash 本体の Web API でアクセスするので、接続周りに気を使う必要がない 移行量が多い場合は、 Destination 側の API Limit を変えておく
Summary This post is my hobby and has nothing to do with work. I have wanted Extensible Records (a library in Haskell ) for a long time. The time has finally come. The language features we need to implement it are there in C++ 20! Therefore, this post will show you how to emulate row polymorphism in C++ 20. The latest, complete code can be found in this repository . Row Polymorphism Row polymorphism is a kind of polymorphism that allows one to write programs that are polymorphic on record field types (also known as rows, hence row polymorphism). Here is a TypeScript example: type foo = { first: string, last: string }; const o = { first: "Foo", last: "Oof", age: 30 }; const p = { first: "Bar", last: "Rab", age: 45 }; const q = { first: "Baz", last: "Zab", gender: "m" }; const main = <T extends foo>(o: T) => (p: T) => o.first + o.last main(o) (p); // type checks main(o) (q); // type error Mitama.Data.Extensible.Record In TypeScript, it is implemented as a language feature, but in C++ 20, there is no such feature, so we need to emulate it somehow. The reference is the famous Haskell library extensible which emulates the same feature. In the end I succeeded in making a library which allows the following syntax. import Mitama.Data.Extensible.Record; #include <iostream> #include <format> using namespace mitama::literals; using namespace std::literals; void print(mitama::has<"name"_, "age"_> auto person) { std::cout << std::format("name = {}, age = {}\n", person["name"_], person["age"_]); } int main() { using mitama::as; // declare record type using Person = mitama::record < mitama::named<"name"_, std::string> , mitama::named<"age"_, int> >; // make record Person john = Person{ "name"_v = "John"s, "age"_v = 42, }; // access to rows john["name"_]; // "John" john["age"_]; // 42 print(john); // OK auto tom = mitama::empty += as<"name"_>("Tom"s) ; print(tom); // ERROR: constraints not satisfied } Guide-level explanation mitama::named The syntax "name" and "age" is a UDL (User-Defined Literal). These literals create a type mitama::static_string which is a structual type (non-type template enabled class). Thus, mitama::named<"age"_, int> is a wrapped type of int named with mitama::static_string . You can construct mitama::named<_, T> by applying operator%(static_string, T) . mitama::named age = "age"_v = 42; mitama::named has some interfaces like std::optional . mitama::named age = "age"_v = 42; age.value(); // 42 mitama::named name = "name"_v = "Mitama"s; name->length(); // calls std::string::length and returns 6 mitama::record mitama::record<Rows... > is constrained to only take mitama::named in Rows... and strings in mitama::named must be distinct. When initializing a particular record type, the order of the initializers is free, because the Row strings are distinct. using Person = mitama::record < mitama::named<"name"_, std::string> , mitama::named<"age"_, int> >; // OK Person john = Person { "name"_v = "John"s, "age"_v = 42, }; // Also OK Person tom = Person { "age"_v = 42, "name"_v = "Tom"s, }; We can make mitama::record with CTAD (Class template argument deduction). In this case, Rows... of the record is inferred in the order of the initializers, so different record types are inferred depending on the order of the initializers. // john: record< named<"name"_, std::string>, named<"age"_, int> > auto john = mitama::record { "name"_v = "John"s, "age"_v = 42, }; // tom: record< named<"age"_, int>, named<"name"_, std::string> > auto tom = mitama::record { "age"_v = 42, "name"_v = "Tom"s, }; Use mitama::shrink to convert between records that have the same rows but in a different order. // decltype(john) _ = tom; // ERROR decltype(john) _ = mitama::shrink(tom); // OK In fact, a = shrink(b); converts b: B to a: A where A ⊆ B . using Person = mitama::record < mitama::named<"name"_, std::string> , mitama::named<"age"_, int> >; auto tom = mitama::record { "name"_v = "Tom"s, "age"_v = 42, "gender"_v = "m", // an extra row }; Person tom2 = mitama::shrink(tom); // OK Reference-level explanation How to specify string literals as a non-type template parameter Basic idea In order to specify string literals as a non-type template parameter, we first create a structural type class that holds const CharT [N] . CharT is a structural, and an array of a structural type is also structural. And the class such that all base classes and non-static data members are public and non-mutable and the types of all bases classes and non-static data members are structural types or (possibly multi-dimensional) array thereof is structural. Thus, fixed_string below is a structural type. template<std::size_t N, class CharT> struct fixed_string { static constexpr std::size_t size = N; using char_type = CharT; constexpr fixed_string(CharT const (&s)[N]) : fixed_string(s, std::make_index_sequence<N>{}) {} template<std::size_t ...Indices> constexpr fixed_string(CharT const (&s)[N], std::index_sequence<Indices...>) : s{ s[Indices]... } {} CharT const s[N]; }; We can use fixed_string as a non-type template parameter, and CTAD will automatically infer CharT and N from string literals. template <fixed_string S> struct static_string { using char_type = typename decltype(S)::char_type; static constexpr auto size = decltype(S)::size; }; int main() { using ss1 = static_string<"test">; static_assert(std::same_as<char, typename ss1::char_type>); using ss2 = static_string<u"test">; static_assert(std::same_as<char16_t, typename ss2::char_type>); using ss3 = static_string<U"test">; static_assert(std::same_as<char32_t, typename ss3::char_type>); } Complete idea In order to be able to handle std::string_view at compile time, static_string should have static constexpr std::string_view . template<fixed_string S> struct static_string { using char_type = typename decltype(S)::char_type; static constexpr std::basic_string_view<char_type> const value = { S.s, decltype(S)::size }; }; Furthermore, creating a static_string with UDL gives an appearance like "name" . The operator "" () allows us to pass string literals directly to the template parameter, so that we can construct a static_string using the fixed_string deduced by CATD. namespace mitama:: inline literals:: inline static_string_literals{ template <fixed_string S> inline constexpr auto operator ""_() noexcept { return static_string<S>{}; } } How to access to rows in a record mitama::named has a protected member operator[] for record. template <static_string Tag, class T> class named { public: static constexpr std::string_view str = decltype(Tag)::value; // ... protected: template <auto S> requires (static_string<S>::value == str) constexpr delctype(auto) operator[](static_string<S>) const noexcept { return storage::deref(); } }; Rows... in mitama::record<Rows... > should all be mitama::named . mitama::record inherits Rows... and make operator[] visible by using declaration. template <named_any ...Rows> class record : protected Rows... { public: // ... using Rows::operator[]...; }; This will enable to access rows through operator[] by overload resolution. How to check the equivalence of rows in two records In C++ 20, - lots of features can be used at compile time, - lots of classes and functions can be used at compile time, - template syntax for generic lambdas is available and - consteval is available. However, due to lot of compiler bugs, some lack of implementation and some Core Issues, it has become difficult to deliver accurate and elegant code to you. So I leave you with the challenge of the modern metaprogramming techniques in C++ 20. The code, with workarounds everywhere, can be found here . Appendix A: development environment Visual Studio 2022 Version 17.0.0 Preview 5.0 References ISO/IEC 14882:2020 Programming Languages -- C++ extensible
こんにちは😉 @ryokotmng です。 今日は社内ドキュメントの、Rust初心者向けのクックブックを公開しようと思います。 私自身コードを書くのに四苦八苦していた頃にとても助けられたので、Rustをはじめたばかりの方の参考になれば嬉しいです。 目次 [ toc ] はじめに この記事では、 The Book に記載されている知識を前提としています。 Rustを全く書いたことがない方は、先に読んでみることをお勧めします。 サンプルコードが結構長いこと、実行環境があった方が良い内容も多いことから、サンプルコードは大体Rust Playgroundのリンクとなっています。 ぜひご自身で修正して遊んでみてください。 単位つきの計算を型で厳格に縛る 例えば複数の長さの単位 (mm, cm, mなど) を扱う場合に、単位が合っていない長さ同士の計算をする場合、単位を揃える必要がありますね。 この時、最終的に欲しいのは1つの「長さ」、つまりプリミティブな数字のデータになるでしょう。 計算を行うとき、最終的に得たい「長さ」の単位は1つになるので、コード上では単位を比較して異なる場合はエラーを返す、もしくは、異なる単位の長さ同士の計算の場合はある単位に換算したうえで計算できる状態にする、のどちらかの処理が必要になります。 しかし、単位を型として定義すると、計算の実装をする際に単位をチェックするようなコードを書くことなく、異なる単位の長さ同士の計算を実装しようとしたら コンパイル エラーを出すことで、意図しない挙動を防いでくれます。 また、下記のサンプルコードのように、traitを使って単位の変換処理を実装することもできます。 サンプルコード 上記のサンプルコードには、型の書き方の他にも、以下のような多くの知識が詰め込まれています。 Rustの enum が、 JavaScript でいうUnion型のような使い方もできること ジェネリクス PhantomData (幽霊型) これらの概念を知らなくてもさらっと読んで雰囲気を掴むこともできますが、よく使うテクニックのはずなので、慣れていない方はそのような概念をひとつずつ調べながら読むことをお勧めします。 The Book にも記載されているので、ぜひ読んでみてください。 参考 (The Book): Enumを定義する 、 ジェネリクス 、 幽霊型パラメータ 参考 (外部ブログ): Rust で Phantom Type (幽霊型) なお、RustベースのWebエンジンである Servoの内部実装 でも、このパターンが使われています。よければ参考にしてみてください。 エラーハンドリング Rustは基本的に、 f() -> Result<T, E> 型でエラーを伴う処理を表します。 T が成功した場合の値, E がエラーだった場合の型です。 Result<T, E> 型は、パターンマッチで T と E のどちらに値が入っているのか判定できるので、以下のように使うことができます。 match f() { Err(e) => //エラー処理(eはE型の値), Ok(r) => // 成功したときの処理(rはT型の値), } これを利用してエラーハンドリングすると、以下のような処理を書くことが出来ます。 サンプルコード 1 更にRustでは、 ? オペレーターを利用して以下のように書くことも出来ます。 サンプルコード 2 ? を利用するために下準備で必要となるコード量が多いため、実際には以下のcrateを利用したりして使いやすくすることができます。弊社では errer crateを利用しています。 errer errer_derive OptionとResultに対する処理にcombinatorを使う Option と Result について処理を行う場合、match式で場合分けを行いながら処理を進めることも出来ますが、combinatorを使うと短く書くことができて便利です。 なお、Productionでは、 unwrap() , expect() は処理が失敗した場合にpanicを返すので原則使わない方が良いでしょう。 (テストやサンプルコードで使うのは問題ありません。 また紛らわしいですが、 unwrap_or***() 系のpanicを出さないものは問題なく使えます。) サンプルコード Productionコードでは、 Result を返す小さい関数を、 and_then() などのcombinatorを使って合成し、大きい処理を表したりするのに使います。 また単純なエラーハンドリングの場合は、combinatorで頑張らなくても、上記に挙げた ? オペレーターで処理もできるので、読みやすいように適宜調整すると良さそうです。 Rustでは厳密には Monad はありませんが、考え方は使えるので以下の記事などで少しでも理解しておくと理解しやすいでしょう。 参考: 箱で考えるFunctor、ApplicativeそしてMonad なお弊社では、 anyhow クレート を使用しています。エラーにコンテキスト情報を含める機能 ( with_context ) や便利なマクロ ( bail! など),独自のエラー型などを提供しています。 collect() で Vec<Result > と Result<Vec > を相互に変換できる イテレータ の処理で collect() を実行し、返り値として Result<Vec< >> がほしいと仮定します。 単純に collect() を実行した結果、 Vec<Result< >> が返り値となった場合でも、その逆の形に簡単に変換することができます。 以下引用 fn main() { // 全てSomeならSome(配列)を返し、どれかがNoneなら全体もNoneになる assert_eq!([Some(1), Some(2)].iter().cloned().collect::<Option<Vec<_>>>(), Some(vec![1, 2])); assert_eq!([None, Some(2)].iter().cloned().collect::<Option<Vec<_>>>(), None); } 引用元: RustでOptionやResultの配列ができてしまったときの一般的なテク4つ このテクニックを知らないままVecの複雑な処理に直面すると絶望的な気持ちになるので、すぐには使う場面がなくても、「こんなことができるんだな」くらいに覚えておく価値はあると思います。 _ の表す意味 変数名に使う => 変数が未使用であることを宣言する 型の一部として使う => 型推論 してねとRustにお願いする サンプルコード コードの公開範囲(public/private) Rustで定義したものはデフォルトでprivateで定義されます。定義されたモジュールの外で利用する場合は pub キーワードで公開することを宣言する必要があります。 サンプルコード また、pub(crate)などと指定することにより、公開する範囲を限定することができます。 参考: Visibility and Privacy 注意点 タプルも同様に、デフォルトの公開範囲はprivateです。 // Sampleタプルは外に公開されている // この場合、タプル内部のStringは非公開 pub Sample(String); // このように内部にpubをつけることで公開すことができる pub Sample(pub String); PRを出す前にやっておきたいcargoコマンド プッシュする前に、下記のコマンドを実行し、エラーがないことを確認しておきましょう。 cargo build アプリケーションをビルド cargo test テストコードを実行 cargo clippy Linterで構文チェック rust-lang/rust-clippy cargo fmt formatterにかける rust-lang/rustfmt 副作用のある処理をMock化して、実装を切り替えられるようにする 弊社では、 ビジネスロジック ( ドメイン 層など)などは、クリーン アーキテクチャ で言う外側の層に影響されないように記述しています。 例えば、 ビジネスロジック の 単体テスト を行うのに、DBや API など外部のシステムと連携したテストを作成するのは、環境構築等色々な前工程を行う必要が生じるため辛いことになります。 このため、外部リソースを使って計算を行うロジックはロジック部分と外部の連携部分を切り分けたくなります。 以下のサンプルコードでは、実際の ビジネスロジック は、その外部リソースを扱う処理に直接依存するのではなく、「外部リソースの扱い方を定義したtraitに依存するように記述する」ことで処理の分離を実現しています。 サンプルコード turbofish(::<>) turbofishとは型注釈の一種で、型を引数のように関数に対して与える表現方法です。 例えば、strに対する parse() メソッドの型定義は以下の通りです。 pub fn parse<F>(&self) -> Result<F, <F as FromStr>::Err> where F: FromStr, 出所: Primitive Type str ここで F は、 FromStr を実装している型となるように抽象化されています。 つまり、実際に使う時には コンパイラ が F の型を推定できないと、 str 型を何に変換すればいいか特定できないでしょう。 この時、推定できるように記述する方法が2つあります。 // 1. 型が決まるように束縛するxに対して型注釈をつける // なお、xの型注釈は Result<i32, _> のように省略可能 // => 理由は以下のturbofishの例に記述 let x: Result<i32, ParseIntError> = "10".parse() // 2. turbofishを使う // この際turbofishとしてはi32になることが特定できれば // エラー型はFromStrの定義からError型の具体型が特定できる let x = "10".parse::<i32>() 参考: Rustのturbofishを理解する (おまけ) cargoの独自コマンドを作る cargo bookに、「$PATHに cargo-XXX というバイナリが入っていたら cargo XXX でcargoのサブコマンドのように実行できる」との記述がありますが、これはバイナリに限った話ではなく実行権限がついていれば大丈夫です。なので、簡単なshellを登録しておいてcargoから実行することも可能です。 (例) #!/bin/sh rg -l todo: 上記のshellを$PATHの通る場所に置いておくと、 cargo todolist で todo: のあるファイルを探してくれます。出力がPATHになるので、弊社では、 VSCode のターミナルで実行 -> PathをCtrl+クリックで該当ファイルに飛ぶなどに利用している人もいます。 shell自体をPATHにあるところに置いて呼び出せばいいじゃんという声が聞こえてきそうですが、 cargo --list で利用できるサブコマンドの一覧を取れるところが強みです。 いかがでしたでしょうか? Rustはすらすら書けるようになるまでが難しい言語だと思います。私の場合は、Rustを書き始めて2ヶ月くらいの間は、他の言語ではスラスラと書けたようなロジックでも全然書けなくて、とても悲しい気持ちになりました。 ですが、書けるようになってみるとやっぱり良いところもたくさんありますし、勉強すればするほどその強力さがわかって楽しくなってくるなと感じています。 最後に、弊社でRustを長く書いているエンジニアに勉強のコツを聞いてみたのですが、「 The Book は論理から非常によくまとまっているため、何度も読むと良い」というアド バイス をいただきました。本記事のようなテクニック的なところではなく、Rustのコード自体読んでもよくわからないと言う方は、 The Book を読み直すと良いかもしれません。何度読んでも学びがある内容なので、損はないと思います。 この記事を読んでいる皆様が、Rustを楽しんでくれることを願っております! We’re hiring!!! キャディでは、エンジニアを含め全職種積極採用中です! Rustを使って開発がしたい方、会社に興味を持ってくださった方、気になるから話を聞いてみたいという方、ぜひ面談にお越しください。 弊社がRustを採用している背景や、実際に開発してみてのメリットデメリットなどは、 「Rust についてカジュアル面談で頻繁に訊かれる質問と、それに対する個人的な回答」 をご参考ください。 ご応募は こちら カジュアル面談のお申し込みは こちら 募集職種一覧 長文お読みいただき、ありがとうございました!
エンジニアリングマネージャーの村上 (@mura_mi) です。採用関連で面談に出ることが多いのですが、大体7割くらいの確率で 「なんで Rust 使ってるのですか?」「Rust 使っててどうですか」と聞かれるので先回りして書いておこうと思った記事です。 なんで Rust を選んだの Rust をエンジニアリングチームの武器の中心に据える意思決定がされたのは私の入社前ですが、伝え聞いている話しと自分の解釈を混ぜ合わせた話を書きます。 「データ指向アプリケーションを堅牢に作るのに必要な型システムを求めたこと」と、「キャディがもともと C++ の会社だったこと」の2つが、キャディが Rust を使っていることの背景にあったのだと理解しています。 後述しますが、キャディが 原価計算システム やサプライチェーン・マネジメントシステム を Rust を使って開発しはじめたのは、2019年の中頃だったと伝え聞いています。これらのシステムは、「キャディがどんなものを、いくら費やして、どのように製作し、いくらで販売するか」という、キャディのビジネスの根幹となるデータを扱った データ指向アプリケーション です。 扱うデータの複雑さや、常に変化の可能性の下にあるビジネスルールに立ち向かう手段はいくつか考えられるでしょうが、データストアを担う物理層だけでなく、アプリケーション層でデータ形式の制限をすることは常套手段のひとつでしょう。 静的型付けのないプログラミング言語を否定するつもりは毛頭ありませんが、具体的な技術選定に口出しをしない CTO も、”テストをたくさん書くことに時間を使わず、型を書いてバリエーションを自明にしてラクをしよう” とは, しばしば口にします。 ではなぜ、そこで Rust なのか?選択肢に挙がった言語たちをなぜ見送ったのかの具体的な理由を記述することは割愛しますが、選択肢の中に Rust があった背景には、CADDi がもともと C++ の会社だったことが挙げられます。 「CADDi」という社名の由来のひとつには “CAD から Direct” という意味がありました。この記事の執筆時点では、キャディがお客様からいただく設計図の情報は二次元図面がほとんどですが、創業当初のキャディのビジネスの中心にはCADデータの自動解析アルゴリズムがありました。この頃 CAD データの解析に使っていたのが C++ で、江添亮さんには一時期テクニカルアドバイザーになってもらっていた事もありました。 C++ のプログラマから見て、パフォーマンスを犠牲にせずに安全性を手に入れることができるのが Rust です。当時社内で Rust を推した一人である いなむさんのnote を読むと、少しだけ当時の雰囲気が垣間見えるかもしれません。 数多のテック企業が採用候補者を奪いあう戦国時代の中で、採用市場で「え、Rust を業務システムに使っているの?」でアテンションを惹けることは、キャディにとって良い副産物 でした。そもそも C++ と一口に言っても C++17 を 2018 年の段階で実戦投入したりしていた というのもあり、最新の技術を使っている組織だというイメージは強化されたのかなと思います。そういえば、筆者自身も「は?Rust でサービス作るの?」と釣り針に引っかかってカジュアル面談の話を聞きに行った一人でした。 Rust の用途とメリット 主に3つのエリアで使っていたり、使おうとしています。 1つは前述した、原価計算システムやサプライチェーン管理を行う「基幹システム」の開発です。 tonic (一部、まだ tower-grpc を使ってる部分もあります…😫 ) を用いて gRPC API サーバーを建てています。リレーショナルデータベースを接続してのデータ CRUD には、 diesel 上に構築した、データレコードの更新履歴も保存するフレームワークを独自に開発して利用しています。 ブラウザからユーザーがアクセスしたり、RDB のみならず Redis やメッセージキューなど多種多様なミドルウェアと接続されたシステムなので日々様々なトラブルは起きるのですが、Rust 自体が不明瞭な挙動を引き起こしたり、パフォーマンス劣化に悩まされる事象は見た記憶がありません。 (ビジネスルールの設計や実装に起因する問題だったり、データベースに異常な負荷を掛けてしまうケースが多い) 2つ目は、現在のキャディのビジネスの中心を流れる 「二次元図面」の解析を行うアルゴリズム の開発です。これに関しては、図面解析チーム (orama) のテックリードを務める寺田の記事 がとても良いので読んでほしい… のですが、Rust に関して彼の言っていることの抜粋が以下です。 Rust は目新しさがあるかもしれません。Rust は安全性とスピードを兼ね備えているので、パフォーマンスが求められるアルゴリズムの開発には本当に適していると実感しています。Python + OpenCV だけで賄えない理由は、ベクターデータの処理が必要になるからです。上で紹介した表の罫線認識では、まず画像をベクターデータに変換してから様々なアルゴリズムを組み込んでいます。ベクター化アルゴリズムを含めた各種のアルゴリズムの実装に、Rust が活躍しています それ以外に、まだ実戦投入していませんが、WASM を用いたブラウザアプリケーションの開発に利用できないかとフロントエンドエンジニア陣が試行錯誤をしています。ブラウザ上で2次元図面のデータを表示したり、その図面への注記の追加をブラウザ上でできるようにする (社内では 図面版Figma と呼ばれていたりします) ようなアプリケーションの開発が視野にあるのですが、このようなアプリケーションの開発にはどうしてもパフォーマンスを追求したくなるケースが出てくると思っており、そのときに備えて様々な技術調査をしています。 Rust を仕事で使って、「頑張らないといけない」ところ 現場で Rust をこれからも使い続けていくに際し、組織的に頑張らないといけない点もいくつかあります。 まず思い当たるのが ビルドや CI に時間が掛かる点。開発者にパワフルなマシンを貸与するという “札束勝負” もしつつ、並行ビルドが効くような工夫 も頑張っています。 独特な書き味であったり、ライフタイム、所有権など他の言語では馴染みのない概念も多く、新規参入メンバーに各種知識のキャッチアップをしてもらうのはどうしても大変です。 しかし、私達のチームは「誰もが Rust のキャッチアップを頑張る道を通った経験がある」ことが強みであると思っていて、より一層「学習の高速道路」を整備しなきゃいけないなと思っています。 言語仕様や性質から来る制約という観点では、外部通信のような比較的大きい副作用が絡むテストに於いて テストダブルを差し込むことは可能なのですが、かなりの労力が必要になる印象を持っています。これは、Rust の場合コンパイル時に Dependency Injection される具体的なデータ型が指定されている必要があることに由来しています。 (キャディでは CakePattern を使った DI を実装しています) 同様の理由で、The Clean Architecture の「domain のレイヤをピュアに保つ」という方針を徹底しきれないな、と思うこともしばしばあります。 とはいえ、長所と短所 を天秤に乗せても、ビジネスの基幹となる情報システムを、硬く・速く動くようにするという意味では良い選択肢だと思っているので、もっとうまく使いたいと思っているところです。 キャディの人って元から Rust 書ける人ばっかりなんですか? 否。 入社前から Rust を経験していたメンバーもいますが、 「がっつりチームで Rust 使った開発してた!」「ウェブアプリケーションの API サーバーを Rust で開発・運用していた」という人はいない はずです。皆それぞれのバックグラウンドをもって入社しています。先述したように、誰もがキャディに入社してから Rust 周辺についてキャッチアップをしてきた経験値を持っていることが、チームの強みなんじゃないかと思っています。 実際、 採用活動の中で候補者の方とお話する際には、Rust の経験があればもちろん良いのですが、それ以外にどういう経験してきたかを重視しています。サーバーサイドアプリケーションの開発をメインの領域とする自分からすると、しっかりとドメインモデリングに向き合ってきた経験や、他の言語で Clean Architecture をイチから書いたり、理解して使ってきた経験の方が重要だなーと思っています。 一方で、赤裸々なことを書くと、実際に社内の Rust のコードを読んでいても、API の設計をする際に参照と実態の区別をしっかりつけられていない (よって無用な .clone() の必要が生じる) ケースも結構見かけ、まだまだ Rustacean の集団として成熟しないといけないなぁと思うこともあります。 We’re hiring てなわけで、社内で書くコードが Rust オンリーというわけではないけど、キャディは「書きたい」と言えば十中八九 Rust を書ける環境だと思います。そんなCADDi の仲間になってくれる方を募集しています。「すぐ転職する感じじゃないんだけど Rust の話は聞きたい!」「Rust 書いたことないけど興味あります!」みたいな方でもお気軽にカジュアル面談からお申し込みください。 応募はコチラ https://caddi-careers.studio.site/jobs-tech-backend からどうぞ。 The post Rust についてカジュアル面談で頻繁に訊かれる質問と、それに対する個人的な回答 appeared first on CADDi Tech Blog.
はじめに こんにちは。キャディで原価計算システムの開発を担当しております、高橋です。 この記事は キャディ Advent Calendar 2020 の23日目です。前日は朱さんの 「【開発カルチャー発信 vol.1】原価計算システム開発チームの開発理念を大公開!」でした! さて本日は掲題の通り、私がスキルアップを兼ねて趣味的に取り組んでいる、コストモデル可視化システムの開発について紹介させていただきます。 目次 課題意識 弊社のビジネスの核は、コストモデル コストモデルは、名前の通り「コスト」の計算を「モデル」化したことで、原価計算という作業を弊社内で民主化しました。「正しい原価」を誰でもすばやく計算できるということです。 これが無くては弊社のビジネスがスケールアウトすることは不可能であり、スケールアウトしなければ受発注プラットフォームは作れません。従って、弊社のビジネスの核は1にも2にもコストモデルなのです。 コストモデルは生き物 しかしこの「正しい原価」というのが曲者です。正しいの定義は時々刻々と変わります。従って、コストモデルはこの正しさに常に追従する必要があります。 例えば材料費が高騰したら、原価は変わります。また、弊社が今まで対応していなかった新しい加工方法をコストモデルで取り扱えるように拡張しないと正しい原価が出せない場合もあります。このような事情で、コストモデルは常に改訂・拡張を繰り返しながら、あるべき原価を求めてさまよい続けています。 しかし、コストモデルは見えにくい このように、弊社ビジネスの核でありながらも常に動き続けるコストモデルですが、現状はRustのソースコードで実装されています。従って、コストモデルを常日頃からメンテナンスしている立場でない限り、具体的にコストモデルの定義がどうなっているのか把握しにくいというデメリットがあります。 コストモデルを作るのではなく扱う立場であっても、コストモデルが前提とするコストの構造・概念を理解していることは重要なのですが、ここで「コストモデルが見えにくい」ということが障壁になっていると私は考えています。 コストモデルをどうやって可視化するか 可視化したいものがコードの中にしか無い 上記の通り、現状ではコストモデルの定義はRustでハードコーディングされています。 こんな感じのものがたくさんコーディングされています。 コストモデルの定義の例(体積計算): 原価 = 加工時間 × 時間当たり原価 加工時間 = 加工工程 XXX の時間 + 加工工程 YYY の時間 時間辺り原価 = とある定数 加工工程 XXX の時間 = XXX加工単体の時間 × XXX加工の数 加工工程 YYY の時間 = XXX加工単体の時間 × XXX加工の数の2倍 抽象的に書いていますが、例えば穴あけ加工が1個1分かかってそれが2箇所、1分あたり100円なら、概算で200円、みたいなことを考えるとわかると思います(実際にはこんな単純ではなく、もっと複雑です)。 ここから可視化しようと思うと、Rustのコードをパースしてコストモデルの定義を抽出してくるような仕組みが必要ですが、あまり現実的ではありません。 コストモデルの定義をデータとして分離する コストモデルは数式のグラフ 上記の例から分かるように、コストモデルは数式の集合であり、それらは依存関係を持つことから、数式や定数(引数を持たない数式)をノードとする非循環有向グラフ(DAG)を考えることができます。これを数式グラフと呼ぶことにしましょう。 上記の定義を数式グラフとして表現した例: document.write("graph TD;\n原価-->加工時間;\n原価-->時間当たり原価;\n加工時間-->加工工程XXXの時間;\n加工時間-->加工工程YYYの時間;\n加工工程XXXの時間-->XXX加工単体の時間;\n加工工程XXXの時間-->XXX加工の数;\n加工工程YYYの時間-->XXX加工単体の時間;\n加工工程YYYの時間-->XXX加工の数;\n"); 例えば上図のように表現された数式グラフを計算する場合は、依存の階層の一番下から順番に計算していくと、最終的に原価を求めることができます。コストモデルの定義がどのようなものであれ、計算可能な関数と依存関係の集合であれば可能なことです。 グラフ構造をデータとして計算処理から分離できる さてこのように考えていくと、コストモデルの定義は数式グラフの中にしか登場せず、数式グラフの計算処理には登場しません。したがって、現状のようにプログラム中にコストモデルの定義をハードコーディングせずに、どこかにデータとして保存された数式グラフを計算実行処理に外側から注入して結果を得る、というやり方ができそうです。要は、コストモデルの定義を数式のグラフ構造のデータとして、計算処理から切り離すということです。 このやり方であれば、グラフ構造の保存・編集・可視化ができれば、コストモデルを可視化できると言えそうです。Rustのプログラムをパースしてコストモデルの定義を解析するよりも、大分現実味があります。 技術選定とシステム構成 上記のやり方を試すために、Frontend, backend, db を直列につないだ極めてシンプルな構成でシステムを組んでみることにしました。 component 仕様技術、ライブラリなど 役割 Frontend React + Typescript , G6.js, typed-rest-client 数式グラフと計算結果の表示 Backend Java + Spring boot + Spring Data Neo4j, mxParser 数式グラフのロードと計算 DB Neo4j 数式グラフの保存 まず、数式グラフを保存する手立てとして、グラフ構造をそのまま扱えるグラフ指向データベースを使うことにしました。とりあえず今回は、一番有名っぽいNeo4jにしました。 すると、Neo4jとエンティティ定義のマッパー(ObjectGraphMapping)に対応したフレームワークであるSpring Data Neo4j を使うのが一番楽につくれそうなので、バックエンドはJavaに決定。 バックエンドでは数式グラフを計算するので、Neo4jに文字列として保存された数式(簡単のために算術演算に限定)を動的にパースして計算する処理が必要になるのですが、そのようなライブラリをJavaから探した結果、mxParserというのがあるので使ってみました。「キャディのエンジニアなんだからパーサーくらい自分で書け」と言われそうですが、今はサクサク作って動かしたいので、あるものは最大限活用します。 UIは、業務上のスキルアップも兼ねてReact + Typescript を使うことにしました。グラフ構造を描画するUIライブラリとしては、G6.js を使ってみました。弊社で使用実績はなさそうなものの、MITライセンス・機能が豊富・公式ドキュメントが充実の3点で決めました。バックエンドのAPIを叩くクライアントは、本家が作ってて信頼できそうなのでtyped-rest-clientに(適当)。 実装 今回は簡単のため、UIからのコストモデルへのパラメータの入力は受け付けないものとします。 また、コストモデルの編集は実装が多いので、ここでは割愛します。 データベースに予め数式の定義と入力が保存されている状況から、計算と表示ができるところまでを紹介します。 特に難しいことはしていないので、同じものはこの記事を読みながらどなたでも作れると思います。 Backend ここでは、この記事のために「数式グラフのロード」「数式グラフの計算」の2つのAPIを用意してみます。 Entity定義 ノードに数式をもたせて、ノード間のエッジに、数式同士の依存関係と、依存先の数式が対応する依存元の数式中の変数名を保持させます。 SpringDataNeo4j のおかげで、クラスにアノテーションを付けるだけでグラフの構成要素として設定できます。 @NodeEntity public class ExprNode { @Id @GeneratedValue public Long id; public String name; public String expr; @Relationship(type = "SUBEXPR", direction = Relationship.OUTGOING) public ArrayList subExpressions = new ArrayList (); // 依存先の数式を定義する関数 // 第二引数で、数式中のどの変数に第一引数の数式の評価結果を割り当てるかを // 指定している public boolean setSubexpr(ExprNode node, String token) { Expression expr = new Expression(this.expr); this.subExpressions.add(new Edge(this, node, token)); } // 自身の値を計算する関数。 // 関数の引数の値が別の関数で求まる場合、再帰的に潜っていって計算する。 // Expression は mxParser が提供する数式型。 public Double evaluate() { // ここで文字列の数式をパースして関数を生成すると同時に、関数の引数に依存先の関数の評価結果を割り当てる Expression expression = new Expression(this.expr, this.subExpressions.stream() .map(edge -> new Argument(edge.startToken, edge.end.evaluate())).toArray(Argument[]::new)); return expression.calculate(); } } @RelationshipEntity(type = "SUBEXPR") public class Edge { @Id @GeneratedValue public Long id; @StartNode public ExprNode start; @EndNode public ExprNode end; // startnode の数式のどの 変数名に endnodeが対応するかを保存するフィールド public String startToken; } Repository Neo4jにアクセスするインターフェースを定義し、数式グラフをロードしてくる関数を定義します。 これもSpring DataNeo4jの恩恵を受けることが出来て、interface さえ定義すれば実装はSpringが勝手に作ってくれます。 public interface ExpressionRepository extends Neo4jRepository { // cyper query を直接書くこともできる @Query("MATCH p=(n:ExprNode)-[:SUBEXPR *]->(:ExprNode) RETURN nodes(p), relationships(p)") ArrayList getAllExprNodesWithEdges(); // query を書かない場合、実装は関数名から自動で定義される // デフォルトではグラフの深さ1までしか取ってきてくれないので、アノテーションで指定する @Depth(value=4) Option findByName(String name); } Service 作りたいAPIには、DBへの数式グラフのシード、数式グラフの取得、数式グラフ計算の3つの処理が必要なので、それをここで用意します。 @Service @Transactional @EnableNeo4jRepositories(basePackageClasses=ExpressionRepository.class) public class ExpressionService{ @Autowired ExpressionRepository expressionRepository; // 数式全体の取得 // 戻り値は適当に用意したResponse型 public ExpressionsResponse getAllExpressions() { ArrayList nodes = expressionRepository.getAllExprNodesWithEdges(); ArrayList nodeResponses = nodes .stream() .map(node -> { return new NodeResponse(node.id, node.name, node.expr); }).collect(Collectors.toCollection(ArrayList::new)); ArrayList edgeResponses = nodes .stream() .flatMap(node -> { return node .subExpressions .stream() .map(edge->{ return new EdgeResponse(edge.id, edge.start.id, edge.end.id, edge.startToken); }); }).collect(Collectors.toCollection(ArrayList::new)); return new ExpressionsResponse(nodeResponses, edgeResponses); } // 架空の体積計算を表す数式グラフを保存してみる public String seedExpressions() { // 数式ノード定義 ExprNode volume = new ExprNode("volume", "x * y * z"); ExprNode x = new ExprNode("x", "10"); ExprNode y = new ExprNode("y", "10"); ExprNode z = new ExprNode("z", "a + b"); ExprNode a = new ExprNode("a", "20"); // 定数 ExprNode b = new ExprNode("b", "30"); // 定数 // 数式中の変数に別の数式を割り当てる。 volume.setSubexpr(x, "x"); volume.setSubexpr(y, "y"); volume.setSubexpr(z, "z"); z.setSubexpr(a, "a"); z.setSubexpr(b, "b"); expressionRepository.save(volume); return volume.name; } // 数式グラフ中の指定した数式ノードの値を計算する public double calculateExpression(String name){ ExprNode rootNode = expressionRepository .findByName(name) .orElseThrow(() -> new RuntimeException()); return rootNode.evaluate(); } } Controller RestAPI経由でそれぞれのビジネスロジックを呼んでResponseを返すようにします。 @RestController @RequestMapping("/") // UI は yarn start で立てるので、そのアドレスをcorsに設定 @CrossOrigin(origins = "http://localhost:3000") @ResponseBody public class Controller { @Autowired ExpressionService expressionService; // 数式グラフをシードしてロード @RequestMapping(value = "/expressions", method = RequestMethod.GET) public ExpressionsResponse readExpressions(@PathVariable String version) { expressionService.seedExpressions(version); return expressionService.getAllExpressions(version); } // 数式グラフの計算 @RequestMapping(value = "/calculate", method = RequestMethod.GET) public String calculateSeededNode(@PathVariable String version) { // DB初期化 String rootNodeName = expressionService.seedExpressions(version); return String.format("calclation finished: %f", expressionService.calculateExpression(rootNodeName, version)); } }; UI こんな感じで、Reactのコンポーネント内でG6.jsのグラフオブジェクトを初期化した後、バックエンドからグラフ取得したグラフのデータを流し込んで描画します。(長いので一部省略しています) G6.js は pureJS ライブラリなので、Reactに組み込むのがちょっと手間です。公式のサンプルを元に実装しましたが、そのままだと警告が出るので一部手を加えました。 function GraphView() { const ref = React.useRef(null); const graph = React.useRef (null); useEffect(() => { if (!graph.current) { graph.current = new G6.Graph({ container: ReactDOM.findDOMNode(ref.current) as HTMLElement, layout: { type: 'dagre', // 有向グラフを階層的にレイアウトするためのアルゴリズム rankdir: 'LR', // レイアウトの向きを指定 ranksep: 70, // レイアウト方向のノード間隔を指定 }, // グラフ上で描画されるノードのデフォルト設定 defaultNode: { type: 'modelRect', anchorPoints: [ [0, 0.5], // source [1, 0.5], // target ], // ...その他設定は省略 }, // グラフ上で描画されるエッジのデフォルト設定 defaultEdge: { // ノードのアンカーポイントのどれとどれをつなぐかを // defaultNode の anchorPointsのindex指定で設定 sourceAnchor: 1, targetAnchor: 0, }, }); } let rest: trc.RestClient = new trc.RestClient('test', 'http://localhost:8080/'); rest .get (`expressions`) .then((res: trc.IRestResponse ) => { let data: GraphData = { nodes: res.result!.nodes.map(/* G6.js のノードの形式に変換*/), edges: res.result!.edges.map(/* G6.js のエッジの形式に変換*/), }; graph.current!.data(data); graph.current!.render(); }); return () => { graph.current!.destroy(); graph.current = null; } }, []); return ; } 起動 以上作ったものをローカルで起動させてみました。 まず、DBは公式ドキュメントに従うとDockerコマンド一発で立ち上がります。 $ docker run -p7474:7474 -p7687:7687 -e NEO4J_AUTH=neo4j/s3cr3t neo4j backend と Frontend はそれぞれ、gradle bootRun, yarn start として起動しました。 起動してみるとこんな感じです。簡素な画面ではありますが、数式のグラフを可視化できています。 上のコードでは省略していますが、G6.jsのプラグインで、画面左上に計算処理を叩くボタンを用意して、押すと計算結果を alertするようにしてみました。 volume = 10 * 10 * (20 + 30) = 5000 なので、確かにちゃんと計算できています。 これで一応、数式グラフの保存・ロード・表示まではできたことになります。 今後はもう少し作り込んで、あわよくば仕事につながったら面白そうに思っています。 おわりに 今回は、コストモデルの背景や課題に加え、それをReactとNeo4jを用いて可視化する試みをご紹介しました。 私は元々CADアルゴリズムグループとして採用されたので、Webエンジニアは初めてまだ1年程です。しかし、この記事のような小さなシステムを自分で一から一通り作ってみると、中々いい勉強になりました。 また、ここに書いた実装は私1人では達成し得なかったことで、色々な方のアドバイスを経ながらできたものです。弊社にはこのように一緒に技術を楽しんでくれるメンバーが揃っておりますので、興味を持っていただいた方は、ぜひご連絡いただければと思います。 The post React + Neo4j によるコストモデル可視化の取り組み紹介 appeared first on CADDi Tech Blog.
こんにちは。CADDi でバックエンドエンジニアをしている 高藤 です。 この記事は CADDi Advent Calendar 21日目の記事です。昨日は、寺田さんによる RustでRAMの動作原理をシミュレートする でした! 今回はRustのtracintg crateについて紹介したいと思います。 目次 はじめに キャディではバックエンドのAPIをgRPCを使って実装しています。 実装にはtonicというRustでは比較的新しいcrateを使っています。使いやすいこともあり比較的使ってみたなどの記事は散見されるのですが、今回は本番環境で運用するのに大事なloggingの観点で説明をしたいと思っています。 gRPCサーバの実装 まずはtonicのサンプルを紹介します。以下のコードはtonic にあるexamples/src/helloworld/server.rsをもとに説明を行います。 まずコードを見てみましょう。非常にシンプルなサーバです。Requestに名前を含めると返事をしてくれるそれだけのサーバですが、まずはこのコードを使っていくつか検証をして行こうと思います。 use tonic::{transport::Server, Request, Response, Status}; use hello_world::greeter_server::{Greeter, GreeterServer}; use hello_world::{HelloReply, HelloRequest}; pub mod hello_world { tonic::include_proto!("helloworld"); } #[derive(Default)] pub struct MyGreeter {} #[tonic::async_trait] impl Greeter for MyGreeter { async fn say_hello( &self, request: Request , ) -> Result , Status> { println!("Got a request from {:?}", request.remote_addr()); let reply = hello_world::HelloReply { message: format!("Hello {}!", request.into_inner().name), }; Ok(Response::new(reply)) } } #[tokio::main] async fn main() -> Result > { let addr = "[::1]:50051".parse().unwrap(); let greeter = MyGreeter::default(); println!("GreeterServer listening on {}", addr); Server::builder() .add_service(GreeterServer::new(greeter)) .serve(addr) .await?; Ok(()) } 挙動を確認するためにこちらのServerをまずは実行してみます。 ❯ cargo run --bin helloworld-server` サーバを起動すると GreeterServer listening on [::1]:50051 このように表示されてサーバが起動します。 このままRequestを送るためgrpcurlを使って見ます ❯ grpcurl -plaintext -d '{"name": "foo"}' -proto proto/helloworld/helloworld.proto -import-path ./proto localhost:50051 helloworld.Greeter/SayHello { "message": "Hello foo!" } サーバ側 Got a request from Some([::1]:58752) ちゃんと動いていますね。 あくまでサンプルですが、このコードにDBとの接続処理やロジックを記述していくことでサービスを提供できそうです。ですが、本番でちゃんと運用するにはログをちゃんと出力しないと難しいです。 上記の例では標準出力にprintln!を使ってRequestが来たことは出力されていますがtimestampもなくいつ処理されたものなのかもわかりません。 本番環境での運用を考えてgRPCサーバのloggingについて考えて見ようと思います。 env_loggerの利用 Rustではログの出力を行うためログ出力機能が抽象化されたlogcrateとその実装crateが存在しています。crates.ioでも上位にあるenv_loggerを使ってログの出力を行ってみます。 まず、Cargo.tomlに対して依存するcrateの追加を行います。dependenciesに以下の2つのcrateを追加します。 env_logger = "0.8.2" log = "0.4.11" env_loggerの初期化 main関数部分でenv_loggerの初期化処理を追加します。あわせてprintln!を使って標準出力を行っている部分をlog::info!に書き換えて見ましょう #[tokio::main] async fn main() -> Result > { // ここを追加 env_logger::init(); let addr = "[::1]:50051".parse().unwrap(); let greeter = MyGreeter::default(); log::info!!("GreeterServer listening on {}", addr); Server::builder() .add_service(GreeterServer::new(greeter)) .serve(addr) .await?; Ok(()) } こちらを改めて起動して確認をします。 起動直後 [2020-11-06T16:25:56Z INFO helloworld_server] GreeterServer listening on [::1]:50051 Requestの送信時 [2020-11-06T16:26:43Z INFO helloworld_server] Got a request from Some([::1]:34238) 無事timestampやlogレベルをあわせて出力することが出来ました。 ただし、これだけだと処理の内容がわからずlogを出力する意味があまりない状態なので処理の終了時にRequestの内容と処理が終わった旨を出力するように修正してみます。 #[tonic::async_trait] impl Greeter for MyGreeter { async fn say_hello( &self, request: Request , ) -> Result , Status> { let reply = hello_world::HelloReply { message: format!("Hello {}!", request.get_ref().name), }; log::info!("Request: {:?}, Done", request); Ok(Response::new(reply)) } } 実行結果 [2020-11-06T16:35:39Z INFO helloworld_server] GreeterServer listening on [::1]:50051 [2020-11-06T16:35:40Z INFO helloworld_server] Request: Request { metadata: MetadataMap { headers: {"content-type": "application/grpc", "user-agent": "grpc-go/1.30.0", "te": "trailers"} }, message: HelloRequest { name: "foo" }, extensions: Extensions }, Done これで意図したとおりに処理が終わった旨の出力とRequestの内容が表示されるようになりました。 もう少し実用的なアプリケーションを想定して処理を追加してみます 冒頭のimport宣言にCodeを追加します。 use tonic::{transport::Server, Code, Request, Response, Status}; 何かしらの処理を行う関数some_logic()の追加とそれを利用するようにsay_hello()メソッドの修正を行います。 #[tonic::async_trait] impl Greeter for MyGreeter { async fn say_hello( &self, request: Request , ) -> Result , Status> { let reply = hello_world::HelloReply { message: format!("Hello {}!", some_logic(&request.get_ref().name).await?), }; log::info!("Request: {:?}, Done", request); Ok(Response::new(reply)) } } async fn some_logic(name: &str) -> Result { log::info!("run some logic"); match name { "foo" => { log::error!("Failed some_logic"); Err(Status::new(Code::InvalidArgument, "who is foo")) } _ => Ok(name.to_string()), } } Requestに含む名前によってはエラーを出力するように修正を行いました。サーバを起動し、先程と同様にgrpcurlでRequestを投げると以下のような出力を得ることが出来ます。 [2020-11-07T00:39:25Z INFO helloworld_server] GreeterServer listening on [::1]:50051 [2020-11-07T00:39:36Z INFO helloworld_server] run some logic [2020-11-07T00:39:36Z ERROR helloworld_server] Failed some_logic 想定している通り失敗した時にERRORログが出力されることが確認できました。 しかしこの方法だと問題があります。 ERRORログにRequestの情報がないので複数のRequestを受けている時にどのRequestがエラーになったのか判断出来ない 今回の処理は全てasync fnにより非同期に実行されるため、ログの出力に1つのRequestからなる処理の内容がが混ざって表示される 愚直に問題を解決させるなら、RequestやRequestヘッダーにRequesを識別できるIdを含めてそれをsome_logic()関数に渡すことで解消はできます。 async fn some_logic(request_id: RequestId, name: &str) -> Result ただしこのやり方では更にlogicが複雑になったときなどに全てのlogicに対してRequestや識別子を持ち回す事を行わないと実現することが出来ません。 このような問題を解決するためにtracing crateを利用することが出来ます。 tracing crateの利用 Cargo.tomlにはすでにtracingの依存が含まれている状態なので、修正はserver.rsのみとなります。 #[derive(Default, Debug)] pub struct MyGreeter {} #[tonic::async_trait] impl Greeter for MyGreeter { #[tracing::instrument] async fn say_hello( &self, request: Request , ) -> Result , Status> { let reply = hello_world::HelloReply { message: format!("Hello {}!", some_logic(&request.get_ref().name).await?), }; tracing::info!("Done"); Ok(Response::new(reply)) } } async fn some_logic(name: &str) -> Result { log::info!("run some logic"); match name { "foo" => { tracing::error!("Failed some_logic"); Err(Status::new(Code::InvalidArgument, "who is foo")) } _ => Ok(name.to_string()), } } #[tokio::main] async fn main() -> Result > { tracing_subscriber::fmt() .with_max_level(tracing::Level::INFO) .init(); let addr = "[::1]:50051".parse().unwrap(); let greeter = MyGreeter::default(); log::info!("GreeterServer listening on {}", addr); Server::builder() .trace_fn(|_| tracing::info_span!("gRPC server")) .add_service(GreeterServer::new(greeter)) .serve(addr) .await?; Ok(()) } いくつかの修正を行っているため、修正点を列挙します。 MyGreeterにDebug traitを実装 say_helloメソッドに#[tracing::instrument]を追加 log::info, log::errorとしている部分をそれぞれtracing::info, tracing::errorとなるように修正 main関数で初期化していたenv_loggerの初期化処理を削除し、tracing_subscriberの初期化処理を追加 このコードを実行し先程のエラーが起きるRequestを送信するとログの出力が以下のようになります Nov 07 09:57:42.891 INFO helloworld_server: GreeterServer listening on [::1]:50051 Nov 07 10:02:51.585 INFO gRPC server:say_hello{self=MyGreeter request=Request { metadata: MetadataMap { headers: {"content-type": "application/grpc", "user-agent": "grpc-go/1.30.0", "te": "trailers"} }, message: HelloRequest { name: "foo" }, extensions: Extensions }}: helloworld_server: run some logic Nov 07 10:02:51.585 ERROR gRPC server:say_hello{self=MyGreeter request=Request { metadata: MetadataMap { headers: {"content-type": "application/grpc", "user-agent": "grpc-go/1.30.0", "te": "trailers"} }, message: HelloRequest { name: "foo" }, extensions: Extensions }}: helloworld_server: Failed some_logic 実際にログを出力しているsome_logic()関数内にはRequestの情報は渡していないにも関わらずログの出力にRequestの情報など付与されるようになりました。 どのような仕組みになっているのか少し説明をします。 tracing crateは In−Process Tracing機能を提供するcrateとなります。Microservice等の分散処理システムの文脈ではJaeger、Zipkinを始めとする分散トレーシングという技術を利用してどこのサービスからどのサービスへ通信がされたか、その処理時間はなどメトリクスを取得することが出来ます。tracing crateも同様にプロセス内部の処理を追跡できるような形で記録する仕組みを提供しています。 仕組みを理解する上で重要になるのが以下の3つの要素となります。 Span 処理を記録する期間を表します 名前やあわせて記録しておきたい情報を保持することができる Event Spanに記録するトレースしたい事象を表します 発生した事象を記録したい情報とあわせて保持することが出来ます Subscriber Spanや紐付いたEventを収集するための処理を表します 今回の例を上記3つの要素を明確に使ってsay_hello()メソッドの部分を書き直すと以下のようになります。 async fn say_hello( &self, request: Request , ) -> Result , Status> { let args = format!("{:?}", request); let span = tracing::span!(tracing::Level::INFO, "say_hello", request = args.as_str()); let _enter = span.enter(); let reply = hello_world::HelloReply { message: format!("Hello {}!", some_logic(&request.get_ref().name).await?), }; tracing::event!(tracing::Level::INFO, "Done"); Ok(Response::new(reply)) } 処理の冒頭でSpanを定義します 定義内容 名前: say_hello Spanに含める情報 = RequestをDebug traitを使って文字列にした情報 Span.enter()を行いSpanの中に入る事を表す。(enter()はRAIIガードオブジェクトを返し、DropされたタイミングでSpanを閉じます) event!()マクロを使って記録する内容を記述します。 上記の例からわかるとおり、#[tracing::instrument]の処理ではSpanの定義とSpan::enter()の処理を自動的に生成しています。また、tracing::info!()やtracing::error!()はEventの生成をlog crateと同様のI/Fで定義できるように作られています。 注意公式のドキュメントにも記述されていますが非同期処理内でのSpan::enter()の処理は慎重に利用するか避けることが明記されています。非同期関数の場合は#[tracing::instrument]を使った場合にただしく生成できるとドキュメントに書かれているように#[tracing::instrument]を利用することを推奨します。 tonicとの統合 すでに実装例で示していますが、tonicのServer::Builderにはtrace_fn()メソッドが用意されており、ここでRequest毎のSpanを生成しています。次の例ではRequest全体の情報をSpanに含めず、Request Headerにtrace_idという文字列の情報を出力するように変更しています。(もちろんClient側でRequestする際にIdをヘッダーに入れる必要があります) #[derive(Default, Debug)] pub struct MyGreeter {} #[tonic::async_trait] impl Greeter for MyGreeter { #[tracing::instrument(skip(self, request))] async fn say_hello( &self, request: Request , ) -> Result , Status> { let reply = hello_world::HelloReply { message: format!("Hello {}!", some_logic(&request.get_ref().name).await?), }; tracing::info!("Done"); Ok(Response::new(reply)) } } async fn some_logic(name: &str) -> Result { tracing::info!("run some logic"); match name { "foo" => { tracing::error!("Failed some_logic"); Err(Status::new(Code::InvalidArgument, "who is foo")) } _ => Ok(name.to_string()), } } #[tokio::main] async fn main() -> Result > { tracing_subscriber::fmt() .with_max_level(tracing::Level::INFO) .init(); let addr = "[::1]:50051".parse().unwrap(); let greeter = MyGreeter::default(); log::info!("GreeterServer listening on {}", addr); Server::builder() .trace_fn(|header| { let trace_id = header .get("trace_id") .map(|value| value.to_str().unwrap_or("Unknown")) .unwrap_or("Unknown"); tracing::info_span!("gRPC server", trace_id = trace_id) }) .add_service(GreeterServer::new(greeter)) .serve(addr) .await?; Ok(()) } 修正点 #[tracing::instrument(skip(self, request))] instrumentを使ってSpanを生成する場合、引数を全てSpanに含める挙動になりますが、今回はtraice_idのみを出力するために引数をSpanに含めないようにしています trace_fn(|header| ....) trace_fn()メソッドは引数にHeaderMap型をとり、Requestに含まれるHeaderの情報を取得することができます 実行結果 Nov 07 12:02:03.233 INFO helloworld_server: GreeterServer listening on [::1]:50051 Nov 07 12:02:06.875 INFO gRPC server{trace_id="Unknown"}:say_hello: helloworld_server: run some logic Nov 07 12:02:06.875 ERROR gRPC server{trace_id="Unknown"}:say_hello: helloworld_server: Failed some_logic Nov 07 12:02:12.284 INFO gRPC server{trace_id="xxxxxxxxxxxxxxxxxxxx"}:say_hello: helloworld_server: run some logic Nov 07 12:02:12.284 ERROR gRPC server{trace_id="xxxxxxxxxxxxxxxxxxxx"}:say_hello: helloworld_server: Failed some_logic 出力結果にHeaderから取得したtrace_idを含めることができました。 このようにtracing crateを利用することで非同期に実行される処理に対してContextを含めたログの出力を行うことが出来ます。 おわりに 今回取り上げたtracingにはこの処理機構を使って様々な処理を拡張するためのcrateが存在しており1つのecosystemが形成されて来ています。今回は単純にログを出力するだけのFmtSubscriberを利用しましたが、tracing-opentelemetry crateなどを利用すると前述した分散トレーシングシステムに対して出力することも可能です。 こちらは依存するopentelemetry crateの変更が激しく、今回割愛していますが興味がある方は試してみると面白いと思います。私が検証した内容では tracing-opentelemetry = "0.7" opentelemetry-jaeger = "0.7.0" opentelemetry = "0.8" 上記のような依存関係だとうまく実装が出来ましたが、すでにopentelemetry crateは0.10.0がリリースされている状態なので、本番への適用はもう少し様子を見たほうが良いかもしれません。 この記事がどこかの誰かの役に立つ日がくれば幸いです。 The post tracing crateを利用したRustのlogging方法について appeared first on CADDi Tech Blog.
頭おかしいタイトルですね。何を言っているんだお前は。 本記事は CADDi とは何の関係もありませんし、実用的価値も一切ありません。その点はご了承を。 あ、Rust が分からないからといって帰る必要はありません。この記事はほとんどRustと無関係です。なんらかのプログラム言語に親しんでいる方であれば雰囲気で読める程度の機能しか使っていないのでご安心ください。 nand2tetris 先日、こちらの記事が話題になっていました。Nand2Tetris(コンピュータシステムの理論と実装)でCPUからOSまで一気通貫で作るのが最高に楽しかった話 この記事にあるように、O’Reilly Japan – コンピュータシステムの理論と実装 、またの名を nand2tetris と呼ばれる本があります。NAND素子を出発点として簡単なゲームを作るまで(何故か作るのは名前に反してテトリスではない…)を一気通貫に説明してくれる本です。 上の記事の方は完走されたそうで、すごいですね。私は根気が続かず、途中でやめてしまいました…。お恥ずかしい。 しかしながら、やはりこの本が最高に楽しいのは前半のハードウェアのところではないかと思っています。私は本書に沿って、ハードウェアの動作をシミュレートするプログラムをRustで書いてみました。 やったのはずいぶん昔なのですが、上記の記事で思い出したので紹介してみます。キッカケを作ってくれた記事に感謝です。 NANDとフリップフロップ NAND pub fn nand(a: bool, b: bool) -> bool { !(a && b) } 言わずとしれたNAND素子です。 論理ゲートの入出力は電圧による 0/1 ですから、これを bool 型でシミュレートすることにします。この形式の関数で、2つの入力と1つの出力を持つ論理ゲートがシミュレートできることがわかると思います。 実装には && や ! といった演算子が使われていますね。こういった「高度な」演算子を使うのはここだけで、他の箇所では一切使いません。NAND素子を最もプリミティブな要素として、それを組み合わせて && のような論理演算をシミュレートしていくというのが目的なのですから、こういった演算子を使ってしまっては意味がありません。一方、nand() 関数だけはブラックボックスとして与えられるプリミティブな素子ですから、この実装だけはズルをするしかない、というわけです。 フリップフロップ nand2tetrisの紹介で「NAND素子を出発点として」と書きましたが、実はもうひとつ「フリップフロップ」も所与のものとして与えます。 コンピュータの状態遷移はクロック信号によって駆動されます。従って、コンピュータやそれを構成する部品は、次のようなクロック信号のループに駆動されて動くというモデルで考えていきましょう。 loop { hardware.clock(...); } これを踏まえて、フリップフロップを次のようなコードでモデル化します。 pub struct Flipflop { bit: bool } impl Flipflop { pub fn new() -> Self { Self { bit: false } } pub fn out(&self) -> bool { self.bit } pub fn clock(&mut self, a: bool) { self.bit = a; } } フリップフロップは1bitの状態を持っています。 入力 in が変化しても、すぐには出力 out には反映されず、内部で持っている bit の値を出力し続けます。そしてカシャッと clock が入力されたタイミングで、入力の値が内部に取り込まれます。 これをRustでシミュレートしたものが上記のコードです。out() 関数は単に self.bit を返す関数であり、clock() 関数によって内部状態を入力値に置き換えます。 clock() の実装において、値(状態)の代入という「高度な」操作が使われています。しかしこれ以降、Flipflop の内部以外では一切、mutable な状態変数への代入という操作は行いません。コンピュータは状態遷移機械であり、その「状態」を保持するための最もプリミティブな機構がこのフリップフロップです。それをシミュレートするのが目的ですから、Rust言語が備えている状態保持の機能を使ってしまっては意味がありません。フリップロップだけは、NAND素子と同様にブラックボックスとして与えられるものですから、その実装では「ズル」をしています。しかしこれ以降は、「状態」はすべてフリップフロップを組み合わせて表現していくことになります。 1bit レジスタを作る ではいよいよ、レジスタを作っていきましょう。まずは最も簡単な、1bitだけを保持するレジスタです。こんな形をしています。 フリップフロップと比較すると、load という入力が増えていることが分かります。clock のタイミングで内部の状態が遷移するという点はフリップフロップと同じです。 この構造をRustのコードにすると、次のようになります。 pub struct BitRegister { flipflop: Flipflop } impl BitRegister { pub fn new() -> Self { Self { flipflop: Flipflop::new() } } pub fn out(&self) -> bool { self.flipflop.out() } pub fn clock(&mut self, input: bool, load: bool) { ... } } BitRegister 型は、内部にフリップフロップを1つ保持しています。そして out() はフリップフロップの out() をそのまま返しています。 問題は clock() の実装です。この関数で、input と load という2つの入力に応じて内部の状態が遷移します。 実現したいのは次のような動きです。 impl BitRegister { pub fn clock(&mut self, input: bool, load: bool) { self.flipflop.clock(if load { input } else { self.out() }) } } 要するに load が true の場合のみ input が取り込まれて、load が false の時には状態は遷移しない、というわけですね。 しかし、上記は if 式を利用しています。これはズルです。物理デバイスに if を直接実現するものはありません。ですから、NANDを組み合わせて if に相当する回路を組まなくてはなりません。if すら使ってはいけないプログラミング、相当頭おかしい感じがしますが、やっていきましょう。 1bit レジスタを実現する回路は、下図のようなものです。(※ DFF と書かれているのは Data Flipflop の略で、要するに上で定義した Flipflop 型です。) Mux という素子が登場しています。これは multiplexor と呼ばれる素子で、if に相当する機能を担うものです。Rust で表現すると次のような動作をします。 pub fn mux(a: bool, b: bool, sel: bool) -> bool { if sel { b } else { a } } Mux は a, b, sel の3つの入力を持ち、sel の値に応じて a または b を出力します。上のコードは if を使ってズルをした実装になっていますが、これはあとで直すとして、まずはこの mux() を使って BitRegister::clock() の実装を書き換えてみましょう。 impl BitRegister { pub fn clock(&mut self, input: bool, load: bool) { // self.flipflop.clock(if load { input } else { self.out() }) self.flipflop.clock(mux(self.out(), input, load)) } } 上の回路図と見比べると、きちんと対応していることが分かるでしょう? これで BitRegister から if を取り除くことが出来ました。あとは mux() の実装のズルを取り除いて、全てNANDの組み合わせで実現できれば完了です。 mux() は次のように書き換えることが出来ます。 pub fn mux(a: bool, b: bool, sel: bool) -> bool { // if sel { b } else { a } (a && !sel) || (b && sel) } あとは &&, ||, ! という3つの論理演算子を nand() で表現できればOKです。 どん!答えは下記のとおりです。 pub fn not(a: bool) -> bool { nand(a, a) } pub fn and(a: bool, b: bool) -> bool { not(nand(a, b)) } pub fn or(a: bool, b: bool) -> bool { nand(not(a), not(b)) } pub fn mux(a: bool, b: bool, sel: bool) -> bool { or(and(a, not(sel)), and(b, sel)) } これで1bitのレジスタの完成です。 それにしても load という入力はどう役に立つのでしょうか? それは後ほどのお楽しみ。 16bit レジスタを作る 16bit を 1 word とするレジスタを作りましょう。まず Word を次のように定義しておきます。 pub type Word = [bool; 16]; [bool; 16] というのは長さ16(固定長)のboolの配列型を意味しています。 ところで、「配列」を使うのはズルではないのでしょうか。我々は if や && すら使ってはいけないプログラミングに取り組んでいます。「配列」は使ってはいけない「高度な」機能ではないのでしょうか。 心配は無用です。16本の導線を束にすれば、ハードウェアで [bool; 16] を実現することが出来ます。もちろん可変長の配列を使うことは出来ませんが(ハードウェアで動的に導線が増減したら怖い)、固定長なら問題ありません。 というわけで、16bit レジスタは下記のコードになります。 pub struct Register { bits: [BitRegister; 16] } impl Register { pub fn new() -> Self { Self { bits: [ BitRegister::new(), BitRegister::new(), BitRegister::new(), BitRegister::new(), BitRegister::new(), BitRegister::new(), BitRegister::new(), BitRegister::new(), BitRegister::new(), BitRegister::new(), BitRegister::new(), BitRegister::new(), BitRegister::new(), BitRegister::new(), BitRegister::new(), BitRegister::new(), ] } } pub fn out(&self) -> Word { [ self.bits[ 0].out(), self.bits[ 1].out(), self.bits[ 2].out(), self.bits[ 3].out(), self.bits[ 4].out(), self.bits[ 5].out(), self.bits[ 6].out(), self.bits[ 7].out(), self.bits[ 8].out(), self.bits[ 9].out(), self.bits[10].out(), self.bits[11].out(), self.bits[12].out(), self.bits[13].out(), self.bits[14].out(), self.bits[15].out(), ] } pub fn clock(&mut self, input: Word, load: bool) { self.bits[ 0].clock(input[ 0], load); self.bits[ 1].clock(input[ 1], load); self.bits[ 2].clock(input[ 2], load); self.bits[ 3].clock(input[ 3], load); self.bits[ 4].clock(input[ 4], load); self.bits[ 5].clock(input[ 5], load); self.bits[ 6].clock(input[ 6], load); self.bits[ 7].clock(input[ 7], load); self.bits[ 8].clock(input[ 8], load); self.bits[ 9].clock(input[ 9], load); self.bits[10].clock(input[10], load); self.bits[11].clock(input[11], load); self.bits[12].clock(input[12], load); self.bits[13].clock(input[13], load); self.bits[14].clock(input[14], load); self.bits[15].clock(input[15], load); } } 単に BitRegister を16個並べたものが Register です。動作は上のコードを読めばすぐに分かるでしょう。 それにしても Register::clock() の実装、これはひどいですね。for ループ使えや!と言いたくなります。 が、「ハードウェアに for ループはない!」という強い信念の元(?)、あえてループは使わずに実装しました。こうやってベタッと書いたほうが、回路図が透けて見える気がしませんか? 8ワード(16バイト)のRAMを作る Register を8個並べてRAMを作りましょう。骨組みは下記のようなコードになります。 pub struct RAM8 { registers: [Register; 8] } impl RAM8 { pub fn new() -> Self { Self { registers: [ Register::new(), Register::new(), Register::new(), Register::new(), Register::new(), Register::new(), Register::new(), Register::new(), ]} } pub fn out(&self, address: [bool; 3]) -> Word { ... } pub fn clock(&mut self, address: [bool; 3], input: Word, load: bool) { ... } } Register とよく似ていますが、よく見ると out() と clock() に address: [bool; 3] という引数が新たに加わっています。address は要するに、ポインタです。レジスタは8個ですから、3bit のアドレスで一意に指定することが出来ます。out() は address で指定されたアドレスのレジスタを読み取りますし、clock() は address で指定されたレジスタの値を書き換えるというわけです。 RAM8::out() 実現したいのはこういう動作です。 impl RAM8 { pub fn out(&self, address: [bool; 3]) -> Word { match address { [false, false, false] => self.registers[0].out(), [true, false, false] => self.registers[1].out(), [false, true, false] => self.registers[2].out(), ... [true, true, true ] => self.registers[7].out(), } } } もちろん match 式は「ズル」ですから、これを使わずに論理回路で分岐を実現しなくてはなりません。 1bit レジスタのときには、if 式を mux() に置き換えたのでした。ここでも mux() を組み合わせて拡張していきます。 まず次のような動作をする mux16() というものを作ります。 pub fn mux16(a: Word, b: Word, sel: bool) -> Word { if sel { b } else { a } } ほとんど mux() と同じに見えますが、入出力が bool から Word (16bit)に拡張されていることに注意してください。 これは、次のように mux() をひたすら16個ならべることで実装できます。 pub fn mux16(a: Word, b: Word, sel: bool) -> Word { [ mux(a[ 0], b[ 0], sel), mux(a[ 1], b[ 1], sel), mux(a[ 2], b[ 2], sel), mux(a[ 3], b[ 3], sel), mux(a[ 4], b[ 4], sel), mux(a[ 5], b[ 5], sel), mux(a[ 6], b[ 6], sel), mux(a[ 7], b[ 7], sel), mux(a[ 8], b[ 8], sel), mux(a[ 9], b[ 9], sel), mux(a[10], b[10], sel), mux(a[11], b[11], sel), mux(a[12], b[12], sel), mux(a[13], b[13], sel), mux(a[14], b[14], sel), mux(a[15], b[15], sel), ] } 続いて、次のような動作をする mux4way16() というものを作ります。 pub fn mux4way16(a: Word, b: Word, c: Word, d: Word, sel: [bool; 2]) -> Word { if sel[1] { mux16(c, d, sel[0]) } else { mux16(a, b, sel[0]) } /* 次のコードと等価 match sel { [false, false] => a, [true, false] => b, [false, true ] => c, [true, true ] => d, } */ } これは下記の実装で実現できることがすぐ分かるでしょう。 pub fn mux4way16(a: Word, b: Word, c: Word, d: Word, sel: [bool; 2]) -> Word { mux16(mux16(a, b, sel[0]), mux16(c, d, sel[0]), sel[1]) } 同様にして mux8way16() を作ることが出来ます。 pub fn mux8way16( a: Word, b: Word, c: Word, d: Word, e: Word, f: Word, g: Word, h: Word, sel: [bool; 3] ) -> Word { mux16( mux4way16(a, b, c, d, [sel[0], sel[1]]), mux4way16(e, f, g, h, [sel[0], sel[1]]), sel[2] ) } これを使って、RAM8::out() は次のように実装できます。 impl RAM8 { pub fn out(&self, address: [bool; 3]) -> Word { mux8way16( self.registers[0].out(), self.registers[1].out(), self.registers[2].out(), self.registers[3].out(), self.registers[4].out(), self.registers[5].out(), self.registers[6].out(), self.registers[7].out(), address) } } 以上で、指定された address のレジスタを読み取る回路が作れました。 RAM8::clock() address からの読み取りは出来ましたから、今度は address への書き込みを実装しましょう。実現したいのはこういう動作です。 impl RAM8 { pub fn clock(&mut self, address: [bool; 3], input:Word, load: bool) -> Word { match address { [false, false, false] => self.registers[0].clock(address, input, load), [true, false, false] => self.registers[1].clock(address, input, load), [false, true, false] => self.registers[2].clock(address, input, load), ... [true, true, true ] => self.registers[7].clock(address, input, load), } } } しかし、ちょっとこれは無理があります。このコードは address の値に応じてクロック信号を入力するレジスタを切り替える書き方になっていますが、クロック信号は常に全ての素子に入力し続けなくてはなりません。 ですので、こんなふうな方針に切り替えます。 impl RAM8 { pub fn clock(&mut self, address: [bool; 3], input:Word, load: bool) -> Word { let load8: [bool; 8] = match address { [false, false, false] => [load, false, false, false, false, false, false, false], [true, false, false] => [false, load, false, false, false, false, false, false], [false, true, false] => [false, false, load, false, false, false, false, false], ... [true, true, true ] => [false, false, false, false, false, false, false, load], }; self.registers[0].clock(input, load8[0]); self.registers[1].clock(input, load8[1]); self.registers[2].clock(input, load8[2]); self.registers[3].clock(input, load8[3]); self.registers[4].clock(input, load8[4]); self.registers[5].clock(input, load8[5]); self.registers[6].clock(input, load8[6]); self.registers[7].clock(input, load8[7]); } } 常に全てのレジスタにクロック信号が入力されていることが一目瞭然ですね。input も常に全てのレジスタに入力されています。 ではどうやって指定された address だけに書き込む制御をしているかというと、ここで load ビットが活躍します。指定された address のレジスタだけ load に true を入力することで、この制御をしています。BitRegister で仕込んだ load 入力の伏線を、ようやくここで回収することが出来ました。 では今までと同様に、if や match を使っている箇所(load8 を求めている箇所)を論理回路に置き換えていきましょう。 まず DMux (Demultiplexor)という素子を作ります。これは次のような動作をするものです。 pub fn dmux(input: bool, sel: bool) -> [bool; 2] { match sel { false => [input, false], true => [false, input], } } これは次のような論理回路で実現できます。 pub fn dmux(input: bool, sel: bool) -> [bool; 2] { [and(input, not(sel)), and(input, sel)] } これを組み合わせて、次の動作仕様の dmux4way() dmux8way() を作ります。 pub fn dmux4way(input: bool, sel: [bool; 2]) -> [bool; 4] { match sel { [false, false] => [input, false, false, false], [true, false] => [false, input, false, false], [false, true ] => [false, false, input, false], [true, true ] => [false, false, false, input], } } pub fn dmux8way(input: bool, sel: [bool; 3]) -> [bool; 8] { match sel { [false, false, false] => [input, false, false, false, false, false, false, false], [true, false, false] => [false, input, false, false, false, false, false, false], [false, true, false] => [false, false, input, false, false, false, false, false], ... [true, true, true ] => [false, false, false, false, false, false, false, input], } } これらから match 式を除去して論理回路としてどう実装できるか、考えてみて下さい。 これを使うと、RAM8::clock() は次のように実装できます。 impl RAM8 { pub fn clock(&mut self, address: [bool; 3], input: Word, load: bool) { let load = dmux8way(load, address); self.registers[0].clock(input, load[0]); self.registers[1].clock(input, load[1]); self.registers[2].clock(input, load[2]); self.registers[3].clock(input, load[3]); self.registers[4].clock(input, load[4]); self.registers[5].clock(input, load[5]); self.registers[6].clock(input, load[6]); self.registers[7].clock(input, load[7]); } } おわりに このあとは、RAM8 を8個並べて組み合わせて RAM64 を作り、RAM64 を8個並べて RAM512 を作り…、と続けてRAMを大きくしていきます。 そして、CPUを論理ゲートとレジスタの組み合わせから構成し、CPUとRAMを繋げて、ROMから機械語コードを読み出して実行するようにしていきます。 これが組み上がって動いたとき、何とも言えない感動を覚えたものです。特にレジスタやRAM周りの仕組みにワクワクしました。コンピュータというのは、クロック信号でカチカチと動いていく壮大なピタゴラ装置なんだということが実感できました。 書いたコードはここに置いてあります。ドヤァ!https://github.com/u1roh/nand2tetris …と思ったら、あれ?これ動かないっすね…。 ディスプレイをシミュレートするところを glium という OpenGL ラッパーで作ったのですが、久しぶりに動かそうとしたら動かない…。 今ちょっと原因を調べる時間も取れないので、すんません、ダサい感じの終わり方になりましたが、以上です。 The post RustでRAMの動作原理をシミュレートする appeared first on CADDi Tech Blog.
こんにちは。テクノロジー本部バックエンド開発グループの江良です。 この記事は CADDi Advent Calendar 19 日目の記事です。昨日は、狭間さんによる「GraphQL PaginationのNestJSでの実装」でした! 「バックエンド開発グループの〜」と自己紹介したばかりで恐縮なのですが、今日はフロントエンドの話をします。 目次 はじめに これはなに Apollo Client の 3.0 で追加されたキャッシュ周りの新機能を試してみた記事です offsetLimitPagination と relayStylePagination について触れています 実際に手元で動かせるコードを使って、ステップ・バイ・ステップで説明します 能書きはいいからコードを見せてくれ、という人はこちらをご覧ください。gushernobindsme/apollo-client-v3-practice まえがき 私が所属する原価計算システムの開発チームでは、 バックエンド BFF フロントエンド という構成でシステムを提供しています。 バックエンド・BFF 間は gRPC、BFF・フロントエンド間は GraphQL で通信しています。 フロントエンドから BFF の GraphQL サーバにアクセスする際に使用しているのが、今回お話する Apollo Client というライブラリです。 弊チームでは、現在 Apollo Client のバージョン 2.6.9 を使用しているのですが、3.0 以降で登場したキャッシュ周りの機能がなかなか便利そうだったので、今後のバージョンアップに備えて試してみたことをまとめてみます。 使用したライブラリのバージョン 検証には以下のバージョンを使用しました。 @apollo/client: 3.3.4 graphql: 15.4.0 Apollo Client について Apollo Client のキャッシュとは Apollo Client は、GraphQL クエリの結果をインメモリのキャッシュに保存します。 クエリの結果は正規化して保存され、 InMemoryCache というクラスから簡単に操作できます。 InMemoryCache は 公式ガイド にも記載の通り、簡単に使い始められます。 import { InMemoryCache, ApolloClient } from '@apollo/client'; const client = new ApolloClient({ // ...other arguments... cache: new InMemoryCache(options) }); 保存されたキャッシュには InMemoryCache の以下のメソッドを使うことでアクセスできます。 readQuery readFragment writeQuery writeFragment また、Apollo Client 3.0 からはキャッシュ内の個々のフィールドを更新するために modify というメソッドが追加されています。「mutation を実行した後、その結果をキャッシュに書き戻したい」といったユースケースで便利です。 詳細は 公式ガイド のほか、弊社フロントエンドエンジニアの 桐生さんの記事 にも詳しく書かれていますので、気になる方は読んでみてください。 Apollo Client 3.0 の新機能 Apollo Client 3.0 ではいくつもの新機能が追加されています。 詳細は Apollo の公式ブログ と マイグレーションガイド に譲りますが、その中でも特にパワフルなのが Pagination helpers の追加です。 これは文字通りページネーションの実装を助ける便利なヘルパ機能になります。 ページネーションの設計 さて、ここでちょっと脱線してページネーションを実現する API の設計方針について考えてみましょう。 ページネーションの設計は数あれど、大まかなパターンとしては以下の二種類に整理できるかと思います。 オフセットベース(Offset-based pagination) カーソルベース(Cursor-based pagination) オフセットベース オフセットベースはいわゆる offset と limit を使ってページングを行うやり方です。 offset にデータの取得開始位置を指定し、 limit に取得するデータ件数を指定します(SQL を書いたことのある人には馴染みのあるアレですね)。 例えばこんな風に指定すると、 SELECT * FROM transactions LIMIT 10 OFFSET 20; 先頭の 20 行目から 10 件のデータを取得してください、という意味になります。 カーソルベース カーソルベースはデータの取得を開始する位置をインデックスではなく、トークンで指定するやり方です。 この方式では first にデータ件数、 after にデータの取得開始位置を表す base64 エンコードされたカーソルを指定します。 { user { id name friends(first: 10, after: "opaqueCursor") { edges { cursor node { id name } } pageInfo { hasNextPage } } } } この方式は GraphQL のサイトにてベストプラクティス として紹介されているほか、GraphQL クライアントの Relay でも紹介されています。 GraphQL Cursor Connections Specification 閑話休題 さて、話を Apollo Client に戻します。 Apollo Client 3.0 では、上述した二種類の API のページング処理をいい感じにしてくれる便利な機能を提供しています。 Pagination helpers は InMemoryCache に対するオプションとして設定できます。 先ほど紹介した InMemoryCache のインスタンスを生成するコードを思い出してみましょう。 公式ガイド によると、ここに offsetLimitPagination を指定するとオフセットベースの API のページング処理がいい感じになります。 const cache = new InMemoryCache({ typePolicies: { Query: { fields: { comments: offsetLimitPagination(), }, }, }, }); また公式ガイドの このページ によれば、ここに relayStylePagination を指定するとカーソルベースの API のページング処理もいい感じになるそうです。 const cache = new InMemoryCache({ typePolicies: { Query: { fields: { comments: relayStylePagination(), }, }, }, }); 本当に、そんなうまい話があるのでしょうか? サンプルコードで学ぶ Apollo Client 3.0 導入 ということで、早速コードを書いて検証してみます。 やってみたことは以下の通りです。 オフセットベースのレスポンスを返す GraphQL のエンドポイントを実装する カーソルベースのレスポンスを返す GraphQL のエンドポイントを実装する Apollo Client 3.0 を組み込んだフロントエンドを実装し、Pagination helpers を設定する ここでは、ページングの動作を検証するためのシンプルな CRUD アプリケーションを実装してみます。 概要 ということで完成したのがこちらのリポジトリです。gushernobindsme/apollo-client-v3-practice backend ディレクトリに NestJS 製の Graph サーバを実装 frontend ディレクトリに React 製のフロントエンドを実装 という構成になっています。 ページネーション以外の話題については、本記事では省略します。NestJS を使ったバックエンドの実装については、前日の狭間さんの記事に詳しく書いてありますので、是非読んでみてください! Offset-based なページネーションを実装する まず、オフセットベースの GraphQL の定義を用意します。 (バックエンド側の実装については割愛します。) type Query { sharks(offset: Int, limit: Int): [Shark] } type Shark { id: Int originalTitle: String japaneseTitle: String rate: Int } 次に、 offsetLimitPagination を設定した InMemoryCache を用意します。 const cache = new InMemoryCache({ typePolicies: { Query: { fields: { sharks: offsetLimitPagination(), } } } }) const client = new ApolloClient({ // other settings cache }); GraphQL のドキュメント定義を用意して、 const GET_SHARKS = gql` query getSharks(offset: Int,limit: Int) { sharks(offset: offset, limit:limit) { id originalTitle japaneseTitle rate } } `; 戻り値の型を用意して、 interface SharksModel { sharks: Shark[]; } useQuery の hooks を実装します。 const { loading, error, data, fetchMore } = useQuery ( GET_SHARKS, { variables: { offset: 0, limit: 10 }, }, ); 最後に hooks を呼び出す component を実装して完成です。 // ... 略 // ... 略 {data && data.sharks.map((shark) => { return ( {shark.id} {shark.originalTitle} {shark.japaneseTitle} {shark.id && ( )} ); })} // ... 略 次の 10 件をフェッチするためのボタンも設置します。 次のデータの取得は fetchMore メソッドを呼ぶことで簡単に実装できます。Core pagination API – Client (React) – Apollo GraphQL Docs { await fetchMore({ variables: { offset: data?.sharks.length, }, }); }} > fetch more Cursor-based なページネーションを実装する 次にカーソルベースの GraphQL の定義を用意します。 お作法にしたがって connection に edges と pageInfo を、 edges に node を定義してみます。 type Query { sharks(first: Int!, after: String): SharkConnection } type SharkConnection { edges: [SharkEdge] pageInfo: PageInfo } type SharkEdge { node: Shark cursor: String } type PageInfo { endCursor: String hasNextPage: Boolean } 次に、 relayStylePagination を設定した InMemoryCache を用意します。 const cache = new InMemoryCache({ typePolicies: { Query: { fields: { sharks: relayStylePagination(), } } } }) const client = new ApolloClient({ // other settings cache }); GraphQL のドキュメント定義を用意して、 export const GET_SHARKS = gql` query getSharks(cursor: String) { sharks(first: 10, after:cursor) { edges { cursor node { id originalTitle japaneseTitle rate } } pageInfo { endCursor hasNextPage } } } `; 戻り値の型を用意して、 interface SharksModel { sharks: SharkConnection; } useQuery の hooks を実装します。 const { loading, error, data, fetchMore } = useQuery ( GET_SHARKS, { variables: { cursor: '' }, }, ); 最後に hooks を呼び出す component を実装して完成です。 // ... 略 // ... 略 {data && data.sharks && data.sharks.edges && data.sharks.edges.map((shark) => { const node = shark.node; return ( {node?.id} {node?.originalTitle} {node?.japaneseTitle} {node?.id && ( )} ); })} // ... 略 次の 10 件をフェッチするためのボタンはこんな感じです。 {data && data.sharks.pageInfo?.hasNextPage && ( { await fetchMore({ variables: { cursor: data?.sharks?.pageInfo?.endCursor, }, }); }} > fetch more )} 動作確認 実装が一通り書けたのでさっそく動かしてみましょう。 「fetch more」ボタンを押すと次の 10 件が表示されます。 さっそくデータを追加してみましょう。 追加できました。 ☆アイコンを押して評価をつけることもできます。 ( フランケンジョーズ は CG が本当にひどいので☆ 1 つです。) こちらも無事更新できました。 (ちょっとわかりにくいのですが)実際にうまくキャッシュが動作している様子は、先ほどご紹介したリポジトリをクローンして起動することでも検証できます。是非お手元で動かしてみてください。 おわりに ということで Apollo Client 3.0 で追加された新機能 Pagination helpers のご紹介でした。 明日は、寺田さんによる「RustでRAMの動作原理をシミュレートする」です。お楽しみに! The post Apollo Client 3.0 ではじめる快適キャッシュ生活 appeared first on CADDi Tech Blog.
こんにちは! @ryokotmng です。本記事は、 キャディ Advent Calendar 2020 – Qiita の4日目の記事です。昨日の記事はagate-prisさんの Orphan Ruleよありがとう ~Rustを採用したおかげでリファクタリングが捗った話~ でした。 キャディのエンジニアがどんな開発環境で仕事をしているのかについて、アンケートをとってみました。その結果を (私の心の声を挟みつつ) まとめてみましたので、本日は弊社エンジニアチームの雰囲気を感じてもらえるとうれしいなと思っています💁 回答してくれたエンジニアの経歴と今のお仕事 合計18人のエンジニアが回答してくれました!まずは、回答してくれたエンジニアたちのバックグラウンドと今のお仕事について、ざっくり見ていきたいと思います。 エンジニア経験は何年くらいですか? ※ 一部未回答の質問もあるため、グラフの数字の合計は必ずしも全回答者の合計人数と一致しません。 ※ 縦軸は人数です。 一番経験が長い言語は? ※ 縦軸は人数です。 経験年数、言語共に、バックグラウンドはかなり多様なようです! 今ざっくり言うとどんな開発をしている? 今一番よく使う言語は? ※ 横軸は人数です。 最もよく使われているのはRustでした。 キャディではほぼ全てのサービスでバックエンドをRustで書いており、フロントエンドやBFFではTypeScriptを使っています。また、フロントエンドやバックエンドの担当と言っても境界ははっきりしているわけではなく、バックエンドエンジニアがBFFやフロントを書いたり、その逆もよくあります。 お待ちかね、開発環境について 使っているPCは? ※ 以下円グラフ内に記載された数は回答者数です。 Macを使っている人が多いですね。 個人的には、自作PCを使っている人が4人もいることに驚きました!Rustを用いた開発では、コンパイルに時間がかかることやメモリが足りなくなりがちなこともあり、高スペックなPCを求めて自作しようと思うようになるのかもしれません。 自作PCの詳細を教えてくれた人たちは、Ryzen 9 3900X + GEFORCE RTX 2080 SUPERを使っているそうです。「コンパイル速度もゲームもいい感じ」とのこと。(Ryzen、私も使ってみたいです!めっちゃ速いんだろうなぁ。) バックエンドエンジニアの @kuwana_kb_ さんより、自慢の自作PCの写真をいただきました🧑🏻💻もはやSFっぽい写真ですね!かっこよすぎて、ウェブから適当にイケてる写真を引っ張ってきたんじゃないかとつい疑ってしまいました (ご本人が作成したものです)w 他にも、「ノートPCながらGTX1070搭載はなかなか熱いと思ってます!(排熱的にも)」という意見もありました。 使っているOSは? 使っているシェルは? zshを使っている人が7割と大多数でした。 fishを使っている人は、「履歴補完が便利で他のshellを使う気にならない」とのこと (そんなにすごいのか、ちょっと使ってみたいな...)。 Nushellは知らなかったのですが、Rust製なのですね!2019年9月頃公開された新しいシェルで、fishのように入力補完機能が強力なようです。 使っているエディタは? ※ 複数回答が含まれます。 VSCodeが最も多く、VimとIntelliJ系がそれに続きました。「VSCodeはデファクトスタンダード」という声や、「IntelliJ系の補完、リファクタリング機能、文字列選択機能が優秀」、「Vimは思考のスピードで編集できる」などの感想もあり、なかなかどれが良いとは一概に言えなそうですね。複数使い分ける派の人も数人いました。 なかには、同じJetBrains製品を複数個使い分けている人も。「Rust は CLion、TypeScript は WebStrom、インフラコードは IntelliJ、という風に使い分けています。どのコードを書く時にも同じ操作感で使える点が気に入っています。CLion は Rust の静的解析がとにかく速いので助かってます」とのことです。 VSCodeにはTabNineというAIによるオートコンプリート機能のプラグインがあり、これをおすすめするエンジニアもいました。 これは開発に必須!と思われる便利なツールは? fzf + ghq、zsh-autosuggestions、fishの履歴補完機能など、コマンドを補完するためのツールが多く挙げられていました。 Vimの場合はLSPのcocを使用している人が多いようです。ほかには、ripgrep、iTerm、tmuxなどを使っている人が多いみたいです。 個人にインタビューする機会があれば、この辺は実際の作業を見ながら細かく突っ込みたいところです!(今回の企画が好評だったらやりたい!) 使っているキーボードは? ※ 複数回答が含まれます。 この結果には含まれていませんが、私も今年のクリスマスにHHKBデビューする予定です。HHKBは大人気な一方で、打鍵音がやや大きいため、「奥さんに不評なので、Apple公式のものと使い分けている」という意見も。 普段使うぶんには気になりませんが、ご家庭がある方は、特に音の問題は難しいですよね。 Nizを使っている人からは、「キーが軽くて深くてしっかり戻ってくるのでかなり打ちやすいです。キーバインドがないので Vim & tmux を使う方でも衝突しなくてよい」という声もありました。 こちらはミートアップ等でおなじみ、エンジニアリング・マネージャー @mura_mi さんのキーボード。Realforceの変荷重だそう。配色が可愛いです✨英字配列であることには拘っているとのことです。 自宅、見せてください!! さて、ここで数人のエンジニアの自宅のデスクを写真でお届けしちゃいます💁🏻 まずはアルゴリズムエンジニア、 @ngtkana さんのデスク。キーボードはNiZですね⌨️左手にペンがありますが、メモを取る時にはiPadのGoodNoteを使っているそうです。左手にある書籍スタンドが便利そうすぎて、これを見てつい私もポチってしまいました❣️ アルゴリズムエンジニアいなむさんのデスク。手元でメモを取れるようにiPadを置いていて、Notabilityというアプリを使っているそうです。奥で虹色に光る自作PCがきれいですね🤤こだわりは、「IDEの背景が可愛いキャラクターであることが重要だと思っています」とのこと。 バックエンドエンジニア、 @gushernobindsme さんのデスク。 スッキリしているなかにもオーディオ機器へのこだわりがみられますね。ディスプレイの左手に立てかけてあるのは、プライベート用のPCでしょうか?「 サウナイキタイ 」のステッカーが貼ってありますね🧖‍♂️ 次にバックエンドエンジニアの木村さんです。 ドラマに出てきそうなかっこいい空間に仕上がっています😍 左側のディスプレイの上にある丸いものは、カメラと女優ライト的なやつでしょうか!?これなら登壇や動画配信もバッチリですね💪🏻 こちらは @mura_mi さんのデスク。 ディスプレイやキーボードスライダーはリモートワークの影響で整備したそう😃 メモ用の紙・ノート類は、「1週間のタスクを書き続けるスケジュール手帳」「1on1記録ノート」「ブレスト用のA4コピー用紙」3種類を使っているそうです。 ついテンションが上がってしまいました💦 私の家の環境はしょぼすぎて残念ながら公開できないのですが、みなさんの写真を見ると環境を整えたくなりますね! 仕事に欠かせないものは? 下記3つは、どれも10人以上から回答されていました。 - ディスプレイ - イヤフォン (含ネックスピーカー) - 良い椅子 他にも、お菓子や音楽、ノートは多くの人が挙げていました。 リモートワークの影響でイヤフォンの長時間利用から耳を痛める人も出てきていて、ネックスピーカーを購入した人も数人いました。私も最近愛用しているのですが、かなり軽くてちょっと席を離れる時もつけてて気にならないので (何より耳が痛くないの最高) 個人的には重宝しています。 一方で、「イヤフォンにノイズキャンセリングは必須」という声や、「FocalのStellia(ヘッドホン)は最高に良い音だぞ...」との意見もありました。こちらの方が没入感があって集中力を上げやすそうなので、使い分けるのも良さそうですね。 @kuwana_kb_ さんより、Focalのヘッドホン。またまた写真がイケてますね!悔しいけどかっこいいです! 椅子については、「思い切ってコンテッサの椅子を買ったら腰痛が一気に楽になった」との意見も。また、電動昇降机を使用している人や、座椅子で仕事をしているという人もいました。キャディも基本的にはリモートワークなので、家にいながら生産性を上げる環境は大切ですね。(ぐぬぬ〜〜、椅子高いけど、いいやつ欲しいな〜〜〜〜。) また、「マイクはAKGのC451という楽器用のものを使用しています~」、「毎シーズン良いダージリンを取り揃えております」というこだわり派も。 木村さんのマイク。本格的すぎる😯 いなむさんこだわりのダージリン。収穫年やグレードまで書いてあります! 他にも、確かに!と思ってしまったものに、このような意見もありました→「最近は、開発する人間の環境(近くの飯屋とか)のほうが重要度が高い気がします」。家の近くのランチにはもう飽きた...なんて方も世の中には多いのかもしれません。 最近気になるものは? 下記のようなものが複数人から挙げられていました。 - M1搭載Mac mini - キーボード - 4Kディスプレイ - 椅子 - 自作PC 自作PCについては、私も年末年始を利用して取り組んでみたいと思っています!社用PCは特に問題ないのですが、Rustを始めてから、自分のMac book pro (ケチって4コア😇) ではなかなか開発に苦労するようになってきてしまいました💦 いかがでしたでしょうか、楽しんでいただけましたか?これまで開発環境について話したりする機会は少なかったので、書いている私としてもとても勉強になりました。 キャディでは、楽しいエンジニアたちが日々業界を変えるために頑張っています!今回は仕事の話は全然していませんが、キャディでの開発や技術に興味を持ってくださった方は、ぜひ カジュアル面談に申し込んでみてください ☺️ お読みいただきありがとうございました!寒い日が続きますが、健康に気をつけてくださいね〜!
業務でRustのコードを書いていて、 rustfmt が失敗する事象に遭遇した。 少し調べたところ、 MatchArms の後にカンマを含むコメントがあると、うまく動かないことが分かった。 以下は2つの連続した改行が1つの改行に詰められることを期待したコードである。 rustfmt はマッチ式全体のフォーマットを諦めてしまう。 fn f() { let x = 0; match x { 0 => {} 1 => {} _ => {} // foo // bar, } } 尚、マッチ式の外のフォーマットは継続される。ファイル全体がフォーマットされなくなったりはしない。 おそらく、コメント中のカンマと MatchArms 中の Expression に対応するカンマの区別が出来ず、混乱していると思われる。 いくらか恐ろしいのは、 rustfmt は上記のコードについてフォーマットが失敗したことを一切エラーとして報告しないことだ。フォーマットに失敗するコードがCIの際に検知されず、デプロイまで素通りしてしまう恐れがある。 マッチ式の途中に意図的に空白だけの行を配置する等の手段でエラーとして報告させることはできる。その場合は error[internal]: left behind trailing whitespace として報告される。フォーマット後のバリデーションチェックで( rustfmt 自身の)エラーチェックを行っているものと思われる。 この事象は GitHub でIssueとして報告した。 rustfmt fail to format if there is a multi-line comment at the end of the match expression and it is terminated by a comma · Issue #4037 · rust-lang/rustfmt
キャディのバックエンドエンジニアをして働いている高藤です。 キャディでは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
はじめに はじめまして、キャディでバックエンドエンジニアとして働いている高藤です。 キャディでは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 以外にも様々なライブラリ、 フレームワーク が存在するので、そちらも試していただければと思います。
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 の導入を検討している方の参考になれば幸いです。