Rustでheadコマンドを実装する①(デフォルト仕様まで)

Rust

現在Rustの学習を進めており、基本的な文法と所有権などの独自の概念を何となく理解できました。しかし、実際にアプリを作ってみないと開発に繋げられる実践的な理解を得ることは難しいです。

そのため、LinuxのheadコマンドをRustで実装してみてRustに関する理解を深めたいと思います。今回はheadコマンドのデフォルト仕様を実装します。

・Rustでheadコマンドを実装する①(デフォルト仕様まで) ← 今回はココ!
Rustでheadコマンドを実装する②(-n, -cオプションを実装する)

構築環境

コマンドの実装にあたり使用した環境は以下の通りです。

・Ubuntu 20.04 (WSL2)
・rustc 1.66.0
・cargo 1.66.0

$ cat /etc/lsb-release
DISTRIB_ID=Ubuntu
DISTRIB_RELEASE=20.04
DISTRIB_CODENAME=focal
DISTRIB_DESCRIPTION="Ubuntu 20.04.4 LTS"
$ 
$ rustc --version
rustc 1.66.0 (69f9c33d7 2022-12-12)
$ 
$ cargo --version
cargo 1.66.0 (d65d197ad 2022-11-15)

headコマンドの仕様を理解する

headコマンドはテキストファイルまたは標準出力の先頭のn行を抜き出すLinuxコマンドです。
以下の例ではsample.txtの先頭2行を出力しています。

なお、引数を与えない場合はデフォルト仕様として指定されたファイルの先頭10行を出力します。

$ cat sample.txt
Hello World!
thank you!
Welocome!

$ head sample.txt -n 2
Hello World!
thank you!

細かい仕様についてはman headで見ることができます。主要なオプションとしては、
・-n:指定された引数分だけ先頭から行を抜き出す
・-c:指定された引数分だけ先頭から文字列を抜き出す
があります。

$ man head
HEAD(1)

NAME
       head - output the first part of files

SYNOPSIS
       head [OPTION]... [FILE]...

DESCRIPTION
       // --snip--
       -c, --bytes=[-]NUM
              print the first NUM bytes of each file; with the leading '-', print all but the last NUM bytes of each file

       -n, --lines=[-]NUM
              print the first NUM lines instead of the first 10; with the leading '-', print all but the last NUM lines of each file

       // --snip--
       --version
              output version information and exit

       // --snip--

デフォルト仕様を実装する

では、プログラムを作成していきましょう。

プロジェクト名についてはheadコマンドを真似ているので「minihead」とします。Rustだとcargo newコマンドでプロジェクトファイルが作成できます。

$ cargo new minihead
     Created binary (application) `minihead` package
$ tree minihead
minihead
├── Cargo.toml
└── src
    └── main.rs

1 directory, 2 files

今回はheadコマンドのデフォルト仕様を実装するところまでがゴールです。
デフォルト仕様は与えられたファイルの先頭3行(*)を出力することとします。
* 本来の仕様では出力行数は10行ですがテストコードが書きづらいので3行とします

デフォルト仕様を実装したプロジェクトの構成とソースコードは以下の通りです。

採用するガイドライン

プロジェクト構成およびソースコードの分割基準を決定するにあたり、Rustコミュニティのガイドラインを採用することにします。ガイドラインは次の通りです。

main関数に複数の仕事の責任を割り当てるという構造上の問題は、多くのバイナリプロジェクトでありふれています。 結果として、mainが肥大化し始めた際にバイナリプログラムの個別の責任を分割するためにガイドラインとして活用できる工程をRustコミュニティは、 開発しました。この工程は、以下のような手順になっています:

・プログラムをmain.rslib.rsに分け、ロジックをlib.rsに移動する。
・コマンドライン引数の解析ロジックが小規模な限り、main.rsに置いても良い。
・コマンドライン引数の解析ロジックが複雑化の様相を呈し始めたら、main.rsから抽出してlib.rsに移動する。

この工程の後にmain関数に残る責任は以下に限定される:
・引数の値でコマンドライン引数の解析ロジックを呼び出す
・他のあらゆる設定を行う
lib.rsrun関数を呼び出す
runがエラーを返した時に処理する

このパターンは、責任の分離についてです: main.rsはプログラムの実行を行い、 そして、lib.rsが手にある仕事のロジック全てを扱います。main関数を直接テストすることはできないので、 この構造により、プログラムのロジック全てをlib.rsの関数に移すことでテストできるようになります。 main.rsに残る唯一のコードは、読めばその正当性が評価できるだけ小規模になるでしょう。 この工程に従って、プログラムのやり直しをしましょう。

引用:The Rust Programming Language 日本語版 バイナリプロジェクトの責任の分離

プロジェクトの構成

ガイドラインに従い作成したプロジェクトは以下の通りです。
src配下にheadコマンドのロジックを記載した「lib.rs」とheadコマンドの設定やrun関数の呼び出し、エラー処理を行う「main.rs」を配置しています。

$ tree -I target
.
├── Cargo.lock
├── Cargo.toml
├── spec.md  // テストデータ
└── src
    ├── lib.rs // headコマンドのロジックを記載する
    └── main.rs // headコマンドの設定やrun関数の呼び出し、エラー処理を行う

1 directory, 5 files

ソースコードについて

main.rsとlib.rsの内容について以下に示します。内容については適宜コメントで補足しています。

以下にmain.rsを示します。

extern crate minihead;

use std::env;
use std::process;

// コマンドの設定情報を保有する構造体
use minihead::Config;

fn main() {
  // (1) 実行時に与えられた引数をargs: Vec<String>に格納する
    let args: Vec<String> = env::args().collect();

    // (2) argsを引数としてconfig: Configインスタンスを作成する
    //     呼び出し時にエラーを検知した場合は、エラーメッセージを出力して処理を終了する
    let config = Config::new(&args).unwrap_or_else(|err| {
        eprintln!("Problem parsing arguments: {}", err);
        process::exit(1);
    });

    // (3) configを引数としてrun関数を呼び出す
    //     呼び出し時にエラーを検知した場合は、エラーメッセージを出力して処理を終了する
    if let Err(e) = minihead::run(config) {
        eprintln!("Application error: {}", e);
        process::exit(1);
    }
}

以下にlib.rsを示します。

use std::error::Error;
use std::fs::File;
use std::io::{prelude::*, BufReader};

// headコマンドの設定情報を保有する構造体
pub struct Config {
    filename: String,
}

// Configのメソッド
impl Config {
    // Configのコンストラクタ
    // 引数をConfigのフィールド(filename)に格納する
    // 引数が足りなければエラーメッセージを返却する
    pub fn new(args: &[String]) -> Result<Config, &'static str> {
        if args.len() < 2 {
            return Err("not enough arguments!");
        }

        let filename = args[1].clone();
        Ok(Config { filename })
    }
}


// 先頭から3行分だけファイルから文字列を抽出する
pub fn extract(buff: &mut BufReader<File>) -> String {
    let mut results = String::new();
    let lines_num = 3;

    for _ in 0..lines_num {
        buff.read_line(&mut results).expect("failed to read file");
    }

    results
}

// headコマンドのドメインロジック
pub fn run(config: Config) -> Result<(), Box<dyn Error>> {
    let f = File::open(config.filename)?;
    let mut buff = BufReader::new(f);

    for line in extract(&mut buff).lines() {
        println!("{}", line);
    }

    Ok(())
}


// テストコード
#[cfg(test)]
mod test {
    use super::*;

    #[test]
    fn normal_test1() {
        let f = File::open("spec.md").unwrap();
        let mut buff = BufReader::new(f);
        let ok_contents = "\
# head
## NAME
       head - output the first part of files
";
        assert_eq!(ok_contents, extract(&mut buff));
    }
}

実装したプログラムのポイント

実装したプログラムのポイントは以下の通りです。

  • BufReaderを用いることでファイル全体を読まずに文字列を取得している。これにより、サイズの大きいファイルでも即座に処理することを可能にしている。
  • lib.rsで検知したエラーメッセージをmain.rsに返してからエラーメッセージを出力することで、全体のロジックが見通しやすい構成にしている。

所感

headコマンドのデフォルト仕様を実装することができました。続いては、外部クレートを導入することにより、headコマンドのオプション解析機能を実装していきます。

続きはこちら

参考文献

入出力プロジェクト:コマンドラインプログラムを構築する - The Rust Programming Language 日本語版

コメント

タイトルとURLをコピーしました