#17 유사 grep 작성하기 上 프로젝트 작성 및 리펙토링

Pt J·2020년 8월 29일
0

[完] Rust Programming

목록 보기
20/41
post-thumbnail

이 시리즈는 Rust 공식문서를 통해 공부한 흔적임을 밝힙니다.

이번 시간부터 두 시간 동안 지금까지 내용을 복습하는 프로젝트를 진행할 것이다.
추가적으로 새로 다루는 내용도 있겠지만 말이다.
이번에 작성할 것은 grep을 단순화한 프로그램이다.
grep은 Globally search a Regular Expression and Print으로,
입력으로 들어온 파일 내부의 특정 정규 표현식을 포함한 부분을 찾아 출력한다.
우리는 정규표현식까지는 아니고 문자열이 포함된 부분을 출력하도록 하겠다.

명령줄 인수 Command Line Argument

//항상 Command Line Argument라고 불렀는데 이렇게 번역하는구나...를 이번에 알게 되었다;;

grep 명령어는

$ grep REGEX FILEPATH

과 같이 사용하거나

$ OTHER_COMMAND | grep REGEX

과 같이 사용한다.

우리는 첫번째 방식과 비슷하게

$ cargo run STRING FILEPATH

으로 기능을 수행할 수 있도록 할 것이다.

그런데 지금까지 우리가 작성해온 프로그램은 이렇게 cargo run 옆에 값을 전달하지 못한다.
이런 식으로 전달되는 값을 명령줄 인수라고 부르는데
표준 라이브러리의 std::env::args 함수를 통해 읽어들일 수 있다.

일단 이러한 명령줄 인수를 받아오는 것부터 시작하도록 하자.

peter@hp-laptop:~/rust-practice$ mkdir chapter12
peter@hp-laptop:~/rust-practice$ cd chapter12
peter@hp-laptop:~/rust-practice/chapter12$ cargo new minigrep
     Created binary (application) `minigrep` package
peter@hp-laptop:~/rust-practice/chapter12$ cd minigrep/
peter@hp-laptop:~/rust-practice/chapter12/minigrep$ vi src/main.rs

src/main.rs

use std::env;

fn main() {
    let args: Vec<String> = env::args().collect();
    println!("{:?}", args);
}

cargo run 옆에 이것 저것 적어보고 그 결과를 확인해보자.

peter@hp-laptop:~/rust-practice/chapter12/minigrep$ cargo run
   Compiling minigrep v0.1.0 (/home/peter/rust-practice/chapter12/minigrep)
    Finished dev [unoptimized + debuginfo] target(s) in 0.23s
     Running `target/debug/minigrep`
["target/debug/minigrep"]
peter@hp-laptop:~/rust-practice/chapter12/minigrep$ 
peter@hp-laptop:~/rust-practice/chapter12/minigrep$ cargo run peter and ferris
    Finished dev [unoptimized + debuginfo] target(s) in 0.00s
     Running `target/debug/minigrep peter and ferris`
["target/debug/minigrep", "peter", "and", "ferris"]
peter@hp-laptop:~/rust-practice/chapter12/minigrep$ 

우리의 minigrep 프로그램은 두 개의 문자열만을 받아 사용할 것이니
args[1]args[2]를 적절한 변수에 저장하도록 수정하겠다.

peter@hp-laptop:~/rust-practice/chapter12/minigrep$ vi src/main.rs

src/main.rs

use std::env;

fn main() {
    let args: Vec<String> = env::args().collect();
   
    let query = &args[1];
    let filename = &args[2];

    println!("Search for {}", query);
    println!("In file {}", filename);
}
peter@hp-laptop:~/rust-practice/chapter12/minigrep$ cargo run test sample.txt
   Compiling minigrep v0.1.0 (/home/peter/rust-practice/chapter12/minigrep)
    Finished dev [unoptimized + debuginfo] target(s) in 0.22s
     Running `target/debug/minigrep test sample.txt`
Search for test
In file sample.txt
peter@hp-laptop:~/rust-practice/chapter12/minigrep$ 

파일 읽기

그럼 이제 filename이라는 이름의 파일에서 query라는 문자열이 포함된 줄을 출력해야 한다.
그러기 위해서는 파일을 읽어올 수 있어야 한다.

먼저 그 대상이 되는 파일을 준비하도록 하겠다.

peter@hp-laptop:~/rust-practice/chapter12/minigrep$ vi poem.txt

poem.txt

I’m nobody! Who are you?
Are you nobody, too?
Then there’s a pair of us - don’t tell!
They’d banish us, you know.

How dreary to be somebody!
How public, like a frog
To tell your name the livelong day
To an admiring bog!

그리고 파일을 읽어들이는 코드를 출력하도록 하자.
우선 검색 기능을 아직 구현하지 않았으니 파일을 잘 읽어들이는지 확인하기 위해
파일의 모든 내용을 출력하도록 작성하겠다.
그리고 같은 이유로 query 변수는 일단 무시한다.

peter@hp-laptop:~/rust-practice/chapter12/minigrep$ vi src/main.rs

src/main.rs

use std::env;
use std::fs;

fn main() {
    let args: Vec<String> = env::args().collect();
  
    let query = &args[1];
    let filename = &args[2];

    println!("Search for {}", query);
    println!("In file {}", filename);

    let contents = fs::read_to_string(filename)
        .expect("Something went wrong reading the file");
   
    println!("With text:\n{}", contents);
}
peter@hp-laptop:~/rust-practice/chapter12/minigrep$ cargo run test poem.txt 
   Compiling minigrep v0.1.0 (/home/peter/rust-practice/chapter12/minigrep)
    Finished dev [unoptimized + debuginfo] target(s) in 0.24s
     Running `target/debug/minigrep test poem.txt`
Search for test
In file poem.txt
With text:
I’m nobody! Who are you?
Are you nobody, too?
Then there’s a pair of us - don’t tell!
They’d banish us, you know.

How dreary to be somebody!
How public, like a frog
To tell your name the livelong day
To an admiring bog!
peter@hp-laptop:~/rust-practice/chapter12/minigrep$ 

인자로 전달된 이름을 가진 파일의 내용을 읽어오는 fs::read_to_string 함수를 통해
파일의 내용을 잘 읽어들였음을 확인할 수 있다.

그런데 잠깐,
우린 지금 src/main.rs에 모든 것을 작성하고 있다.
하지만 관리와 테스트 측면에서는 구조를 수정할 필요가 있다.

리팩토링 Refactoring

우리는 다음 작업을 진행하기 전에 네 가지 문제를 처리하고 갈 것이다.

ISSUE 1 | main 함수 하나로 모든 작업을 하고 있다.

물론 지금처럼 코드가 짧고 단순할 땐 큰 문제가 되지 않을 수 있지만
하나의 함수는 하나의 기능만 하도록 작성하는 것이 좋다.
하나의 함수가 여러 작업을 하게 되면 테스트를 하기도 어렵고 효과적으로 관리할 수 없게 된다.

ISSUE 2 | config 변수와 logic 변수가 혼재되어 있다.

filename, query와 같은 config 변수는 프로그램 실행 도중 바뀔 일이 없고
input의 역할을 하는 녀석이지만
contents와 같은 logic 변수는 프로그램의 논리적인 흐름에 이용되는 녀석이다.
변수가 많아질수록 그것들을 관리하기가 어려워지는데 그럴 경우
이렇게 모든 변수를 혼재된 상태로 두는 것보다
config 변수는 그것끼리 구조체로 묶어두는 편이 관리하기 용이하다.

ISSUE 3 | 파일을 열지 못했을 때에 대한 오류 메시지가 불친절하다.

파일을 열지 못햇을 때 그 원인은
파일이 존재하지 않아서일 수도 있고 존재하긴 하지만 권한이 없어서일 수도 있고
또 다른 어떤 이유가 있을 수도 있지만 우리 프로그램은
그냥 파일을 여는 데 문제가 있다는 정도만 말해주고 있다.
원인에 따라 좀 더 친절한 설명을 해주는 편이 좋다.

ISSUE 4 | expect로 명시되지 않은 부분에서 오류가 발생했을 때 그 원인을 파악하기 어렵다.

expect를 통해 일일이 명시함으로써 오류 메시지의 원인을 명확하게 알릴 수 있다.
하지만 오류 처리 코드가 분산되어 있으면 유지 보수에 어려움이 있을 수 있으므로
이를 위한 로직을 따로 작성하는 것이 좋다.

이러한 네 가지 관점에서 우리가 작성한 코드를 리팩토링 해보자.

main 함수가 너무 커졌을 때 이를 분리하는 과정은 다음과 같다.

  1. 프로그램을 main.rslib.rs로 분할하고 로직을 lib.rs로 옮긴다.
    Split your program into a main.rs and a lib.rs and move your program’s logic to lib.rs.
  2. 명령줄 구문분석 로직이 충분히 작다면 그것은 main.rs에 놔두어도 된다.
    As long as your command line parsing logic is small, it can remain in main.rs.
  3. 명령줄 구문분석 로직이 복잡해지기 시작하면 main.rs에서 뽑아내 lib.rs로 옮긴다.
    When the command line parsing logic starts getting complicated, extract it from main.rs and move it to lib.rs.

ISSUE 1 & ISSUE 2

우선, 우리 코드에서 명렬줄 인수를 읽어들이는 부분을 따로 함수로 분할해보자.

peter@hp-laptop:~/rust-practice/chapter12/minigrep$ vi src/main.rs

src/main.rs

use std::env;
use std::fs;

fn main() {
    let args: Vec<String> = env::args().collect();

    let (query, filename) = parse_config(&args);

    println!("Search for {}", query);
    println!("In file {}", filename);

    let contents = fs::read_to_string(filename)
        .expect("Something went wrong reading the file");
  
    println!("With text:\n{}", contents);
}

fn parse_config(args: &[String]) -> (&str, &str) {
    let query = &args[1];
    let filename = &args[2];

    (query, filename)
}

수정하고 보니 우리는 튜플을 반환하여 그것을 개별 변수로 저장하고 있다.
그러나 이 둘은 연관된 녀석이므로 그룹화하여 저장하는 편이 좋다.
따라서 그룹화를 하되 각각의 의미가 잘 드러날 수 있도록 구조체로 변경해준다.

peter@hp-laptop:~/rust-practice/chapter12/minigrep$ vi src/main.rs

src/main.rs

use std::env;
use std::fs;

fn main() {
    let args: Vec<String> = env::args().collect();

    let config = parse_config(&args);

    println!("Search for {}", config.query);
    println!("In file {}", config.filename);

    let contents = fs::read_to_string(config.filename)
        .expect("Something went wrong reading the file");
   
    println!("With text:\n{}", contents);
}

struct Config {
    query: String,
    filename: String,
}

fn parse_config(args: &[String]) -> Config {
    let query = args[1].clone();
    let filename = args[2].clone();

    Config { query, filename }
}

Rust의 대여 규칙을 위반하지 않도록 &str이 아니라 String으로 변경하였다.
복제에 대한 오버헤드가 있을 수 있지만 간결하게 문제를 해결하는 방법이다.
이 부분에 대한 개선의 여지가 존재하지만 일단 이대로 넘어가겠다.

아무튼 이제 queryfilename의 연관성이 드러나며
프로그램의 설정을 구성하고 있음을 확실히 하고 있다.
그런데 가만, parse_config 함수는 Config를 생성하는 함수인데
Config의 연관함수가 아닌 일반 함수로 구현되어 있다.
이 녀석도 연관성을 드러내며 관리에 용이하도록 연관함수 new로 수정해주자.

peter@hp-laptop:~/rust-practice/chapter12/minigrep$ vi src/main.rs

src/main.rs

use std::env;
use std::fs;

fn main() {
    let args: Vec<String> = env::args().collect();

    let config = Config::new(&args);

    println!("Search for {}", config.query);
    println!("In file {}", config.filename);

    let contents = fs::read_to_string(config.filename)
        .expect("Something went wrong reading the file");
  
    println!("With text:\n{}", contents);
}

struct Config {
    query: String,
    filename: String,
}

impl Config {
    fn new(args: &[String]) -> Config {
        let query = args[1].clone();
        let filename = args[2].clone();

        Config { query, filename }
    }
}

ISSUE 3 & ISSUE 4

우리는 명령줄 인수 두 개를 받아 Config 인스턴스에 저장한다.
만약 전달된 명렬줄 인수가 모자라다면,
그러니까 아무것도 전달되지 않았거나 하나만 전달되었다면
우리 프로그램은 index out of bounds panic을 띄운다.
사용자 입장에서는 이 메시지만으로는 무엇이 문제인지 파악하기 어렵다.
따라서 이에 대한 오류 메시지를 따로 작성하도록 하겠다.

Config::new에서 명령줄 인수의 개수를 확인하여 부족하면 panic하도록 수정해보자.

peter@hp-laptop:~/rust-practice/chapter12/minigrep$ vi src/main.rs

src/main.rs

// snip
impl Config {
    fn new(args: &[String]) -> Config {
        if args.len() < 3 {
            panic!("not enough arguments");
        }
        let query = args[1].clone();
        let filename = args[2].clone();

        Config { query, filename }
    }
}

그런데 우리는 오류를 처리하는 다른 방법에 대해 다룬 적이 있다.
여기서 panic을 띄울 경우 backtrace에 대한 정보 등
사용자에게 알릴 필요 없는 정보까지 결과 화면에 출력하게 되는데
이렇게 작성하는 것 보다는 여기서는 Result 변수를 반환하고
main에서 그 결과에 따라 Err라면 그 메시지를 띄우고
panic이 아닌 정상 종료 상태로 종료시키는 것이 좋을 수 있다.

peter@hp-laptop:~/rust-practice/chapter12/minigrep$ vi src/main.rs

src/main.rs

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

fn main() {
    let args: Vec<String> = env::args().collect();

    let config = Config::new(&args).unwrap_or_else(|err| {
        println!("Problem parsing arguments: {}", err);
        process::exit(1);
    });

    println!("Search for {}", config.query);
    println!("In file {}", config.filename);

    let contents = fs::read_to_string(config.filename)
        .expect("Something went wrong reading the file");
   
    println!("With text:\n{}", contents);
}

struct Config {
    query: String,
    filename: String,
}

impl Config {
    fn new(args: &[String]) -> Result<Config, &'static str> {
        if args.len() < 3 {
            return Err("not enough arguments");
        }
        let query = args[1].clone();
        let filename = args[2].clone();

        Ok(Config { query, filename })
    }
}

이제 명령줄 인수의 개수가 부족할 경우 그것에 대한 정보만 출력하고 종료된다.

peter@hp-laptop:~/rust-practice/chapter12/minigrep$ cargo run   
   Compiling minigrep v0.1.0 (/home/peter/rust-practice/chapter12/minigrep)
    Finished dev [unoptimized + debuginfo] target(s) in 0.27s
     Running `target/debug/minigrep`
Problem parsing arguments: not enough arguments
peter@hp-laptop:~/rust-practice/chapter12/minigrep$ 

우리가 사용한 Result의 메서드 unwrap_or_else
Ok일 경우 그것이 가지고 있는 값을 반환하고
그렇지 않을 경우 클로저를 통해 전달한 익명 함수를 호출하는데
클로저와 익명함수에 대해서는 추후에 알아보도록 하겠다.
일단은 Err에 전달한 문자열이 err를 통해 전달되어 문자열이 출력된 후 종료된다고만 이해하자.
process::exit 함수는 불필요한 backtrace 등의 정보 없이 깔끔하게 종료되도록
프로그램을 즉시 중단하고 종료 상태 코드를 반환한다.

main.rs & lib.rs

main 함수의 내용 중 구성 설정 및 예외 처에 대한 부분이 아닌 로직은
main 함수에서 분리해내는 것이 좋다.
따라서 우리는 그 부분을 run이라는 이름의 함수로 구분할 것이다.

peter@hp-laptop:~/rust-practice/chapter12/minigrep$ vi src/main.rs

src/main.rs

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

fn main() {
    let args: Vec<String> = env::args().collect();

    let config = Config::new(&args).unwrap_or_else(|err| {
        println!("Problem parsing arguments: {}", err);
        process::exit(1);
    });

    println!("Search for {}", config.query);
    println!("In file {}", config.filename);

    run(config);
}

fn run(config: Config) {
    let contents = fs::read_to_string(config.filename)
        .expect("Something went wrong reading the file");
  
    println!("With text:\n{}", contents);
}
// snip

그런데 run에도 아까의 Config::new와 같이 오류에 대한 처리가 존재한다.
따라서 이 녀석도 앞서 했던 것처럼 Result를 반환하는 방식으로 수정하도록 하겠다.

peter@hp-laptop:~/rust-practice/chapter12/minigrep$ vi src/main.rs

src/main.rs

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

use std::env;
use std::fs;
use std::process;
use std::error::Error;

fn main() {
    let args: Vec<String> = env::args().collect();

    let config = Config::new(&args).unwrap_or_else(|err| {
        println!("Problem parsing arguments: {}", err);
        process::exit(1);
    });

    println!("Search for {}", config.query);
    println!("In file {}", config.filename);

    if let Err(e) = run(config) {
        println!("Application error: {}", e);
        process::exit(1);
    }
}

fn run(config: Config) -> Result<(), Box<dyn Error>> {
    let contents = fs::read_to_string(config.filename)?;
  
    println!("With text:\n{}", contents);

    Ok(())
}
// snip

이번에는 성공했을 시의 값은 의미가 없어 알 필요가 없기 때문에
unwrap_or_else가 아닌 if let을 사용했다.

트레이트 객체 Box<dyn Error>는 Error 트레이트를 구현한 무언가를 의미하며
구체적인 자료형은 특정되지 않는다는 정도만 알고 넘어가자.
트레이트 객체에 대한 이야기는 추후에 하도록 하겠다.

이제 main.rs에는 간결하게 main 함수만 남기고
실제적인 기능에 대한 부분은 lib.rs로 옮겨보자.
이 때, main.rs에서 사용할 녀석들에 pub 키워드를 붙이는 것을 잊지 말자.

peter@hp-laptop:~/rust-practice/chapter12/minigrep$ vi src/lib.rs

src/lib.rs

use std::fs;
use std::error::Error;

pub fn run(config: Config) -> Result<(), Box<dyn Error>> {
    let contents = fs::read_to_string(config.filename)?;
  
    println!("With text:\n{}", contents);

    Ok(())
}

pub struct Config {
    pub query: String,
    pub filename: String,
}

impl Config {
    pub fn new(args: &[String]) -> Result<Config, &'static str> {
        if args.len() < 3 {
            return Err("not enough arguments");
        }
        let query = args[1].clone();
        let filename = args[2].clone();

        Ok(Config { query, filename })
    }
}

그리고 main.rs에서는 lib.rs에 있는 녀석들을 사용할 때
main.rs의 범위로 가져와서 사용하는 것을 잊지 말자.

peter@hp-laptop:~/rust-practice/chapter12/minigrep$ vi src/main.rs

src/main.rs

use std::env;
use std::process;
use minigrep::Config;

fn main() {
    let args: Vec<String> = env::args().collect();

    let config = Config::new(&args).unwrap_or_else(|err| {
        println!("Problem parsing arguments: {}", err);
        process::exit(1);
    });

    println!("Search for {}", config.query);
    println!("In file {}", config.filename);

    if let Err(e) = minigrep::run(config) {
        println!("Application error: {}", e);
        process::exit(1);
    }
}

자, 이제 코드가 많이 정리 되었으며 테스트를 할 수 있게 되었다.
파일에서 원하는 문자열을 탐색하는 실질적인 라이브러리 기능은
다음 시간에 마저 구현해보도록 하자.

이 포스트의 내용은 공식문서의 12장 1절 Accepting Command Line Arguments & 12장 2절 Reading a File & 12장 3절 Refactoring to Improve Modularity and Error Handling에 해당합니다.

profile
Peter J Online Space - since July 2020

0개의 댓글