阿猫的博客

阿猫的博客

Project Root Is All You Need

Go
252
2024-06-08

前言

你是否也遇到过这样的问题:你的项目中有一个配置文件夹,底下有若干个配置文件。你需要在代码中根据环境读取其中的某一个,然后继续后面的流程。

- myAmazingProject
	- config
		- config.test.yaml
		- config.prod.yaml
	- internal
		- config
			- config.go
			- config_test.go
	- main.go

能行,好用

一般来说,我们在 main.go 里面通过参数传入环境名称,然后在 config.go 里通过这个路径来找到文件。因为我们一般从 main.go 来启动程序,这个通常没有什么问题。

// main.go
package main

import (
	"flag"
	"fmt"
	"log"
	"myAmazingProject/internal/config"
)

func main() {
	// 用一个参数来区分环境 (test/prod)
	env := flag.String("env", "test", "Environment for configuration (test or prod)")
	flag.Parse()

	// 根据环境加载配置
	data, err := config.LoadConfig(*env)
	if err != nil {
		log.Fatalf("Error reading config file: %v", err)
	}

	fmt.Printf("Configuration file content:\n%s\n", data)
}
// internal/config/config.go
package config

import (
	"fmt"
	"io/ioutil"
)

func LoadConfig(env string) (string, error) {
	configFilePath := fmt.Sprintf("config/config.%s.yaml", env)

	data, err := ioutil.ReadFile(configFilePath)
	if err != nil {
		return "", err
	}

	return string(data), nil
}

这个方法看起来没什么大问题,直到有一天你想起来要写单测(或迫于 KPI 要写单测,x),然后你又点了 IDE 自带的运行测试按钮。

当你使用相对路径时,程序会以当前工作路径(Working Directory, 有时也称 CWD)来判断路径。因此,如果你点击了 IDE 的运行按钮,你的工作路径会变成 /...(省略一部分绝对路径)/internal/config,这个目录下的 config/config.%s.yaml 并不存在,因此这个测试跑不通。

要解决这个问题也有很简单的方法,例如把 LoadConfig 的参数变成一个路径,然后通过命令参数传入一个路径。这样在跑测试的时候,可以把路径替换成 ../../../config/config.test.yaml 来解决。但这个问题是,这路径也太多 . 了,它就像烦人的临时脚本一样,永远不可能在第一次就成功。

Project Root Is All You Need

这个问题的根本原因是相对路径的问题,如果我们使用绝对路径,这个问题就迎刃而解。绝对路径的缺点是,我们开发的软件可能会在多种环境上运行,这个软件可能会被放在系统的任何一个地方,因此绝对路径不能写死(绝对路径 != 静态路径)。对于一个项目里的文件,有一个最公共的锚点——项目根目录。只要找到项目根目录,我们就能很有信心地找到项目中的任意文件。因此,我们只需要动态地去找项目根目录就可以了。

P.S. 在以前写 PHP 的时候,就有不少框架有这样的设计,例如 ThinkPHP 就有一个 $rootPath

这个根目录怎么找呢?我求助了 GPT-4o。它给出的方案是:

  1. 在项目根目录放置一个 .project_root 的文件。
  2. 写一个函数,一直往上层文件夹找,直到找到这个 .project_root 文件,或者已经走到根目录。

看看代码:

// MarkerFile 项目根目录标识文件
const MarkerFile = ".project_root"

var (
    projectRoot string
    once        sync.Once
)

// GetProjectRoot 获取项目根目录
func GetProjectRoot() string {
    once.Do(func() {
       // 从当前目录开始
       dir, err := os.Getwd()
       if err != nil {
          projectRoot = getDefaultRoot()
          return
       }

       for {
          // 检查标记文件是否在当前目录
          if _, err := os.Lstat(filepath.Join(dir, MarkerFile)); err == nil {
             projectRoot = dir
             return
          }

          // 移动到上一级目录
          parentDir := filepath.Dir(dir)
          if parentDir == dir {
             // 到达根目录,还是没找到文件
             projectRoot = getDefaultRoot()
             return
          }
          dir = parentDir
       }
    })

    return projectRoot
}

func getDefaultRoot() string {
    _, b, _, _ := runtime.Caller(0)
    return filepath.Dir(b)
}

这个方案有一些好处和坏处,好处是能非常准确地找到项目根目录,代码里不需要写死任何目录;坏处是执行起来并不快,需要逐级往上找。

在这个代码里,我优化了两个地方:

  • 缓存+懒加载:用 sync.Once 来保证这个代码只会执行一次,并且缓存结果,只有第一次使用会有性能影响。
  • 默认的目录:这个是在 stackoverflow 上找到的方法,在以 main.go 为入口时可以正确找到根目录。

回到开头的问题,要找到配置文件就变得非常简单了。

configFilePath := fmt.Sprintf("%s/config/config.%s.yaml", GetConfigPath(), env)

参考

go - Is it possible to get the current root of package structure as a string in golang test? - Stack Overflow