构建一个伟大的应用程序从其结构开始。一个应用程序的结构为应用程序的开发定下了基调,因此从一开始就把它弄好是很重要的。
Go是一种相对简单的语言,对应用程序的结构没有任何意见。在这篇文章中,我们将探讨你可以通过两种主要方式来结构你的Go应用程序。
在我们继续之前,需要注意的是,没有一种结构对所有应用程序都是完美的。我们要介绍的一些内容可能不适合你的库或项目。但是,你应该了解有哪些东西可以使用,这样你就可以轻松决定如何最好地构建你的应用程序。
用扁平结构构建Go应用程序
这种构建项目的方法将所有的文件和包都放在同一个目录中。
最初,这可能看起来是一种糟糕的项目结构方式,但有些构建方式完全适合它。一个使用扁平结构的示例项目会有如下结构。
flat_app/
main.go
lib.go
lib_test.go
go.mod
go.sum
使用这种结构的主要优点是它很容易操作。所有创建的包都位于同一个目录中,所以它们可以很容易地被修改,并在需要时使用。
这种结构最好用于构建库、简单的脚本或简单的CLI应用程序。HttpRouter,一个广泛使用的用于构建API的路由库,使用类似的扁平结构。
然而,一个主要的缺点是,随着项目变得更加复杂,它将变得几乎无法维护。例如,这样的结构不适合于构建REST API,因为API有不同的组件使其功能良好,如控制器、模型、配置和中间件。这些组件不应该全部保存在一个文件目录中。
理想情况下,在启动一个应用程序时,你应该使用扁平结构。一旦你对杂乱无章感到不舒服,你可以升级到其他任何项目结构。
使用扁平结构构建一个简单的API
为了演示扁平结构,让我们为一个记事本应用程序建立一个API。
通过运行为这个项目创建一个新的目录。
mkdir notes_api_flat
该目录被命名为notes_api_flat ,因为这个应用程序可能会有使用其他结构的变化,我们将在后面介绍。
现在,初始化该项目。
go mod init github.com/username/notes_api_flat
这个应用程序将允许用户存储笔记。我们将使用SQLite3来存储笔记,使用Gin来进行路由。运行下面的片段来安装它们。
go get github.com/mattn/go-sqlite3
go get github.com/gin-gonic/gin
接下来,创建以下文件。
main.go:应用程序的入口点models.go:管理对数据库的访问migration.go:管理创建表格
创建它们之后,文件夹结构应该是这样的。
notes_api_flat/
go.mod
go.sum
go.main.go
migration.go
models.go
编写migration.go
将以下内容添加到migration.go ,以创建将存储我们的笔记的表。
package main
import (
"database/sql"
"log"
)
const notes = `
CREATE TABLE IF NOT EXISTS notes (
id INTEGER PRIMARY KEY AUTOINCREMENT,
title VARCHAR(64) NOT NULL,
body MEDIUMTEXT NOT NULL,
created_at TIMESTAMP NOT NULL,
updated_at TIMESTAMP NOT NULL
)
`
func migrate(dbDriver *sql.DB) {
statement, err := dbDriver.Prepare(notes)
if err == nil {
_, creationError := statement.Exec()
if creationError == nil {
log.Println("Table created successfully")
} else {
log.Println(creationError.Error())
}
} else {
log.Println(err.Error())
}
}
在上面的片段中,我们声明包是main 。请注意,我们不能把它设置成与main.go 中不同的东西,因为它们在同一个目录中。因此,每个文件中的所有内容都是全局可用的,因为所有的文件都位于同一个包中。
注意,我们导入了与SQL交互所需的包,以及记录任何发生的错误的日志包。
接下来,我们有一个SQL查询,创建了一个有以下字段的笔记表:id,title,body,created_at, 和updated_at 。
最后,我们定义了函数migrate ,它执行上面写的查询,并打印过程中出现的任何错误。
创建models.go
在models.go 中加入以下内容。
package main
import (
"log"
"time"
)
type Note struct {
Id int `json:"id"`
Title string `json:"title"`
Body string `json:"body"`
CreatedAt time.Time `json:"created_at"`
UpdatedAt time.Time `json:"updated_at"`
}
func (note *Note) create(data NoteParams) (*Note, error) {
var created_at = time.Now().UTC()
var updated_at = time.Now().UTC()
statement, _ := DB.Prepare("INSERT INTO notes (title, body, created_at, updated_at) VALUES (?, ?, ?, ?)")
result, err := statement.Exec(data.Title, data.Body, created_at, updated_at)
if err == nil {
id, _ := result.LastInsertId()
note.Id = int(id)
note.Title = data.Title
note.Body = data.Body
note.CreatedAt = created_at
note.UpdatedAt = updated_at
return note, err
}
log.Println("Unable to create note", err.Error())
return note, err
}
func (note *Note) getAll() ([]Note, error) {
rows, err := DB.Query("SELECT * FROM notes")
allNotes := []Note{}
if err == nil {
for rows.Next() {
var currentNote Note
rows.Scan(
¤tNote.Id,
¤tNote.Title,
¤tNote.Body,
¤tNote.CreatedAt,
¤tNote.UpdatedAt)
allNotes = append(allNotes, currentNote)
}
return allNotes, err
}
return allNotes, err
}
func (note *Note) Fetch(id string) (*Note, error) {
err := DB.QueryRow(
"SELECT id, title, body, created_at, updated_at FROM notes WHERE id=?", id).Scan(
¬e.Id, ¬e.Title, ¬e.Body, ¬e.CreatedAt, ¬e.UpdatedAt)
return note, err
}
model 包含了笔记结构的定义和允许笔记与数据库交互的三个方法。注释结构包含了一个注释可以拥有的所有数据,并且应该与数据库中的列同步。
create 方法负责创建一个新的笔记,并返回新创建的笔记和在此过程中出现的任何错误。
getAll 方法将数据库中的所有笔记作为一个片断来获取,并返回它和在这个过程中发生的任何错误。
Fetch 方法从它的id ,获得一个特定的笔记。所有这些方法在将来都可以用来直接获取笔记。
在Go中完成API
API中剩下的最后一块是路由。修改main.go ,包括以下代码。
package main
import (
"database/sql"
"log"
"net/http"
"github.com/gin-gonic/gin"
_ "github.com/mattn/go-sqlite3"
)
// Create this to store instance to SQL
var DB *sql.DB
func main() {
var err error
DB, err = sql.Open("sqlite3", "./notesapi.db")
if err != nil {
log.Println("Driver creation failed", err.Error())
} else {
// Create all the tables
migrate(DB)
router := gin.Default()
router.GET("/notes", getAllNotes)
router.POST("/notes", createNewNote)
router.GET("/notes/:note_id", getSingleNote)
router.Run(":8000")
}
}
type NoteParams struct {
Title string `json:"title"`
Body string `json:"body"`
}
func createNewNote(c *gin.Context) {
var params NoteParams
var note Note
err := c.BindJSON(¶ms)
if err == nil {
_, creationError := note.create(params)
if creationError == nil {
c.JSON(http.StatusCreated, gin.H{
"message": "Note created successfully",
"note": note,
})
} else {
c.String(http.StatusInternalServerError, creationError.Error())
}
} else {
c.String(http.StatusInternalServerError, err.Error())
}
}
func getAllNotes(c *gin.Context) {
var note Note
notes, err := note.getAll()
if err == nil {
c.JSON(http.StatusOK, gin.H{
"message": "All Notes",
"notes": notes,
})
} else {
c.String(http.StatusInternalServerError, err.Error())
}
}
func getSingleNote(c *gin.Context) {
var note Note
id := c.Param("note_id")
_, err := note.Fetch(id)
if err == nil {
c.JSON(http.StatusOK, gin.H{
"message": "Single Note",
"note": note,
})
} else {
c.String(http.StatusInternalServerError, err.Error())
}
}
在这里,我们导入了所有需要的包。注意最后的导入。
"github.com/mattn/go-sqlite3"
这个代码段是与SQLite一起工作所必需的,尽管它没有被直接使用。主函数首先初始化数据库,如果失败则退出。数据库实例被存储在DB 全局变量上,这样就可以很容易地访问它。
接下来,我们通过调用migrate 函数来迁移表,这个函数是在migrations.go 中定义的。
我们不需要导入任何东西来使用这个函数,因为它在main 包中,全局可用。
接下来,定义路由。我们只需要三个路由。
- 一个
GET请求到/notes,检索所有已经创建并存储在数据库中的笔记。 - 一个
POST请求到/notes,创建一个新的笔记并将其持久化到数据库中。 - 一个
GET请求到/note/:note_id,通过它来检索一个笔记。id
这些路由有单独的处理程序,使用笔记模型来执行所需的数据库操作。
使用扁平结构的好处
我们可以看到,通过使用扁平结构,我们可以快速构建简单的API,而不需要管理多个包。这对库的作者特别有用,因为大多数模块都需要成为基础包的一部分。
使用扁平结构的缺点
尽管使用扁平结构有很多好处,但在构建API时,它并不是最好的选择。首先,这种结构有相当大的局限性,它自动使函数和变量成为全局可用。
也没有真正的关注点分离。我们试图将模型与迁移和路由分开,但这几乎是不可能的,因为它们仍然可以被彼此直接访问。这可能会导致一个文件修改它不应该修改的项目,或者在另一个文件不知情的情况下修改,所以这个应用程序不容易维护。
我们接下来要介绍的结构解决了许多使用扁平结构的问题。
在Go中使用分层架构(经典的MVC结构)。
这种结构根据文件的功能来分组。处理与数据库通信的包(模型)被分组,并与处理来自路由的请求的包不同地存储。
让我们看看分层架构结构是什么样子的。
layered_app/
app/
models/
User.go
controllers/
UserController.go
config/
app.go
views/
index.html
public/
images/
logo.png
main.go
go.mod
go.sum
注意这种分离。因为它很容易维护这样结构的项目,而且使用MVC结构,你的代码中会有更少的杂乱。
虽然分层结构对于构建简单的库来说并不理想,但它很适合构建API和其他大型应用。这通常是使用Revel构建的应用程序的默认结构,Revel是一个流行的Go框架,用于构建REST APIs。
更新具有分层架构的 Go 应用程序
现在你已经看到了一个使用分层架构的例子项目,让我们把我们的项目从扁平结构升级到MVC结构。
创建一个名为notes_api_layered 的新文件夹,并通过运行下面的代码段在其中初始化一个Go模块。
mkdir notes_api_layered
go mod init github.com/username/notes_api_layered
安装所需的SQLite和Gin包。
go get github.com/mattn/go-sqlite3
go get github.com/gin-gonic/gin
现在,更新项目的文件夹结构,使其看起来像这样。
notes_api_layered/
config/
db.go
controllers/
note.go
migrations/
main.go
note.go
models/
note.go
go.mod
go.sum
main.go
从新的文件夹结构中你可以看到,所有的文件都是根据其功能来安排的。所有的模型都位于模型的目录中,迁移、控制器和配置也是如此。
接下来,我们把在扁平结构实现中所做的工作重构到这个新结构中。
从数据库配置开始,在config/db.go 中添加以下内容。
package config
import (
"database/sql"
_ "github.com/mattn/go-sqlite3"
)
var DB *sql.DB
func InitializeDB() (*sql.DB, error) {
// Initialize connection to the database
var err error
DB, err = sql.Open("sqlite3", "./notesapi.db")
return DB, err
}
在这里,我们要声明一个名为config 的包,并导入所有相关的库以实现与数据库的通信。注意,我们可以声明多个包,因为它们并不都在同一个目录下。
接下来,我们创建一个DB 变量,它将保存与数据库的连接,因为每个模型都有不同的数据库实例,这并不理想。注意:变量名或函数名以大写字母开头意味着它们应该被导出。
然后我们声明并导出一个InitializeDB 函数,该函数打开数据库并将数据库引用存储在DB 变量中。
一旦我们完成了数据库的设置,接下来我们就进行迁移工作。我们在migrations文件夹下有两个文件:main.go 和note.go 。
main.go 负责加载要执行的查询,然后执行这些查询,而note.go 则包含专门针对notes表的SQL查询。
如果我们有其他的表,例如,一个用于注释的表,它们也会有一个迁移文件,其中包含创建注释表的查询。
现在,在migrations/note.go 中添加以下内容。
package migrations
const Notes = `
CREATE TABLE IF NOT EXISTS notes (
id INTEGER PRIMARY KEY AUTOINCREMENT,
title VARCHAR(64) NOT NULL,
body MEDIUMTEXT NOT NULL,
created_at TIMESTAMP NOT NULL,
updated_at TIMESTAMP NOT NULL
)
`
更新migrations/main.go ,以包括。
package migrations
import (
"database/sql"
"log"
"github.com/username/notes_api_layered/config"
)
func Run() {
// Migrate notes
migrate(config.DB, Notes)
// Other migrations can be added here.
}
func migrate(dbDriver *sql.DB, query string) {
statement, err := dbDriver.Prepare(query)
if err == nil {
_, creationError := statement.Exec()
if creationError == nil {
log.Println("Table created successfully")
} else {
log.Println(creationError.Error())
}
} else {
log.Println(err.Error())
}
}
正如前面所解释的,migrations/main.go 处理从各个迁移文件中加载查询,并在Run 方法被调用时运行它。migrate 是一个私有函数,不能在本模块之外使用。唯一输出到外部世界的函数是Run 。
运行完迁移后,我们需要更新模型。这里的分层结构实现和扁平结构实现之间的变化是相当小的。
所有要对外使用的方法都应该被导出,所有对DB 的引用都应该被改为config.DB 。
应用这些修改后,models/note.go 应该是这样的。
package models
import (
"log"
"time"
"github.com/username/notes_api_layered/config"
)
type Note struct {
Id int `json:"id"`
Title string `json:"title"`
Body string `json:"body"`
CreatedAt time.Time `json:"created_at"`
UpdatedAt time.Time `json:"updated_at"`
}
type NoteParams struct {
Title string
Body string
}
func (note *Note) Create(data NoteParams) (*Note, error) {
var created_at = time.Now().UTC()
var updated_at = time.Now().UTC()
statement, _ := config.DB.Prepare("INSERT INTO notes (title, body, created_at, updated_at) VALUES (?, ?, ?, ?)")
result, err := statement.Exec(data.Title, data.Body, created_at, updated_at)
if err == nil {
id, _ := result.LastInsertId()
note.Id = int(id)
note.Title = data.Title
note.Body = data.Body
note.CreatedAt = created_at
note.UpdatedAt = updated_at
return note, err
}
log.Println("Unable to create note", err.Error())
return note, err
}
func (note *Note) GetAll() ([]Note, error) {
rows, err := config.DB.Query("SELECT * FROM notes")
allNotes := []Note{}
if err == nil {
for rows.Next() {
var currentNote Note
rows.Scan(
¤tNote.Id,
¤tNote.Title,
¤tNote.Body,
¤tNote.CreatedAt,
¤tNote.UpdatedAt)
allNotes = append(allNotes, currentNote)
}
return allNotes, err
}
return allNotes, err
}
func (note *Note) Fetch(id string) (*Note, error) {
err := config.DB.QueryRow(
"SELECT id, title, body, created_at, updated_at FROM notes WHERE id=?", id).Scan(
¬e.Id, ¬e.Title, ¬e.Body, ¬e.CreatedAt, ¬e.UpdatedAt)
return note, err
}
我们已经声明了一个新的包,models ,并从github.com/username/notes_api_layered/config 中导入了配置。有了这个,我们就可以访问DB ,一旦InitializeDB 函数被调用,它就会被初始化。
对控制器的修改也相当小,主要包括导出函数和从新创建的模型中导入模型。
改变这个代码片段。
var note Note
var params NoteParams
到这个。
var note models.Note
var params models.NoteParams
经过这样的修改,控制器将看起来像这样。
package controllers
import (
"net/http"
"github.com/gin-gonic/gin"
"github.com/username/notes_api_layered/models"
)
type NoteController struct{}
func (_ *NoteController) CreateNewNote(c *gin.Context) {
var params models.NoteParams
var note models.Note
err := c.BindJSON(¶ms)
if err == nil {
_, creationError := note.Create(params)
if creationError == nil {
c.JSON(http.StatusCreated, gin.H{
"message": "Note created successfully",
"note": note,
})
} else {
c.String(http.StatusInternalServerError, creationError.Error())
}
} else {
c.String(http.StatusInternalServerError, err.Error())
}
}
func (_ *NoteController) GetAllNotes(c *gin.Context) {
var note models.Note
notes, err := note.GetAll()
if err == nil {
c.JSON(http.StatusOK, gin.H{
"message": "All Notes",
"notes": notes,
})
} else {
c.String(http.StatusInternalServerError, err.Error())
}
}
func (_ *NoteController) GetSingleNote(c *gin.Context) {
var note models.Note
id := c.Param("note_id")
_, err := note.Fetch(id)
if err == nil {
c.JSON(http.StatusOK, gin.H{
"message": "Single Note",
"note": note,
})
} else {
c.String(http.StatusInternalServerError, err.Error())
}
}
从上面的代码片段中,我们将函数转换为方法,这样它们就可以通过NoteController.Create ,而不是controller.Create 。这是为了考虑到有多个控制器的可能性,对于大多数现代应用程序来说,这将是一种情况。
最后,我们更新main.go ,以反映重构的结果。
package main
import (
"log"
"github.com/gin-gonic/gin"
"github.com/username/notes_api_layered/config"
"github.com/username/notes_api_layered/controllers"
"github.com/username/notes_api_layered/migrations"
)
func main() {
_, err := config.InitializeDB()
if err != nil {
log.Println("Driver creation failed", err.Error())
} else {
// Run all migrations
migrations.Run()
router := gin.Default()
var noteController controllers.NoteController
router.GET("/notes", noteController.GetAllNotes)
router.POST("/notes", noteController.CreateNewNote)
router.GET("/notes/:note_id", noteController.GetSingleNote)
router.Run(":8000")
}
}
在重构之后,我们有一个精简的main 包,它导入了所需的包:config,controllers, 和models 。然后,它通过调用config.InitializeDB() 来初始化数据库。
现在我们可以转向路由了。路由应该被更新,以使用新创建的笔记控制器来处理请求。
在Go中使用分层结构的好处
使用分层结构的最大好处是,从目录结构上看,你可以了解每个文件和/或文件夹在做什么。还有一个明显的关注点分离,因为每个包都有一个单一的功能要执行。
有了分层结构,这个项目很容易被扩展。例如,如果增加了一个允许用户对笔记进行评论的新功能,这将很容易实现,因为所有的基础工作都已经完成。在这种情况下,只需要创建model 、migration 、controller ,然后更新路线,就可以了。该功能已经被添加。
使用分层结构的弊端
对于简单的项目来说,这种结构可能是矫枉过正的,在实施之前需要进行大量的规划。
结论
总之,我们已经看到,为你的Go应用程序选择一个结构取决于你正在建立的项目,项目的复杂程度,以及你打算在上面工作多长时间。
对于创建简单的项目,使用扁平结构就可以了。不过,当项目比较复杂时,必须退一步重新思考,为应用程序选择一个更适合的结构。
在构建Go应用程序时,流行使用的其他结构是领域驱动的开发和六边形架构。如果你的应用程序继续扩展,可能也值得学习一下这些。
The postFlat structure vs. layered architecture:构建你的Go应用程序,首先出现在LogRocket博客上。