先日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::prelude
はstd::io
でよく使われるtraitをまとめてimportするためのモジュールである。この例では、std::fs::File
がimplementしているstd::io::Write
traitがstd::io::prelude
に含まれているため、std::io::Write
で定義されているwrite_all
が使えるようになる。Rustは字面はオブジェクト指向っぽいが、ライブラリ構造や使い方はHaskellのそれっぽさを感じる。
std::io::Result
は標準のResult<T, E>
のE
のエラー型をstd::io::Error
に固定したもので、std::io::Error
はstd::error::Error
traitを実装したstd::io
固有のエラーを表すstructである。
Javaとの対応を書いて整理すると下記表の感じ
| Java | Rust |
|-----------------------------------+-------------------------------|
| java.lang.Throwable
| std::error::Error
(trait) |
| java.io.IOException
| std::io::Error
(struct) |
このように、RustはError
やResult
などの一般性の高いものでも、ライブラリの中で同じ名前で定義していくスタイルらしい。
File::create
もfile.write_all
もResult
型を返すが、rustにはモナド用の構文がない。
代わりにOption
とResult
については?
演算子を使うと、値の取り出しとエラー時の早期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::Error
とstd::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())
}
?
を使うと、先に述べた早期リターンのコード書き換えだけでなく、エラー型をInto
traitによって型の変換をしてくれる。つまりserde_json::to_string(&model).map_err(Into::into)
的なことが行われる。
serde_json::Error
→std::io::Error
の変換ができるかを調べるために、serde_json::Error
のrustdocのTrait Implementationsを見るとError
を含む定義がいくつか並んでいる。
Error
にマウスをホバーすると、ツールチップに名前空間が見えるので、それぞれ異なるError
だと分かる(このUIに誰も疑問を感じないのだろうか???)。From<serde_json::Error> for std::io::Error
によって変換が可能となっている。(From
またはInto
のどちらかが定義されていれば変換ができる)
今回は変換できたのでいいが、そうでない場合に自力で型の変換をするのはだるい。std::io::Error
もserde_json::Error
もstd::error::Error
traitを実装しているため、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_string
はResult<String, serde_json::Error>
を返すが、HandlerError
にserde_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::Error
→failure::Error
→HandlerError
と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-runtime
のfuture
は0.3系だが、rust用のAWS SDKライブラリであるrusoto
が使っているfuture
は0.1系で混乱したりした。
[2020.06.25 追記]
aws-lambda-rust-runtime
のfuture
も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-runtime
もrusoto
も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.toml
でfutures
の0.3.4
を指定していたことで0.1.29
に変更したら無事コンパイルが通った。当時の自分が特に思慮なく最新を入れていたのだと思う。
便利なtrait
Into/From trait
?
演算子によるエラー型の変換に使われるInto
/From
traitによる変換だが、それ以外の場所でもしばしば使われる。
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
型ごとのデフォルト値をDefault
traitを実装することで定義できる。
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);
この機能とDefault
traitを組み合わせると下記のように書ける。
let param = ComplexParameter(some: Some("taro"), ..Default:default())
ComplexParameter
にオプショナルなフィールドがたくさんあっても、使用するフィールドのみ指定すれば良い。
serdeでもdefaultを指定することでフィールドない場合にDefault
traitを使って値を生成することができる。
#[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::UpdateItemInput
のexpression_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
に数値で格納するために、AttributeValue
のn
フィールドに値を入れる。文字列なら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には記述をシンプルにする仕組みもあり、ライブラリ実装者も気の利いた作りにしてくれたりするが、実際に裏側で何をやっているか把握しないとハマることがある。
また、例外処理や非同期のような基本的なところでも活発に議論されているし、ライブラリも刻々と変わっている。他の言語以上に流行り廃りが激しいことに留意しておくことが大切に感じた。