CLI with Rustで幸せになる話、タイムゾーンを添えて
自己紹介
@shunsock
: GitHub・X- ファインディ株式会社 データソリューションチーム所属
- Rust歴、1年行かないくらい。趣味で書きまくって覚えた。
平和な日本の時間事情
日本はいいぞ、なんてったって、単一のタイムゾーンで夏時間が存在しないからね!
- 単一のタイムゾーン: 日本全土が同じ標準時(JST)を使用しており、国内に時差はない。
- 夏時間の不採用: 現在、日本ではサマータイム(夏時間)は採用されていない。そのため、年間を通じて時刻の変更はない。
※ 日本標準時(JST)は、日本国内で公式に採用されている標準時であり、協定世界時(UTC)に9時間を加えた時間。
UTCの魔の手がやってきた!!!
あるCloudサービスを使っていたときに、「時刻設定はUTCで指定できる」と言われた。
える、知っているか、UTCで指定できるということは、JSTでは指定できないんだよ。
手軽かつ安全にTimezoneの変換したい
Timezoneを使う機会はだいたい、コードの実行するタイミングを指定するタイミングでやってくる。時間を間違えると、他の更新日時の関係で動かないコードがあれば、大惨事に。
設定ミスはしたくないが、いちいちブラウザを開くのも面倒。
SystemのPythonに変更を加えたくない
「CLIで手軽に使えるものはないか」とChatGPTに聞いたら、Python製のソフトウェアをお勧めされた。しかし、globalでpipを使うと月日とともに崩壊する未来があるので、systemのPythonはできれば使いたくない。
とはいえ、それ以外の方法で作ると過剰な気がする。
※ Pythonにご飯を食べさせてもらっている立場なので文句を言うつもりはない
補足: Pythonの開発環境が年々良くなっている話(余ったら話す)
少し前まで、Docker経由が前提となりがちだったPythonだが、uv
によるプロジェクトの一元管理が可能になったため、docker経由しなくても十分開発できるようになりつつある。
uvとは、次世代Python Package Managerのryeの作者であるMitsuhiko氏とastral社がタッグを組んで開発をしているPython Projectのマネージャー。RustのCargoに近い。
uvでは以下の要素を管理できる。
- Python Interpreter (Pythonを解釈・実行するバイナリ)
- Python Module (⇒ RustのCrate)
- Pythonの周辺ツール ( ⇒ RustのClippy, rustfmtなど)
CLIはできればワンバイナリで入って欲しい
汎用言語のインタープリターが強力な手段であることは、理解しつつも、仕事で使うツールであれば、持ち運びのしやすいバイナリ形式、特に単一のバイナリ形式で欲しい。(雑に usr/local/bin/
に突っ込んだら動いてほしい)
せっかくなので作ることに。
補足: インタープリタ型言語の実行方法
Pythonなどのインタープリタ型の言語(特にASTを利用する言語)の場合、以下のような工程をインタープリタというソフトウェアが行い実行される。
- 字句解析
- 構文解析
- (オペコード変換)
- 実行・評価
※ 実際には、JITコンパイル(Just In Compile)というプログラムの実行時にバイトコードや中間言語をネイティブコード(機械語)に動的にコンパイル(変換)する技術があり、JavaScriptやRubyなど主要言語で採用されている。 ※ オペコードを生成しなくても動かすことは可能、例えば、ASTを巡回しながら順番に実行する方法がある。
Rustを採用した背景
Rustを使った背景としては以下の要素がある。
- 個人開発
- 単一のバイナリで動かせる
- CLIパーサーが使いやすい
- 自分が使える
個人開発
自分用のツールだったので、Rustを導入しやすかった。
チームで使うツールであれば、(よほどの理由がない限り)チームメイトが慣れている言語や、それに近い言語を選定する傾向があるので、ここは大きいと思う。
単一のバイナリで動かせる
持ち運びが便利なツールが欲しくて、自作した。つまり、簡単に使える単一バイナリのツールが欲しかった。
使いやすい入力パーサーがある
Rustには、使いやすいCLIのユーザー入力のパーサーがあり、開発がしやすい。
use clap::Parser; /// Simple program to greet a person #[derive(Parser, Debug)] #[command(version, about, long_about = None)] struct Args { /// Name of the person to greet #[arg(short, long)] name: String, /// Number of times to greet #[arg(short, long, default_value_t = 1)] count: u8, } fn main() { let args = Args::parse(); for _ in 0..args.count { println!("Hello {}!", args.name); } }
https://github.com/clap-rs/clap
この件以来、RustでCLIを作ることが多くなったので、どう使うのがいいか色々検証している。最近の趣味のプロジェクト(AST巡回型言語)だと以下のようなReceiverを作ってwrapしている。
※ これが最適という主張ではないので注意
use clap::{Arg, ArgAction, ArgMatches, Command}; pub(super) struct Receiver {} impl Receiver { pub(super) fn new() -> Self { Receiver {} } pub(super) fn receive(&self) -> ArgMatches { let command: Command = Receiver::load_command_settings(); command.get_matches() } fn load_command_settings() -> Command { Command::new("rast") .about("AST Based Language") .author("shunsock") .version("0.1.0") .arg(Arg::new("expression").required(false).short('e')) .arg(Arg::new("file").required(false).short('f')) .arg( Arg::new("debug") .short('d') .long("debug") .action(ArgAction::SetTrue) .help("Enable debug mode"), ) } }
自分が使える
すぐに使いたかったので、普段書いている言語から選んだ。選択肢は3つで、気分で決めた。
- Golang
- 趣味で書いている。楽しい。
- 最近
http/template
でPolars Cheat Sheetのpdfの再実装をした。
- CSharp
- 趣味で書いている。楽しい。
dotnet publish
でバイナリ吐ける。
- Rust
- 趣味で書いている。楽しい。
- Rust Rover最高
開発秘話的な
サマータイム(DST: Daylight Saving Time)
GitHubでPublicに公開した後、RedditというSNSで意見を募った。インストールしてくれた人がいて、「コーナーケースでバグあったよ」と教えてくれた。
サマータイム(夏時間)とは、夏季の一定期間に時計を通常の標準時より1時間進める制度です。これにより、夕方の明るい時間を有効活用し、エネルギーの節約や生活の質の向上を図ることが目的とされています。
サマータイムの開始時(春)
- 時計を1時間進める: サマータイムが始まる日には、標準時よりも時計を1時間早める。たとえば、午前2時を午前3時に変更する。
- 調整のタイミング: 多くの国では、サマータイムの開始は3月の最終日曜日の深夜に行われる。
サマータイムの終了時(秋)
- 時計を1時間戻す: サマータイムが終了する日には、時計を1時間遅らせて標準時に戻す。たとえば、午前3時を午前2時に変更する。
- 調整のタイミング: 多くの国では、サマータイムの終了は10月または11月の最終日曜日の深夜に行われる。
サマータイム開始時(春に時計を1時間進めるとき)
- 存在しない時刻が発生。
- 例: 午前2時から午前3時に時計を1時間進める場合、午前2時から午前2時59分までの時刻が存在しなくなります。
- この間に予定されていたイベントや交通機関の時刻は、一時間飛ばされることになります。
サマータイム終了時(秋に時計を1時間戻すとき)
- 二回存在する時刻が発生。
- 例: 午前2時に時計を1時間戻す場合、午前1時から午前1時59分までの時刻が二度繰り返される。
- この時間帯に起きた出来事は、どちらの「午前1時」だったのかを明確にする必要がある。
サマータイム対応
タイムゾーンの変換は、ChronoとChrono Tzというライブラリを使っている。このライブラリでは、タイムゾーンの変換が一対一でないことを前提に、 Single
, Ambiguous
, None
という計算結果の値を用意している。
Timezone Translatorでもこの実装を採用し、エラーハンドリングを行っている
pub(crate) fn convert(&self) -> Result<DateTime<Tz>, TranslationError> { // Extract the time from the `time` field with `from_tz` field let mapped: MappedLocalTime<DateTime<Tz>> = self.from_tz.from_local_datetime(&self.time); match mapped { LocalResult::Single(time) => Ok(time.with_timezone(&self.to_tz)), LocalResult::Ambiguous(time_earliest, time_latest) => { Ok(select_time_with_ambiguous_time_strategy( self.ambiguous_time_strategy, self.to_tz, time_earliest, time_latest, )) } LocalResult::None => { let error = TranslationError::TranslationError { time: self.time, from_tz: self.from_tz, to_tz: self.to_tz, }; Err(error) } } }
タイムゾーンが入力されていないときの挙動
タイムゾーンの入力がめんどくさい人むけに時間の自動入力をサポートしています。主に自分。
pub(crate) fn command_provider() -> Command { let now: String = provide_local_timezone_string(); let now_str: &'static str = Box::leak(now.into_boxed_str()); Command::new("tzt - Timezone Translator") .version("0.3.0") .author("shunsock") .about("translate time from one timezone to another") .arg(time()) .arg(from(now_str)) .arg(to(now_str)) .arg(ambiguous_time_strategy()) }
変数を渡すことでデフォルトの値を設定している。
use clap::Arg; pub(crate) fn from(timezone: &'static str) -> Arg { Arg::new("from_timezone") .short('f') .long("from") .value_name("FROM_TIMEZONE") .help("The original timezone (e.g. America/New_York) @see https://docs.rs/chrono-tz/latest/chrono_tz/enum.Tz.html") .required(false) .default_value(timezone) }
実は、この機能気合いでやっていたりします。環境変数を見たり、システムのタイムゾーンのファイルを見に行ったりします。
pub(crate) fn provide_local_timezone_string() -> String { // read environment variable TZ let env_var_tz: Option<String> = EnvironmentVariableTzProvider::new(None).get_env_var_tz(); if let Some(env_var_tz) = env_var_tz { return env_var_tz; } // read /etc/localtime let tz_from_etc_localtime: Option<String> = get_system_timezone_from_etc_localtime(); if let Some(tz_from_etc_localtime) = tz_from_etc_localtime { return tz_from_etc_localtime; } // read /etc/timezone let tz_from_etc_timezone: Option<String> = get_system_timezone_from_etc_timezone(); if let Some(tz_from_etc_timezone) = tz_from_etc_timezone { return tz_from_etc_timezone; } let error_message = "System Timezone Not Found: Could not find local timezone. Please set TZ environment variable. "; panic!("{}", error_message); }
まとめ
参考にしている本とか
Rustのプログラミングは公式ドキュメントとこの本で学びました。
途中でプログラミング言語の話が出てきたので、こちらも載せておきます。
JavaとCですが、参考になると思います。