Web 服务体系结构是构建每个项目之前的第一个阶段,就像您准备构建房屋并从创建体系结构计划开始一样。
本文将介绍当我需要在 Golang 中创建一个简单的 Web 服务时如何构造项目。保持简单但直观的体系结构非常重要,因为众所周知,在 golang 中,您可以通过引用包名称来调用方法。
在接下来的几行中,我将介绍一个简单但传统的 Web 服务体系结构模型,该模型在我涉及的大多数项目中都使用了,该模型处理每个 Web 服务的组件。
/api
API 包是将所有 API 端点按照其服务目的分组到子包中的文件夹。这意味着,我希望特定的包来解决特定的问题。
例如所有的登录,注册,忘记密码,重置密码处理程序,我更喜欢将其定义为名为 registration 的程序包。
注册包如下所示:
.
├── api
│ ├── auth
│ │ ├── principal.middleware.go
│ │ └── jwt.helper.go
│ ├── cmd
│ │ └── main.go
│ ├── registration
│ │ ├── login.handler.go
│ │ ├── social_login.handler.go
│ │ ├── register.handler.go
│ │ ├── social_register.handler.go
│ │ ├── reset.handler.go
│ │ ├── helper.go
│ │ └── adapter.go
├── cmd
│ └── main.go
├── config
│ ├── config.dev.json
│ ├── config.local.json
│ ├── config.prod.json
│ ├── config.test.json
│ └── config.go
├── db
│ ├── handlers
│ ├── models
│ ├── tests
│ ├── db.go
│ └── service.go
├── locales
│ ├── en.json
│ └── fr.json
├── public
├── vendor
├── Makefile
..........................
代码已被折叠,点此展开
handler.go
如你所见,文件名中有一个 handler.go 后缀。在这些代码中,您可以有效地编写处理请求的代码,从数据库中检索请求的数据,进行处理,最后构成响应。
一个可以更好地解释的简单示例如下所示:
http.HandleFunc("/bar", func(w http.ResponseWriter, r *http.Request) {
...
})
helper.go
有时,在发送响应之前,您需要从多个地方收集数据以进行处理,然后,在收集所有详细信息之后,可以将响应发送到客户端应用程序。但是代码必须在处理程序中保持尽可能简单,因此可以将过程中所有多余的代码放在此处。
adapter.go
在客户端和 Web 服务之间的交互中,它们在发送和接收数据时,同时可能存在第三方 API,另一个应用程序或数据库。考虑到这一点,在将数据从一个应用程序传输到另一个应用程序之前,我们需要先转换格式,然后才能被新应用程序接受。可以在此 adapter.go 文件中编写此转换函数。
例如,如果我需要将结构 A 转换为结构 B,则需要一个类似于以下内容的适配器函数:
type A struct {
FirstName string
LastName string
Email string
}
type B struct {
Name string
Email string
}
func ConvertAToB(obj A) B {
return B{
Name: obj.FirstName + obj.LastName,
Email: obj.Email,
}
}
/api/auth
大多数 Web 服务必须至少实现一种授权方法,例如:
OAuth — 开放身份验证
基本身份验证
令牌身份验证 (我更喜欢用 JWT — JSON Web Token)
OpenID
就我个人而言,我使用 JWT 是因为我为我们的客户 (ATNM) 编写 web 服务,主要用于移动应用程序或 CMS。如果您想了解有关 Web 身份认证 API 的更多信息,Mozilla 上有一篇文章对此进行了很好的解释。
什么是 JWT ?
JSON Web 令牌是一种开放的行业标准,RFC 7519 讲述了具体内容。
为什么要使用 JWT ?
授权: 这是使用 JWT 的最常见方案。一旦用户登录,每个后续请求将包括 JWT,从而允许用户访问该令牌允许的路由,服务和资源。单一登录是当今广泛使用 JWT 的一项功能,因为它的开销很小并且可以在不同的域中轻松使用。
信息交换: JSON Web 令牌是在各方之间安全地传输信息的一种好方法。因为可以对 JWT 进行签名 (例如,使用公钥 / 私钥对),所以您可以确保发件人是他们所说的人。此外,由于签名是使用标头和有效载荷计算的,因此您还可以验证内容是否遭到篡改。
因此,您必须验证签名,对主体进行编码或解码,或者编写 JWT 主体。对于这类过程的处理,我创建了文件 jwt.helper.go,以保持一致性,并在 auth 包下的一个地方找到所有与 JWT 相关的代码。
让我们讨论一下来自 auth 包的另一个文件,principal.middleware.go。该文件名称的由来是因为它是与所有 API 交互的第一个中间件,所以所有请求都通过它。在此文件中,我编写了一个函数来阻止任何请求,如果未通过规则,则会发送 401 状态码作为响应。现在,如果您想知道这些规则是什么,我们已经讨论过 JWT,所以附加到任何请求 (除了不需要授权的端点如 login、register) 的客户机必须发送一个 HTTP 头信息,授权,其中必须包含在 JWT 令牌。
总而言之,如果客户端应用未发送令牌,或者令牌已损坏或无效,则 web 服务将使请求无效。
从哪里获得令牌?
在阅读上一段时,令牌的来源可能是您想到的一个问题,因此让我们澄清一下。我提到过,在登录或注册时 (是的,也许其他路由也不需要身份验证),您不需要发送令牌,因为您实际上是从这些请求中获取令牌的。因此,您填写自己的凭据,如果凭据正确,您将在登录时在响应中得到一个令牌,该令牌将针对每个提出该请求的请求发送。
/cmd
我总是喜欢将 main.go 文件放在这个包中,它包含了来自一个项目的所有子包。它就像一个封装所有子模块的包装器,可以一起工作。
为什么这样命名? 很简单,因为 cmd 是命令的缩写。
通过命令要了解什么? 命令表示某个任务的一部分、调用其他任务或独立运行。main.go 文件是一个命令,通常将 Web 服务的所有功能和软件包包装在一个文件中,并仅调用包的主要功能。无论何时,如果要删除功能,只需在主文件中注释实例即可将其删除。
/config
我认为这个包非常重要,因为我发现将所有配置保存在一个位置而不是分散在项目的每个角落是非常有用的。在这个包中,我通常编写一个名为 config.go 的文件,其中包含配置的模型。这个模型只不过是一个 结构,例如:
type JWT struct {
required:"true"
}
type Database struct {
default:"postgres"
default:"false"
required:"true"
required:"true"
required:"true"
Port int
SSLMode bool
}
type Configuration struct {
required:"true"
required:"true"
}
但这仅仅是结构定义,我们仍然需要将实际数据放置在某处。对于那部分,我更喜欢有多个 JSON 文件,具体取决于环境,并将它们命名为 config.ENV.json。在定义结构之前的伪 JSON 例子如下所示:
{
}
让我们来谈谈业务,因为这部分对我来说很特别,对于寻找最佳答案所花费的时间也很重要。我不知道您是否遇到了这个问题,或者对您来说,也许这不是问题,但是我确实遇到了一些问题,试图以一种很好的方式导入配置。有很多可能性,但是我不得不面对两难选择的困境:
将 config 对象作为变量从 main.go 传递到最终函数,我需要在其中使用它。当然,这是个好主意,因为我仅针对需要该变量的实例传递了该变量,因此,我不会降低速度质量。但这对于开发或重构来说非常耗时,因为我需要一直将配置从一个函数传递给另一个函数,因此最后,您想杀死自己,嗯..,也许不是,但是我仍然不喜欢这样。
声明全局变量,并在需要的地方使用该实例。但这在我看来根本不是最好的选择,因为我必须在 main.go 文件中声明一个变量,然后再在 main 函数中声明 Unmarshal() JSON 文件,以将该内容放入声明为全局变量的变量对象中。但是,请猜怎么着,也许我正试图在初始化准备好之前调用该对象,所以我将有一个空对象,没有实际值,因此在这种情况下,我的应用程序将崩溃。
直接在需要的地方注入配置对象,是的,这是我的最佳选择,非常适合我。在 config.go 文件的末尾,我声明了以下几行:
var Main = (func() Configuration {
var conf Configuration
if err := configor.Load(&conf, "PATH_TO_CONFIG_FILE"); err != nil {
panic(err.Error())
}
return conf
})()
对于这个实现,您需要知道的是,我使用了一个名为 Configor 的库,它解组了一个文件 (在我们的例子中是 JSON),并将其加载到一个返回的变量 conf 中。
当您需要使用配置中的内容时,只要键入包名称 config 并调用变量 Main 就足够了,如下面的示例所示,它检索数据库配置:
var myDBConf = config.Main.Database
!!! 提示:如您所见,您必须在其中插入配置文件的路径,但是由于您想为不同的环境使用不同的文件,因此也许可以设置一个名为 CONFIG_PATH 的环境变量。将其定义为 env 变量,或在运行之前将其放入:
$ CONFIG_PATH=home/username/.../config.local.json go run cmd/main.go
将 PATH_TO_CONFIG_FILE 换成 os.Getenv("CONFIG_PATH")。这样,它就不在文件路径所在的位置。因此您可以跳过一些操作系统错误。
/db
db 包是 web 服务中最重要的包之一,你必须花费大量时间来思考体系结构并开发这个包,以为它是 web 服务的目的之一,收集和存储数据。我将介绍我自己的版本,该版本非常适合构建通常情况下的 Web 服务,因此请继续关注。
在深入了解文件夹结构之前,我有两点要向您坦白,我更喜欢使用 ORM,因为它更容易使用,并且提供了一种处理对象的好方法而不是使用 SQL 查询并将该数据转换为数组并尝试调试简单的查询。我使用 GORM 是因为满足我所有的要求:具有所有基本的 ORM 功能 (查找,更新,删除等。),接受关联 (具有一个,具有多个,属于,多对多,多态),接受事务,具有 sql builder,具有自动迁移和其他出色功能。
/db.go
此文件保留 GORM 的所有重要配置。因此在此文件中,我创建了一个函数,该函数以对象的形式返回到数据库的连接,该函数将在 main.go 中调用并传递给所有需要与数据库交互的 API。
// NewDatabase 返回一个新的数据库客户端连接
func NewDatabase(config config.Database) (gorm.DB, error) {
db, err := gorm.Open(config.Dialect, config.Source)
if err != nil {
return nil, err
}if err := InitDatabase(db, config); err != nil {
return nil, err
}return db, nil
}
上面的 NewDatabase 函数的第 8 行调用了 InitDatabase(),这个函数定义了我们的 ORM 的行为并操作了 AutoMigration(自动迁移)
// InitDatabase 初始化了数据库
func InitDatabase(db gorm.DB, config config.Database) error {
db.LogMode(config.Debug) // auto migrate
models := []interface{}{
&models.Account{},
&models.PersonalInfo{},
&models.Category{},
&models.Subcategory{},
}
if err := db.AutoMigrate(models...).Error; err != nil {
return err
}// Personal info
if err := db.Model(&models.PersonalInfo{}).AddForeignKey("account_id", fmt.Sprintf("%s(id)", models.AccountTableName), "CASCADE", "CASCADE").Error; err != nil {
return err
}// Subcategories
if err := db.Model(&models.Subcategory{}).AddForeignKey("category_id", fmt.Sprintf("%s(id)", models.CategoryTableName), "CASCADE", "CASCADE").Error; err != nil {
return err
}return nil
}
Auto Migration 会验证表是否存在,如果表不存在或者表结构与模型不对应则会尝试进行同步。
除自动迁移外,我手动设置外键,并在需要时设置索引和其他 sql 约束。
在 main.go 文件中的一个简单的实例化例子:
// 配置数据库
dbc, err := db.NewDatabase(&config.Config.Database)
if err != nil {
}
/service.go
这个文件的目标是为所有处理器维护一个结构体,而不是在多个地方导入一个处理器从而导致混乱,而是传递仅仅一个对象,从而仅从 main.go 传递单个对象给所有 API 处理程序,其中它包含所有的数据库处理器。如此,它看起来如下所示:
package db
import (
"github.com/jinzhu/gorm"
"PROJECT_FOLDER/db/handlers"
)
type Service struct {
Account *handlers.AccountHandler
Category *handlers.CategoryHandler
}
func NewService(db *gorm.DB) Service {
return Service{
Account: handlers.NewAccountHandler(db),
Category: handlers.NewCategoryHandler(db),
}
}
正如你所看到的,我有两个处理器,他们不包含 PersonalInfo 和 Subcategory, 因为这里不需要它们,它们已经成为了主处理器的一部分了。在不知道个人账户信息的情况下,知道个人信息是没必要的,所以,他们两个都被包装到一个对象里面.
可以在 main.go 中简单调用它,如下所示:
// 创建一个数据库服务 dbService := db.NewService(dbc)
/db/models
Models 通常只是普通的 Golang 结构,基本的 Go 类型或它们的指针。
如你所见,我将 Account, PersonalInfo, Category 和 Subcategory 这 4 个模型放入自动迁移功能中。我将在每个模型定义到不同的文件中,选择一个直观的名字,例如,account.go, personalInfo.go, category.go 和 subcategory.go。
仅作为示例,account.go 的内容如下所示:
package modelsimport (
"crypto/md5"
"encoding/hex"
"PROJECT_FOLDER/utils""github.com/jinzhu/gorm"
"golang.org/x/crypto/bcrypt"
)type Role stringconst (
RoleAdmin Role = "admin"
RoleUser Role = "user"
)const (
AccountTableName = "accounts"
)type Account struct {
gorm:"type:varchar(100); unique; not null"
gorm:"not null"
gorm:"type:varchar(5); not null"
gorm:"not null"
gorm:"not null"gorm:"not null"
Provider string
PersonalInfo PersonalInfo
}func (account *Account) BeforeCreate() error {
password, err := HashPassword(account.Password)
if err != nil {
return err
}
account.Password = *password
account.Token = GenerateToken()return nil
}func HashPassword(password string) (*string, error) {
hash, err := bcrypt.GenerateFromPassword([]byte(password), bcrypt.DefaultCost)
if err != nil {
return nil, err
}pass := string(hash)
return &pass, nil
}func GenerateToken() string {
hasher := md5.New()
// 你可以在本文 /utils 章节中检查 utils.RandStr()
hasher.Write([]byte(utils.RandStr(32)))return hex.EncodeToString(hasher.Sum(nil))
}
代码已被折叠,点此展开
/db/handlers
数据库处理程序在多个地方的代码是相同的,因此调用 GORM 函数时,最好调用一个准备在 API 处理程序中使用的函数。
package handlers
import (
"PROJECT_FOLDER/db/models"
"fmt"
"github.com/jinzhu/gorm"
)
type AccountHandler struct {
db *gorm.DB
}
func NewAccountHandler(db gorm.DB) AccountHandler {
return &AccountHandler{
db,
}
}
func (h AccountHandler) Find(accountID uint) (models.Account, error) {
var res models.Account
if err := h.db.Find(&res, "id = ?", accountID).Error; err != nil {
return nil, err
}
return &res, nil
}
func (h AccountHandler) FindBy(cond models.Account) (*models.Account, error) {
var res models.Account
if err := h.db.Find(&res, cond).Error; err != nil {
return nil, err
}
return &res, nil
}
func (h AccountHandler) Update(account models.Account, accountID uint) error {
return h.db.Model(models.Account{}).Where(" id = ? ", accountID).Update(account).Error
}
func (h AccountHandler) Create(account models.Account) error {
return h.db.Create(account).Error
}
func (h AccountHandler) UpdateProfile(profile models.PersonalInfo, accountID uint) error {
var personalInfo models.PersonalInfo
cond := &models.PersonalInfo{
AccountID: accountID,
}
// only create it if it doesn't already exist
if h.db.First(&personalInfo, cond).RecordNotFound() {
profile.AccountID = accountID
return h.db.Create(profile).Error
}
return h.db.Model(models.PersonalInfo{}).Where(cond).Update(profile).Error
}
代码已被折叠,点此展开
如果您想要一个 SQL 包处理程序的例子,我推荐您使用 XZYA 中的 SQLHandler。
/db/tests
我知道你的项目经理因为项目紧急而经常跳过这个步骤,但是请相信我,编写测试会更好。我认为一个编写单元测试的好方法是 Testify,它使用起来非常简单而且功能非常强大。
/gen
Gen 文件夹是放置第三方库生成的所有代码的文件夹。将所有代码都放在一个地方非常简单,因为也许.. 您需要不定期清理它,然后才能生成新的版本,您只需使用 Makefile 任务即可完成操作,稍后我们将对此进行讨论。
在工作中,我们通常使用 Swagger,该工具可以作为 API 声明,代码生成和文档使我们的工作更轻松并帮助我们维护一个文件。但是由于 Swagger 非常酷并且我们只是人类,因此使用图形界面比编写 YAML 或 JSON 规范文件要简单得多。对于这种工作,我们使用 StopLight。它的作用是在我们完成导出所需规格文件之后提供图形界面展示。
/locales
在大多数情况下,翻译是由客户端应用程序实现的,但有时可能需要发送一些自定义错误或翻译后的电子邮件模板,这样就会遇到问题。我有时遇到了这样的难题,我可以选择构建非常简单且非常基础的东西,或者浏览现有的软件包 (也许适合我的软件包)。是的,我发现一个非常简单,但正是我所需要的。我想要一个可以读取 JSON 文件的简单程序包,因为客户端应用程序已经具有了这些翻译功能,因此我不必创建额外的内容,因此我发现了 GoTrans。这个包最酷的地方是,您可以在 cmd / main.go 中声明它,然后就可以在项目中的任何位置调用 translate 函数。
如何初始化 Gotrans?
// 初始化 locales
if err := gotrans.InitLocales("locales"); err != nil {
panic(err)
}
如何使用 Gotrans ?
JSON files looks like:
{
}
JSON 文件名应使用浏览器支持的标准语言代码或语言国家代码。本地化文件夹中至少应有一个文件「en.json」。
gotrans.Tr("fr", "hello_world")
/public
也许你会问自己?!是 Web 服务中的公共文件夹吗?!是的,也许不是所有时候都需要它,但是我试图尽我所能解释 Web 服务的通用体系结构,有时您需要像 条款和条件 页面或 隐私策略 或 HTML 邮件模板或任何可以公开的内容,并可以作为资源导出到公共 API。
/utils
构建大型项目中,有时需要额外的工具,或者是一些解决小问题的帮助函数。但是这些 helpers 只是一小段代码,因此不需要为一个单独的程序创建单独的程序包。utils 包可以解决这个问题。因为您可以在此处将不同的代码放入单独的文件中,例如:
var letters = []rune("abcdefghijklmnopqrstuvwxyzABCDEFGHIJKLMNOPQRSTUVWXYZ")
func RandStr(n int) string {
b := make([]rune, n)
for i := range b {
bi = lettersrand.Intn(len(letters))
}
return string(b)
}
生成哈希密码
创建上传到 Cloud 的处理程序
创建发送电子邮件的处理程序
日志管理
等等
基本上,这里是存储所有无法分类的代码的地方。虽然混乱,但是却可以使工作变的更轻松,并且可以避免在很多地方重复编写相同的代码,从而可以节省时间。
/vendor
这个文件夹是唯一不需要更改的地方,这里下载并存储了项目中导入的所有外部依赖项或软件包,为了你的构建工作。这是在 build 或 run 任务上自动创建的,因为在编译项目之前,它会验证所有导入是否都在 vendor 文件夹中。
如何下载包?
这有很多方法,我不想探讨这个问题,但我我可以告诉你默认的方法是 go get PACKAGE,它将放置依赖项在 $GOPATH/src 或 go install PACKAGE 会将二进制文件放入 $GOPATH/bin 和 $GOPATH/pkg 中的包。
如何管理包?
可能现在您的问题是「好吧,但是如果我需要更改环境,那么如何将所有依赖关系放在一起并使用简单的命令安装它们,而不是运行多个命令 ?」答案很简单,请使用管理依赖项工具。使用依赖项工具,您可以完成基本任务,并且可以节省一些时间。
我更喜欢 DEP,它是 Golang 的默认设置。
在 MAC 上可以通过 brew 轻松安装
$ brew install dep
$ brew upgrade dep
或使用 CURL
Makefile
我使用 make file,因为它很简单,并且可以让我时不时重复执行的某些任务自动化执行,因为在创建一个构建之前我必须执行一些步骤,而且我需要在几个月或几年之后执行这个过程,所以我可能需要花一些时间来弄清楚如何进行构建。而不是花时间再一次思考我应该如何构建我的项目,在想执行时,我只需要查看内部的 make file,并选择想要执行的任务并运行即可。我想与您分享一些我在大多数项目中使用的简单基本任务:
IP = "XXX.XXX.XXX.XXX"
PEM_FILE = "...PATH_TO_PEM/key.pem"
Run the serverrun:
CONFIG=config/config.local.json go run cmd/main.go --port 8000
Remove generated files under gen/ folderclean:
rm -r gen
serve:
realize start
构建build:
go build cmd/main.go
为 Linux 构建build-linux:
env GOOS=linux go build cmd/main.go
只需将代码部署到 devdeploy-dev-code: build-linux copy-project
完全部署到 dev通过测试并生成 swaggerdeploy-dev: gen deploy-dev-code
将项目构建和依赖项复制到服务器copy-project:
ssh -i $(PEM_FILE) ubuntu@$(IP) 'sudo service api stop'
scp -i $(PEM_FILE) -r locales/ ubuntu@$(IP):/home/ubuntu/project_name
scp -i $(PEM_FILE) main ubuntu@$(IP):/home/ubuntu/project_name
ssh -i $(PEM_FILE) ubuntu@$(IP) 'sudo service api start'
rm main
你可以从 GNU.org 找到有关 makefile 以及如何使用它的精彩文章。
在本文中,您将了解 API 以及如何构建体系结构,如何通过 Web 服务与数据库进行交互,如何使用 JWT 创建配置文件、处理客户机和服务器之间的安全性和权限,以及如何使用其他软件包简化工作,最后,您学习了如何使用 make file 运行多个任务。