Embed resources in crystal

Crystal에서 리소스 파일을 바이너리에 Embed 하는 방법에 대해 기록해둡니다. 깃헙 이슈등을 찾아보면 stdlib로 만들어줄 것 같진 않았고 찾아보니 Rucksack이란 좋은 shard를 발견해서 간단하게 정리해둘게요. 참고로 Rucksack은 Linux와 macOS에서만 동작하고 Windows는 지원하지 않는다고 하니 이 점 참고하면 좋을 것 같습니다.

Rucksack works on Linux and OSX. Windows is not supported.

Install shard

먼저 shard.yml에 디펜던시를 명시하고 shards install 로 패키지를 설치해줍니다.

name: embed_test
version: 0.1.0

targets:
  embed_test:
    main: src/embed_test.cr

dependencies:
  rucksack:
    github: busyloop/rucksack

crystal: 1.7.3

license: MIT

Write code

이후 테스트를 위해 간단한 txt 파일을 하나 만들고 rucksack을 이용하여 로드해봅니다.

# cat ./config/123.txt
aaa
require "rucksack"

module EmbedTest
  VERSION = "0.1.0"
end

rucksack("./cnofig/123.txt").read(STDOUT)

Build and Packing

이제 빌드를 하게되면 123.txt 파일은 바이너리에 내장되게 되고 단일 바이너리로 배포하더라도 해당 파일을 포함한 상태로 배포할 수 있습니다.

# build
shards build

빌드가 마무리되면 rucksack macro에 의해 .rucksack 파일이 생성됩니다. 이를 실행 파일로 전달하여 패킹합니다.

cat .rucksack >> bin/embed_test

이제 123.txt 파일이 없더라도 ./embed_test를 실행하면 내장된 데이터를 사용하여 실행됩니다.

rm ./config/123.txt
./bin/embed_test

Multiple Packing

아래는 files 하위 디렉토리의 파일을 모두 포함시키는 코드입니다.

{% for name in `find ./files -type f`.split('\n') %}
  rucksack({{name}})
{% end %}

Analysis

How to Pack

그럼 이쯤에서 .racksack 파일의 정체가 궁금해집니다. 파일을 봐선 바이너리로 보이는데 실제로 이 파일을 만드는 코드를 살펴보죠.

macro로 .rucksack_packer.cr란 파일을 만들어서 실행합니다.

dst = File.open(".rucksack", "a")
src = File.open ARGV[0]
size = src.size
dio = IO::Digest.new(dst, Digest::SHA256.new, mode: IO::Digest::DigestMode::Write)
dst.write_bytes ARGV[0].size.to_u16, IO::ByteFormat::LittleEndian
dst.write(ARGV[0].to_slice)
dst.write_bytes size.to_u64, IO::ByteFormat::LittleEndian
bytes_copied = IO.copy(src, dio)
dst.write(dio.final)
dst.write(EOF_DELIM)

# https://github.com/busyloop/rucksack/blob/0884cb1c9c98eb0404f5e0c2d8c2400cef918499/src/rucksack.cr#L215

checksum을 위해 sha256을 생성하고 대상 파일을 읽어 파일 이름과 내용의 사이즈와 내용을 byte로 추가하고 sha256 해시를 붙여 마무리합니다. 그리고 ==RUCKSACK== 문자열을 맨 앞에 붙여서 이 부분부터 rucksack으로 리소스가 추가됬단걸 식별할 수 있도록 알려줍니다. 대략적인 구조를 보면 아래와 같습니다.

==RUCKSACK==/{filename_size}/{filename}/{file_size}/{file}/{checksum}

그래서 아까 dump를 잘 확인해보면 123.txt에 있던 aaa 란 값이 들어있는 것을 볼 수 있습니다.

How to Read

그럼 실행파일에 packing된 파일 데이터는 어떻게 읽는걸까요? rucksack은 본인이 실행되는 바이너리를 다시 파일로 읽은 후 KNAUTSCHZONE을 찾습니다. KNAUTSCHZONE이란 \000으로 특정 길이만큼 덮여진 구간으로 아까 파일로 봤을 때 00 00 00 00 00 00 00 00 과 같은 부분이 해당 영역입니다.

loop do
  bytes_read = file.read(buf)
  @@offset += bytes_read
  raise RucksackNotFound.new("Knautschzone not found") if 0 == bytes_read
  break if buf == KNAUTSCHZONE
end

그리곤 12바이트 씩 읽어가며 ==RUCKSACK== 을 찾습니다.

loop do
  # ...
  buf = Bytes.new(12)
  file.read(buf)
  @@offset += 12
  # ...
end

이제 실제 파일이 포함된 offset을 찾았으니 해당 구간부터 루프를 돌면서 바이트를 읽어 상대경로와 매핑된 파일로 치환하여 사용합니다.

16              ./config/123.txt  3          aaa
{filename_size} {filename}       {file_size} {file} {checksum}

Other shards

Conclusion

코드가 아닌 yaml 파일 등으로 config나 데이터에 대한 정의를 만들 때 유용할 것 같습니다. windows까지 지원되지 않는 부분은 살짝 아쉽지만, 그래도 리소스를 쉽게 바이너리에 바인딩할 수 있어서 분명히 괜찮은 shard라고 생각이 드네요. 역시 코드 분석은 재미있습니다 :D