目的
golang におけるファイルロードのベストプラクティスを知る。
結論
go:embed
やgo:generate
を使うべき。
説明
パスの使い方を確認
embed
, os
, runtime.Caller
におけるパスの使い方を確認する。おまけに、godotenv.Load
も試してみる。
下記の結果を見ると、os
, godotenv.Load
はバイナリ実行ディレクトリを基準として相対パスを指定する必要があり、embed
は embed
が書かれているファイルからの相対パスを指定する必要がある。
バイナリ実行ディレクトリは確定しないので(バイナリ実行はどこからでもできるから)、バイナリ実行ディレクトリからの相対パスを指定して os.ReadFile
を使うのは難しい。
そこで、runtime.Caller(0)
を使って、runtime.Caller(0)
が呼ばれているファイルの絶対パスを取得することでこの問題に対処することができる。main.go の (4-1) に実際のコードが書かれている。※ 後で説明するが、runtime.Caller(0)
は非推奨
~/gopractice$ tree -La 3 -I .git
.
├── README.md
├── binarydir
│ └── main
├── builddir
│ └── .gitkeep
├── cmd
│ └── gopractice
├── envdir
│ └── .env
├── execdir_parent
│ └── execdir
│ └── .gitkeep
├── go.mod
├── go.sum
├── main.go
└── mymemodir
└── mymemo.txt
TESTTEXT=dddeeefff
XXXYYYZZZ
package main
import (
_ "embed"
"fmt"
"log"
"os"
"path/filepath"
"runtime"
"github.com/joho/godotenv"
)
//go:embed mymemodir/mymemo.txt
var memoContent string
func main() {
fmt.Println(" =========================================== (0) os.Getwd ではバイナリ実行ディレクトリが取得される。")
currentdir, err := os.Getwd()
if err != nil {
log.Printf("Error (0): %v", err)
}
fmt.Println("現在のディレクトリ:", currentdir)
fmt.Println(" =========================================== (1-1) main.go から見た相対パスを指定する -> エラー")
err = godotenv.Load("./envdir/.env")
if err != nil {
log.Printf("Error (1-1): %v", err)
}
testtext := os.Getenv("TESTTEXT")
fmt.Println(testtext)
fmt.Println(" =========================================== (1-2) バイナリ実行ディレクトリ(execdir_parent/execdir)からの相対パスを指定する -> 成功")
err = godotenv.Load("../../envdir/.env")
if err != nil {
log.Printf("Error (1-2): %v", err)
}
testtext2 := os.Getenv("TESTTEXT")
fmt.Println(testtext2)
fmt.Println(" =========================================== (2-1) main.go から見た相対パスを指定する -> エラー")
data, err := os.ReadFile("../mymemodir/mymemo.txt")
if err != nil {
log.Printf("Error (2-1): %v", err)
}
fmt.Println(string(data))
fmt.Println(" =========================================== (2-2) バイナリ実行ディレクトリ(execdir_parent/execdir)からの相対パスを指定する -> 成功")
data, err = os.ReadFile("../../mymemodir/mymemo.txt")
if err != nil {
log.Printf("Error (2-2): %v", err)
}
fmt.Println(string(data))
fmt.Println(" =========================================== (3-1) embed はこのファイルからの相対パスを指定する -> 成功")
if memoContent == "" {
log.Println("Error (3-1): mymemo.txt is empty")
}
fmt.Println(memoContent)
fmt.Println(" =========================================== (4-1) main.go から見た相対パスを指定する -> 成功 ※ ただし、 -trimpath オプションを使ってビルドしたらエラー")
_, filePath, _, _ := runtime.Caller(0)
fmt.Printf("filePath: %v\n", filePath)
dirPath := filepath.Dir(filePath)
fmt.Printf("dirPath: %v\n", dirPath)
memoFilePath := filepath.Join(dirPath, "mymemodir/mymemo.txt")
fmt.Printf("memoFilePath: %v\n", memoFilePath)
data2, err := os.ReadFile(memoFilePath)
if err != nil {
log.Printf("Error (4): %v", err)
}
fmt.Println(string(data2))
fmt.Println(" =========================================== (4-2) バイナリ実行ディレクトリ(execdir_parent/execdir)からの相対パスを指定する -> エラー ※ ただし、 -trimpath オプションを使ってビルドしたら成功")
_, filePath, _, _ = runtime.Caller(0)
fmt.Printf("filePath: %v\n", filePath)
dirPath = filepath.Dir(filePath)
fmt.Printf("dirPath: %v\n", dirPath)
memoFilePath = filepath.Join(dirPath, "../../mymemodir/mymemo.txt")
fmt.Printf("memoFilePath: %v\n", memoFilePath)
data2, err = os.ReadFile(memoFilePath)
if err != nil {
log.Printf("Error (4): %v", err)
}
fmt.Println(string(data2))
}
~/gopractice/builddir$ go build -o ../binarydir/main ../main.go
~/gopractice/execdir_parent/execdir$ ../../binarydir/main
=========================================== (0) os.Getwd ではバイナリ実行ディレクトリが取得される。
現在のディレクトリ: /home/username/gopractice/execdir_parent/execdir
=========================================== (1-1) main.go から見た相対パスを指定する -> エラー
2024/12/10 12:43:29 Error (1-1): open ./envdir/.env: no such file or directory
=========================================== (1-2) バイナリ実行ディレクトリ(execdir_parent/execdir)からの相対パスを指定する -> 成功
dddeeefff
=========================================== (2-1) main.go から見た相対パスを指定する -> エラー
2024/12/10 12:43:29 Error (2-1): open ../mymemodir/mymemo.txt: no such file or directory
=========================================== (2-2) バイナリ実行ディレクトリ(execdir_parent/execdir)からの相対パスを指定する -> 成功
XXXYYYZZZ
=========================================== (3-1) embed はこのファイルからの相対パスを指定する -> 成功
XXXYYYZZZ
=========================================== (4-1) main.go から見た相対パスを指定する -> 成功 ※ ただし、 -trimpath オプションを使ってビルドしたらエラー
filePath: /home/username/gopractice/main.go
dirPath: /home/username/gopractice
memoFilePath: /home/username/gopractice/mymemodir/mymemo.txt
XXXYYYZZZ
=========================================== (4-2) バイナリ実行ディレクトリ(execdir_parent/execdir)からの相対パスを指定する -> エラー ※ ただし、 -trimpath オプションを使ってビルドしたら成功
filePath: /home/username/gopractice/main.go
dirPath: /home/username/gopractice
memoFilePath: /home/mymemodir/mymemo.txt
2024/12/10 12:43:29 Error (4): open /home/mymemodir/mymemo.txt: no such file or directory
どのようにファイルロードするべきなのか
結論から言うと、embed
を使ってください。
先ほどの、(4-1)の runtime.Caller(0)
を使った方法は、-trimpath
オプションを使ってビルドすると破綻します。
下記は、-trimpath
オプションを使ってビルドした場合ですが、(4-1)がエラーになっていること分かります。
~/gopractice/builddir$ go build -o ../binarydir/main -trimpath ../main.go
~/gopractice/execdir_parent/execdir$ ../../binarydir/main
=========================================== (0) os.Getwd ではバイナリ実行ディレクトリが取得される。
現在のディレクトリ: /home/username/gopractice/execdir_parent/execdir
=========================================== (1-1) main.go から見た相対パスを指定する -> エラー
2024/12/10 12:44:07 Error (1-1): open ./envdir/.env: no such file or directory
=========================================== (1-2) バイナリ実行ディレクトリ(execdir_parent/execdir)からの相対パスを指定する -> 成功
dddeeefff
=========================================== (2-1) main.go から見た相対パスを指定する -> エラー
2024/12/10 12:44:07 Error (2-1): open ../mymemodir/mymemo.txt: no such file or directory
=========================================== (2-2) バイナリ実行ディレクトリ(execdir_parent/execdir)からの相対パスを指定する -> 成功
XXXYYYZZZ
=========================================== (3-1) embed はこのファイルからの相対パスを指定する -> 成功
XXXYYYZZZ
=========================================== (4-1) main.go から見た相対パスを指定する -> 成功 ※ ただし、 -trimpath オプションを使ってビルドしたらエラー
filePath: ./main.go
dirPath: .
memoFilePath: mymemodir/mymemo.txt
2024/12/10 12:44:07 Error (4): open mymemodir/mymemo.txt: no such file or directory
=========================================== (4-2) バイナリ実行ディレクトリ(execdir_parent/execdir)からの相対パスを指定する -> エラー ※ ただし、 -trimpath オプションを使ってビルドしたら成功
filePath: ./main.go
dirPath: .
memoFilePath: ../../mymemodir/mymemo.txt
XXXYYYZZZ
os.ReadFile("../../mymemodir/mymemo.txt")
のように、バイナリ実行ディレクトリからの相対パスを指定する方法もあるが、バイナリ実行ディレクトリに依存するコードは、余計な気遣いが必要なので推奨しません。
embed の弱点を回避するために go:generate
を使う
embed
は相対パスを指定するとき、親ディレクトリを辿れません。
例えば、main.go を cmd/gopractice/main.go に移動させる場合、下記のように../../mymemodir/mymemo.txt
と書いてロード対象のファイルへのパスを指定したくなりますが、エラーになります。
...
//go:embed ../../mymemodir/mymemo.txt
var memoContent string
...
上記の理由により、ロード対象のファイルも main.go と一緒に移動させるべきです。
しかし、どうしてもそれができない場合もあります。例えば、モノレポ構成で、複数のプロジェクトが同じCSVを参照したい場合は、CSVをルートに配置するべきでしょう。
その場合の、回避策を二つ提示します。
- (A) ロード対象のファイルを main.go と同じ階層にコピペする。念のために、CI でオリジナルのファイルと差分がないことをチェックする。
- (B)
go:embed
を使わずに、go:generate
を使う。
(A) の方法は説明するまでもないと思います。
(B) の方法を説明します。
embed
は使わないので、下記のようにコメントアウトしておきます。
generate/loadtext/main.go は自動生成のためのコードなので、ランタイムに使用されることはないです。
var memoContent
の定義がコメントアウトされてしまったので、memoContent
を使っている部分のコードは警告が出ているはずです。
...
////go:embed ../../mymemodir/mymemo.txt
// var memoContent string
...
//go:generate go run main.go -output ../../cmd/gopractice/loadedtext.go -filepath ../../mymemodir/mymemo.txt
package main
import (
"bytes"
"flag"
"fmt"
"log"
"os"
)
var (
output = flag.String("output", "", "The output file path")
filepath = flag.String("filepath", "", "The input file path")
)
func main() {
flag.Parse()
content, err := os.ReadFile(*filepath)
if err != nil {
log.Fatalf("failed to read file: %v", err)
}
var buf bytes.Buffer
fmt.Fprintln(&buf, "// Code generated by go generate; DO NOT EDIT.")
fmt.Fprintf(&buf, "package main\n\n")
fmt.Fprintf(&buf, "var memoContent = %q\n", content)
err = os.WriteFile(*output, buf.Bytes(), 0644)
if err != nil {
log.Fatalf("failed to write file: %v", err)
}
}
プロジェクトルート(go.sum
がある位置)で、go generate ./...
コマンドを実行します。
そうすると、cmd/gopractice/loadedtext.go
ファイルが作成されます。
~/gopractice$ go generate ./...
// Code generated by go generate; DO NOT EDIT.
package main
var memoContent = "XXXYYYZZZ\n"
これによって、ランタイム中にファイルをロードすることなく、データを利用することが可能になりました。