1
0

Delete article

Deleted articles cannot be recovered.

Draft of this article would be also deleted.

Are you sure you want to delete this article?

[golang] golang におけるファイルロードのベストプラクティス

Last updated at Posted at 2024-12-10

目的

golang におけるファイルロードのベストプラクティスを知る。

結論

go:embedgo:generateを使うべき。

説明

パスの使い方を確認

embed, os, runtime.Caller におけるパスの使い方を確認する。おまけに、godotenv.Load も試してみる。

下記の結果を見ると、os, godotenv.Load はバイナリ実行ディレクトリを基準として相対パスを指定する必要があり、embedembed が書かれているファイルからの相対パスを指定する必要がある。

バイナリ実行ディレクトリは確定しないので(バイナリ実行はどこからでもできるから)、バイナリ実行ディレクトリからの相対パスを指定して 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
envdir/.env
TESTTEXT=dddeeefff

mymemodir/mymemo.txt
XXXYYYZZZ

main.go
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と書いてロード対象のファイルへのパスを指定したくなりますが、エラーになります。

cmd/gopractice/main.go
...

//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を使っている部分のコードは警告が出ているはずです。

cmd/gopractice/main.go
...

////go:embed ../../mymemodir/mymemo.txt
// var memoContent string

...
generate/loadtext/main.go
//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 ./...
cmd/gopractice/loadedtext.go
// Code generated by go generate; DO NOT EDIT.
package main

var memoContent = "XXXYYYZZZ\n"

これによって、ランタイム中にファイルをロードすることなく、データを利用することが可能になりました。

1
0
0

Register as a new user and use Qiita more conveniently

  1. You get articles that match your needs
  2. You can efficiently read back useful information
  3. You can use dark theme
What you can do with signing up
1
0

Delete article

Deleted articles cannot be recovered.

Draft of this article would be also deleted.

Are you sure you want to delete this article?