RustでWebアプリケーションのバックエンドを開発するには ─ 型システムの堅牢性と柔軟性を業務システムにも!

安全性に大きな特徴があるプログラミング言語Rustは、C言語やC++に代わるシステム記述言語として注目されてきました。しかし、その安全性とパフォーマンスの高さにより、ビジネスアプリケーションの構築にも採用されています。この記事ではキャディ株式会社による事例を紹介します。

RustでWebアプリケーションのバックエンドを開発するには ─ 型システムの堅牢性と柔軟性を業務システムにも!

こんにちは。キャディ株式会社バックエンドエンジニアの松田と申します。

キャディ株式会社では、「モノづくり産業のポテンシャルを解放する」をミッションとして、製造業分野に関連するさまざまなソフトウェアの開発を行っています。また、そのための主要な開発言語としてRustを採用しており、Rustで書かれた複数のWebアプリケーションバックエンドを開発・運用しています。

Rustについては、安全性やパフォーマンスの高さが注目を集めており、近年は国内外での採用事例や、言語機能に関する解説記事も増えてきているところです。しかしながら、他のメジャーな言語に比べるとまだまだ事例は少なく、業務での具体的な開発事例についての情報もそれほど多くはありません。

キャディでは2018年からRustによる開発を開始しており、その中で、試行錯誤を繰り返しながら、社内におけるRust開発のプラクティスを蓄積してきました。この記事では、その中で得られた知見を紹介することで、これから開発現場にRustを導入したいと考えているエンジニアの方々に微力ながら貢献できればうれしいです。

キャディでのRust活用シーン

前提として、キャディにおいてどのようなシーンでRustを活用しているか、紹介させてください。キャディにおけるRustの活用シーンは、大きく以下の2つに分かれます。

  • Webアプリケーションのバックエンド開発
  • 図面解析などのアルゴリズム開発

同じRustを使っているとは言っても、この両者では技術的特性や求められるスキルセットが異なっています。筆者は、前者のWebアプリケーションバックエンドの開発を担当しており、この記事も、この分野を想定した内容となっています。

Webアプリケーションバックエンドの具体的な内容としては「受発注・サプライチェーン管理システム」や「サプライパートナー(加工会社)向けポータル」などがあります。ざっくり言ってしまえばBtoBの業務系Webアプリケーションです。技術スタックとしては、マイクロサービス構成の中のgRPCサーバとして運用されるものが主ですが、GraphQLサーバも存在します。

なぜRustを使うのか

Rustといえば、C/C++に匹敵するパフォーマンスや、メモリ安全性を両立していること、ベアメタル環境での低レイヤプログラミングが可能な言語であることに注目が集まることが多く、低レイヤのシステムプログラミング向け言語というイメージを持たれがちだと思います。しかしながら、キャディで開発しているWebアプリケーションバックエンドでは、このような特性は特に求められません。

それではなぜRustを利用しているかというと、最大の目的は、型システムの堅牢性と柔軟性の恩恵を得ることです。キャディが開発している業務系Webアプリケーションでは、たくさんのフィールドを持ったドメインモデルに複雑な業務ロジックを適用するといった局面も登場しますが、Rustの型システムにより、ヒューマンエラーを防止しながら実装できています。

例えば、ResultOptionといった型により、「エラーが発生するかもしれない」とか「値が存在しないかもしれない」といった情報を、型として表現できます。これにより、例えば「『値が存在しないパターンがある』という事実に気づかないままロジックを実装してバグを埋め込む」といった事態を防止できます。

また、アプリケーションを実装していると、ドメインモデルが3値以上の「タイプ」を持っていて、この「タイプ」によって処理が分岐するというシチュエーションが発生することがあります。Rustの場合、match式を使えば、存在し得る全てのパターンが網羅されているかどうかをコンパイラが確認してくれます。この機能により、例えば「要件変更によってタイプが追加されたが、それに対応する分岐を実装漏れしてしまった」といった事態を防止できます。

本記事で実装するWebアプリケーションの概要

ここからは、RustによるWebアプリケーションバックエンドの実装例を紹介していきます。

キャディでは製造業領域のアプリケーションを開発しているのですが、今回は製造業ドメインになじみのない方にも分かりやすいよう、「ユーザー情報の登録と取得ができる」という簡単なサーバーを作ってみることにします。

どのように実装するかの全体像

他の言語でWebアプリケーションを実装する場合、以下の2つの方法があると思います。

  • Ruby on Railsのようなフルスタックフレームワークに全面的に依存する
  • リクエスト・レスポンスの送受信を行うWebアプリケーションフレームワークや、データベースの読み書きを行うORM / クエリビルダといった、特定の目的に特化したライブラリを組み合わせる

Rustの場合、筆者の知る限り、メジャーなフルスタックフレームワークは存在しません。したがって、必然的に後者の方法で開発をすることになります。そのため、まず利用するライブラリを選定する必要があります。なお、Rustでは一般的に言うライブラリのことをクレートと呼びます。

Webアプリケーションフレームワークの選定

リクエスト・レスポンスの送受信を担うWebアプリケーションフレームワークとしては、通信方式ごとに以下のような選択肢があります。

  • RESTの場合
    • Axum
    • actix-web
    • Rocket
    • warp
  • gRPCの場合
    • tonic
  • GraphQLの場合
    • async-graphql
    • juniper

今回は、筆者が業務で利用しているtonicを用いることにします。筆者の知る限り、現時点では、RustでgRPCサーバを構築する場合、tonicがデファクトスタンダードのようです。tonic(というよりはgRPC)を採用するメリットとしては以下があります。

  • 言語中立的なスキーマ定義言語でAPIの仕様を定義できる
  • 利用するプログラミング言語の型定義ファイルが自動生成されるため、API定義と実装の差分が生じないことが保証されている
  • RESTに比べて通信効率もよい

一方、現時点において、gRPCは通常の方法ではWebブラウザから直接呼び出すことができません。そのため、gRPCを利用する場合、マイクロサービス構成とし、Webブラウザとの間に置いたBFFでgRPCをGraphQLなどに変換する構成とすることが一般的なようです。筆者が担当するアプリケーションもそのような構成となっています。また、別のアプローチとしてgRPC-webConnect-Webを利用する方法もあるようですが、詳細は割愛します。

なお、後述するように、今回の実装例では、クリーンアーキテクチャを採用しています。クリーンアーキテクチャでは、責務ごとにレイヤが分割されているため、アプリケーション内部のドメインロジックに影響を与えることなくフレームワークを交換できます。そのため、gRPCではなくRESTやGraphQLを採用したい読者の方は、本記事の実装例を参考にしつつ、Webアプリケーションフレームワークを任意のものに交換するだけで、簡単にRESTやGraphQLのサーバを構築できるはずです。

データベースとの接続方法の選定

データベースとの接続を担うフレームワークとしては、以下のような選択肢があります。

  • diesel
  • sqlx
    • SeaORM
    • ormx

今回は、こちらも同じく筆者が業務で利用しているdieselを用いることにします。dieselは、マイグレーションの管理、クエリの実行、クエリ結果とRustの構造体の間のマッピングといった、ORMに求められる機能を一通りカバーしており、Rustの型の堅牢性を生かしながらクエリを書くことができます。

なお、dieselはこれまでRustにおけるORMのデファクトスタンダードとされてきたクレートではありますが、async/awaitを利用した非同期処理に対応していないという問題を抱えています。筆者が担当しているアプリケーションでは今のところ実用上の問題は生じていないのですが、非同期処理対応のライブラリと比較すると、パフォーマンス面では非効率的になっていると考えられます。

この点が気になる方は、async/await対応の新しいクレートであるsqlxや、sqlxの上に構築されたORMであるSeaORMの利用を検討するのもよいでしょう。

クリーンアーキテクチャの採用

筆者のチームでは、責務ごとのレイヤ分割と依存性の反転を導入したクリーンアーキテクチャを採用しています。

rust2

図のように、内側から順にDomain、Usecase、Infraの3層に分けています。Domain層にはエンティティと、エンティティを入出力するためのリポジトリのインターフェースを置きます。エンティティは構造体として表現され、ドメインロジックをエンティティのメソッドとして実装していくことになります。なお、リポジトリのインターフェースは、トレイトという言語機能を用いて表現します。トレイトは型が持つ共通の振る舞いを抽象的に定義するもので、他の多くの言語でインターフェイスなどと呼ばれている言語機能に類似しています。

Usecase層は、Domain層で定義したドメインモデルとリポジトリを組み合わせて、ユーザーが求める操作を実現する責務を持ちます。

Infra層はアプリケーション外部との入出力処理を担っています。今回の場合、具体的には、gRPCによるリクエストの送受信を行うためのハンドラと、データベースの読み書きを行うためのリポジトリの実装を置きます。

以上のような構成により、以下のメリットを得ることができます。

  • 外部との通信方式やデータベースの種類の変更、あるいはそのためのライブラリの変更やバージョンアップの影響がUsecase層やDomain層に波及しないことを担保できる
  • 外部入出力をモック化して単体テストを書くことができる

ディレクトリ構成

ディレクトリ構成は以下のようにします。

.
|- proto
|- app
|  |- src
|  |- Cargo.toml
|
|- context
|  |- src
|  |- Cargo.toml
|
|- domain
|  |- src
|  |- Cargo.toml
|
|- usecase
|  |- src
|  |- Cargo.toml
|
|- infra
   |- grpc_handler
   |  |- src
   |  |- Cargo.toml
   |
   |- repository_impl
      |- src
      |- Cargo.toml

いくつか補足します。

  • appディレクトリには、環境変数の読み込み、依存性の注入、gRPCサーバの立ち上げなど、アプリケーションの起動に必要な処理を置きます。このディレクトリが、アプリケーション全体のエントリポイントとなります。
  • contextディレクトリには、リポジトリ(やその他の外部入出力インターフェース)の実装を持ち回るためのデータ構造を定義します。これは依存性の注入のために必要となるもので、詳しくは後ほど説明します。
  • protoディレクトリには、protobufの定義ファイルを置きます。protobufの定義ファイルはこのアプリケーションに依存する他サービスとも共有する必要があるため、実際の業務運用では別のGitリポジトリに切り出し、submoduleとして読み込むケースもあるでしょう。なお、筆者のチームではフロントエンド / BFFも含めたモノレポ構成を採用しているため、protoファイルもアプリケーションと同一のGitリポジトリに共存する形となっています。

なお、domainusecaseなどのディレクトリごとにCargo.tomlが置かれており、さらにプロジェクトルート直下にもCargo.tomlが置かれていることから分かるように、責務ごとに分割された各ディレクトリがそれぞれひとつのクレートとなっており、複数のクレートを組み合わせることでひとつのアプリケーションが構築されています。

これによって、以下のメリットが得られます。

  • クレート間の依存関係が明確になり、循環依存を防げる
  • コードを変更した場合の再ビルドの範囲を、変更箇所よりも下流のクレートのみに限定でき、ビルドチェックの所要時間を短縮できる。また、相互に依存関係のないクレート同士はマルチコアで並列にビルド可能となり、ビルド時間を短縮できる

特にビルド時間については、エディタの静的解析の反応速度やプリントデバッグの速度に直結するため、開発生産性を維持する上でとても重要だと感じています。

なお、筆者が担当しているプロダクトの場合、クレートをさらに細分化するため、usecaseinfraの内部を、ドメイン駆動設計でいうところの集約単位で分割しています(domainについては、複数の集約をまたぐロジックも存在するため、分割していません)。その結果、ひとつのマイクロサービスが20個程度のクレートに分割された状態になっています。そんなに分けて大変じゃないかと思われるかもしれませんが、クレートが分かれていてもエディタのコードジャンプなどは問題なく機能するので、特に問題はありません。

ドメイン層の実装

ここからは、実装を進めていきます。

なお、以下では適宜コード例を引用しながら解説を進めていきますが、紙幅の都合上、サンプルアプリを完成させるために必要な全てのコードを紹介することはできません。代わりに、完成版のコード全体を以下のリポジトリで公開しています。

m-ysk/enghub-rust-sample-webapp

記事の内容をお手元で再現したい方は、上記のリポジトリを適宜ご参照の上、作業を進めていただければと思います。

エンティティの実装

まずは、エンティティを定義します。今回は「ユーザの作成と取得ができる」というアプリケーションを作るので、ユーザを表すエンティティが必要です。

ユーザはIDと名前を持つものとします。IDはUUIDということにします。また、名前は2文字以上10文字以下のアルファベットまたは数字で構成されていなければならないものとします。

// domain/src/model/user.rs
use anyhow::bail;
use derive_getters::Getters;
use uuid::Uuid;

use error::AppError;

#[derive(Clone, Debug, Getters)]
pub struct User {
    id: UserId,
    name: UserName,
}

impl User {
    pub fn new(name: UserName) -> User {
        User {
            id: UserId::new(),
            name,
        }
    }
}

#[derive(Clone, Copy, Debug)]
pub struct UserId(Uuid);

impl UserId {
    pub fn new() -> UserId {
        UserId(Uuid::new_v4())
    }
}

#[derive(Clone, Debug)]
pub struct UserName(String);

impl UserName {
    pub fn new(name: String) -> anyhow::Result<UserName> {
        // anyhow::ensure!を使うともっと短く書けます。
        if name.chars().any(|char| !char.is_ascii_alphanumeric()) {
            bail!(AppError::InvalidArgument(
                "username should consist of ascii alphanumerics".to_string(),
            ));
        }

        if !(2..=10).contains(&name.len()) {
            bail!(AppError::InvalidArgument(
                "username should consist of from 2 to 10 characters".to_string(),
            ));
        }

        Ok(UserName(name))
    }
}

idnameは、Uuid型やString型をラップしたUserId型やUserName型によって表現します。今回のような単純な例だとあまりありがたみを感じませんが、実際のアプリケーションでは、一口にIDと言ってもUserのID、CompanyのID、ArticleのIDというふうに、エンティティの種類ごとにたくさんのIDが登場することになります。種類ごとに型を定義することにより、型名から種類が明確に分かるようになり、ソースコード自体をドキュメント化できます。また、誤った種類のIDを引数に渡してしまった場合に、コンパイラで検知することも可能になります。

また、UserName型のnewの内部で、「アルファベットと数字以外を含んでいない」「2文字以上10文字以下」という制約を確認しています。これにより、Usernameフィールドに値をセットする方法が複数存在する場合に、どの方法を利用しても必ずこれらの制約が満たされることを保証できます。例えば「Userの作成APIでは正しくバリデーションが行われるのに、Userの名前を変更するAPIではとても長い名前や日本語の名前を設定できてしまう」というようなバグを防止できます。

リポジトリのトレイトの定義

次に、リポジトリのトレイトを定義します。ドメイン層内に置くのはリポジトリのトレイト定義のみであることに注意してください。このトレイトに対してInfra層で実装を与えることにより、リポジトリが完成します。

// domain/src/repository/user_reopsitory.rs

use anyhow;
use async_trait::async_trait;

use crate::{User, UserId};

#[async_trait]
pub trait UserRepository {
    async fn save(&self, user: &User) -> anyhow::Result<()>;
    async fn get_by_ids(&self, ids: &[UserId]) -> anyhow::Result<Vec<User>>;
}

pub trait ProvideUserRepository {
    type Repository: UserRepository;

    fn provide(&self) -> &Self::Repository;
}

今回は新規作成と更新を区別せずにsaveというメソッドで表現しましたが、createupdateに分ける方法もあるでしょう。

また、筆者の担当プロダクトでは、取得系のメソッドは単数と複数を区別せず、get_by_idsのように複数取得のメソッドだけで統一しています。これは、gRPCサーバーの前段にBFFとしてGraphQLサーバーを置いている関係上、N+1問題回避のために複数取得のAPIが求められることが多く、大は小を兼ねるの発想で最初から複数取得に統一してしまった方が楽だからです。

加えて、ProvideUserRepositoryなるトレイトが定義されています。これは、「UserRepositoryトレイトを実装した何かを返す」という振る舞いを抽象化したトレイトです。この役割については、次のセクションで説明します。

なお、#[async_trait]という記述が気になったかもしれません。現状、Rustではトレイトのメソッドとしてasyncな関数を定義できないという制約があり、これを回避するためにasync-traitというトレイトを利用する必要があります。もっともアプリケーションを実装するだけであれば「単なるおまじない」だと思っておいても何とかなります。詳細は公式の説明をご覧ください。

ユースケース層の実装

ドメイン層で定義したエンティティとリポジトリ(のトレイト)を使って、ユースケースを実装します。このユースケースは「ユーザーを新規作成する」という処理を行います。

// usecase/src/create_user.rs
use anyhow::{self, Context};
use typed_builder::TypedBuilder;

use domain::{ProvideUserRepository, User, UserName, UserRepository};
use error::AppError;

#[derive(TypedBuilder)]
pub struct CreateUserCommand {
    name: UserName,
}

pub async fn create_user<T>(ctx: &T, cmd: CreateUserCommand) -> anyhow::Result<User>
where
    T: ProvideUserRepository,
{
    let user = User::new(cmd.name);

    let user_repository = ProvideUserRepository::provide(ctx);

    user_repository
        .save(&user)
        .await
        .with_context(|| AppError::Internal("failed to create user".to_string()))?;

    Ok(user)
}

ユースケース層で重要なのは、ctxという引数を介して依存性の注入(DI: Dependency Injection)を行っていることです。

関数のシグネチャを見ると、ctxT型であり、T型は、先程ドメイン層で定義したProvideUserRepositoryというトレイトを実装している旨が示されています。そして、ドメイン層での定義によれば、ProvideUserRepositoryprovideメソッドによってUserRepositoryを提供する機能を持っているのでした。以上をまとめると「引数ctxは、provideメソッドによって、UserRepositoryトレイトを実装する何らかの型を提供する機能を持っている」ということになります。

したがって、ProvideUserRepository::provide(ctx)によって、ctxからUserRepositoryを実装する何らかの型を取り出すことができます。なお、ProvideUserRepository::provide(ctx)の代わりにメソッド記法を用いてctx.provide()と書くこともできます。ただし、後者の記法は、ctxprovideという名前のメソッドを持つトレイトを複数個実装している場合、うまく型推論が働かない場合があるので、ここでは前者の記法を採用しています。

以上の説明の中で、UserRepositoryはあくまでもトレイトであるため、具体的な型が何であるかは分からない、ということに注意してください。UserRepositoryの中身は、例えばリレーショナルデータベースにデータを読み書きする本番用のリポジトリ実装である可能性もあれば、テスト用のモックである可能性もあります。ちょっと複雑ですが、この仕組みによって依存性の注入が実現されているのです。

データベースとの接続

ここから、いよいよアプリケーションをデータベースに接続していきます。そのために、まずはデータベースのマイグレーションを行います。

今回使用するORMのdieselにはマイグレーション管理の仕組みも備わっているので、dieselを用いて説明していきます。まずは、diesel公式のガイドに沿ってdiesel cliのセットアップを行ってください(セットアップの操作手順は割愛します)。

また、データベースは、PostgreSQLをDockerなどで適宜立ち上げていただくことを想定します。

データベースのマイグレーション

今回のサンプルアプリでは、./infra/db_schema/以下にマイグレーション関連のファイルを設置することにします。

以下のコマンドを実行してください。

$ cd infra
$ cargo new --lib db_schema
$ cd db_schema
$ diesel migration generate create_users

その結果、migrationsというディレクトリの中に2022-05-22-141451_create_usersのような名前のディレクトリが作成され、その中にup.sqldown.sqlが生成されます。

up.sqlの中に以下のようにSQLを書きます。

CREATE TABLE users (
    id   CHAR(36) PRIMARY KEY,
    name TEXT
);

また、./infra/db_schema/diesel.tomlとして以下のようなファイルを設置します。

[print_schema]
file = "src/schema.rs"

その上で、以下のコマンドを実行することにより、データベースに対してマイグレーションが適用されます。

$ diesel migration run

同時に、./infra/db_schema/src/schema.rsに以下のようなRustのコードが生成されます。

table! {
    users (id) {
        id -> Bpchar,
        name -> Nullable<Text>,
    }
}

マクロが使われていて分かりにくいですが、これは、データベース上のテーブルのスキーマをRustの型として表現したものです。これをインポートするため、./infra/db_schema/src/lib.rsを以下のように編集しておいてください。

#[macro_use]
extern crate diesel;

mod schema;

pub use schema::*;

リポジトリの実装

ドメイン層で定義したUserRepositoryトレイトに対して実装を与えます。

// infra/repository_impl/src/lib.rs

use anyhow::{self, Context};
use async_trait::async_trait;
use derive_new::new;
use diesel::{
    pg::{upsert::excluded, PgConnection},
    prelude::*,
    r2d2::ConnectionManager,
    Insertable, Queryable,
};
use r2d2::Pool;

use db_schema::users;
use domain::{User, UserId, UserRepository};
use error::AppError;

#[derive(Queryable, Insertable)]
#[table_name = "users"]
struct UserRecord {
    pub id: String,
    pub name: String,
}

impl From<&User> for UserRecord {
    fn from(user: &User) -> UserRecord {
        UserRecord {
            id: user.id().to_string(),
            name: user.name().to_string(),
        }
    }
}

#[derive(new)]
pub struct UserRepositoryImpl {
    pool: Pool<ConnectionManager<PgConnection>>,
}

#[async_trait]
impl UserRepository for UserRepositoryImpl {
    async fn save(&self, user: &User) -> anyhow::Result<()> {
        tokio::task::block_in_place(|| {
            let user = UserRecord::from(user);
            let conn = self.pool.get()?;

            diesel::insert_into(users::table)
                .values(user)
                .on_conflict(users::id)
                .do_update()
                .set(users::name.eq(excluded(users::name)))
                .execute(&conn)
                .with_context(|| {
                    AppError::Internal("failed to insert or update user".to_string())
                })?;

            Ok(())
        })
    }

    async fn get_by_ids(&self, _ids: &[UserId]) -> anyhow::Result<Vec<User>> {
        todo!()
    }
}

データベースのusersテーブルの1行をマッピングするための型としてUserRecordを定義しています。UserRecordに対してFromトレイトを定義することにより、ドメイン層の型とUserRecordの間の変換を可能にしています。

なお、前述の通り、dieselは残念ながらasync/awaitに対応しておらず、ブロッキングAPIしか提供されていません。asyncな関数上でブロッキングAPIを呼び出すと、同一スレッド上で実行されている他のタスクの実行をブロックしてしまうという問題があります。これを回避するため、tokio::task::block_in_place内でdieselのAPIを呼び出すということをしています。これにより、同一スレッド上の他のタスクを別のスレッドに退避させることができ、後続のタスクをまとめてブロックしてしまう事態を防止できます。この点について詳細な解説は、tokioのドキュメントをご覧ください。

gRPCハンドラの実装

ここからは、gRPCのリクエスト・レスポンスの送受信部分を実装していきます。いよいよ完成に近づいてきました。

protoファイルを定義する

まずは、protocol buffersのインターフェース記述言語を用いて、protoファイルを定義する必要があります。以下のような内容を./proto/user/v1/user.protoとして記述します。

// proto/user/v1/user.proto

syntax = "proto3";
package user.v1;

service UserService {
  rpc CreateUser(CreateUserRequest) returns (CreateUserResponse);
}

message CreateUserRequest {
  string name = 1;
}

message CreateUserResponse {
  User user = 1;
}

message User {
  string id = 1;
  string name = 2;
}

上記では、UserServiceというサービスがあって、そこにはCreateUserというAPIが存在するということを定義しています。また、そのリクエスト・レスポンスのスキーマも定義しています。

tonicを使ってprotoファイルからRustの型を生成する

次に、RustのgRPCライブラリであるtonicを用いて、先程作成したprotoファイルを読み込みます。tonicは、Rustのビルド時にprotoファイルを読み込み、protoファイルに対応するRustの型定義を生成します(厳密に言うと、tonicが内部で依存しているprostというクレートがRustの型定義の生成を行っているようです)。

まず、./infra/grpc_handler/build.rssrcディレクトリの外側であることに注意!)として、以下のようなファイルを用意します。build.rscargo buildなどでRustのビルドを行う際に自動で呼び出されるファイルで、ここではprotoファイルの読み込みを行っています。

// infra/grpc_handler/build.rs

fn main() -> Result<(), Box<dyn std::error::Error>> {
    tonic_build::configure().compile(TARGET_PROTO, INCLUDE)?;
    Ok(())
}

const TARGET_PROTO: &[&str] = &["../../proto/user/v1/user.proto"];
const INCLUDE: &[&str] = &["../../proto"];

次に、./infra/grpc_handler/src/lib.rsに、以下のような内容を記述します。これにより、生成されたRustのコードをインポートして使えるようになります。

// infra/grpc_handler/
pub mod user {
    pub mod v1 {
        tonic::include_proto!("user.v1");
    }
}

gRPCの型とアプリケーション内部の型の間の変換を定義する

今回のアーキテクチャでは、gRPCのインターフェースとアプリケーション内部の世界を粗結合に保つため、tonicにより自動生成された型はユースケース層やドメイン層に持ち込まない方針としています。そこで、gRPCの型とアプリケーション内部の型の変換を、./infra/grpc_handler/src/convert.rsにて以下のように定義します。

これにより、gRPCのCreateUserRequest型をユースケース層で定義したCreateUserCommand型に変換したり、ユースケース層の処理の結果として返ってきたドメインのUser型をgRPCのレスポンスに変換したりできるようになります。

// infra/grpc_handler/src/convert.rs

use anyhow::{self, Context};

use domain::User;
use error::AppError;
use usecase::CreateUserCommand;

use crate::user::v1::{CreateUserRequest, CreateUserResponse, User as PbUser};

impl TryFrom<CreateUserRequest> for CreateUserCommand {
    type Error = anyhow::Error;

    fn try_from(request: CreateUserRequest) -> anyhow::Result<CreateUserCommand> {
        let CreateUserRequest { name } = request;
        let cmd = CreateUserCommand::builder()
            .name(
                name.try_into()
                    .with_context(|| AppError::InvalidArgument(format!("invalid name")))?,
            )
            .build();
        Ok(cmd)
    }
}

impl From<User> for CreateUserResponse {
    fn from(user: User) -> CreateUserResponse {
        CreateUserResponse {
            user: Some(user.into()),
        }
    }
}

impl From<User> for PbUser {
    fn from(user: User) -> PbUser {
        PbUser {
            id: user.id().to_string(),
            name: user.name().to_string(),
        }
    }
}

ここでも、FromTryFromといったトレイトを活用して型の変換を定義しています。クリーンアーキテクチャを採用するとレイヤ間の型変換が多発するのが面倒なところです。しかし、Rustであれば、これらのトレイトを活用することにより、型変換を比較的すっきりと書くことができます。

gRPCハンドラを実装する

./infra/grpc_handler/src/lib.rsにて、以下のようにgRPCハンドラを実装します。

// infra/grpc_handler/src/lib.rs

use derive_getters::Getters;
use derive_new::new;
use tonic::{Request, Response, Status};

use app_context::AppContext;
use error::AppError;

use user::v1::user_service_server::UserService;
use user::v1::{CreateUserRequest, CreateUserResponse};

#[derive(new, Getters)]
pub struct UserServiceHandler {
    ctx: AppContext,
}

#[tonic::async_trait]
impl UserService for UserServiceHandler {
    async fn create_user(
        &self,
        request: Request<CreateUserRequest>,
    ) -> Result<Response<CreateUserResponse>, Status> {
        // gRPCのRequestをUsecaseの引数型に変換する
        let cmd = request
            .into_inner()
            .try_into()
            .map_err(|e| handle_error(e))?;

        // Usecaseを呼び出す
        let user = usecase::create_user(self.ctx(), cmd)
            .await
            .map_err(|e| handle_error(e))?;

        // Responseを返す
        Ok(Response::new(user.into()))
    }
}

fn handle_error(err: anyhow::Error) -> Status {
    // エラーのロギングなどを行う
    // 省略
}

まず、UserServiceHandlerという構造体を用意しています。そしてそこに対してUserServiceというトレイトを実装しています。このUserServiceトレイトは、protoファイルの内容を元にtonicが自動生成してくれたものです。UserServiceトレイトが要求するシグネチャの通りにメソッドを実装することにより、protoファイルで定義したAPIが完成するようになっています。

なお、UserServiceHandlerが保持しているAppContextは、ユースケース層の実装の際に説明した依存性の注入を行うためのデータ構造で、リポジトリの実装を保持しています。これをユースケースの関数に対する引数として、usecase::create_user(self.ctx(), cmd)のように渡しているのが見て取れると思います。詳細については、この後のセクションで紹介します。

アプリケーション全体を組み立てる

ここまでで、アプリケーションに必要な部品はほぼ揃いました。残された最後の部品は、先程のgRPCハンドラの実装の際に触れたAppContextです。

AppContextを実装する

AppContextは、リポジトリの実装を保持し、provideメソッドを通じて必要なユースケースに提供する役割を担います。./context/app_context/src/lib.rsに以下のように実装します。

// context/app_context/src/lib.rs

use domain::ProvideUserRepository;
use repository_impl::UserRepositoryImpl;

pub struct AppContext {
    pub user_repository: UserRepositoryImpl,
}

impl ProvideUserRepository for AppContext {
    type Repository = UserRepositoryImpl;

    fn provide(&self) -> &UserRepositoryImpl {
        &self.user_repository
    }
}

ドメイン層で定義したProvideUserRepositoryトレイトが、ここでやっと実装されています。provideメソッドを呼ぶと、UserRepositoryImplが返されることが分かります。

なお、今回のアプリケーションでは使いませんが、例えば以下のようにすることで、リポジトリのモックを返すMockContextを定義できます。

// context/mock_context/src/lib.rs

pub struct MockContext {
    pub user_repository: UserRepositoryMock,
}

impl ProvideUserRepository for AppContext {
    type Repository = UserRepositoryMock;

    fn provide(&self) -> &UserRepositoryMock {
        &self.user_repository
    }
}

ポイントは、ユースケース層から見ると、引数として渡されたContextがAppContextなのか、それともMockContextなのかを区別できないということです。

なぜなら、ユースケース層はProvideUserRepositoryというトレイトのみを指定しており、AppContextMockContextといった具体的な型には依存していないからです。このような仕組みにより、ユースケース層の実装を変更することなく、テスト用のモックを差し込んだりできるわけです。

サーバを立ち上げる

ついに最後の作業です。これまでに作ってきた部品を組み立て、サーバを立ち上げましょう。

./app/src/main.rsに、以下のように実装します。ここでDBへのコネクション確立、リポジトリの作成、AppContextの作成、gRPCハンドラの作成、そしてサーバの立ち上げを順に行っています。

// app/src/main.rs

use diesel::r2d2::ConnectionManager;
use tonic::transport::Server;

use app_context::AppContext;
use grpc_handler::user::v1::user_service_server::UserServiceServer;
use grpc_handler::UserServiceHandler;
use repository_impl::UserRepositoryImpl;

#[tokio::main]
async fn main() -> Result<(), Box<dyn std::error::Error>> {
    // DBに接続し、コネクションプールを作成する
    let database_url =
        std::env::var("DATABASE_URL").expect("failed to read the env var DATABASE_URL");
    let manager = ConnectionManager::new(database_url);
    let pool = r2d2::Pool::new(manager).expect("failed to create the connection pool");

    // UserRepositoryを作成する
    let user_repository = UserRepositoryImpl::new(pool);

    // AppContextにUserRepositoryの実装を持たせる
    let context = AppContext { user_repository };

    // gRPCのハンドラを作成する
    let user_service = UserServiceHandler::new(context);

    let addr = "[::1]:50051".parse()?;
    println!("Start sample app server!");

    // gRPCのハンドラを登録してサーバを起動する
    Server::builder()
        .add_service(UserServiceServer::new(user_service))
        .serve(addr)
        .await?;

    Ok(())
}

データベースを立ち上げ、そこにマイグレーションを適用した上で、アプリケーションをcargo runすることでサーバを立ち上げることができます。サンプルリポジトリに用意したdocker-compose.ymlを使用する場合の操作手順を以下に示します。

$ docker compose up -d
$ cd infra/db_schema
$ diesel migration run
$ cd ../../app
$ cargo run

Start sample app server!というメッセージが表示されれば起動成功です。

次に、立ち上がったサーバに対してgRPCのリクエストを送信し、ユーザの作成機能が実際に動作することを確認してみましょう。grpcurlを用いて、以下のコマンドを実行してください。なお、grpcurlはcurlのgRPC版のようなCLIツールです。詳細は公式ドキュメントをご覧ください。

$ grpcurl -plaintext -d '{"name": "testuser"}' -import-path ./proto -proto user/v1/user.proto localhost:50051 user.v1.UserService/CreateUser

以下のようなレスポンスが返ってくれば成功です。

{
  "user": {
    "id": "df254bf7-90a6-4a51-8114-b3d2dabcbdda",
    "name": "testuser"
  }
}

念のため、データベース内にも正常にデータが保存されていることを確認しましょう。

$ psql -U postgres -h localhost -p 5432 -d sample
SELECT * FROM users;

以下のように、データベース内のデータが確認できるはずです。

                  id                  |   name
--------------------------------------+----------
 df254bf7-90a6-4a51-8114-b3d2dabcbdda | testuser
(1 row)

おつかれさまでした。同じようにしてAPIを増やしていけば、複雑な機能を持ったアプリケーションも実装できるはずです。なお、「ユーザを取得する」APIの実装例は、冒頭に紹介した筆者のGitHubリポジトリをご覧ください。

エラーハンドリングやテストのTips

以下では、上記のサンプルアプリケーション内で触れられなかった細かい知見をいくつか紹介します。

anyhowを活用したエラーハンドリング

アプリケーションを実装していると、独自のエラー型を定義したくなることがあります。例えば、以下のようにenumを定義することで、エラーの種類を型で表現できそうです。

#[derive(Clone, Debug)]
pub enum AppError {
    // 引数が不正である
    InvalidArgument(String),

    // エンティティが存在しない
    NotFound(String),

    // データベースへの接続失敗などの技術的問題
    Internal(String),
}

しかし、これだけでは実は使い勝手が悪いです。例えば、次の例を見てみましょう。

// この例はコンパイルが通りません!
pub fn parse_even(number: &str) -> Result<i32, AppError> {
    let number: i32 = number.parse()?;

    if number % 2 != 0 {
        return Err(AppError::InvalidArgument("number is not even".to_string()));
    }
    Ok(number)
}

やや人工的な例で恐縮ですが、この関数は「引数として与えられた文字列を整数として解釈し、その結果が偶数であれば整数型を返す。結果が奇数の場合や、引数をそもそも整数として解釈できない場合はエラーを返す」というものです。標準ライブラリの関数であるparseが失敗した場合にはそのエラーをそのまま返そうとし、その後の偶奇判定に失敗した場合はAppError型を返しています。

しかし、この例はコンパイルできません。なぜかというと、標準ライブラリで実装されたメソッドであるi32::parseが失敗したときに返ってくるエラー型はAppError型ではないからです。標準ライブラリが独自定義エラー型を知っているわけがないので、これは当然ですね。これでは、独自定義エラー型と標準ライブラリの関数が共存できないことになってしまいます。

このような問題は、以下のようにanyhowクレートを使って解決できます。詳細な仕組みは割愛しますが、anyhow::Result型やbailマクロを活用することにより、異なる種類のエラー型をanyhow::Errorという共通のエラー型として扱うことができるようになります。

pub fn parse_even(number: &str) -> anyhow::Result<i32> {
    let number: i32 = number.parse()?;

    if number % 2 != 0 {
        bail!(AppError::InvalidArgument("number is not even".to_string()));
    }
    Ok(number)
}

さらに、以下のようにanyhow::Contextを用いることで、マトリョーシカ人形のようにエラーをラップし、コンテキスト情報を付加することもできます。以下の例では、標準ライブラリのparseが返したエラーに対して、with_contextにより独自定義のAppError::InvalidArgumentを付加しています。

pub fn parse_even(number: &str) -> anyhow::Result<i32> {
    let number: i32 = number
        .parse()
        .with_context(|| AppError::InvalidArgument(format!("failed to parse: {number}")))?;

    if number % 2 != 0 {
        bail!(AppError::InvalidArgument("number is not even".to_string()));
    }
    Ok(number)
}

コンテキスト情報が付加されていることを確認するため、以下のようにparse_evenを呼び出してみましょう。

let err = parse_even("not integer").unwrap_err();
println!("{err:?}");

以下のように出力されました。

failed to parse: not integer

Caused by:
    invalid digit found in string

1行目のfailed to parse: not integerwith_context内で付加した内容で、その大元の原因がCaused by以下に示されています。標準ライブラリが返したエラーの内容を保持しつつ、独自定義エラー型をコンテキスト情報として付加できていることが分かるかと思います。

入れ子になったエラーたちは、printするだけでなく、chain,root_cause,downcast_refといったメソッドを活用してアクセスすることもできます。詳細はドキュメントをご覧ください。

mockallでユースケース層のテストを書く

Webアプリケーションのユースケース層の単体テストを書く上で問題となるのが、データベースや外部サービスとの間の入出力の扱いです。テストコードの中で本物のデータベースや外部サービスに接続してしまうというアプローチもありますが、単体テスト実行のたびに本物のデータベースなどを立ち上げる必要があって手間がかかることや、テストが失敗した場合の原因の切り分けが難しくなるといったデメリットもあります。そこで、ここでは外部入出力をモックして単体テストを書くことを考えます。

Rustで外部入出力をモック化するために役立つのが、mockallクレートです。このクレートは、サンプルアプリで定義したUserRepositoryのようなtrait定義に#[automock]というアトリビュートを付与することにより、traitのモック実装を自動生成してくれます。以下のように使います。

// domain/src/user_repository.rs
#[mockall::automock]
#[async_trait]
pub trait UserRepository {
    async fn save(&self, user: &User) -> anyhow::Result<()>;
    async fn get_by_ids(&self, ids: &[UserId]) -> anyhow::Result<Vec<User>>;
}

見た目では分かりにくいのですが、マクロによってMockUserRepositoryというモックのコードが生成されます。このMockUserRepositoryUserRepositoryトレイトを実装しています。

これを使って、create_userユースケースの単体テストを以下のように書くことができます。

// usecase/src/create_user.rs
#[cfg(test)]
mod test {
    use super::*;

    use domain::MockUserRepository;
    use mock_context::MockContext;

    #[tokio::test(flavor = "multi_thread")]
    async fn test_create_user() {
        let mut user_repository = MockUserRepository::new();

        // mockに対して期待する振る舞いを設定する
        user_repository
            // `save`というメソッドが呼ばれたら、
            .expect_save()
            // 引数として渡された`user`の内容が期待通りか確認して、
            .withf(|user| user.name().to_string() == "TestUser")
            // `Ok`を返す
            .returning(|_| Ok(()));

        // mockをContextにセットする
        let ctx = MockContext { user_repository };

        let cmd = CreateUserCommand::builder()
            .name("TestUser".to_string().try_into().unwrap())
            .build();

        // mockを使ってユースケースを実行する
        create_user(&ctx, cmd).await.unwrap();
    }
}

UserRepositoryが持っているfooというメソッドに対応して、MockUserRepository上にはexpect_fooというメソッドが自動生成されます。expect_fooに対するメソッドチェーンでwithfreturningというメソッドを呼ぶことにより、fooが呼ばれたらこのように振る舞ってね、という指示をモックに与えることができます。

上記のサンプルコードでは、expect_saveを呼ぶことにより、saveメソッドに対するモックの振る舞いを定義しています。続くwithfの引数としてクロージャを渡すことにより、saveメソッドに渡されたuserの内容が期待通りかを確認しています。さらに、returningにより、saveメソッドの返り値を指定しています。

mockallクレートは他にもさまざまな機能を持っていますので、詳細はドキュメントをご覧ください。

開発組織にRustを導入するために

ここからは、開発組織でRustを活用するための取り組みや心構えについて簡単に説明します。

最初から「完璧なRust」を目指さない

Rustは、同じ処理を実現するために何通りもの書き方ができてしまう言語です。例えば、繰り返し処理の書き方にはforloopを使う方法とイテレータを使う方法がありますし、OptionResultの中身を取り出す場合はmatchif letを使う方法とmapand_thenなどのコンビネータを使う方法があります。また、スライスとVec&strStringのように、同じデータを表現する型が所有権の扱いに応じて複数種類存在したりもします。関数の引数として文字列を取るときに、&strStringだと何が違うんだ?と戸惑った方も多いでしょう。

このように書き方の選択を開発者に委ねる言語であるがゆえに、可読性とパフォーマンスの両面で無駄がない完璧なRustのコードを書くのはとても大変です。完璧なコードが出来上がるまでプルリクエストを提出しない、あるいはレビューを通さないというような態度でRustを書いていると、いつまでたっても開発が前に進まないでしょう。

私たちのチームでは、新しいメンバーがRustを書き始めた場合、最初は最低限のコード品質を満たしていればよいこととし、コードレビューの中で「こういう書き方もあるよ」という知見を共有しながら、少しずつチーム全体のRust力を上げていくようにしています。特にイテレータやコンビネータは、最初から全種類を使いこなすのは難しく、こういう処理ならmatchよりもmapを使った方が簡単に書けるよ、といった知見をレビューの中で共有することで、より可読性の高いコードの書き方を身に付けていきます。

「最低限のコード品質」といっても、Rustはコンパイラがしっかりしていますから、プロダクトに重大な影響をもたらすような危険なコードはある程度コンパイラが排除してくれます。コンパイラが指摘してくれないのは、成功・失敗が外部からの入力値に依存するようなコードで安易にunwrapを使わない、といった点くらいではないでしょうか。

また、公式のLintツールであるClippyは必ずチーム全員で導入し、CIにも組み込みましょう。ベストプラクティスに沿っていないコードをガンガン指摘してくれるため、使っているだけでRustへの理解を深めることができます。

Rustはメモリのアロケーションを書き手に意識させる言語であるがゆえ、ついついパフォーマンスチューニングにこだわりたくなってしまいます。しかし、一般的なWebアプリケーションの場合、細かなメモリアロケーションの差がユーザへの影響として表出することはあまりないでしょう。ユーザ影響のない部分にこだわり過ぎず、一定の割り切りをしながら少しずつチームのRust力を高めることが大切だと思います。

必要なライブラリがない場合はサービス間連携という選択肢もある

Rustを含め、採用事例が少ない言語を採用する上でハードルになりがちなのが、外部サービスのSDKなど、特定のニッチな目的で必要なライブラリが存在しない、という問題でしょう。このような場合、SDKに依存する部分のみをマイクロサービスやCloud Functionsなどに切り出し、他の言語で書くという考え方も選択肢のひとつです。

実際、私たちのチームでもこのような選択肢を採っています。例えば、キャディでは、BtoBビジネスという性質上、Excelで記載された製品データなどをバルクでインポートしたいというニーズがよく発生します。このような要件に対し、当初、Excelファイルを操作するRustのクレートを探しましたが、少なくともその時点では目的に叶うものが見つかりませんでした。そこで、Excelのファイルをパースする処理については、Node.js/TypeScriptで書かれたBFFに委ねるという選択を採りました。

Rustの採用を検討する場合、全てをRustで済ませることを前提にするのではなく、必要に応じて他言語で書かれたサービスとの連携も視野に入れるとよいでしょう。

実装パターンが確立すれば後は楽

Rustに限った話ではないかもしれませんが、社内で一度実装パターンを確立してしまえば、後から入ったメンバーはそれを見ながら学習できるので意外と楽です。私自身も、キャディに入社した時点である程度実装パターンが確立されていたため、知の高速道路を走る形で学習を進めることができました。

また、学習用に簡略化されたサンプル実装を提供すれば、学習がより効率化されるでしょう。キャディにも一応、学習用のサンプルアプリケーションが存在しましたが、実運用中のアプリケーションの変更スピードに追いつかず、最近はメンテナンスが滞ってしまいました。実はこの記事には、キャディの新規メンバー向けの新たな学習リソースとして活用したいという裏の目的があったりします。

社内で初めてRustを使う一人目エンジニアには、相応の技術力とフロンティア精神が求められるのは事実だと思います。ですが、あなたが一人目エンジニアとして社内におけるRust活用のスタンダードを確立すれば、上述のとおり、後に続くメンバーはきっとついてくることができるはずです。ぜひ挑戦してください!

おわりに

日々の開発にRustを活用する中で、Rustは、ハイパフォーマンスや低レイヤアクセスが求められるシステムプログラミングだけでなく、Webアプリケーションの開発言語としても非常に強力な言語だと実感しています。この記事が、あなたの開発現場にRustを導入する一助となればうれしいです。

松田 圭紀(MATSUDA Yoshika) GitHub: m-ysk

rust3
東京大学文学部卒業後、総務省にて情報通信政策の立案に従事。その後、ソフトウェアエンジニアに転身。matsuri technologies株式会社にてGoによるバックエンド開発等に従事した後、キャディ株式会社に入社。キャディでは、Rustによる受発注管理システムのバックエンド開発を中心に担当。

編集:中薗 昴
制作:はてな編集部

若手ハイキャリアのスカウト転職