Rust 로 CLI 애플리케이션 만들기 #2
Rust 로 CLI 애플리케이션 만들기 #1에서 CLI 애플리케이션의 1 ~ 3 단계까지 구현해 보았습니다. 이번 글에서는 4단계부터 구현을 해보도록 하겠습니다.
- Rust 프로젝트 생성
- 통합 테스트 (Integration Test)
- 커맨드라인 옵션 파싱
- 유닉스 파이프 지원
- 멀티플랫폼 빌드
- CI
- 배포
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"
이전과 변경된 부분은 다음과 같습니다:
exiftool
의 실행 결과를|
를 사용해exif-rename
의 입력으로 사용합니다.exif-rename
에서--exif
옵션이 빠졌습니다.
유닉스 파이프 (|
)
$ 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)?))),
}
}
open
함수는filename
을 인자로 받으며, error 혹은BufRead
트레이트를 구현한 박싱된 값을 반환합니다.filename
이 빈 문자열인 경우std::io::stdin
에서 읽어옵니다.- 그 외의 경우
File::open
을 사용해 파일을 열게 되며, 에러 발생시 에러를 반환합니다. File::open
이 성공하면, 반환값은File
타입이 됩니다.File
과std::io::stdin
은 모두std::io::BufRead
트레이트를 구현하고 있으므로,BufRead::lines
함수를 사용해 Exif 정보를 읽을 수 있습니다.
함수의 반환타입에서 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
명령을 사용해 확인할 수 있습니다.
여기서는 일반적으로 많이 사용하는 플랫폼들을 지원하도록 플랫폼을 설치해보겠습니다.
aarch64-apple-darwin
: ARM 기반 Macarmv7-unknown-linux-musleabihf
: 라즈베리 파이 4x86_64-apple-darwin
: x86_64 Macx86_64-pc-windows-gnu
: x64_64 윈도우x86_64-unknown-linux-musl
: x86_64 리눅스
$ 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
리눅스용 플랫폼은
gnu
와musl
이 있는데, 여기서는musl
을 사용합니다.
gnu
: 동적 링크된 바이너리를 생성하기 때문에glibc
라이브러리에 의존성을 갖습니다.musl
: 정적 링크된 바이너리를 생성하기 때문에 별도의 의존성을 갖지 않습니다. 대신glibc
에 비해 약간 느릴 수 있습니다.
라즈베리 파이 4 용으로 빌드하기 위해
musleabihf
를 사용했습니다. 라즈베리 파이 3 이나 Zero 용으로 빌드하려면musleabi
와 같이 수정해야 합니다.
eabi
: Embedded Application Binary Interfacehf
: 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 실행 파일을 지정해야 하는데, 다음과 같은 방법들을 사용합니다.
Cargo.toml
파일의[target]
블럭에linker
설정 추가- target.
.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 들은 다음과 같습니다.
- actions/checkout : Git 저장소 체크아웃
- actions-rs/toolchain : Rust 와 ToolChain 설치
- action-rs/cargo : Cargo 명령 실행
코드를 GitHub 에 푸시한 후, GitHub 저장소의 Actions 탭에서 워크플로 실행이 성공하는지 확인합니다.
여기까지의 소스 코드는 이 곳 에서 확인할 수 있습니다.
GitHub Actions 로컬 실행 (선택 사항)
GitHub Actions 를 테스트하려면 코드를 GitHub 에 푸시하고 결과를 확인해야 합니다. 바로 성공한다면 괜찮겠지만, 대부분의 경우 여러 번의 시도와 에러가 반복됩니다.
좀 더 편한 방법은 act를 설치해 로컬에서 Docker 를 사용해 GitHub Actions 를 실행할 수 있습니다.
$ brew install act
# 디폴트 'push' 이벤트 실행
$ act --container-architecture linux/amd64
참고 사항:
- 여기서는 ARM 계열 Mac 을 기준으로 설명했는데, GitHub Action은 x86 계열 Linux 에서 실행되므로 실행시
--container-architecture linux/amd64
인자를 사용해야 동일한 환경에서 문제없이 실행되는지 확인할 수 있습니다. - ARM 계열 Mac 에서
linux/amd64
아키텍처로 모드로 도커 컨테이너를 실행하는 경우 속도가 많이 느리기 때문에, GitHub 에 푸시해서 테스트하는 것이 더 빠를 수 있습니다.
7. 배포
배포 준비가 되었다면 GitHub Release 기능을 사용해 플랫폼별 바이너리를 배포할 수 있습니다.
배포는 다음과 같이 진행합니다:
- 소스코드 push
- Tag 생성 후 push
- GitHub Action 에서 플랫폼별 바이너리 빌드
- 빌드한 바이너리를 압축해 배포용
*.zip
,*.tgz
파일 생성 - 배포용 파일을 포함하는 Release 생성
워크플로는 .github/workflows/publish.yaml
파일에 작성합니다.
name: publish exif-rename
on:
push:
tags:
- "v*"
on.push.tags
의 값을 v*
으로 설정했는데, 이는 v
로 시작하는 태그가 생성된 경우에만 이 워크플로를 실행하겠다는 의미입니다.
빌드
build
작업에서 matrix 를 사용해 빌드할 OS와 rust target 을 지정합니다.
x86_64-unknown-linux-gnu
x86_64-apple-darwin
aarch64-apple-darwin
x86_64-pc-windows-msvc
aarch64-pc-windows-msvc
빌드한 바이너리 파일은 릴리즈를 위해 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
릴리즈를 확인할 수 있습니다.
여기까지의 소스 코드는 이 곳 에서 확인할 수 있습니다.