5
4

Delete article

Deleted articles cannot be recovered.

Draft of this article would be also deleted.

Are you sure you want to delete this article?

More than 5 years have passed since last update.

Configファイルのマイグレーションがしたい in Rust

Last updated at Posted at 2018-05-22

必要になる場面

たとえばコマンドラインアプリケーションを作っているとき、ユーザー固有の情報をどこかに保存する場面がある。
開発が進み設定できる内容を追加したくなったとき、最新バージョンの期待するフォーマットと設定ファイルの内容をすり合わせる必要が生まれる。

たとえば

  • アクセスキーやトークンを用いて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, Deserializeserde_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という項目を追加したとする。

設定を追加したのでConfigListstr_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,
        }
    }
}

のちのマイグレーションコードに繋がる、ConfigV02ConfigV01から生み出すためのコード。
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 aliasConfigV03を示すようにする

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 FromConfigV03に実装

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を更新してから一本道でした。

まとめ

  • 導かれるコーディングは素敵
  • コード外のリソースをハンドリングするのは面倒くさい
5
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
5
4

Delete article

Deleted articles cannot be recovered.

Draft of this article would be also deleted.

Are you sure you want to delete this article?