8
4

More than 3 years have passed since last update.

Rustのプロジェクトで型に困惑した話

Last updated at Posted at 2020-07-07

前回(Rustのプロジェクトを始める前に知っておきたかったこと) の記事ではRustでAWS Lambdaの関数を書く中で知ったRustのお作法や慣習、開発事情などに触れた。今回はRustの型システムを中心に触れる。

分かってない所だけでなく、不正確なところ、間違ってはいないがミスリードしそう、言い回しが不自然、これも触れたほうが親切、等々、ご指摘いただけると助かります。

API GWの統合プロキシを使う

API GWの「統合プロキシ」を使うと、HTTP Request(body, url query, header, etc..)をLambdaのPayloadとしてまとめて渡してくれる。またLambdaはレスポンスとして、HTTP Response(body, header, status code, etc...)を表す決められたJSON形式でAPI GWに返す。
aws-lambda-rust-runtimeを使う場合、lambda_httpを使うと統合プロキシ用のRequestとResponseの受け取り/返却ができる。RequestとしてBodyがJSONで来る場合の統合プロキシ対応版のコード例は以下のようになる。

use serde_derive::{Serialize, Deserialize};
use lambda::{Context, error::HandlerError};
// 分かりやすいように型の名前を変えている
use lambda_http::{lambda, Request as HttpRequest, Response as HttpResponse, Body};
use std::result::Result as StdResult;
type Result<A> = StdResult<A, HandlerError>;

#[derive(Serialize, Deserialize)]
pub struct SampleRequest{ id: String, num: i64 }

#[derive(Serialize)]
pub struct SampleResponse{ message: String }

// 特定のリクエストの型を受け取り、特定のレスポンスの型を返すハンドラ
fn hoge_handler(event: SampleRequest, _ctx: Context) -> Result<SampleResponse> {
    let json = serde_json::to_string(&event)?;
    Ok(SampleResponse{ message: format!("request was {}", json) })
}

// bodyを取り出すヘルパー関数
fn get_body<'a>(req: &'a HttpRequest) -> StdResult<&'a str, failure::Error> {
    match req.body() {
        Body::Text(txt) => Ok(txt),
        Body::Empty | Body::Binary(_) => failure::bail!("not text request")
    }
}

// 統合プロキシの形式のリクエスト/レスポンスを処理するハンドラ
fn wrapped_handler(req: HttpRequest, context: Context) 
  -> Result<HttpResponse<String>> 
{
    let request_body = get_body(&req)?;
    let request_obj = serde_json::from_str(&request_body)?;

    let response_obj = hoge_handler(request_obj, context)?;         // (※) ハンドラの呼び出し

    let response_text = serde_json::to_string(&response_obj)?;
    Ok(HttpResponse::new(response_text))
}

fn main() -> StdResult<(), Box<dyn std::error::Error>> {
    simple_logger::init_with_level(log::Level::Debug)?;
    lambda!(wrapped_handler);
    Ok(())
}

コード中の(※)の前でRequestのJSONを読み込んで、そのオブジェクトをハンドラ(ここではhoge_handler)に渡して、結果を文字列にしてResponseとして返している。
この(※)の前後の処理を共通の処理だとして括りだして、任意のハンドラ関数を外部から注入できるようにすることを本記事の目標としよう。ただし、ここでハンドラ関数のRequestおよびResponseはDeserialize/Serialize可能な任意の型とする。

注意

上記のコードはエラーが発生した場合、レスポンスを返さない。実際はResultをパターンマッチして、エラー時のResponseを生成する必要がある。

serde

ここで、少し脱線してserdeの説明をする。
serde(SERialize and DEserialize)はRustのシリアライズ/デシリアライズのフレームワークである。serde本体と特定の用途のライブラリ(例えば、JSONならserde_json)を組み合わせて使う。

use serde_derive::Deserialize;

#[derive(Deserialize, Debug)]
#[serde(rename_all = "camelCase")]
struct Sample{
  #[serde(rename = "userId")]
  id: String,
  phone_number: String
}

fn print_obj() -> Result<(), serde_json::Error> {
   let text = """{"userId": "xxx", "phoneNumber": "xxx-xxxx-xxxx"}""";
   let x : Sample = serde_json::from_str(text)?;
   println!("{:?}", x);
   Ok(())
}

serde_deriveserde本体のモジュールであり、このモジュールにSerializeDeserializeというattributeが定義されている。このattributeをstructにつけると、そのstructの構造に合わせたメソッドが生成される。生成されたメソッドに特定の形式のシリアライザ/デシリアライザを注入して、その形式へのシリアライズ/デシリアライズが実行される。
serdeのおかげで、ユーザーとしても異なる出力形式でもattributeの記述の仕方が統一されるし、各形式のライブラリ開発者も実装の負担が軽減される。

さらにRustらしい点として、下記のようなstructも使える。

#[derive(Deserialize, Debug)]
struct Sample<'a>{
  id: &'a str,
  phone_number: &'a str
}

structのフィールドがスライスになっている。つまり各フィールドがパース元の文字列の中のどの位置かだけを保持しており、コピーが発生しない。ちゃんと測定したことはないがきっと速いに違いない。

ここで強調しておきたいことは、スライスを使用した場合、デシリアライズしたstructには位置の情報しかないので、それが生存している間、元の文字列を解放することはできない。Rustはこのような不正な解放がないかをコンパイル時に検証する。Samplestructにはライフタイムパラメータ<'a>がついているが、これは元の文字列の生存を保証するために必要となる。

ハンドラをラップする

話を戻して、ハンドラを引数から渡せるようにしよう。結論から書くと、ハンドラは関数だからといって関数をそのまま渡すと大変なことになる。ハンドラを定義するためのTraitを定義してそれを渡すのがRust的正解と思う。この記事ではまず関数で渡すとどう大変かを示しながら、話を進める。

具体的な型の関数を引数として渡す

最初に具体的な型のハンドラ(SampleRequest→SampleResponse)を渡せるwrap関数を定義する。

fn wrap(
    mut handler: impl FnMut(SampleRequest, Context) -> Result<SampleResponse>
) -> impl FnMut(HttpRequest, Context) -> Result<HttpResponse<String>> {
    move | req: HttpRequest, context: Context | {
        let request_body = get_body(&req)?;
        let request_obj = serde_json::from_str(&request_body)?;
        let response_obj = handler(request_obj, context)?;
        let response_text = serde_json::to_string(&response_obj)?;
        Ok(HttpResponse::new(response_text))
    }
}
fn main() -> StdResult<(), Box<dyn std::error::Error>> {
    simple_logger::init_with_level(log::Level::Debug)?;

    lambda!(wrap(hoge_handler));    // ここでハンドラをラップしてる
    Ok(())
}

細かいことを説明するとキリがないので省略するが、Rustのクロージャについて触れておきたい。クロージャは匿名の型として実装されており、その匿名の型はFn, FnMut, FnOnceのいずれかのtraitを実装している。
クロージャのtraitには特別な記法が用意されており、ABを引数、戻り値をCとする関数はFn(A, B) -> Ctraitになる。
実行時まで具体的な型が分からないときdyn Traitと指定するのに対し、コンパイル時に型が決定する場合はimpl Traitと指定する。

wrap関数のシグネチャが長いので下記のようにエイリアスを使えないかと思ったがエラーとなる。

type Handler = FnMut(SampleRequest, Context) -> Result<SampleResponse>;
type WrappedHandler = FnMut(HttpRequest, Context) -> Result<HttpResponse<String>>;

fn wrap(mut handler: impl Handler) -> impl WrappedHandler{ ... }

/*
error[E0404]: expected trait, found type alias `Handler`
  --> src/main.rs:78:27
   |
78 | fn wrap(mut handler: impl Handler) -> impl WrappedHandler
   |                           ^^^^^^^ type aliases cannot be used as traits
*/

下記のように引数についてはimplを使わずに型パラメータに対する制約の形で書くことはできる。

fn wrap<Handler>(mut handler: Handler)
 -> impl FnMut(HttpRequest, Context) -> Result<HttpResponse<String>>
where
    Handler: FnMut(SampleRequest, Context) -> Result<SampleResponse>,
{ ... }

しかし次のように戻り値の型も型パラメータで定義するとコンパイルエラーになる。

fn wrap<Handler, WrappedHandler>(mut handler: Handler) -> WrappedHandler
where
    Handler: FnMut(SampleRequest, Context) -> Result<SampleResponse>,
    WrappedHandler: FnMut(HttpRequest, Context) -> Result<HttpResponse<String>>,
{ ... }

/*
error[E0308]: mismatched types
  --> src/main.rs:81:5
76 |   fn wrap<Handler, WrappedHandler>(mut handler: Handler) -> WrappedHandler
   |                    -------------- this type parameter       -------------- expected `WrappedHandler` because of return type
...
81 | /     move | req: HttpRequest, context: Context | {
82 | |         let request_body = get_body(&req)?;
83 | |         let request_obj = serde_json::from_str(&request_body)?;
84 | |         let response_obj = handler(request_obj, context)?;
85 | |         let response_text = serde_json::to_string(&response_obj)?;
86 | |         Ok(HttpResponse::new(response_text))
87 | |     }
   | |_____^ expected type parameter `WrappedHandler`, found closure
   |
   = note: expected type parameter `WrappedHandler`
                     found closure `[closure@src/main.rs:81:5: 87:6 handler:_]`
   = help: type parameters must be constrained to match other types
   = note: for more information, visit https://doc.rust-lang.org/book/ch10-02-traits.html#traits-as-parameters
*/

戻り値の型がtraitの場合、そのtraitを実装した任意の型とするとサイズが決まらないため、Box<dyn Trait>とする必要がある。
コンパイル時に具体的な型が確定することを明示するためにimplを使ってもコンパイルエラーとなる。

fn wrap<Handler, WrappedHandler>(mut handler: Handler) -> impl WrappedHandler
where
    Handler: FnMut(SampleRequest, Context) -> Result<SampleResponse>,
    WrappedHandler: FnMut(HttpRequest, Context) -> Result<HttpResponse<String>>,
{ ... }

/*
error[E0404]: expected trait, found type parameter `WrappedHandler`
  --> src/main.rs:76:64
   |
76 | fn wrap<Handler, WrappedHandler>(mut handler: Handler) -> impl WrappedHandler
   |                                                                ^^^^^^^^^^^^^^ not a trait
*/

この辺りは長い議論があるっぽい。 https://github.com/rust-lang/rust/issues/55628

リクエスト/レスポンスの型をジェネリックにする

シグネチャの書き方は置いといて、

  • SampleRequest → デシリアライズできる任意の型(serde::Deserializetrait)
  • SampleResponse → シリアライズできる任意の型(serde::Serializetrait)

に置き換える。

fn wrap<A, B>(mut handler: impl FnMut(A, Context) -> Result<B, HandlerError>)
           -> impl FnMut(HttpRequest, Context) -> Result<HttpResponse<String>>
where
    A: serde::Deserialize,
    B: serde::Serialize,
{ ... }

このコードはコンパイルエラーになる。

error[E0106]: missing lifetime specifier
  --> src/main.rs:74:8
   |
74 |     A: serde::Deserialize,
   |        ^^^^^^^^^^^^^^^^^^ expected lifetime parameter

前節で説明したとおり、デシリアライズ元のオブジェクトはデシリアライズされたオブジェクトより長生きすることが保証されなければならない。そのためにserde::Deserializeにはライフタイムパラメータの指定が必要である。具体的にどういう生存期間かを考えると、ラップしたハンドラのリクエストの生存期間である。こんな絶妙な場所のライフタイム変数の指定が可能だろうか?

fn wrap<A, B>(mut handler: impl FnMut(A, Context) -> Result<B>)
           -> impl FnMut(HttpRequest, Context) -> Result<HttpResponse<String>>
where
    A: for <'a> serde::Deserialize<'a>,
    B: serde::Serialize,
{ ... }

for <'a>をつけた上記のコードはコンパイルを通過する。こういうのを"Higher-Rank Trait Bounds"というらしい。https://doc.rust-lang.org/nomicon/hrtb.html
よく分からないけど試してみたらコンパイル通っただけで、ちゃんと理解できてない。

DeserializeOwned

実はfor <'a> serde::Deserialize<'a>serde::de::DeserializeOwnedと定義されている。名前から察するに参照を含まないようなstructなどを指しているように感じる。
実際に先のサンプルのリクエストに参照を含むように変えると下記のエラーとなる。

#[derive(Serialize, Deserialize)]
pub struct SampleRequest<'a>{ id: &'a str, num: i64 }

/*
error: implementation of `_::_serde::Deserialize` is not general enough
   --> src/main.rs:112:13
    |
112 |       lambda!(wrap(org_handler));
    |               ^^^^ implementation of `_::_serde::Deserialize` is not general enough
    | 
   ::: /home/maeda/.cargo/registry/src/github.com-1ecc6299db9ec823/serde-1.0.114/src/de/mod.rs:531:1
    |
531 | / pub trait Deserialize<'de>: Sized {
532 | |     /// Deserialize this value from the given Serde deserializer.
533 | |     ///
534 | |     /// See the [Implementing `Deserialize`][impl-deserialize] section of the
...   |
569 | |     }
570 | | }
    | |_- trait `_::_serde::Deserialize` defined here
    |
    = note: `SampleRequest<'_>` must implement `_::_serde::Deserialize<'0>`, for any lifetime `'0`...
    = note: ...but `SampleRequest<'_>` actually implements `_::_serde::Deserialize<'1>`, for some specific lifetime `'1`
*/

参照を含むstructが使用できないということは、このオブジェクトを生成するタイミングでコピーが発生してしまうことになる。

traitを渡すようにする

先の問題はクロージャを渡すのではなく、ハンドラを関連型を持つtraitとして定義すると解決する。traitにはライフタイムパラメータをつけることができるので、そのライフタイムパラメータをserde::Deserializeのライフタイムパラメータとして渡すことができる。
下記のサンプルのSampleRequestは参照を含むがコンパイルは通る。

trait Handler<'a> {
    type Request : serde::Deserialize<'a>;
    type Response : serde::Serialize;

    fn handle(&self, req: Self::Request, context: Context) -> Result<Self::Response>;
}

fn wrap(handler: impl for<'a> Handler<'a>)
           -> impl FnMut(HttpRequest, Context) -> Result<HttpResponse<String>>
{
    move | req: HttpRequest, context: Context | {
        let request_body = get_body(&req)?;
        let request_obj = serde_json::from_str(&request_body)?;
        let response_obj = handler.handle(request_obj, context)?;
        let response_text = serde_json::to_string(&response_obj)?;
        Ok(HttpResponse::new(response_text))
    }
}

#[derive(Serialize, Deserialize)]
pub struct SampleRequest<'a>{ id: &'a str, num: i64 }

struct HogeHandler;

impl <'a> Handler<'a> for HogeHandler {
    type Request = SampleRequest<'a>;
    type Response = SampleResponse;

    fn handle(&self, req: Self::Request, _context: Context) -> Result<Self::Response> {
        let json = serde_json::to_string(&req)?;
        Ok(SampleResponse{ message: format!("request was {}", json) })
    }
}

解放のタイミング

wrap関数が返すクロージャを見ると、引数のreqの所有権をクロージャの最後まで持っており、この変数は最後まで解放されない。

    move | req: LambaRequest, context: Context | {
        let request_body = get_body(&req)?;
        let request_obj = serde_json::from_str(&request_body)?;
        let response_obj = handler.handle(request_obj, context)?;
        let response_text = serde_json::to_string(&response_obj)?;
        Ok(HttpResponse::new(response_text))
    }

欲を言えば、response_objを生成した時点でreqは解放できるし、handlerRequstの型が参照を含まず所有権をhandler側で持つならば、request_objを生成したタイミングで解放できる。リクエストのサイズが大きい場合は影響あるかもしれないが、幸い今回は問題なかったので、そこまではやらずに済んだ。

Zero Copy?

このサンプルでは、まだコピーが避けられない箇所がある。ラップしたハンドラのリクエストの型(lambda_http::Request)にはライフタイムパラメータがない。つまり参照を含んでいない。lambdaに渡される生のリクエストからコピーされてlambda_http::Requestに詰められていることになる。

FnMut(HttpRequest, Context) -> Result<HttpResponse<String>, HandlerError>

lambda-httprequest.rsを読むと、
impl<'a> From<LambdaRequest<'a>> for lambda-http::Requestが定義されている。

LambdaRequest<'a>lambda-http内部で使用される型でAPI GWからの渡されるリクエストからserde_jsonでデシリアライズされる。このLambdaRequest<'a>lambda-http::Requestへ変換されて、ハンドラに渡される。

lambda-httpbody.rsの下記のコードで、Bodyとして渡された文字列をlambda-httpBody型に変換している。1

pub(crate) fn from_maybe_encoded(is_base64_encoded: bool, body: Cow<'_, str>) -> Body {
    if is_base64_encoded {
        Body::from(::base64::decode(body.as_ref()).expect("failed to decode aws base64 encoded body"))
    } else {
        Body::from(body.as_ref())
    }
}

リクエストがBASE64の場合はデコードする時に、所有権を持ったバイナリが作られるのはいいとして、そうでない場合にもBody::fromの内部でコピーが発生している。コピーが発生しないような型にしようとすると、条件によって所有権があったりなかったりで、型が複雑になりライブラリ利用者側が使いにくくなってしまう。利用者の使い勝手を優先した判断の結果と推測している。

いかがでしたか?

Rustの型システムについて調べてみましたが、よく分かりませんでした。
Rustは効率的なコードを型安全に書く仕組みはありますが、実際に使ってみると大変さがあります。
静的型検査によって、型から導出されるあらゆる可能性について不整合がないことの保証がされますが、一方で型で表現できることが増えれば、その分コンパイラに納得してもらうことは難しいのだなぁと感じました。

version

  • Rust 1.41.1
  • lambda_runtime 0.2.1

  1. serde_jsonがデシリアライズするタイミングでbodyCow型で受け取るようになっているため、ここでも引数はCow型になっている。なぜCowにしたのかはよく分からない。ちなみに手元で試したところ、serde_jsonはCow型に対してCow::Ownedにデシリアライズした。 

8
4
0

Register as a new user and use Qiita more conveniently

  1. You get articles that match your needs
  2. You can efficiently read back useful information
  3. You can use dark theme
What you can do with signing up
8
4