必要になる場面
たとえばコマンドラインアプリケーションを作っているとき、ユーザー固有の情報をどこかに保存する場面がある。
開発が進み設定できる内容を追加したくなったとき、最新バージョンの期待するフォーマットと設定ファイルの内容をすり合わせる必要が生まれる。
たとえば
- アクセスキーやトークンを用いてSNSへ投稿するCLIアプリケーションを作ってる場合
- 対応するSNSを増やしたい、など
なんでこの話題でRust ?
-
type alias
を使ったら、書かなきゃいけないことをコンパイラが教えてくれていい感じだったから。 - 作業録でもある。
手順
- バージョン毎にConfigの構造体を作って
-
trait From
を実装してバージョン間のすり合わせを行い -
type alias
で最新バージョンのsturctを強制する
これを満たすことでマイグレーションを実現する。
以下は、マイグレーションに対応したConfig系sturctを作成する流れ。
下準備
以下は本題で話したい事ではないが、無いと不明瞭なコードになってしまうので記載。
# [macro_use]
extern crate serde_derive;
extern crate toml;
# [derive(Debug)]
enum Error {
SimpleError(String),
TomlDeserializeError(::toml::de::Error),
TomlSerializeError(::toml::ser::Error),
}
type Result<T> = ::std::result::Result<T, Error>;
macro_rules! impl_from_for_errors {
($( $id:ident : $type:ty => $translate:block ),*) => {
$(
impl From<$type> for Error {
fn from($id: $type) -> Self $translate
}
)*
}
}
impl_from_for_errors!(
error: String => { Error::SimpleError(error) },
error: ::toml::de::Error => { Error::TomlDeserializeError(error) },
error: ::toml::ser::Error => { Error::TomlSerializeError(error) }
);
serde_derive
, toml
はConfigをファイルに保存するため。
Result<T>
のエイリアスは、独自のエラー型に収束させるため。
この手法はrust reference v1.6 エラーハンドリング 独自のエラー型を定義するに記載されている。
impl_from_for_errors!
は、try!
マクロ対応のための冗長なFrom
実装をキュッとしたくて書いた。
struct ConfigV01
を定義
# [derive(Serialize, Deserialize, Debug)]
struct ConfigV01 {
version: u8,
access_key: String,
access_secret: String,
}
後にConfigがどんどん増えることを前提に、V**
サフィックスを付けた構造体を作成する。
Serialize
, Deserialize
はserde_macro
から。
enum ConfigList
を定義
# [derive(Debug)]
enum ConfigList {
V01(ConfigV01),
}
各バージョン毎に生まれる構造体を一緒に扱うため、enumを定義する。
デシリアライズしてこのenumに含まれるいずれかのvariant
を返したり、マイグレーションの時に便利になる。
str_to_config()
を実装
fn str_to_config(contents: &str) -> Result<ConfigList> {
let parsed = contents.parse::<::toml::Value>?;
let version = parsed
.as_table()
.and_then(|table| table.get("version"))
.and_then(|value| value.as_integer())
.ok_or("Missing version".to_string())?;
let config = match version {
1 => ConfigList::V01(parsed.try_into()?),
_ => return Err("Unknown config version".to_string().into()),
}
Ok(config)
}
ファイルから読み出す以上、このコードは必要と考えて記載。
toml文字列からシリアライズ体(なんと言えばよいのか…)toml::value
に変換して、
バージョン番号を取り出した後に、それに応じたConfigへ更に変換している。
バージョン番号取り出し部分とmatch部分がなんだか長ったらしい。
version
を文字列に変えて、コードネームで識別するのも面白いかもしれない。
struct ConfigV02
を定義
# [derive(Serialize, Deserialize, Debug)]
struct ConfigV02 {
version: u8,
access_key: String,
access_secret: String,
append_location: bool
}
例えば、投稿に位置情報を付与できる機能を作ったとして、ユーザーそれぞれに有効無効を設定できた方が望ましい。
ということで、設定にappend_location
という項目を追加したとする。
設定を追加したのでConfigList
、str_to_config()
に追加しておく。
enum ConfigList {
V01(ConfigV01),
V02(ConfigV02),
}
fn str_to_config(contents: &str) -> Result<ConfigList> {
/* 省略 */
let config = match version {
1 => ConfigList::V01(parsed.try_into()?),
2 => ConfigList::V02(parsed.try_into()?),
_ => return Err("Unknown config version".to_string().into()),
}
Ok(config)
}
type aliasを定義
type Config = ConfigV02;
設定の構造体が複数登場したところでtype alias
を定義する。
設定を利用するコードではこのエイリアスを参照するようにしておけば、設定のバージョンが変わったときの負担が軽減できる。
ConfigV01
から変換するために、trait From
を実装
impl From<ConfigV01> for ConfigV02 {
fn from(c: ConfigV01) -> Self{
ConfigV02 {
version: 2,
access_key: c.access_key.clone(),
access_secret: c.access_secret.clone(),
append_location: true,
}
}
}
のちのマイグレーションコードに繋がる、ConfigV02
をConfigV01
から生み出すためのコード。
version
と新たに追加される(初期値が必要となる)append_location
には新たな値を与えるところがポイントか。
migrate()
を実装
fn migrate(config: ConfigList) -> Config {
let mut config = config;
loop {
config = match config {
ConfigList::V01(c) => ConfigList::V02(c.into()),
ConfigList::V02(c) => return c,
}
}
}
あとは簡単。ConfigList::V01
ならConfigList::V02
へと昇華させ、
ConfigList::V02
なら中身を放り出してあげればよい。
次のバージョンを書く時に効く
この書き方は次のバージョンを書く時に効く。コンパイラが怒ってくれるから。
struct ConfigV03
を定義
# [derive(Serialize, Deserialize, Debug)]
struct ConfigV03 {
version: u8,
access_key: String,
access_secret: String,
append_location: bool,
append_signature: bool,
}
例えば、投稿に何らかの署名を追加できるようにしたとする。
ということで、設定にappend_signature
という項目を追加したとする。
type alias
はConfigV03
を示すようにする
type Config = ConfigV03;
ここでcargo build
を走らせるとコンパイラがmigrate()
に対して怒ってくれる。
error[E0308]: mismatched types
--> src/main.rs:37:46
|
37 | ConfigList::V02(c) => return c,
| ^ expected struct `ConfigV03`, found struct `ConfigV02`
|
= note: expected type `ConfigV03`
found type `ConfigV02`
migrate()
を短絡的に変更
ということでmigrate()
からConfigV03
を得られるように書き換える。
短絡的に、つまりは怒られたところの前後の文脈だけを見て書き換えてみる。
enum ConfigList {
V01(ConfigV01),
V02(ConfigV02),
V03(ConfigV03),
}
fn migrate(config: ConfigList) -> Config {
let mut config = config;
loop {
config = match config {
ConfigList::V01(c) => ConfigList::V02(c.into()),
ConfigList::V02(c) => ConfigList::V03(c.into()),
ConfigList::V03(c) => return c,
}
}
}
前後の文脈だけで導いたコードを書いた。
ここでcargo build
を走らせる。
error[E0277]: the trait bound `ConfigV03: std::convert::From<ConfigV02>` is not satisfied
--> src/main.rs:38:57
|
38 | ConfigList::V02(c) => ConfigList::V03(c.into()),
| ^^^^ the trait `std::convert::From<ConfigV02>` is not implemented for `ConfigV03`
|
= note: required because of the requirements on the impl of `std::convert::Into<ConfigV03>` for `ConfigV02`
当然trait From
を実装していないのでコンパイラが怒ってくれる。
この、雰囲気コーディングをしようが正道に導いてくれる点が重要でいいところだなーと感じている。
trait From
をConfigV03
に実装
impl From<ConfigV02> for ConfigV03 {
fn from(c: ConfigV02) -> Self {
ConfigV03 {
version: 3,
access_key: c.access_key.clone(),
access_secret: c.access_secret.clone(),
append_location: c.append_location,
append_signature: true,
}
}
}
はい。コンパイラが許してくれるようになりました。
これで新しい設定の追加はおしまいです。
新しいバージョンの構造体を定義して、「これを使います!」とtype alias
を更新してから一本道でした。
まとめ
- 導かれるコーディングは素敵
- コード外のリソースをハンドリングするのは面倒くさい