你好,我是徐逸。
上节课我们学习了如何拆分项目,不过对于复杂系统来说,即便拆分了项目,每个子项目里的文件庞大且复杂,管理和维护都非常困难。这时候我们就需要学会如何抽丝剥茧,为这样的文件建立一个合理的目录结构。
今天我就用分层架构的思想,带你将一个包含订单、商品、用户业务逻辑,有好几万行代码的大文件,一步步重构成一个合理的项目目录结构。

假如现在产品提了个需求,在获取用户订单的时候,需要将订单涉及的商品信息一起返回给前端展示。
为了找到修改点,我们需要从几万行代码的大文件里面,找到获取订单逻辑。这是一个效率非常低的事。我们要么从main.go文件的开头,一行行浏览代码,找到获取订单逻辑。要么通过order关键词进行搜索。但对于有几万行代码的文件来说,可能会搜索出来大量的order关键词,需要一个个去辨别。
有什么方法能快速定位到订单获取业务逻辑呢?
三层目录结构:数据访问逻辑复用
我们可以把接口里面的业务逻辑从main.go文件拆出来,并把业务逻辑按业务功能归类到一个个文件里,比如下面的order_handler.go、product_handler.go文件。再将这些文件放到handler目录中。
1 2 3 4 5 6 7 8 9
| . ├── go.mod ├── go.sum ├── handler │ ├── init.go │ ├── order_handler.go // 订单逻辑 │ ├── product_handler.go // 商品逻辑 │ └── user_handler.go // 用户逻辑 └── main.go
|
这样main.go中的接口就变成了简单的取参和路由操作。
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23
| r.GET("/order/:order_id", func(c *gin.Context) { orderID := c.Params.ByName("order_id") order, err := handler.GetOrder(c, orderID) if err != nil { c.JSON(http.StatusOK, gin.H{"order": ""}) } else { c.JSON(http.StatusOK, gin.H{"order": order}) } })
r.GET("/product/:product_id", func(c *gin.Context) { productID := c.Params.ByName("product_id") product, err := handler.GetProduct(c, productID) if err != nil { c.JSON(http.StatusOK, gin.H{"product": ""}) } else { c.JSON(http.StatusOK, gin.H{"product": product}) } })
|
业务逻辑由handler目录下对应的handler文件来实现。
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18
|
type Order struct { OrderID string `json:"order_id"` Amount float64 `json:"amount"` ProductID string `json:"product_id"` }
func GetOrder(ctx context.Context, orderID string) (*Order, error) { var order Order err := db.QueryRow("SELECT order_id, amount, product_id FROM orders WHERE order_id =?", orderID).Scan(&order.OrderID, &order.Amount, &order.ProductID) if err != nil { return nil, err } return &order, nil }
|
重构之后,当我们查找获取订单信息的实现逻辑时,只用去handler目录里面的order_handler.go文件去找,而不用关心其它业务功能,效率会高很多。
实际上,我们现在的目录结构,就是传统 MVC 三层架构的变种。

- **View层。**用于存放html、js等前端页面。由于我们这个项目是前后端分离项目,所以没有View层目录。
- **Controller层。**用于接收前端请求,并调用Model层。相当于我们这个项目中的main.go文件,有些项目会把所有请求的路由放在一个handler.go文件。
- **Model层。**用于具体的业务逻辑处理以及从db等外部存储读取数据。相当于我们这里的handler目录。
回到我们的产品需求,要在订单获取的业务逻辑里,从商品表查询信息,并和订单信息一起返回给前端展示。
假如我们将从商品表查询商品信息的代码,复制到获取订单逻辑里,就会存在代码重复,后续维护变得很麻烦。比如当我们的商品表数据量太大,需要分表查询时,就需要同时修改获取订单和获取商品信息的业务逻辑。
有什么办法可以避免获取订单和获取商品里的逻辑重复呢?
我们可以将从商品表查询商品信息的逻辑下沉,抽象出一个数据访问层,专门负责数据库等外部存储交互以及所有表的增删改查。获取订单和商品的业务逻辑,都可以调用数据访问层的商品查询函数。这样就可以把从商品表里查询商品的逻辑,统一封装在一个地方,避免查询同一张表的逻辑重复。
重构后的目录是后面这样。
1 2 3 4 5 6 7 8 9 10 11 12
| . ├── dal │ ├── init.go │ ├── order.go │ └── product.go ├── go.mod ├── go.sum ├── handler │ ├── order_handler.go │ ├── product_handler.go │ └── user_handler.go └── main.go
|
后面是order_handler.go文件中的获取订单逻辑,可以看到现在是从dal层获取订单信息和商品信息。
1 2 3 4 5 6 7 8 9 10 11 12 13
| func GetOrder(ctx context.Context, orderID string) (*dal.Order, *dal.Product, error) { order, err := dal.GetOrder(ctx, orderID) if err != nil { return order, nil, nil } var product *dal.Product if order != nil { product, err = dal.GetProduct(ctx, order.ProductID) } return order, product, err }
|
实际上,这是经典的后端三层架构的思想。

- Controller层。用于接收前端请求,并调用Service层。相当于我们这个项目中的main.go文件。
- Service层。负责具体的业务逻辑处理,调用DAO层,不直接读写数据库。相当于我们这里的handler目录。
- DAO层。负责MySQL、Redis等数据库的增删改查。相当于我们这里的dal目录。
实践中,为了方便多层之间的数据传输,还会建一个model目录,用于存放层与层之间交互的结构体定义。同时,model目录也会存放和数据库表一一对应的数据实体定义。
而且,在dal目录里,会根据组件的不同新建不同的子目录。
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17
| . ├── dal │ ├── mysql │ │ ├── init.go │ │ ├── order.go │ │ └── product.go │ └── redis ├── go.mod ├── go.sum ├── handler │ ├── order_handler.go │ ├── product_handler.go │ └── user_handler.go ├── main.go └── model // 用于存放层与层之间的数据传输结构体定义和数据库表实体定义 ├── order.go └── product.go
|
假如现在因为商品表读性能问题,我们需要给商品信息加个redis缓存逻辑。当我们在handler目录里的业务逻辑,根据商品id读取商品信息时,对应的读写缓存逻辑是后面这样。
- 根据商品id读Redis,如果Redis中不存在商品信息,则读db。
- 从db中读出商品信息后,写Redis进行缓存。
由于order_handler.go和product_handler.go文件中都有读商品信息逻辑,如果我们把这段缓存读写逻辑,放在handler目录里实现,就会出现和抽象出dal目录之前一样的问题——代码逻辑重复,后续维护麻烦难题。
这段缓存读写逻辑放在哪合适呢?这时候四层目录结构就派上用场了。
四层目录结构:业务逻辑复用
我们可以抽象一层,将商品信息缓存读写的逻辑,放在一个service目录,而item_handler.go和product_handler.go文件读商品信息时,都调用service。
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21
| . ├── dal │ ├── mysql │ │ ├── init.go │ │ ├── order.go │ │ └── product.go │ └── redis │ ├── init.go │ └── product.go ├── go.mod ├── go.sum ├── handler │ ├── order_handler.go │ ├── product_handler.go │ └── user_handler.go ├── main.go ├── model │ ├── order.go │ └── product.go └── service └── product_service.go
|
product_service.go文件,封装了商品读写缓存逻辑。
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16
| func GetProduct(ctx context.Context, productID string) (*model.Product, error) { product, err := redis.GetProduct(ctx, productID) if err != nil { return nil, err } if product != nil { return product, nil } product, err = mysql.GetProduct(ctx, productID) if err != nil { return nil, err } redis.SetProduct(ctx, product) return product, nil }
|
order_handler.go和product_handler.go都从service层读取商品信息。
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22
|
func GetOrder(ctx context.Context, orderID string) (*model.Order, *model.Product, error) { order, err := mysql.GetOrder(ctx, orderID) if err != nil { return order, nil, nil } var product *model.Product if order != nil { product, err = service.GetProduct(ctx, order.ProductID) } return order, product, err }
func GetProduct(ctx context.Context, productID string) (*model.Product, error) { return service.GetProduct(ctx, productID) }
|
其实,这是借鉴阿里4层架构思想构建的目录结构。

- 请求处理层。用于接收前端请求,做简单的参数校验和路由,调用service层。相当于我们这个项目中的main.go文件。
- 业务逻辑层。负责具体的业务逻辑,可以对Manager层和DAO层的能力进行编排。相当于我们这里的handler目录。
- 通用处理层。主要有两个功能:1.将原先Service层的通用业务逻辑下沉到这一层 2.封装第三方接口调用。相当于我们的service目录。
- 数据持久层。与底层MySQL、Redis等数据库交互。相当于我们这里的dal目录。
现在的项目目录结构,对于一个项目,只有一个应用程序的情况,基本够用了。但在一个项目,存在多个应用程序时,也就是一个项目有多个main.go文件时,又该怎么组织呢?
Go社区目录结构:多应用程序代码复用
对于单项目多应用程序,Go社区有个推荐的项目目录结构 project-layout。我们可以参考这个,重新组织我们的目录。
- cmd目录,用于存放我们多个应用程序的main.go文件。
- internal,用于存放不能被其它项目使用的包。
- internal子目录pkg,用于存放可以被多个应用程序代码使用的包。
- internal子目录order_server,用于存放order_server应用程序可以使用的包。
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28
| . ├── cmd │ ├── order_consumer │ │ └── main.go // 订单消费者应用程序 │ └── order_server │ └── main.go // 订单商品服务应用程序 ├── go.mod ├── go.sum └── internal // 本项目私有包,不能被外部项目使用 ├── order_server // 订单商品服务私有包 │ └── handler │ ├── order_handler.go │ ├── product_handler.go │ └── user_handler.go └── pkg // 本项目,多应用程序公有包 ├── dal │ ├── mysql │ │ ├── init.go │ │ ├── order.go │ │ └── product.go │ └── redis │ ├── init.go │ └── product.go ├── model │ ├── order.go │ └── product.go └── service └── product_service.go
|
小结
今天这节课的内容就到这里了,我带你从一个几万行的大文件,一步步重构出了一个规范的项目目录结构。现在我们回顾一下重构的思路。
首先,我们用传统MVC三层架构,新建handler目录,将业务逻辑从main.go文件进行了分离,以便能快速定位到我们的业务逻辑。
接着,为了能实现表操作的逻辑复用,我们借鉴后端三层架构的思想,构建出dal目录。将对MySQL、Redis等数据库的访问逻辑都封装在里面,从而实现了数据访问的逻辑复用。
然后, 为了能实现业务逻辑的复用,我们借鉴了阿里四层架构的思想,构建了service目录,将handler目录里面的通用业务逻辑下沉到service目录。handler负责对dal和service里面的能力进行编排。
最后,为了能在一个项目中承载多个应用程序,实现代码复用。我们借鉴Go社区项目目录结构 project-layout,构建了一个支持多应用程序的项目目录结构。
项目目录结构代码我放在GitHub上了,希望通过这部分内容的学习,你能够构建一个属于自己的项目目录结构。
课后练习
请你模仿这节课里的重构过程,重构下你感觉可维护性差的项目目录。
欢迎你在留言区和我交流讨论,也欢迎你把这节课的内容分享给需要的朋友,我们下节课再见!