きっかけ
Rustでi18nを使っていて、ライブラリを使わずにyamlやjsonにテキストをまとめておくと、実行時にそのファイルがないと動かない状況でした。
しかし、i18nクレイトを使うと実行時にテキストがなくてもいけるので、どういう仕組みなのかと思い、調べました。
ほぼ機械生成記事。
C言語の場合
C言語でテキストファイルなどのリソースをバイナリに含める方法はいくつかあります。主な方法としては、以下のものが挙げられます。
1. オブジェクトファイルとしてコンパイルしてリンクする
この方法は、テキストファイルの内容をC言語のソースコードとして扱い、コンパイラでオブジェクトファイルを作成し、最終的な実行ファイルにリンクする方法です。
手順:
-
テキストファイルをC言語のソースコード形式に変換するスクリプトを作成します。 このスクリプトは、テキストファイルの内容をバイト配列としてC言語の配列定義に変換する処理を行います。例えば、Pythonで以下のようなスクリプトを作成できます。
import sys if len(sys.argv) != 2: print("Usage: text_to_c_array.py <input_file>") sys.exit(1) input_file = sys.argv[1] output_name = input_file.replace('.', '_') array_name = f"g_{output_name}_data" size_name = f"g_{output_name}_size" with open(input_file, 'rb') as f: data = f.read() print(f"const unsigned char {array_name}[] = {{") for i, byte in enumerate(data): print(f"0x{byte:02x}", end=", ") if (i + 1) % 16 == 0: print() print(f"\n}};") print(f"const size_t {size_name} = sizeof({array_name});")
-
作成したスクリプトを実行し、テキストファイルからC言語のソースコード (
.c
ファイル) を生成します。python text_to_c_array.py my_text_file.txt > my_text_file.c
-
生成された
.c
ファイルを他のC言語ソースコードと一緒にコンパイルし、リンクします。gcc main.c my_text_file.c -o my_program
-
C言語のソースコード (
main.c
など) から、生成された配列とサイズにアクセスします。#include <stdio.h> #include <stdlib.h> // my_text_file.c で定義された変数 extern const unsigned char g_my_text_file_data[]; extern const size_t g_my_text_file_size; int main() { printf("Embedded text file content:\n"); for (size_t i = 0; i < g_my_text_file_size; i++) { printf("%c", g_my_text_file_data[i]); } printf("\nSize: %zu bytes\n", g_my_text_file_size); return 0; }
利点:
- 比較的簡単な方法で、特別なツールを必要としません。
- コンパイラによって最適化される可能性があります。
欠点:
- テキストファイルが大きい場合、生成されるC言語のソースコードが非常に大きくなる可能性があります。
- テキストファイルの内容を変更するたびに、変換スクリプトの実行と再コンパイルが必要です。
2. リンカの機能を利用する (objcopy など)
多くのツールチェーンには、バイナリファイルをオブジェクトファイルとして扱うためのユーティリティが含まれています。objcopy
はその一つで、GNU Binutils に含まれています。
手順 (GNU Binutils の場合):
-
objcopy
コマンドを使用して、テキストファイルをオブジェクトファイルに変換します。objcopy --input-target binary --output-target elf32-i386 --binary-architecture i386 my_text_file.txt my_text_file.o
ターゲットアーキテクチャ (
elf32-i386
など) は、使用している環境に合わせて変更する必要があります。 -
生成されたオブジェクトファイルを、他のオブジェクトファイルやライブラリと一緒にリンクします。
gcc main.c my_text_file.o -o my_program
-
C言語のソースコードから、リンカによって生成されたシンボル (
_binary_my_text_file_txt_start
,_binary_my_text_file_txt_end
,_binary_my_text_file_txt_size
) を使用して、埋め込まれたデータにアクセスします。 シンボル名はツールチェーンやオプションによって異なる場合があります。nm
コマンドなどで確認できます。#include <stdio.h> #include <stdlib.h> extern const unsigned char _binary_my_text_file_txt_start[]; extern const unsigned char _binary_my_text_file_txt_end[]; int main() { printf("Embedded text file content:\n"); const unsigned char *ptr = _binary_my_text_file_txt_start; while (ptr < _binary_my_text_file_txt_end) { printf("%c", *ptr++); } printf("\nSize: %ld bytes\n", (long)(_binary_my_text_file_txt_end - _binary_my_text_file_txt_start)); return 0; }
利点:
- 大きなファイルでも効率的に処理できます。
- テキストファイルの内容を直接バイナリに埋め込むため、変換スクリプトが不要になる場合があります。
欠点:
- ツールチェーンに依存する場合があります。
- リンカのシンボル名を理解する必要があります。
3. カスタムビルドプロセスやツールを使用する
より複雑なアプリケーションでは、独自のビルドプロセスや専用のツールを使用してリソースをバイナリに埋め込むことがあります。例えば、CMake などのビルドシステムには、このような処理をサポートする機能がある場合があります。
どの方法を選ぶべきか
- 比較的小さなテキストファイルで、簡単な方法を求める場合: 1. オブジェクトファイルとしてコンパイルしてリンクする方法が適しています。
-
大きなテキストファイルや、より効率的な方法を求める場合: 2. リンカの機能を利用する方法 (
objcopy
など) が適しています。 - 複雑なビルド環境や、より高度な制御が必要な場合: 3. カスタムビルドプロセスやツールを検討してください。
C++の場合
C++で同様の目的、つまりテキストファイルなどのリソースをバイナリに含める方法は、Rustほど標準化されていませんが、いくつかの一般的なアプローチが存在します。主に以下のような方法が用いられます。
1. コンパイル時の埋め込み (手動またはツール利用):
ヘッダーファイルへの埋め込み: 最も単純な方法の一つは、テキストファイルの内容をC++のヘッダーファイルに文字列リテラルとして直接記述することです。これは手動で行うこともできますし、簡単なスクリプトで自動化することも可能です。
#pragma once
namespace Resource {
const char* myYaml = R"(
key: value
another_key: another_value
)";
}
そして、このヘッダーファイルをソースファイルにインクルードして使用します。
ビルドスクリプトによるコード生成: Rustの build.rs と同様に、CMakeなどのビルドシステムを使って、コンパイル前にテキストファイルの内容をC++のソースコード (.cpp ファイル) として生成するスクリプトを実行する方法があります。生成されたソースコードには、テキストデータが配列や文字列として埋め込まれます。この生成された .cpp ファイルは、他のソースコードと一緒にコンパイルされ、バイナリにリンクされます。
2. リンカの機能を利用:
オブジェクトファイルへの埋め込み: 一部のリンカは、任意のバイナリデータをオブジェクトファイルの一部として埋め込む機能を持っています。例えば、GNUリンカ (ld) の場合、objcopy などのツールを使って、テキストファイルなどのデータを特別なセクションを持つオブジェクトファイルに変換し、それを他のオブジェクトファイルとリンクすることができます。この方法を使うと、データはバイナリ内の特定のセクションに格納され、プログラムの実行時にアクセスできます。
3. 外部リソースファイルとライブラリ:
専用のリソースファイル形式: Windowsなどの特定のプラットフォームでは、専用のリソースファイル形式 (.rc ファイルなど) を使用して、アイコン、文字列、ダイアログなどのリソースを定義し、コンパイラやリンカによってバイナリに組み込む仕組みがあります。ただし、これはテキストファイル全般を扱うのにはやや大げさかもしれません。
サードパーティライブラリ: C++のエコシステムには、リソース管理を支援するサードパーティライブラリも存在します。これらのライブラリは、テキストファイルなどのアセットをバイナリに含めるための便利な機能を提供している場合があります。
C++における課題:
Rustと比較すると、C++でリソースをバイナリに含める標準的な方法は確立されていません。そのため、プロジェクトごとに異なるアプローチが取られることが多く、やや煩雑になる場合があります。また、手動での埋め込みは保守が難しく、ビルドスクリプトの作成やリンカの特殊な機能の利用は、ある程度の知識を必要とします。
Rustの場合
Rustでi18nなどのライブラリを使用する際に、通常はコンパイルされないYAMLなどのテキストファイルがバイナリに含まれるのは、主に以下の仕組みによるものです。
1. コンパイル時の埋め込み:
多くのi18nライブラリやアセット管理系のクレートは、ビルドスクリプト (build.rs) を利用して、指定されたディレクトリやファイルをコンパイル時に読み込み、Rustのコードとしてバイナリに埋め込む処理を行います。
具体的には、build.rs の中で以下のような処理が行われます。
- 指定されたYAMLファイルなどのテキストファイルを読み込む。
- 読み込んだ内容をRustの static 変数や配列などのデータ構造としてコード生成する。
- 生成されたRustコードは、通常のソースコードと同様にコンパイルされ、最終的なバイナリに組み込まれます。
例:
例えば、fluent-rs/fluent クレートの場合、.ftl ファイル(Fluentの翻訳ファイル)をコンパイル時に処理し、ロケールに応じたメッセージを取得するための構造体を生成します。この生成された構造体はバイナリに直接含まれます。
2. マクロによる間接的な埋め込み:
一部のライブラリでは、マクロを利用してテキストファイルの内容を直接Rustのコードに埋め込むことがあります。例えば、include_str! マクロを使うと、指定したファイルの内容を文字列リテラルとしてコンパイル時にRustのコードに展開できます。
例:
const TRANSLATIONS_EN: &str = include_str!("locales/en.yaml");
このコードは、locales/en.yaml ファイルの内容をコンパイル時に TRANSLATIONS_EN
という static
な文字列に埋め込みます。
3. 専用のクレートによるサポート:
アセットの管理に特化したクレート(例えば、include_bytes! マクロを提供する std::include_bytes や、より高機能なアセット管理クレート)を利用することで、画像や設定ファイルなどの非Rustファイルも同様にバイナリに含めることができます。これらのクレートも内部的にはビルドスクリプトやマクロを利用して埋め込み処理を実現しています。
なぜこのような仕組みが使われるのか?
- 配布の簡便性: 実行時に外部ファイルに依存しないため、バイナリファイル単体で配布できます。これは、特にデスクトップアプリケーションやCLIツールなどを配布する際に便利です。
- 移植性: 実行環境に特定のファイルが存在することを前提としないため、様々な環境で安定して動作します。
- パフォーマンス: ファイルを読み込む処理がコンパイル時に行われるため、実行時のオーバーヘッドを削減できます。
Goの場合
Goでテキストファイルなどのリソースをバイナリに含める方法は、Go 1.16で導入された go:embed ディレクティブ を使うのが一般的かつ非常に簡単です。
go:embed ディレクティブの仕組み:
go:embed は、Goのコンパイラに対する特別なコメント(ディレクティブ)で、コンパイル時に指定されたファイルやディレクトリの内容をGoのバイナリに埋め込むことができます。
基本的な使い方は以下の通りです。
embed パッケージのインポート: まず、embed パッケージをインポートします。もしパッケージ内の識別子を直接使わない場合でも、副作用で go:embed ディレクティブを有効にするためにブランクインポート (_ "embed") する必要があります。
import _ "embed"
//go:embed ディレクティブの記述: 埋め込みたいファイルのパスを、埋め込む先の変数宣言の直前の行に //go:embed という形式で記述します。ファイルのパスは、Goのソースファイルからの相対パスで指定します。
埋め込み先の変数の宣言: 埋め込まれるデータを受け取る変数を宣言します。変数の型によって、埋め込まれるデータの形式が変わります。
-
string
: ファイルの内容が文字列として埋め込まれます。 -
[]byte
: ファイルの内容がバイトスライスとして埋め込まれます。 -
embed.FS
: 複数のファイルやディレクトリを埋め込む際に使用され、仮想的なファイルシステムとしてアクセスできます。
同じディレクトリにある config.yaml というテキストファイルをバイナリに埋め込む例:
package main
import (
_ "embed"
"fmt"
)
//go:embed config.yaml
var config string
func main() {
fmt.Println(config)
}
複数のファイルを埋め込んだり、ディレクトリ全体を埋め込むことも可能です。その場合は embed.FS 型の変数を使用します。
package main
import (
"embed"
"fmt"
"io/fs"
)
//go:embed locales/*.yaml
var locales embed.FS
func main() {
fs.WalkDir(locales, ".", func(path string, d fs.DirEntry, err error) error {
if err != nil {
return err
}
if !d.IsDir() {
content, _ := fs.ReadFile(locales, path)
fmt.Printf("File: %s\nContent:\n%s\n---\n", path, string(content))
}
return nil
})
}
i18nライブラリでの利用:
Goのi18nライブラリ(例えば、github.com/nicksnyder/go-i18n/v2/i18n など)でも、この go:embed を利用して翻訳ファイル(YAML、JSONなど)をバイナリに含めることができます。これにより、実行時に外部の翻訳ファイルを読み込む必要がなくなり、シングルバイナリでの配布が容易になります。
多くのi18nライブラリは、embed.FS をサポートしており、埋め込まれた翻訳ファイルを効率的に管理・利用するためのAPIを提供しています。
まとめ
多くのコンパイル型言語はテキストを組み込む仕組みを提供してなくはないけど、その実装は様々。
こういった言語仕様を調べるのも面白いですね。