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

# 目的
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
```
``` 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"

```

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