扁平结构与分层架构。构建你的Go应用程序

1,707 阅读13分钟

构建一个伟大的应用程序从其结构开始。一个应用程序的结构为应用程序的开发定下了基调,因此从一开始就把它弄好是很重要的。

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(
        &currentNote.Id,
        &currentNote.Title,
        &currentNote.Body,
        &currentNote.CreatedAt,
        &currentNote.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(
    &note.Id, &note.Title, &note.Body, &note.CreatedAt, &note.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(&params)
  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.gonote.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(
        &currentNote.Id,
        &currentNote.Title,
        &currentNote.Body,
        &currentNote.CreatedAt,
        &currentNote.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(
    &note.Id, &note.Title, &note.Body, &note.CreatedAt, &note.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(&params)
  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中使用分层结构的好处

使用分层结构的最大好处是,从目录结构上看,你可以了解每个文件和/或文件夹在做什么。还有一个明显的关注点分离,因为每个包都有一个单一的功能要执行。

有了分层结构,这个项目很容易被扩展。例如,如果增加了一个允许用户对笔记进行评论的新功能,这将很容易实现,因为所有的基础工作都已经完成。在这种情况下,只需要创建modelmigrationcontroller ,然后更新路线,就可以了。该功能已经被添加。

使用分层结构的弊端

对于简单的项目来说,这种结构可能是矫枉过正的,在实施之前需要进行大量的规划。

结论

总之,我们已经看到,为你的Go应用程序选择一个结构取决于你正在建立的项目,项目的复杂程度,以及你打算在上面工作多长时间。

对于创建简单的项目,使用扁平结构就可以了。不过,当项目比较复杂时,必须退一步重新思考,为应用程序选择一个更适合的结构。

在构建Go应用程序时,流行使用的其他结构是领域驱动的开发六边形架构。如果你的应用程序继续扩展,可能也值得学习一下这些。

The postFlat structure vs. layered architecture:构建你的Go应用程序,首先出现在LogRocket博客上。