기본 콘텐츠로 건너뛰기

Cargo 패키지 시스템 - Cargo로 프로젝트 생성부터 배포까지

왜?

Rust를 배워보자

Cargo?

Cargo는 Rust의 패키지 매니저다. 자바의 Maven이나 Gradle같은 걸로 생각하면 된다. Cargo로 프로젝트 생성 부터 배포, 커스텀 빌드까지 정리 해 두기로 한다.

목차

  • 설치
  • 프로젝트 생성
  • 빌드
  • 실행
  • 유닛 테스트 실행
  • 배포
  • 커스텀 빌드

설치

기본적으로 Cargo는 Rust와 같이 배포가 되기 때문에 rustc가 설치 되어 있다면 Cargo도 설치 되어 있다고 보면 된다.

설치는 간단하다.
curl https://sh.rustup.rs -sSf | sh
info: downloading installer

Welcome to Rust!

...
Current installation options:

  default host triple: x86_64-unknown-linux-gnu
  default toolchain: stable
  modify PATH variable: yes

1) Proceed with installation (default)
2) Customize installation
3) Cancel installation

...
To configure your current shell run source $HOME/.cargo/env
1) Proceed with installation (default) 를 선택해서 진행하면 된다. 설치된 실행 파일들을 자동으로 PATH에 등록되지 않기 때문에 설치 메시지에서 알려 주는 대로 source $HOME/.cargo/env 를 해줘야 한다. ( ~/.bash_profile이나 ~/.profile에 추가해 두면 매번 실행하지 않아도 된다. )

기본 설치를 하지 않고 2) Customize installation을 선택하면 stable/beta/nightly 중에서 툴체인을 선택하거나 컴파일 타겟을 바꿀 수 있다. 나중에 필요하면 rustup명령으로 툴체인이나 타겟은 바꿀 수 있으니 기본으로 설치해도 무방하다.

설치 디렉토리($HOME/.cargo) 아래 bin 디렉토리를 보면 cargo 실행 파일을 확인 할 수 있다. 그 밖에 rustc 컴파일러는 물론이고 디버거등도 기본 설치 됨을 알 수 있다.
root@11382ab871ac:/# ls -al /root/.cargo/bin
total 57032
drwxr-xr-x 2 root root 4096 Mar 21 14:28 .
drwxr-xr-x 3 root root 4096 Mar 21 13:38 ..
-rwxr-xr-x 6 root root 9728608 Mar 21 14:28 cargo
-rwxr-xr-x 6 root root 9728608 Mar 21 14:28 rust-gdb
-rwxr-xr-x 6 root root 9728608 Mar 21 14:28 rust-lldb
-rwxr-xr-x 6 root root 9728608 Mar 21 14:28 rustc
-rwxr-xr-x 6 root root 9728608 Mar 21 14:28 rustdoc
-rwxr-xr-x 6 root root 9728608 Mar 21 14:28 rustup
cargo help 를 보면 Cargo로 빌드, 유닛 테스트, 문서 생성 그리고 배포에 이르기 까지 개발에 필요한 일련의 기능이 포함 되어 있음을 알 수 있다.
cargo help
...
 build    Compile the current project
 check    Analyze the current project and report errors, but don't build object files
 clean    Remove the target directory
 doc      Build this project's and its dependencies' documentation
 new      Create a new cargo project
 init     Create a new cargo project in an existing directory
 run      Build and execute src/main.rs
 test     Run the tests
 bench    Run the benchmarks
 update   Update dependencies listed in Cargo.lock
 search   Search registry for crates
 publish  Package and upload this project to the registry
 install  Install a Rust binary

프로젝트 생성

프로젝트 생성은 init/new 두가지 방법이 있다. 생성 할 때 디렉토리를 지정 하느냐 현재 디렉토리에 생성 하느냐 정도의 차이가 있을 뿐 이다. 프로젝트가 생성되면 자동으로 Git 저장소가  init이 되고 커밋에 제외 될 파일들도 .gitignore에 등록되어 생성 된다.
$ mkdir hello
$ cd hello
$ cargo init
$ hello git:(master) ✗ tree -a
.
├── Cargo.toml
├── .git
├── .gitignore
└── src
 └── lib.rs

$ hello git:(master) ✗ cat .gitignore
target
Cargo.lock
코드는 src 디렉토리 위치한다. --lib 옵션(기본: 생략가능)을 인자로 주면 기본으로 lib.rs가 생성되고 --bin 옵션엔 main.rs가 기본으로 생성 된다.
cargo init --bin (또는 --lib)
lib.rs와 main.rs 두 파일의 차이는 라이브러리 형태 배포가 목적일 땐 lib.rs를 실행 어플리케이션이 목적일 땐 main.rs를 사용한다. 물론, 라이브러리 면서 동시에 실행 어플리케이션일 경우는 두 파일을 같이 사용하면 된다.

빌드

빌드는 cargo build 명령으로 프로젝트를 빌드 할 수 있다. 기본 --debug 모드로 컴파일 되고 target 폴더 아래 빌드 결과가 생성된다. release로 빌드되면 release 폴더가 생성 된다. 

└── target
    └── debug
        ├── build
        ├── examples
        ├── native
...
build나 run 같은 서브 명령어 대부분이 비슷한 실행 옵션을 가지기 때문에 세부적인 옵션은 cargo run 부분에서 같이 정리해 둔다.

빌드와 유사한 명령으로 cargo check 명령이 있다. 문법 체크 정도로 생각 하면 된다.

빌드는 dependency에 있는 모듈들이 같이 컴파일 되기 때문에 시간이 오래 걸린다. 이럴 때 작성 중인 어플리케이션의 문법 체크만 할 수 있다면 시간을 많이 절약 할 수 있다. 이것이 cargo check의 주된 용도다. Rust 1.16릴리즈의 주기능으로 소개 하고 있다. (https://blog.rust-lang.org/2017/03/16/Rust-1.16.html)

실행

cargo run은 main.rs 컴파일 후 실행 하는 것과 동일하다. (lib.rs는 실행 목적이 아니기 때문에 cargo run으로 실행 할 수 없다.)

cargo run 옵션 중 몇 가지를 살펴 보자.
--bin NAME           Name of the bin target to run
--example NAME       Name of the example target to run
-j N, --jobs N       Number of parallel jobs, defaults to # of CPUs
--release            Build artifacts in release mode, with optimizations
--features FEATURES  Space-separated list of features to also build
--all-features       Build all available features
--no-default-features   Do not build the `default` feature
--target TRIPLE      Build for the target triple
...
--bin은 실행 할 타겟을 지정 하는 옵션이다. 보통의 경우 워크스페이스 안에서 다른 실행 타겟을 지정 할 일이 없기 때문에 그렇게 유용한 기능은 아니다. 가령, cargo new hello와 같이 hello패키지를 만들고 빌드한 경우 cargo runcargo run --bin hello는 차이가 없다. cargo run --bin hello2와 같이 실행 할 일이 없다는 말이다.

--example 옵션

cargo는 문서화나 유닛 테스트도 지원 하지만 예제를 별도로 실행 할 수 있는 환경도 제공 한다.

examples 디렉토리에 "hello example"을 출력하는 예제를 만들어 보자.
➜  cargo-test git:(master) ✗ tree
.
├── Cargo.lock
├── Cargo.toml
├── examples
│   └── hello.rs
├── src
│   └── main.rs

➜  cargo-test git:(master) ✗ cat examples/hello.rs
fn main() {
 println!("hello example!");
}
examples 디렉토리이 있는 어플리케이션은 --example 옵션으로 실행 할 수 있다.
cargo run --example hello
hello example!

조건부 컴파일

Rust는 조건부 컴파일을 지원한다. 코드에 "cfg" 속성에 "feature"를 기술해 두면 조건부로 컴파일 할 수 있다. ( Rust attributehttps://doc.rust-lang.org/book/attributes.html )

가령, mysql 실행 환경으로 조건부 빌드 한다면 아래와 같이 사용 할 수 있다.
fn main() {
 let datasource = Datasource {};
 datasource.stmt();
}

struct Datasource {}

#[cfg(feature = "postgres")]
impl A {
 pub fn stmt(self) {
     println!("postgres!");
 }
}

#[cfg(feature = "mysql")]
impl A {
 pub fn stmt(self) {
     println!("mysql!");
 }
}
cargo run --example hello --features mysql

--target 옵션

target은 실행 환경을 말한다. 예를들어 Synology NAS에서 Rust 프로그램을 구동 하려면 armv7-unknown-linux-gnueabihf 타겟을 지정해야 한다.

아래는 실제 Docker로 만든 Synology 용 Rust 컴파일 환경이다.
FROM ubuntu:latest

RUN apt-get update \
 && apt-get install -y curl file sudo build-essential

RUN apt-get install -qq gcc-arm-linux-gnueabihf

ENV PATH "/root/.cargo/bin:$PATH"

RUN curl https://sh.rustup.rs > rustup.sh \
 && sh rustup.sh -y \
 && rustup target add armv7-unknown-linux-gnueabihf \
 && mkdir -p ~/.cargo \
 && echo "[target.armv7-unknown-linux-gnueabihf]\nlinker = \"arm-linux-gnueabihf-gcc\"" > ~/.cargo/config

RUN echo "cargo build --release --target=armv7-unknown-linux-gnueabihf" > /release.sh

VOLUME /work
WORKDIR /work

CMD ["/bin/bash", "/release.sh"]
Docker image를 이용해 코드를 컴파일 해 보면 target 디렉토리 아래 armv7-unknown-linux-gnueabihf 를 확인 할 수 있다.
docker run -it --rm -v “$PWD”:/work freestrings/rust-build-armv7
tree
…

├── src
│   └── main.rs
├── target
│   ├── armv7-unknown-linux-gnueabihf
│   │   └── release
...

Rust로 작성한 ID3 tagger를 Synology에서 컴파일하고 실행한 결과
실제 Synology NAS에서 동작화면

유닛 테스트

유닛테스트는 cargo test로 실행 할 수 있는다. cargo init --lib로 프로젝트를 생성 하면 기본 코드를 생성 해 준다.
➜  test1 git:(master) ✗ cat src/lib.rs
#[cfg(test)]
mod tests {
    #[test]
    fn it_works() {
    }
}

`it_works()`에 간단한 내용을 넣고 유닛 테스트를 실행 해 보자.

➜  test1 git:(master) ✗ cat src/lib.rs
#[cfg(test)]
mod tests {
    #[test]
    fn it_works() {
       println!("###it works!###");
    }
}

➜  test1 git:(master) ✗ cargo test
   Compiling test1 v0.1.0 (file:///home/han/test1)
    Finished dev [unoptimized + debuginfo] target(s) in 0.43 secs
     Running target/debug/deps/test1-392907a04c6c34cd

running 1 test
test tests::it_works ... ok

test result: ok. 1 passed; 0 failed; 0 ignored; 0 measured

   Doc-tests test1

running 0 tests

test result: ok. 0 passed; 0 failed; 0 ignored; 0 measured
유닛 테스트는 통과 했지만 “###it_works!###”는 출력 되지 않는다. cargo test --help를 보면 --nocapture가 없으면 출력이 보이지 않는다고 한다. (디버깅용으로 문자열을 넣었는데 보는데 안보이면 난감하다.)

Usage:
    cargo test [options] [--] [...]
…

By default the rust test harness hides output from test execution to
keep results readable. Test output can be recovered (e.g. for debugging)
by passing `--nocapture` to the test binaries:

  cargo test -- --nocapture


➜  test1 git:(master) ✗ cargo test -- --nocapture
    Finished dev [unoptimized + debuginfo] target(s) in 0.0 secs
     Running target/debug/deps/test1-392907a04c6c34cd

running 1 test
###it works!###
test tests::it_works ... ok

test result: ok. 1 passed; 0 failed; 0 ignored; 0 measured
...

유닛 테스트 작성 방법

Rust에서 유닛 테스트 작성 방법은 두가지다.
  • 모듈에 #[cfg(test)]속성을 선언한 뒤 테스트 케이스에 #[test] 속성을 선언하는 방법 
  • 프로젝트 루트에 tests 폴더 생성하는 방법
➜  tests git:(master) ✗ tree
.
├── Cargo.toml
├── src
│   └── lib.rs
└── tests
    └── hello_test.rs
tests/*.rs 파일에는 cfg속성 없이 #[test] 속성만 적어주면 된다. 실제 사용된 코드를 보면 쉽게 이해할 수 있다. (https://github.com/freestrings/rtag/tree/master/tests)

그리고 cargo test는 아직 test suite같은 것이 없다. 단위 test별 순차 실행이 아니라 병렬 실행이기 때문에 순서 보장도 되지 않는다. 테스트 케이스 4개정도 만들어 실행 해 보면 확인 가능하다.
➜  test1 git:(master) ✗ cargo test
...

running 4 tests
test tests::t3 ... ok
test tests::t1 ... ok
test tests::t2 ... ok
test tests::t4 ... ok

test result: ok. 4 passed; 0 failed; 0 ignored; 0 measured
그리고 특정 유닛 테스트만 실행 해 볼 수 있는데 테스트 케이스명(메소드 이름)을 명시해 주면된다.
cargo test t1
아직 복수개의 유닛 테스트를 실행하는 방법이나 패턴 같은 것은 제공하지 않는 것 같지만, 동일한 테스트 케이스명으로 시작되는 테스트는 모두 실행 시킬 수 있다.

tree
.
├── Cargo.lock
├── Cargo.toml
└── src
    ├── sub
    │   └── mod.rs
    └── lib.rs

➜  test1 git:(master) ✗ cat src/lib.rs
mod sub;

#[cfg(test)]
mod tests {
    #[test] fn t1() {}
    #[test] fn t2() {}
}

➜  test1 git:(master) ✗ cat src/sub/mod.rs
#[cfg(test)]
mod tests {
    #[test] fn t1() {}
    #[test] fn t2() {}
}
# t1으로 시작
cargo test t1
running 2 tests
test sub::tests::t1 … ok
test tests::t1 … ok

# t로 시작
cargo test t
running 4 tests
test sub::tests::t1 … ok
test tests::t1 … ok
test tests::t2 … ok
test sub::tests::t2 … ok

그 밖에 cargo test 옵션은 run과 거의 동일하다.

소스코드 문서화와 실행

참고( https://doc.rust-lang.org/book/documentation.html#documentation-as-tests )

Markdown으로 작성해서 쉽게 문서화 할 수 있기도 하지만 예시된 코드가 실제 동작 가능 해야 한다. 주석내 코드를 문서화 시점에 컴파일하고 실행 한다는 이야기다. Markdown 코드 블럭을 사용하면 rustdoc이 자동으로 main() wrapper를 코드에 붙이기 때문이다. rust test로 문서화 주석내 코드를 실행 해 볼 수 있다. 혹시, 리소스를 삭제하는 예시가 있다면 조심 해야 한다.

문서화 주석은 Triple Slash를 사용하거나 아래 처럼 사용 할 수 도 있다.
//!# Example: reading V1 frame.
//!
//! ```rust
//! use rtag::metadata::Unit;
//! use rtag::metadata::MetadataReader;
//!
//! for m in MetadataReader::new("./test-resources/v1-v2.mp3").unwrap() {
//!     match m {
//!         Unit::FrameV1(frame) => {
//!             assert_eq!("Artist", frame.artist);
//!             assert_eq!("!@#$", frame.comment);
//!             assert_eq!("1", frame.track);
//!             assert_eq!("137", frame.genre);
//!         },
//!         _ => ()
//!     }
//! }
//! ```
그리고 no_run 속성을 사용하면 실행 없이 컴파일만 할 수 있다.
/// ```rust,no_run
/// loop {
///     println!("Hello, world");
/// }
/// ```

배포

https://crates.io/ 사이트가 Rust 커뮤니티에서 호스팅 하는 공식 모듈 저장소다. 배포를 하면 여기에 등록 된다.

전반적인 방법은 http://doc.crates.io/crates-io.html 에 잘 기술 되어 있다. 내용이 많긴 하지만 배포 자체는 간단하다 cargo login으로 최초 로그인을 한번 해 두면 되고 cargo publish로 배포하면 된다.

나머지는 Cargo.toml 파일을 기술하는 방법들인데 양이 좀 있어서 다른 글에서 정리 하기로 하고 한가지 주의할 부분만 정리하면,

배포는 10M제한이 있어서 include, exclude를 잘 적어 줘야 한다. 보통 유닛 테스트에 필요한 mp3 파일들은 모듈에 포함 시켜 배포 할 필요가 없다. [package] 카테고리 아래 exclude 속성을 적어 패키징 때 제외할 파일을 나열 하거나, include 속성으로 포함 할 파일 들만 지정 할 수 있다.
[package]
name = "rtag"
version = "0.3.4"
authors = ["Changseok Han"]
description = "Library for reading and writing a id3 metadata"
repository = "https://github.com/freestrings/rtag"
license = "MIT"
keywords = ["library", "id3", "music", "mp3"]
include = ["src/*.rs", "tests/*.rs", "Cargo.toml"]
include와 exclude를 같이 적어 줄 때는 파일이 중복되지 않게 신경을 써야 한다.

커스텀 빌드

빌드나 배포가 간단하면 좋겠지만 환경 구성이 필요 할 경우 빌드 스크립트를 사용 할 수 있다. ( http://doc.crates.io/build-script.html )

[package]
# ...
build = "build.rs"

Rust도 다른 언어와 마찬가지로 FFI(Foreign Function Interface)를 지원한다. C로 개발된 모듈과 연동 할 수 있지만 아래와 같은 선언이 필요하고 C코드도 컴파일 해야 한다. 이럴 경우 빌드 스크립트가 필요하다.

...
#[link(name = "id3v2")]
extern {
    // frame
    fn parse_text_frame_content(ptr: *mut ID3v2_frame) -> *mut ID3v2_frame_text_content;

    // id3v2lib
    fn load_tag(file_name: *const c_char) -> *mut ID3v2_tag;
    fn set_tag(file_name: *const c_char, tag: *mut ID3v2_tag);

    fn tag_get_title(ptr: *mut ID3v2_tag) -> *mut ID3v2_frame;
    fn tag_get_artist(ptr: *mut ID3v2_tag) -> *mut ID3v2_frame;
    ...
}
...

Git 저장소에서 파일을 내려 받고 컴파일 하는 코드
➜  rust-ffi git:(master) cat build.rs 
extern crate git2;

use std::env;
use std::fs;
use std::process::Command;
use std::path::Path;
use git2::Repository;

fn main() {
    let url = "https://github.com/larsbs/id3v2lib.git";

    let base_path_str = env::current_dir().unwrap();
    let base_path = Path::new(&base_path_str);
    let id3v2_path = base_path.join("target/debug/build/id3v2lib");
    let id3v2_build_path = id3v2_path.join("build");
    let id3v2_library_dir = id3v2_build_path.join("src");
    let id3v2_library_path = id3v2_library_dir.join("libid3v2.a");

    if id3v2_path.exists() {
        // TODO git pull
    } else {
        Repository::clone(url, id3v2_path).unwrap();
    }

    match fs::create_dir_all(id3v2_build_path.to_str().unwrap()) {
        Ok(()) => {
            let status = Command::new("cmake")
                .current_dir(id3v2_build_path.to_str().unwrap())
                .arg("..")
                .status()
                .expect("failed to cmake");
            assert!(status.success());

            let status = Command::new("make")
                .current_dir(id3v2_build_path.to_str().unwrap())
                .status()
                .expect("failed to make");
            assert!(status.success());

            assert!(id3v2_library_path.exists());

            println!("cargo:rustc-link-search=native={}", id3v2_library_dir.to_str().unwrap());
            println!("cargo:rustc-link-lib=static=id3v2");
        },
        Err(_) => ()
    }
}
빌드 스크립트 역시 cargo build 명령으로 실행 할 수 있다.

정리

사이드 프로젝트를 진행 하면서 느낀 점은 Cargo는 사용법이 단순하고 무엇보다 Docker 처럼 배포가 간단해서 좋았다. 유닛 테스트는 다른 프레임 워크에 비해 기능이 부족 한게 약간 아쉽지만, 개인 프로젝트 전체 사이클을 소화 하는데는 부족함이 없었던 것 같다.

댓글