lechuck.dev

Rust 로 CLI 애플리케이션 만들기 #2

마지막 업데이트:

Rust 로 CLI 애플리케이션 만들기 #1에서 CLI 애플리케이션의 1 ~ 3 단계까지 구현해 보았습니다. 이번 글에서는 4단계부터 구현을 해보도록 하겠습니다.

  1. Rust 프로젝트 생성
  2. 통합 테스트 (Integration Test)
  3. 커맨드라인 옵션 파싱
  4. 유닉스 파이프 지원
  5. 멀티플랫폼 빌드
  6. CI
  7. 배포

4. 유닉스 파이프 지원

지금은 exiftool 에서 생성한 Exif 정보를 텍스트 파일에서 읽어오고 있습니다. 셸 스크립트를 사용한다면 다음과 같은 방법으로 이미지 파일의 이름을 변경하게 될 것입니다.

exiftool -s IMG_1234.JPG > exif.txt
filename=$(exif-rename --exif exif.txt --pattern "{y}{m}{D}_{t}_{T2}_{r}.{e}
mv IMG_1234.JPG "$filename"

하지만 실행할 때 마다 exif.txt 파일이 매번 생성되고 있기 때문에, 이를 생성하지 않고 유닉스 파이프를 사용해 정보를 읽을 수 있으면 좋을 것 같네요. 이번 단계에서는 다음과 같은 방법으로 실행할 수 있도록 만들어 보겠습니다.

filename=$(exiftool -s IMG_1234.JPG | exif-rename --pattern "{y}{m}{D}_{t}_{T2}_{r}.{e}")
mv IMG_1234.JPG "$filename"

이전과 변경된 부분은 다음과 같습니다:

유닉스 파이프 (|)

$ echo "foo bar" | wc -w

유닉스에서 위와 같이 파이프를 사용하는 것은, echo 실행으로 stdout 에 출력된 결과를 wc 명령어의 STDIN 에 넣겠다는 것과 동일한 의미입니다.

Rust 에서는 std::io::Stdin 을 사용해 STDIN 의 입력값을 읽어올 수 있습니다.

커맨드라인 옵션

--exif 옵션은 지금까지는 필수였지만, STDIN을 통해 입력받을 수 있기 때문에 옵셔널로 변경해야 합니다.

src/main.rs 파일에서 exif 의 타입을 다음과 같이 Option<String> 으로 변경합니다.

struct Args {
    #[arg(short, long)]
    exif: Option<String>,

    // ...
}

타입이 변경되었기 때문에, exif를 사용하는 곳도 변경해야 합니다. 파일명을 지정하지 않은 경우, exif_filename은 빈 문자열을 사용하도록 변경합니다:

fn main() {
    // ...
    let exif_filename: &str = args.exif.as_ref().map_or("", String::as_str);
    // ...
}

테스트 코드

STDIN 으로 입력받도록만 변경했기 때문에, tests/cli.rs 파일에서 통합 테스트만 추가합니다.

이전에 만들었던 run 헬퍼 함수와 비슷하지만, STDIN 입력 파일을 인자로 받는 run_stdin 이라는 헬퍼 함수를 추가로 정의합니다.

fn run_stdin(input_file: &str, args: &[&str], expected_lines: &[&str]) -> TestResult {
    let input = fs::read_to_string(input_file)?;
    let expected = expected_lines.join("\n") + "\n";
    Command::cargo_bin(EXECUTABLE)?
        .args(args)
        .write_stdin(input)
        .assert()
        .success()
        .stdout(expected);
    Ok(())
}

write_stdin() 함수를 사용해 STDIN 으로 입력 파일의 내용을 전달합니다.

#[test]
fn stdin_exif() -> TestResult {
    run_stdin("tests/inputs/exif-jpg.txt",
              &["--pattern", "{y}{m}{D}_{t}_{T2}_{r}.{e}"],
              &["230908_185654_iPhone 14_2533.JPG"])
}

Exif 정보 입력

이제 --exif 옵션을 사용해 Exif 파일명을 지정하지 않은 경우, STDIN 에서 Exif 정보를 읽어오도록 코드를 변경해야 합니다.

이를 위해 src/lib.rs 파일에 open 이라는 함수를 추가하고, 기존의 read_exif_file 함수에서 open 함수를 사용하도록 변경합니다.

pub fn read_exif_file(filepath: &str) -> MyResult<Vars> {
    let mut vars: Vars = HashMap::new();
    match open(filepath) {
        Err(err) => eprintln!("failed to open {}: {}", filepath, err),
        Ok(buf_read) => {
            for line_result in buf_read.lines() {
                let line = line_result?;
                if let Some((key, value)) = split_exif_line(line.as_str()) {
                    vars.insert(key, value);
                }
            }
        }
    }
    Ok(vars)
}

pub fn open(filename: &str) -> MyResult<Box<dyn BufRead>> {
    match filename {
        "" => Ok(Box::new(BufReader::new(io::stdin()))),
        _ => Ok(Box::new(BufReader::new(File::open(filename)?))),
    }
}

함수의 반환타입에서 Box를 사용해 파일 핸들을 보관하기 위해 Heap 영역에 할당된 메모리 포인터를 생성하고 있습니다. 여기서 Box가 꼭 필요한 것인지 궁금할 수 있을텐데, Box를 사용하지 않는다면 아래와 같이 구현할 수 있습니다.

pub fn open(filename: &str) -> MyResult<dyn BufRead> {
    match filename {
        "" => Ok(BufReader::new(io::stdin())),
        _ => Ok(BufReader::new(File::open(filename)?)),
    }
}

하지만, 이 코드를 컴파일하면 다음과 같은 에러가 발생합니다:

error[E0277]: the size for values of type `(dyn BufRead + 'static)` cannot be known at compilation time
   --> src/lib.rs:116:32
    |
116 | pub fn open(filename: &str) -> Result<dyn BufRead, Box<dyn Error>> {
    |                                ^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^ doesn't have a size known at compile-time
    |
    = help: the trait `Sized` is not implemented for `(dyn BufRead + 'static)`

Rust는 컴파일 시점에 고정된 크기가 아닌 변수는 스택에 저장할 수 없습니다. 위의 코드에서 컴파일러는 리턴 타입인 dyn BufRead의 정확한 사이즈를 알 수 없기 때문에 에러가 발생합니다. 이를 해결하려면 리턴값을 Heap 영역에 할당한 후, 값의 크기가 정해져 있는 포인터를 Box에 넣어 반환해야 합니다.

여기까지의 소스 코드는 이 곳 에서 확인할 수 있습니다.

5. 멀티 플랫폼 빌드

Rust 는 다양한 플랫폼을 지원합니다.

처음 Rust 를 설치하면 rustup은 현재 실행 중인 아키텍처와 OS에 대한 표준 라이브러리만 설치합니다. 다른 플랫폼으로 컴파일하려면 대상 플랫폼과 Linker를 설치해야 합니다.

플랫폼 설치

다른 플랫폼 용으로 컴파일 하기 위해서는 rustup target add 커맨드를 사용해 대상 플랫폼을 설치해야 합니다. 설치 가능한 플랫폼과 현재 설치된 플랫폼은 rustup target list, rustup target list --installed 명령을 사용해 확인할 수 있습니다.

여기서는 일반적으로 많이 사용하는 플랫폼들을 지원하도록 플랫폼을 설치해보겠습니다.

$ rustup target add aarch64-apple-darwin
$ rustup target add armv7-unknown-linux-musleabihf
$ rustup target add x86_64-apple-darwin
$ rustup target add x86_64-pc-windows-gnu
$ rustup target add x86_64-unknown-linux-musl

리눅스용 플랫폼은 gnumusl이 있는데, 여기서는 musl을 사용합니다.

  • gnu : 동적 링크된 바이너리를 생성하기 때문에 glibc 라이브러리에 의존성을 갖습니다.
  • musl : 정적 링크된 바이너리를 생성하기 때문에 별도의 의존성을 갖지 않습니다. 대신 glibc 에 비해 약간 느릴 수 있습니다.

라즈베리 파이 4 용으로 빌드하기 위해 musleabihf를 사용했습니다. 라즈베리 파이 3 이나 Zero 용으로 빌드하려면 musleabi 와 같이 수정해야 합니다.

  • eabi: Embedded Application Binary Interface
  • hf: Hard Float 를 의미하며, FloatingPoint 인자가 FPU 레지스터에 전달되는 것을 의미합니다. Soft Float 를 사용하는 CPU인 경우 FPU 레지스터가 아닌 범용 레지스터에 전달됩니다.

Linker 설치

다른 플랫폼용 바이너리를 생성하려면 해당 플랫폼에 맞는 Linker를 설치해야 합니다.

MacOS

# 윈도우
$ brew install mingw-w64

# 리눅스용 MUSL
$ brew install filosottile/musl-cross/musl-cross

# 라즈베리 파이 4
$ brew install arm-linux-gnueabihf-binutils

Linux

# 윈도우
$ sudo apt-get install -y mingw-w64

# 리눅스용 MUSL
$ sudo apt-get install -y musl-tools

# 라즈베리 파이 4
$ sudo apt-get install -y gcc-arm-linux-gnueabihf 

리눅스에서 MacOS 용 바이너리를 빌드하려면 osxcross 를 설치해야합니다. 자세한 설치 방법은 osxcross Installation 문서를 참고하세요.

빌드

빌드시에는 --target 옵션을 사용해 빌드 대상 플랫폼을 지정합니다.

대상 플랫폼별로 Linker 설정이 다른 경우, Linker 실행 파일을 지정해야 하는데, 다음과 같은 방법들을 사용합니다.

여기서는 환경 변수를 사용해 Linker를 설정해 빌드해 보겠습니다.

# 라즈베리 파이 4
$ CARGO_TARGET_ARMV7_UNKNOWN_LINUX_MUSLEABIHF_LINKER=arm-linux-gnueabihf-ld \
  cargo build --release --target armv7-unknown-linux-musleabihf
      
# x86_64 리눅스      
$ CARGO_TARGET_X86_64_UNKNOWN_LINUX_MUSL_LINKER=x86_64-linux-musl-gcc \
  cargo build --release --target x86_64-unknown-linux-musl
      
# x86_64 윈도우
$ CARGO_TARGET_X86_64_PC_WINDOWS_GNU_LINKER=x86_64-w64-mingw32-gcc \
  cargo build --release --target x86_64-pc-windows-gnu

ARM 기반 Mac 에서 MacOS 용 바이너리를 빌드하는 경우, 별도의 Linker 설정은 필요없습니다.

# x86_64 기반 Mac
$ cargo build --release --target x86_64-apple-darwin

# ARM 기반 Mac
$ cargo build --release --target aarch64-apple-darwin

이제 빌드된 실행파일이 대상 플랫폼용으로 빌드되었는지 file 커맨드를 사용해 확인합니다.

# 라즈베리 파이 4
$ file target/armv7-unknown-linux-musleabihf/release/exif-rename
ELF 32-bit LSB executable, ARM, EABI5 version 1 (SYSV), statically linked, with debug_info, not stripped

# x86_64 기반 Mac
$ file target/x86_64-apple-darwin/release/exif-rename
Mach-O 64-bit executable x86_64

# x86_64 윈도우
$ file target/x86_64-pc-windows-gnu/release/exif-rename.exe
PE32+ executable (console) x86-64, for MS Windows

# x86_64 리눅스
$ file target/x86_64-unknown-linux-musl/release/exif-rename
ELF 64-bit LSB pie executable, x86-64, version 1 (SYSV), static-pie linked, with debug_info, not stripped

여기까지의 소스 코드는 이 곳 에서 확인할 수 있습니다.

6. CI

CIContinuous Integration 는 코드가 저장소에 푸시되었을 때 실행하는 일련의 프로세스들입니다. 여기에서는 테스트를 실행해 푸시된 코드에 문제가 없는지 확인하도록 해보겠습니다.

CI 시스템은 일반적으로 가장 많이 사용하는 GitHub Actions 를 사용합니다.

.github/workflows/test.yaml 파일을 생성한 후, 다음과 같이 내용을 입력합니다.

on:
  push:
    branches:
      - '**'

jobs:
  test:
    runs-on: ubuntu-latest
    steps:
      - name: checkout
        uses: actions/checkout@v4

      - name: install rust toolchains
        uses: actions-rs/toolchain@v1
        with:
          toolchain: stable

      - name: test
        uses: actions-rs/cargo@v1
        with:
          command: test

위에서 사용한 GitHub Action 들은 다음과 같습니다.

코드를 GitHub 에 푸시한 후, GitHub 저장소의 Actions 탭에서 워크플로 실행이 성공하는지 확인합니다.

여기까지의 소스 코드는 이 곳 에서 확인할 수 있습니다.

GitHub Actions 로컬 실행 (선택 사항)

GitHub Actions 를 테스트하려면 코드를 GitHub 에 푸시하고 결과를 확인해야 합니다. 바로 성공한다면 괜찮겠지만, 대부분의 경우 여러 번의 시도와 에러가 반복됩니다.

좀 더 편한 방법은 act를 설치해 로컬에서 Docker 를 사용해 GitHub Actions 를 실행할 수 있습니다.

$ brew install act

# 디폴트 'push' 이벤트 실행 
$ act --container-architecture linux/amd64

참고 사항:

7. 배포

배포 준비가 되었다면 GitHub Release 기능을 사용해 플랫폼별 바이너리를 배포할 수 있습니다.

배포는 다음과 같이 진행합니다:

  1. 소스코드 push
  2. Tag 생성 후 push
  3. GitHub Action 에서 플랫폼별 바이너리 빌드
  4. 빌드한 바이너리를 압축해 배포용 *.zip, *.tgz 파일 생성
  5. 배포용 파일을 포함하는 Release 생성

워크플로는 .github/workflows/publish.yaml 파일에 작성합니다.

name: publish exif-rename

on:
  push:
    tags:
      - "v*"

on.push.tags의 값을 v* 으로 설정했는데, 이는 v로 시작하는 태그가 생성된 경우에만 이 워크플로를 실행하겠다는 의미입니다.

빌드

build 작업에서 matrix 를 사용해 빌드할 OS와 rust target 을 지정합니다.

빌드한 바이너리 파일은 릴리즈를 위해 actions/upload-artifact Action 을 사용해 업로드합니다.

env:
  MACOSX_DEPLOYMENT_TARGET: '10.13'

jobs:
  build:
    runs-on: ${{ matrix.config.os }}

    strategy:
      fail-fast: false
      matrix:
        config:
          - os: ubuntu-latest
            rust_target: x86_64-unknown-linux-gnu
            ext: ''
            args: ''
          - os: macos-latest
            rust_target: x86_64-apple-darwin
            ext: ''
            args: ''
          - os: macos-latest
            rust_target: aarch64-apple-darwin
            ext: ''
            args: ''
          - os: windows-latest
            rust_target: x86_64-pc-windows-msvc
            ext: '.exe'
            args: ''
          - os: windows-latest
            rust_target: aarch64-pc-windows-msvc
            ext: '.exe'
            args: ''

    steps:
      - uses: actions/checkout@v4

      - name: setup rust
        uses: dtolnay/rust-toolchain@stable
        with:
          targets: ${{ matrix.config.rust_target }}

      - name: cache project
        uses: Swatinem/rust-cache@v2
        with:
          key: ${{ matrix.config.rust_target }}

      - name: build
        uses: actions-rs/cargo@v1
        with:
          command: build
          args: --release ${{ matrix.config.args }}

      - name: upload
        uses: actions/upload-artifact@v3
        with:
          name: exif-rename-${{ matrix.config.rust_target }}${{ matrix.config.ext }}
          path: target/release/exif-rename${{ matrix.config.ext }}
          if-no-files-found: error

릴리즈

앞에서 업로드한 바이너리 파일들은 actions/download-artifact Action 을 사용해 outputs 디렉토리에 다운로드합니다.

다운로드한 파일은 실행 파일인데, 그대로 릴리즈하기 보다는 압축 파일로 릴리즈하는 것이 일반적입니다. 윈도우와 맥은 *.zip, 리눅스는 *.tgz 형식을 디폴트로 사용하기 때문에 여기에서도 동일한 형식으로 압축을 하겠습니다.

.scripts/ci/package.sh 파일을 다음과 같이 생성합니다.

#!/bin/bash

set -euxo pipefail

for o in outputs/*; do
  pushd "$o"

  chmod +x exif-rename*

  target=$(basename "$o" | cut -d. -f1)
  if grep -qE '(apple|windows)' <<< "$target"; then
    zip "../${target}.zip" *
  else
    tar cv * | gzip -9 > "../${target}.tgz"
  fi

  popd
done

이제 생성된 압축파일은 svenstaro/upload-release-action Action 을 사용해 Release 합니다.

jobs:
  # ...
  publish:
    needs: build
    runs-on: ubuntu-latest
    permissions:
      contents: write
    steps:
      - uses: actions/checkout@v4

      - name: download built artifacts
        uses: actions/download-artifact@v3
        with:
          path: outputs

      - name: pack archives
        run: .scripts/ci/package.sh

      - name: publish release
        uses: svenstaro/upload-release-action@v2
        with:
          repo_token: ${{ secrets.GITHUB_TOKEN }}
          file: outputs/exif-rename-*.*
          file_glob: true
          tag: ${{ github.ref }}
          overwrite: true
          body: exif-rename release

배포 확인

이제 워크플로가 실행되어 릴리즈가 잘 되는지 확인해 보겠습니다.

소스 코드를 GitHub 에 푸시한 후, 태그를 생성하고 푸시합니다.

$ git tag v0.1
$ git push origin v0.1

이제 GitHub 저장소 Actions 탭에서 워크플로가 빌드되고 성공하게 되면 저장소의 Releases 에서 v0.1 릴리즈를 확인할 수 있습니다.

여기까지의 소스 코드는 이 곳 에서 확인할 수 있습니다.