go 的 API 项目结构组织

2018-04-23 09:53:30 +08:00
 PaulFromLinks

引言

我们在开始做一个新的项目的时候经常要考虑用什么框架呢?框架都包含哪些功能?是不是能够满足我的需求,如果不能够满足需要我需要引入哪些模块去解决呢?上面的问题都确定了以后问题又来了:我该使用什么样的项目结构去组织这些不同的功能模块才能让项目易于理解、方便维护、利于多人协作、功能解耦、杜绝循环依赖等一系列问题

具体项目结构还要根据业务场景来制定,没有说一个可以满足所有需求的。相信大家在做项目的时候都会遇到要开发 api 接口给 app、web、pc 等不同客户端调用的需求。下面我们一起来探讨下这类需求我们应该如何去做。

入口文件

首先一个项目只能有一个入口文件,通过这一个入口文件我们可以线性的导航到任何需要查看的代码,但是项目又会有不同的接入层比如对外提供服务的 restful、对内提供服务的 rpc、多数情况下还会有一些小的命令程序去修一些数据。这时候我们可以用命令子命令的方式去做:

func main() {
	rootCmd := &cobra.Command{
		Use:   "forum",
		Short: "forum api for the project of links123.com's campus",
	}

	rootCmd.AddCommand( http.RunCommand())
	rootCmd.AddCommand(tcp.RunCommand())
	
	if err := rootCmd.Execute(); err != nil {
		panic(err)
	}
}

项目跟踪

当我们把一个项目编译成二进制发布以后初期可能只运行在一台服务器上面,后面随着业务量变大以后同一个二进制文件会部署在不同的服务器上,甚至会出现不同服务器上面运行同一个程序不同版本的情况。如果出问题了能够准确定位正在运行的服务版本是很重要的。我们可以利用 go 的 ldflags 在编译的时候把一些必要信息编译到二进制文件里面。

go build -ldflags "-X main.apiVersion=1.0  -X 'main.gitCommit=`git rev-parse HEAD`' -X 'main.built=`date`'"

公有模块

上面的入口文件里面定义了命令子命令,项目中运行的模块有些是每个子命令运行的时候都需要的,我们可以在入口文件里面把公共模块注入。

package main

func init() {
    // register common module
	cobra.OnInitialize(config.Initialize,log.Initialize,cache.Initialize)
}

func main() {
	rootCmd := &cobra.Command{
		Use:   "skeleton",
		Short: "skeleton api for the project of links123.com's campus",
	}

	rootCmd.AddCommand( http.RunCommand())
	rootCmd.AddCommand(tcp.RunCommand())
	rootCmd.AddCommand(version.RunCommand(apiVersion, gitCommit, built))

	if err := rootCmd.Execute(); err != nil {
		panic(err)
	}
}
config

可以从配置文件里面读取信息来改变 server 运行状态,比如设定当前运行环境是 release、debug、test 或者配置数据库连接信息或缓存服务器连接信息等多种作用。一个好的配置文件要有以下几个功能:

  1. 从环境变量里读取配置信息
  2. 从文件中读取配置信息
  3. 从 etcd 或 consful 中读取
  4. 能够监控文件变化并重新加载

由于配置文件里面保存有很多敏感信息,我们提交代码的时候是不能提交到代码库里面的,需要把本地配置文件添加到.gitignore 里面,同时提供一个不包含敏感配置信息的 demo 让协作开发者可以参考来配置自己的配置信息。等项目上线以后我们可以从统一的配置服务器读取或者不同项目单独加载服务端配置的文件。我们选取 viper 作为配置模块所使用的库。

log

通过日志我们可以收集程序运行信息,错误信息、调试信息等,一般是作为文件存在服务器上某一个路径。随着项目规模变大,运行服务的机器变多我们再一台一台查看日志就很麻烦,这时候就需要有一个统一日志收集的地方比如 syslog、mongodb、InfluxDB 等数据库里面做统计分析。日志大了以后还要有文件分隔等工具防止一个文件过大写入读取速度过慢的问题。我们项目中选中 logrus。

cache

查询数据库比较消耗时间,我们可以用缓存作为数据库前端加快访问速度。还有手机验证码我们可以生成以后先放到缓存中并设定过期时间,指定时间内和用户发过来的进行比对等一系列应用

helper

里面可以放置一些时间处理、字符串处理、错误处理的一系列小工具,但是需要注意的是不要让它成为垃圾堆什么都往里面放

languages

应用有时候要面向不同的国家和地区的人群,这时候接口返回的一些验证信息、数据信息需要根据客户端传递过来的语言进行适配。

service

我把服务分为内部服服务和外部服务。外部服务是指:需要通过网络连接获取数据的比如 mysql、mongodb、pg、redis 等数据库也就是通常我们所说的 model 层,在 model 里面只对数据进行处理不对逻辑进行处理。内部服务:我们从数据库获取信息以后需要对数据进行加工,加工完以后交给我们的接入层来调用,对数据的加工就是我们通常所说的逻辑层。接入层在调用的时候只调用内部服务,不和外部服务打交道。这样可以确保我们的代码分层、独立、解耦,替换某一个部分的时候改动最小化。

cmd

cmd 是指接入层,这一层可以通过不同的子命令接入 http 协议做 restful 接口对外提供服务、可以接入 tcp 的协议对内调用不同服务器上面的方法作为 rpc 使用。还可以做一些小工具修修数据什么的。总之这一层逻辑尽量简单,通过调用 service 层的内部服务来完成逻辑以便尽可能多的代码重用。

接入层的路由需要注意一下几点:

  1. 接口升级以后如何兼容旧版本的 app
  2. 有些接口是需要登陆才可以访问的,有些任何人都可以访问

我们采用路由分组、授权中间件来解决这个问题

// Only the login user can access
v1Auth := r.Group("/v1").Use(middleware.Auth)
{
	v1Auth.POST("/hello", handle.Healthy)
}

// All user can access
v1NoAuth := r.Group("/v1")
{
	v1NoAuth.GET("/hello", handle.Healthy)
}

当用户通过路由地址访问到我们数据的时候,会接收到用户的输入。有句话叫永远不要相信用户的输入,所以在这时候我们要及时绑定用户数据到结构体并验证。当用户输入数据格式不对的时候返回 http 状态码 415,输入的表单数据验证不通过的时候返回 422,并在客户端提示用户具体的错误。其他客户端错误返回 400,不用在 app 界面上都显示出来,只用统一处理一下用户提示即可。都处理成功以后我们要把成功的信息反馈给用户。

涉及几种状态码

200 OK - [GET]:服务器成功返回用户请求的数据,该操作是幂等的( Idempotent )。
201 CREATED - [POST/PUT/PATCH]:用户新建或修改数据成功。
202 Accepted - [*]:表示一个请求已经进入后台排队(异步任务)
204 NO CONTENT - [DELETE]:用户删除数据成功。
400 INVALID REQUEST - [POST/PUT/PATCH]:用户发出的请求有错误,服务器没有进行新建或修改数据的操作,该操作是幂等的。
401 Unauthorized - [*]:表示用户没有权限(令牌、用户名、密码错误)。
403 Forbidden - [*] 表示用户得到授权(与 401 错误相对),但是访问是被禁止的。
404 NOT FOUND - [*]:用户发出的请求针对的是不存在的记录,服务器没有进行操作,该操作是幂等的。
406 Not Acceptable - [GET]:用户请求的格式不可得(比如用户请求 JSON 格式,但是只有 XML 格式)。
410 Gone -[GET]:用户请求的资源被永久删除,且不会再得到的。
422 Unprocesable entity - [POST/PUT/PATCH] 当创建一个对象时,发生一个验证错误。
500 INTERNAL SERVER ERROR - [*]:服务器发生错误,用户将无法判断发出的请求是否成功。

分页、多语言

有些接口需要分页、有些需要多语言。这两个部分一般来说只有少量接口需要,这时候如果通过 header 或者 url 或者 body 每次都传无疑会消耗额外的流量。我们把分页和多语言作为两个 struct,当需要的时候组合进其他绑定参数的 struct 进行参数绑定和数据验证。

私有模块

私有模块只针对子命令,子命令启动的时候注入

package http

func init() {
	// register private module
}

func RunCommand() *cobra.Command {
	var host, port string
	cmd := &cobra.Command{
		Use:   "http",
		Short: "Run the http service",
		RunE: func(cmd *cobra.Command, args []string) error {
			forumRouter := router.Route()

			return manners.ListenAndServe(strings.Join([]string{host, port}, ":"), forumRouter)
		},
	}

	cmd.PersistentFlags().StringVarP(&host, "host", "o", "127.0.0.1", "server hostname")
	cmd.PersistentFlags().StringVarP(&port, "port", "p", "8080", "server port")

	return cmd
}

接口文档

在接入层写代码注释,然后通过 swagger 显示接口文档。

根据上面的描述,我们项目中使用的代码结构大体是这样子的:

.
├── cache
│   └── cache.go
├── cmd
│   ├── http
│   │   ├── handle
│   │   ├── router
│   │   ├── middleware
│   │   └── http.go
│   ├── tcp
│   │   ├── handle
│   │   ├── middleware
│   │   ├── router
│   │   └── tcp.go
│   └── version
│       └── version.go
├── config
│   └── config.go
├── docs
│   ├── docs.go
│   └── swagger
│       ├── swagger.json
│       └── swagger.yaml
├── helper
│   ├── helper.go
│   ├── string.go
│   └── time.go
├── languages
│   ├── en-US.ini
│   └── zh-CN.ini
├── log
│   └── log.go
├── service
    ├── mysql
    │   └── user.go
    ├── redis
    │   └── rank.go
    ├── rank.go
    └── user.go
└── main.go
数据库 migrate

https://github.com/mattes/migrate

上面说的不一定正确,只是个人的一些见解。欢迎大家批评指正。 另客网 云梯

2293 次点击
所在节点    问与答
0 条回复

这是一个专为移动设备优化的页面(即为了让你能够在 Google 搜索结果里秒开这个页面),如果你希望参与 V2EX 社区的讨论,你可以继续到 V2EX 上打开本讨论主题的完整版本。

https://www.v2ex.com/t/449015

V2EX 是创意工作者们的社区,是一个分享自己正在做的有趣事物、交流想法,可以遇见新朋友甚至新机会的地方。

V2EX is a community of developers, designers and creative people.

© 2021 V2EX