segmentfault+answer项目全解析(一):跑通本地环境

answer 项目是、一个基于开源知识的问答社区软件。您可以使用它来快速建立您的问答社区,以获得产品技术支持、客户支持、用户交流等。

answer 是一个比较完整的项目,后端使用的 golang,特别适合想学 go 的朋友练手,我们将会分几期来探索下 answer 的整个开发流程,和这下面的各种包,比如 wire,go-cache,xorm 等。

今天我们看下如何在本地快速搭建 answer 项目。

1、运行 golang 服务

我们先从 GitHub 上拉下项目代码:

git clone git@github.com:answerdev/answer.git

我们进到 cmd 目录,这个目录下存放我们的项目入口文件,我们可以看到有 answer 服务的相关 main 文件:

cmd
└── answer
    ├── command.go
    ├── main.go
    ├── wire.go
    └── wire_gen.go

我们可以看到除了 main 文件还有 command 和 wire 文件。我们可以知道 answer 服务的运行使用了 wire 和 corbra:

1.1、wire

首先我们看下 wire,其实就是依赖注入的代码生成工具。

wire 有两个核心概念:

1.1.1、provider

provider 其实就是我们代码中的工厂方法,比如我们想初始化一个 http 服务,就需要定一个 NewHTTPServer 方法,并返回*gin.Engine。这就是一个 provider。

// NewHTTPServer new http server.
func NewHTTPServer(debug bool,
	staticRouter *router.StaticRouter,
	answerRouter *router.AnswerAPIRouter,
	swaggerRouter *router.SwaggerRouter,
	viewRouter *router.UIRouter,
	authUserMiddleware *middleware.AuthUserMiddleware,
	avatarMiddleware *middleware.AvatarMiddleware,
) *gin.Engine {

	if debug {
		gin.SetMode(gin.DebugMode)
	} else {
		gin.SetMode(gin.ReleaseMode)
	}
	r := gin.New()
	r.Use(brotli.Brotli(brotli.DefaultCompression))
	r.GET("/healthz", func(ctx *gin.Context) { ctx.String(200, "OK") })

	...

	return r
}

再比如我们定义了一个 AuthUserMiddleware 的中间件,NewAuthUserMiddleware 就是 provider:

// AuthUserMiddleware auth user middleware
type AuthUserMiddleware struct {
	authService *auth.AuthService
}

// NewAuthUserMiddleware new auth user middleware
func NewAuthUserMiddleware(authService *auth.AuthService) *AuthUserMiddleware {
	return &AuthUserMiddleware{
		authService: authService,
	}
}

当然我们也可以定义一些带有错误返回的 provider,wire 在帮我们生成代码的时候也会自动处理这些错误:

// NewTranslator new a translator
func NewTranslator(c *I18n) (tr i18n.Translator, err error) {
	GlobalTrans, err = myTran.NewTranslator(c.BundleDir)
	return GlobalTrans, err
}
1.1.2、injector

injector 其实就是定义在 wire.go 一个函数声明,函数可以有参数也可以有返回值,函数中需要调用 wire.Build。wire.Build 的参数就是我们上面的 provider,我们可以看下一个具体的 injector:

// initApplication init application.
func initApplication(
	debug bool,
	serverConf *conf.Server,
	dbConf *data.Database,
	cacheConf *data.CacheConf,
	i18nConf *translator.I18n,
	swaggerConf *router.SwaggerConfig,
	serviceConf *service_config.ServiceConfig,
	logConf log.Logger) (*pacman.Application, func(), error) {
	panic(wire.Build(
		server.ProviderSetServer,
		router.ProviderSetRouter,
		controller.ProviderSetController,
		controller_backyard.ProviderSetController,
		service.ProviderSetService,
		repo.ProviderSetRepo,
		translator.ProviderSet,
		middleware.ProviderSetMiddleware,
		newApplication,
	))
}

这个是 answer 中初始化应用程序的 injector,它传入了一些必须的配置参数,比如是不是 debug 模式,服务配置,数据库配置等。

它返回了 *pacman.Application, func(), error 这三个参数,我们在函数体中调用 wire.Build 之后可以显示的返回,必比如下面这样:

wire.Build(
	server.ProviderSetServer,
	router.ProviderSetRouter,
	controller.ProviderSetController,
	controller_backyard.ProviderSetController,
	service.ProviderSetService,
	repo.ProviderSetRepo,
	translator.ProviderSet,
	middleware.ProviderSetMiddleware,
	newApplication,
)
return &pacman.Application{}, func() {

}, nil

当让如果我们觉得太麻烦,wire 也支持我们用下面的方式:

panic(wire.Build(
	server.ProviderSetServer,
	router.ProviderSetRouter,
	controller.ProviderSetController,
	controller_backyard.ProviderSetController,
	service.ProviderSetService,
	repo.ProviderSetRepo,
	translator.ProviderSet,
	middleware.ProviderSetMiddleware,
	newApplication,
))

支持动态参数传入

我们继续回到 initApplication,可以看到我们在初始化程序的时候需要传入一些外部的配置到我们的 provider 中。比如我们的 newApplication:

func newApplication(serverConf *conf.Server, server *gin.Engine) *pacman.Application {
	return pacman.NewApp(
		pacman.WithName(Name),
		pacman.WithVersion(Version),
		pacman.WithServer(http.NewServer(server, serverConf.HTTP.Addr)),
	)
}

它有 2 个参数,第一个是服务配置,第二个是其他 provider 生成的*gin.Engine。对于第一个是我们在定义 initApplication(injector)时,需要从外部传入的动态参数。Wire 检查 injector 的参数,看到我们在参数列表中添加了一个 *conf.Server 类型的参数,并且同样看到在所有提供者中,newApplication 接受一个*conf.Server 类型的参数,因此它将参数传递给 newApplication。

支持 provider 集

在 wire.Build 中我们可以看到有像 newApplication 这样的直接 provider,也有像下面这样的 provider:

// ProviderSetRouter is providers.
var ProviderSetRouter = wire.NewSet(NewAnswerAPIRouter, NewSwaggerRouter, NewStaticRouter, NewUIRouter)

我们可以把多个 provider 放到一个分组中,如果经常一起使用多个 provider,这将很有用。

比如 answer 中 repo 层的集合,我们都知道 repo 层是和数据打交道的,所以它把关联的 db 和 cache 也放到了这个 provider 集中:

// ProviderSetRepo is data providers.
var ProviderSetRepo = wire.NewSet(
	common.NewCommonRepo,
	data.NewData,
	data.NewDB,
	data.NewCache,
	comment.NewCommentRepo,
	comment.NewCommentCommonRepo,
	...
)

支持 Cleanup 函数

如果 provider 提供了一个需要清理的值(比如关闭文件,关闭数据库连接等),那么它可以返回一个闭包来清理资源。 injector 将使用它向 provider 返回一个聚合的清理函数,或者如果稍后在 injector 的实现中调用的 provider 返回错误,则清理资源。

比如 answer 中的 NewData 和 NewCache,因为这俩个是和数据库和缓存打交道,所以当使用完资源或者遇到错误时需要调用 close 方法,避免 goroutine 泄漏,这时我们的 provider 可以定义成下面这样:

// NewData new data instance
func NewData(db *xorm.Engine, cache cache.Cache) (*Data, func(), error) {
	cleanup := func() {
		log.Info("closing the data resources")
		db.Close()
	}
	return &Data{DB: db, Cache: cache}, cleanup, nil
}

我们需要在 provider 中声明好我们的闭包函数 cleanup,并返回 func(),然后我们的 injector 的返回中也定义了返回值 func()。这样 wire 生成的代码中就会生成相应清理函数的逻辑:

...

cache, cleanup, err := data.NewCache(cacheConf)
	if err != nil {
		return nil, nil, err
	}
	dataData, cleanup2, err := data.NewData(engine, cache)
	if err != nil {
		cleanup()
		return nil, nil, err
	}

...

return application, func() {
		cleanup2()
		cleanup()
	}, nil

如何生成依赖注入文件

首先我们定义好 wire.go 的 injector,注意文件中必须注明此文件不回被编译进 go 的可执行文件:

wireinject
// +build wireinject

package p

然后安装并执行 wire 命令即可,就会生成对应的 wire_gen.go 文件:

go install github.com/google/wire/cmd/wire@latest

wire

1.2、cobra

关于 cobra 的使用方法我这里就不再赘述了,大家可以参考我的另一篇文章:

golang 使用 Cobra 快速创建命令行

1.3、运行服务

首先我们修改下 configs/config.yaml 的配置:

debug:
  true
server:
  http:
    addr: 0.0.0.0:8012
data:
  database:
    driver: "mysql"
    connection: root:liufutian@tcp(localhost:3306)/answer # 填自己本地的数据库连接
  cache:
    file_path: "./data/cache/cache.db"
i18n:
  bundle_dir: "./data/i18n"
swaggerui:
  show: true
  protocol: http
  host: 127.0.0.1
  address: ':8012'
service_config:
  secret_key: "answer"
  web_host: "http://127.0.0.1:8012"
  upload_path: "./data/upfiles"

接着我们构建可执行文件:

go build -o answer ./cmd/answer

执行 answer 下面的 init 方法,初始化目录、数据库等:

./answer init -c configs/config_local.yaml -C ./data/
[config-file] try to install...
[config-file] data/conf/config.yaml already exists
[upload-dir] try to install...
[upload-dir] install success, upload directory is data/upfiles
[i18n] try to install i18n bundle...
[i18n] find i18n bundle 3
[i18n] install en_US.yaml bundle...
[i18n] install en_US.yaml bundle success
[i18n] install it_IT.yaml bundle...
[i18n] install it_IT.yaml bundle success
[i18n] install zh_CN.yaml bundle...
[i18n] install zh_CN.yaml bundle success
install all initial environment done
read config successfully
[database] already exists
init database successfully

执行 answer 下面的 run 方法,运行服务:

./answer run -c ./data/conf/config.yaml

2、编译前端项目

安装 pnpm:

brew install corepack
corepack enable
corepack prepare pnpm@v7.12.2 --activate

构建项目:

cd answer/ui
pnpm install
pnpm build

访问 http://localhost:8012/检查是否成功