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