LoginSignup
137
130

More than 3 years have passed since last update.

Rustのプロジェクトを始める前に知っておきたかったこと

Last updated at Posted at 2020-06-22

先日AWS lambdaをRustで実装する案件をやった。工数的には1ヶ月もないくらい。
Rustは6,7年前にちらっと勉強しただけで、実質未経験だったけど、ちょっとした案件だったので、わからないことはRust by exampleなどを見ながらどうにかなると思ってた。
実際は、ライブラリの作法や事情/背景を知らず混乱したり、雰囲気で分かったと思っていても裏側の仕組みを認識してなくて、後々ハマることが多かった。そこで少し前の自分向けに事前にこういうこと知っておきたかったと思ったことをまとめておく。

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

Resultに関する諸々

Rustにはtry-catchの例外機構はない。他の言語でいうところのEither型と同じ構造のResult型を使う。

pub enum Result<T, E> {
    Ok(T),
    Err(E),
}

main関数はResult型を返すことができる。失敗時の型はstd::fmt::Debug traitが実装されていればなんでもいい。

ファイル出力

例としてファイル出力するコードサンプルから解説する。

use std::io::prelude::*;
use std::fs::File;

fn main() -> std::io::Result<()> {
    let mut file = File::create("foo.txt")?;
    file.write_all(b"Hello, world!")
}

std::ioはファイルに限らない入出力一般のインターフェースおよび実装を提供しており、std::io::preludestd::ioでよく使われるtraitをまとめてimportするためのモジュールである。この例では、std::fs::Fileがimplementしているstd::io::Writetraitがstd::io::preludeに含まれているため、std::io::Writeで定義されているwrite_allが使えるようになる。Rustは字面はオブジェクト指向っぽいが、ライブラリ構造や使い方はHaskellのそれっぽさを感じる。

std::io::Resultは標準のResult<T, E>Eのエラー型をstd::io::Errorに固定したもので、std::io::Errorstd::error::Errortraitを実装したstd::io固有のエラーを表すstructである。
Javaとの対応を書いて整理すると下記表の感じ

Java Rust
java.lang.Throwable std::error::Error (trait)
java.io.IOException std::io::Error (struct)

このように、RustはErrorResultなどの一般性の高いものでも、ライブラリの中で同じ名前で定義していくスタイルらしい。

File::createfile.write_allResult型を返すが、rustにはモナド用の構文がない。
代わりにOptionResultについては?演算子を使うと、値の取り出しとエラー時の早期returnをいい感じにしてくれる。
上記のコードは?演算子を使わないと下記のようなコードになる。

fn main() -> std::io::Result<()> {
    let mut file = match File::create("foo.txt") {
        Ok(t) => t,
        Err(e) => return Err(e)
    };
    file.write_all(b"Hello, world!")
}

JSONにシリアライズする

serde_jsonでJSONのシリアライズ/デシリアライズができる。

use serde_derive::{Serialize, Deserialize};

#[derive(Serialize)]
struct Person {
    name: String,
    age: u8,
}
fn main() -> Result<(), serde_json::Error> {
    let model = Person { name: "taro".to_owned(), age: 18 };
    let json_text = serde_json::to_string(&model)?;
    println!("{}", json_text);
    Ok(())
}

structに#[derive(Serialize)]をつけると、そのstruct用のシリアライザが生成されて、serde_json::to_stringでJSON出力している。JSON出力も失敗しうるのでResult型を返す。エラー型はserde_json::Errorとなる。
JSONに変換してファイルに出力しようとするとserde_json::Errorstd::io::Errorが混在することになるが、このケースでは何もしなくても、戻り型はResult<(), std::io::Error>のままでコンパイルできる。

 fn main() -> Result<(), std::io::Error> {
    let model = Person { name: "taro".to_owned(), age: 18 };
    let json_text = serde_json::to_string(&model)?;
    let mut file = File::create("foo.txt")?;
    file.write_all(&json_text.as_bytes())
}

?を使うと、先に述べた早期リターンのコード書き換えだけでなく、エラー型をIntotraitによって型の変換をしてくれる。つまりserde_json::to_string(&model).map_err(Into::into)的なことが行われる。

serde_json::Errorstd::io::Errorの変換ができるかを調べるために、serde_json::ErrorのrustdocのTrait Implementationsを見るとErrorを含む定義がいくつか並んでいる。
serde_json_error.png

Errorにマウスをホバーすると、ツールチップに名前空間が見えるので、それぞれ異なるErrorだと分かる(このUIに誰も疑問を感じないのだろうか???)。From<serde_json::Error> for std::io::Errorによって変換が可能となっている。(FromまたはIntoのどちらかが定義されていれば変換ができる)

今回は変換できたのでいいが、そうでない場合に自力で型の変換をするのはだるい。std::io::Errorserde_json::Errorstd::error::Errortraitを実装しているため、Box<dyn std::error::Error>型でまとめて扱うことができる。実行時まで具体的な型が分からないのでdynというキーワードがついている。また型が分からないと、サイズが決まらないので、stackではなくheapに置く必要があるためBox型になる。
From<serde_json::Error> for std::io::Errorがなかったと仮定しても、Box型を使った下記のコードでもコンパイルは通る。

fn main() -> Result<(), Box<dyn std::error::Error>> {
    let model = Person { name: "taro".to_owned(), age: 18 };
    let json_text = serde_json::to_string(&model)?;
    let mut file = File::create("foo.txt")?;
    file.write_all(&json_text.as_bytes())?;
    Ok(())
}

最後のOk(())とその前の文の?演算子がないとコンパイルエラーになる。

fn main() -> Result<(), Box<dyn std::error::Error>> {
    let model = Person { name: "taro".to_owned(), age: 18 };
    let json_text = serde_json::to_string(&model)?;
    let mut file = File::create("foo.txt")?;
    file.write_all(&json_text.as_bytes())
}

/*
error[E0308]: mismatched types
   --> src/main.rs:190:5
    |
184 | fn main() -> Result<(), Box<dyn std::error::Error>> {
    |              -------------------------------------- expected `std::result::Result<(), std::boxed::Box<(dyn std::error::Error + 'static)>>` because of return type
...
190 |     file.write_all(&json_text.as_bytes())
    |     ^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^ expected struct `std::boxed::Box`, found struct `std::io::Error`
    |
    = note: expected enum `std::result::Result<_, std::boxed::Box<(dyn std::error::Error + 'static)>>`
               found enum `std::result::Result<_, std::io::Error>`
*/

write_allの戻り型はResult<(), std::io::Error>なので、エラー型をBox型に持ち上げる必要がある。?演算子によってIntoでBOX型へ変換されてコンパイルできている。ネット上のサンプルコードを見ていると変換がなくても最後のOk(())は書いてる。

AWS lambda上で動かす

aws-lambda-rust-runtimeを使用してAWS lambda上で動かすことができる。

use serde_derive::{Serialize, Deserialize};
use lambda_runtime::{lambda, Context, error::HandlerError};

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

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

fn handler(event: SampleRequest, _ctx: Context) -> Result<SampleResponse, HandlerError> {
    let json = serde_json::to_string(&event)?;
    Ok(SampleResponse{ message: format!("request is {}", json) })
}

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

handler関数がリクエストを処理する関数で、serde_jsonを使用してリクエストのJSONをデシリアライズしたものを第1引数に渡して、関数を実行して、戻り値をまたserde_jsonでシリアライズしてレスポンスとして返す。main関数の場合はエラー型はこちら側で決めることができたが、aws-lambda-rust-runtimeを使う場合は、ハンドラのエラー型がlambda::error::HandlerErrorであることが要請される。
serde_json::to_stringResult<String, serde_json::Error>を返すが、HandlerErrorserde_json::Errorからの変換が定義されているため、上記のコードは動作する。

ファイルの出力もするようにハンドラを書き換えてみる。

fn handler(event: SampleRequest, _ctx: Context) -> Result<SampleResponse, HandlerError> {
    let json = serde_json::to_string(&event)?;
    let mut file = File::create("foo.txt")?;
    file.write_all(json.as_bytes())?;
    Ok(SampleResponse{ message: format!("request was {}", json) })
}

これは次のようなコンパイルエラーになる。

error[E0277]: `?` couldn't convert the error to `lambda_runtime_errors::HandlerError`
  --> src/main.rs:45:43
   |
45 |     let mut file = File::create("foo.txt")?;
   |                                           ^ the trait `std::convert::From<std::io::Error>` is not implemented for `lambda_runtime_errors::HandlerError`
   |
   = note: the question mark operation (`?`) implicitly performs a conversion on the error value using the `From` trait
   = help: the following implementations were found:
             <lambda_runtime_errors::HandlerError as std::convert::From<&str>>
             <lambda_runtime_errors::HandlerError as std::convert::From<failure::error::Error>>
             <lambda_runtime_errors::HandlerError as std::convert::From<serde_json::error::Error>>
             <lambda_runtime_errors::HandlerError as std::convert::From<std::alloc::LayoutErr>>
           and 22 others
   = note: required by `std::convert::From::from`

エラーメッセージはかなり親切で、std::io::Errorからlambda_runtime_errors::HandlerErrorへの変換ができないことだけでなく、どんな型なら変換できるか、いくつか候補を出してくれる。

ここで、先の候補の上から2番めにあるfailure::error::Errorとは何か?
rust標準のstd::error::Errorが使いにくいので、代替えのライブラリが現れては消えてが繰り返されており、failureはその中のひとつである。エラーライブラリの歴史はここ( https://blog.yoshuawuyts.com/error-handling-survey/ )にまとめらているが、2017年11月に登場したfailureは2019年にはdeprecatedとなっている。Rust界隈の開発の激しさを感じる。現在も決定打はまだないらしい。

failureはすでにdeprecatedだがaws-lambda-rust-runtimeが使っているなら受け入れざるを得ない。
std::io::Errorfailure::ErrorHandlerErrorと2段階の変換すればうまくいく

fn write(content: &[u8]) -> Result<(), failure::Error> {
    let mut file = File::create("foo.txt")?;
    file.write_all(content)?;
    Ok(())
}

fn handler(event: SampleRequest, _ctx: Context) -> Result<SampleResponse, HandlerError> {
    let json = serde_json::to_string(&event)?;
    write(json.as_bytes())?;
    Ok(SampleResponse{ message: format!("request was {}", json) })
}

Rust界隈の荒波の例として、非同期処理も大変なことになっている。 aws-lambda-rust-runtimefutureは0.3系だが、rust用のAWS SDKライブラリであるrusotoが使っているfutureは0.1系で混乱したりした。
[2020.06.25 追記]
aws-lambda-rust-runtimefutureも0.1系で、ただ自分の設定ミスで混乱してただけだった。次の節を追記した。

[2020.06.29 追記]
最新のコードを見たら、failureからanyhowに置き換えた後、ライブラリ内で下記のように定義されているErrorに置き換えていた。

type Error = Box<dyn std::error::Error + Send + Sync + 'static>

S3のオブジェクトを読み込む

[2020.06.26追記]
aws-lambda-rust-runtimerusotoも0.3系に移行済み or ほぼ移行済みとのこと
https://qiita.com/maeda_/items/d765d514e7c72778f29f#comment-a40c4cef80af00a3b887

use rusoto_core::Region;
use rusoto_s3::{S3, S3Client, GetObjectRequest};
use futures::stream::Stream;
use futures::future::Future;

fn read_s3_object(bucket: String, key: String) -> Result<String, Box<dyn std::error::Error>> {
    let client = S3Client::new(Region::default());
    let request = GetObjectRequest {bucket, key, ..GetObjectRequest::default()};
    let obj = client.get_object(request).sync()?;
    let stream = obj.body.expect("body is empty");
    let body = stream.concat2().wait()?;
    Ok(String::from_utf8_lossy(&body).to_string())
}

S3上のファイルを読み込む上記のコードを書いたら、下記のコンパイルエラーが出た

error[E0599]: no method named `concat2` found for type `rusoto_signature::stream::ByteStream` in the current scope
  --> src/aws.rs:11:23
   |
11 |     let body = stream.concat2().wait()?;
   |                       ^^^^^^^ method not found in `rusoto_signature::stream::ByteStream`
   |
   = help: items from traits can only be used if the trait is in scope
   = note: the following trait is implemented but not in scope; perhaps add a `use` for it:
           `use futures::stream::Stream;`

エラーメッセージにあるuse futures::stream::Stream;もやっているし、なぜmethod not foundが出るのか? 原因はCargo.tomlfutures0.3.4を指定していたことで0.1.29に変更したら無事コンパイルが通った。当時の自分が特に思慮なく最新を入れていたのだと思う。

便利なtrait

Into/From trait

?演算子によるエラー型の変換に使われるInto/Fromtraitによる変換だが、それ以外の場所でもしばしば使われる。
IntoまたはFromのどちらかが定義されていれば.into()をつけると変換できる。

let x: String = "hoge".into();
let y: f64 = 10.into();

Aに変換できる何かという指定をするためにシグネチャにInto<A>と書くこともできる。例えば下記のようなコードが書ける。

struct Person<'a>{
    name: Cow<'a, str>,
}

fn new_person<'a>(name: impl Into<Cow<'a, str>>) -> Person<'a> {
    Person{ name: name.into()}
}

new_person("yamada");            // 引数は&'a str型
new_person("satoh".to_owned());  // 引数はString型

Cow<'a, B>型はBの実体かBの参照のいずれかを持つ型である。new_personの引数はInto<Cow<'a, str>>なので、&'a str型でもString型でも渡すことができる。またstrとStringは暗黙に変換される。

Default trait

型ごとのデフォルト値をDefaulttraitを実装することで定義できる。

let x: i32 = Default::default();   // 0
let y: String = Default::default(); // 空文字列

一部のフィールドを変えてstructのコピーする時、モダンなJavascriptみたいな記法で下記のように書ける。

let taro = Person(first: "yamada", last: "taro", country: "Japan");
let jiro = Person(first: "jiro", ..taro);

この機能とDefaulttraitを組み合わせると下記のように書ける。

let param = ComplexParameter(some: Some("taro"), ..Default:default())

ComplexParameterにオプショナルなフィールドがたくさんあっても、使用するフィールドのみ指定すれば良い。

serdeでもdefaultを指定することでフィールドない場合にDefaulttraitを使って値を生成することができる。

#[derive(Serialize, Deserialize)]
struct Hoge{
  x: Option<i32>,   // フィールドがない、または、値が空の場合はNone

  #[serde(default)]
  y: i32,           // フィールドがないがない場合は0
}

ここで、defaultを指定した場合にフィールドは存在するけど値が空の場合はエラーになるので注意。

structにスコープがある

関数の中でもstructを宣言することができる。もちろん関数外からはそのstructは参照できない。どういう時に便利かの例として、serde_dynamodbというライブラリを紹介する。
AWSのサービスをRustから使う時rusotoを使うが、rusotoはAWSのAPI仕様からコードを自動生成している。自動生成された型を真面目に書こうとするとかなり辛い。
例えば、dynamodbのあるレコードのtimestamp属性をu64型のtime変数の値で書き換えたいとする。
rusoto_dynamodb::UpdateItemInputexpression_attribute_values
フィールドはOption<HashMap<String, AttributeValue>>型であり、これを愚直に書くと

let values:Option<HashMap<String, AttributeValue>> = Some(vec![
    ("timestamp".to_owned(), AttributeValue{n: Some(time.to_string()), ..Default::default()})
].iter().cloned().collect());

となる。(timestampに数値で格納するために、AttributeValuenフィールドに値を入れる。文字列ならsフィールドであったり、型によってフィールドを変える必要がある。)

serde_dynamodbを使うと、下記のように書ける。

#[derive(Serialize, Deserialize)]
struct Update {
    timestamp: u64,
}

let values = serde_dynamodb::to_hashmap(&Update{timestamp: time})?;

serde_dynamodbを使ったほうがboiler plateがなくなり、意図が明確になる。しかし、ここで定義したstructは他の場所で使うことはない。こういう使い捨てのstructを関数内に書けば、命名にも悩まないし、実際に使う場所も絞られてうれしい。

ちなみにserdeはRustの汎用のシリアライザ/デシリアライザで、serde本体と形式固有の実装を組み合わせて使う。serdeがあるおかげで、serde_dynamodbのようなヘルパーライブラリも実装が容易になる。

ここまでのまとめ

Rustには記述をシンプルにする仕組みもあり、ライブラリ実装者も気の利いた作りにしてくれたりするが、実際に裏側で何をやっているか把握しないとハマることがある。
また、例外処理や非同期のような基本的なところでも活発に議論されているし、ライブラリも刻々と変わっている。他の言語以上に流行り廃りが激しいことに留意しておくことが大切に感じた。

137
130
7

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
137
130