使用Golang、Gin和React、esbuild开发的Blog

582次阅读  |  发布于3年以前

本指北手册,手把手跟大家从头开始构建一个完成一个Go作为服务的Web应用程序 — Blog

完整的应用程序 可以在 github上下载 [1]

Go(Golang)是谷歌开发的一种开源语言,更多信息请访问 Go官网[2]

Gin 是一个轻量级的高性能Web框架,支持现代Web应用程序所需的基本特性和功能。更多信息、文档访问 Gin官网[3]

React 是Facebook开发的JavaScript框架。React官网[4]

Esbuild 是新一代的JavasScript打包工具 Esbuild官网[5]

Typescript TypeScript 是一种由微软开发的自由和开源的编程语言,它是 JavaScript 的一个超集,扩展了 JavaScript 的语法。 TypeScript官网[6]

PostgreSQL 是我们将用于存储数据的数据库,可以到 PostgreSQL官网[7]查看了解更多信息。

相关安装都在官网有详细介绍就不在这里赘述了。

一、启动Gin服务

首先,我们要给我们的Web应用程序取个名字,用作我们Blog程序的服务端。这里我用 Pharos(灯塔)

cd ~/go/src
mkdir pharos
cd pharos

如果还没有安装依赖可以 通过下面命令来下载安装它。

go mod download github.com/gin-gonic/gin

在编写我们的第一个后端源文件之前,我们需要在Golang所需的项目根目录中创建go.mod 文件,以查找和导入所需要的依赖。该文件的内容为:

module pharos

go 1.17

require github.com/gin-gonic/gin v1.7.7

通过如下命令来整理一下go.mod文件

go mod tidy

现在,创建一个入口文件 main.go :

package main

import (
  "github.com/gin-gonic/gin"
)

func main() {
  // 创建默认的 gin 路由器,并且已经附加了 Logger 和 Recovery 中间件
  router := gin.Default()

  // 创建 API 路由组
  api := router.Group("/api")
  {
    // 将 /hello GET 路由添加到路由器并定义路由处理程序
        api.GET("/hello", func(ctx *gin.Context) {
      ctx.JSON(200, gin.H{"msg": "world"})
    })
  }

  router.NoRoute(func(ctx *gin.Context) { ctx.JSON(http.StatusNotFound, gin.H{}) })

  // 开始监听服务请求
  router.Run(":8080")
}

现在我们可以通过如下命令来启动服务器:

go run main.go

然后可以打开浏览器输入 http://localhost:8080/api/hello 来验证服务是否正常启动。如果正常您讲看到 {"msg":"world"}

Screen Shot 2021-12-06 at 7.57.09 PM.png

可以在命令行工具里面看到访问情况。

二、添加React

有了后端服务,现在可以添加前端框架。我们使用 esbuild-create-react-app 来安装react框架

现在根目录下安装React

npx esbuild-create-react-app app
cd app
yarn start | npm run start

过程中您会看到语言的选择,请选择Typescript。


            W  E  L  C  O  M  E      T  O         

      .d88b.  .d8888b       .d8888b 888d888 8888b.  
     d8P  Y8b 88K          d88P"    888P"      "88b 
     88888888 "Y8888b.     888      888    .d888888 
     Y8b.          X88     Y88b.    888    888  888 
      "Y8888   88888P'      "Y8888P 888    "Y888888 


Hello there! esbuild create react app is a minimal replacement for create react app using a truly blazing fast esbuild bundler.
Up and running in less than 1 minute with almost zero configuration needed.

? To get started please choose a template 
  Javascript 
❯ Typescript

安装完成后,我们需要进入 site 中 打开 package.json 文件, 在当中增加 ”proxy”: “http://localhost:8080” 。这个会将React开发的服务将我们所有的请求代理到Gin后端,Gin后端将在端口8080上监听。

{
  "name": "site",
  "version": "1.0.0",
  "main": "builder.js",
  "author": "Yuan Liang",
  "license": "MIT",
  "proxy": "http://localhost:8080",
  "scripts": {
    "pre-commit": "lint-staged",
    "lint": "eslint \"src/**/*.{ts,tsx}\" --max-warnings=0",
    "start": "node builder.js",
    "build": "NODE_ENV=production node builder.js"
  },
  "dependencies": {
    "fs-extra": "^10.0.0",
    "react": "^17.0.2",
    "react-dom": "^17.0.2"
  },
  "devDependencies": {
    "@types/node": "^16.9.1",
    "@types/react": "^17.0.20",
    "@types/react-dom": "^17.0.9",
    "@typescript-eslint/eslint-plugin": "^4.31.0",
    "@typescript-eslint/parser": "^4.31.0",
    "chokidar": "^3.5.2",
    "esbuild": "^0.12.26",
    "eslint": "^7.32.0",
    "eslint-config-airbnb": "^18.2.1",
    "eslint-config-prettier": "^8.3.0",
    "eslint-plugin-import": "^2.24.2",
    "eslint-plugin-jsx-a11y": "^6.4.1",
    "eslint-plugin-react": "^7.25.1",
    "eslint-plugin-react-hooks": "^4.2.0",
    "husky": "^7.0.2",
    "lint-staged": "^11.1.2",
    "prettier": "^2.4.0",
    "server-reload": "^0.0.3",
    "typescript": "^4.4.3"
  },
  "lint-staged": {
    "*.+(js|jsx)": "eslint --fix",
    "*.+(json|css|md)": "prettier --write"
  },
  "husky": {
    "hooks": {
      "pre-commit": "lint-staged"
    }
  }
}

另外 live-server 里面的 proxy 里面有一些 bug 重新搞了一个包 ( server-reload ) ,增加了 POST method 的支持 还有 proxy的传参方式。

npm install server-reload --save-dev

所以 builder.js 的传参也进行了修改。

const serverParams = {
  port: 8181, // Set the server port. Defaults to 8080.
  root: 'dist', // Set root directory that's being served. Defaults to cwd.
  open: false, // When false, it won't load your browser by default.
  cors: true,
  // host: '0.0.0.0', // Set the address to bind to. Defaults to 0.0.0.0 or process.env.IP.
  proxy: {
    path: '/api',
    target: 'http://localhost:8080/api'
  } // Set proxy URLs.
  // ignore: 'scss,my/templates', // comma-separated string for paths to ignore
  // file: 'index.html' // When set, serve this file (server root relative) for every 404 (useful for single-page applications)
  // wait: 1000, // Waits for all changes, before reloading. Defaults to 0 sec.
  // mount: [['/components', './node_modules']], // Mount a directory to a route.
  // logLevel: 2, // 0 = errors only, 1 = some, 2 = lots
  // middleware: [function(req, res, next) { next(); }] // Takes an array of Connect-compatible middleware that are injected into the server middleware stack
}
//

然后 site 文件里面可以执行 yarn start | npm run start 项目启动可以看到 Go 服务的数据已经返回。

Screen Shot 2021-12-07 at 6.28.21 PM.png Screen Shot 2021-12-07 at 6.27.07 PM.png

三、整理目录结构和添加路由

现在我们来重新规划目录结构,感觉比较正规一丢丢,像个架构师一样。代码按照功能来区分是一个比较好的最佳时间。

我们现在创建一个 services 来放置我们的 go 服务应用文件 并在其中添加一个 server 文件夹当中放置 server.go 文件作为Gin的启动文件。然后在 server 文件中添加一个 router.go 来作为项目的路由管理文件。

main.go 文件修改为

package main

import"pharos/services/server"

func main() {
    server.Start()
}

调整后的目录结构是这样的

d90bce43-14ac-478e-be3c-69bf884944de.png

现在再次启动一次服务看看效果:)

四、创建用户对象以及登陆注册方法

接下来作为博客程序,要做的第一件事就是来做用户的创建登陆管理。现在我们来制作一个简单的 User 对象放到文件运行在内存中,稍后我们将链接数据库,可以方便的管理储存数据。

第一步 让我们在 services 当中来创建一个新目录,我们将其命名为 store。这个文件当中将保存我们这个blog的所有的数据逻辑。在我们将程序链接到数据哭之前,我们将用简单的对象来储存用户。在 services/store 目录中,我们来创建一个新文件 users.go

package store

type User struct {
    Username string
    Password string
}

var Users []*User

接下来我们在 router.go 当中删除 /hello 路由,换上新的路由 /signup/signin 注册和登录

package server

import (
  “github.com/gin-gonic/gin”
)

func setRouter() *gin.Engine {
  // Creates default gin router with Logger and Recovery middleware already attached
  router := gin.Default()

  // Enables automatic redirection if the current route can’t be matched but a
  // handler for the path with (without) the trailing slash exists.
  router.RedirectTrailingSlash = true

  // Create API route group
  api := router.Group(“/api”)
  {
    api.POST(“/signup”, signUp)
    api.POST(“/signin”, signIn)
  }

  router.NoRoute(func(ctx *gin.Context) { ctx.JSON(http.StatusNotFound, gin.H{}) })

  return router
}

会发现 缺少 signUp 还有 signIn 两个方法的实现 我们把这两个方法的实现放到 services/server/user.go 当中

package server

import (
  “net/http”
  “pharos/services/store”

  “github.com/gin-gonic/gin”
)

func signUp(ctx *gin.Context) {
  user := new(store.User)
  if err := ctx.Bind(user); err != nil {
    ctx.AbortWithStatusJSON(http.StatusBadRequest, gin.H{“err”: err.Error()})
    return
  }
  store.Users = append(store.Users, user)
  ctx.JSON(http.StatusOK, gin.H{
    “msg”: “Signed up successfully.”,
    “jwt”: “123456789”,
  })
}

func signIn(ctx *gin.Context) {
  user := new(store.User)
  if err := ctx.Bind(user); err != nil {
    ctx.AbortWithStatusJSON(http.StatusBadRequest, gin.H{“err”: err.Error()})
    return
  }
  for _, u := range store.Users {
    if u.Username == user.Username && u.Password == user.Password {
      ctx.JSON(http.StatusOK, gin.H{
        “msg”: “Signed in successfully.”,
        “jwt”: “123456789”,
      })
      return
    }
  }
  ctx.AbortWithStatusJSON(http.StatusUnauthorized, gin.H{“err”: “Sign in failed.”})
}

这段代码的作用是。我们创建一个新的用户类型变量,然后将用户变量存储在前端当中,然后我们调用 bind() 方法将数据绑定到 User 类型变量,如果绑定失败,我们立即设置错误代码和错误消息,并从当前函数返回。如果没有错误,我们将响应代码设置状态为 OK 并返回 JWT 进行身份验证。现在您可以不用关心 JWT 是啥,稍后会详细介绍,也可能不会介绍自行搜索。

现在您可以重启服务然后调用一下两个路由的状态 ,注意是 POST请求 需要专门的工具调试 ,或者暂不调试继续往下。

1C23012A-7952-44F1-BA6E-6A18E82E95C5.png F44D09A5-9A76-4E56-8E5F-4567A2EF9B25.png

接下来是填写React的相关前端的页面 可以直接到github上去下载

有了前端页面就可以利用表单来注册,登陆用户了。在 app/src/components/Auth/AuthForm.tsx 当中为我们的表单提交增加一个 submitHandler() 方法。然后点击提交后就可以看到提交的数据了 。

Screen Shot 2021-12-08 at 4.23.19 PM.png

我们还需要给表单增加前后端的验证规则,这里主要来添加服务端的验证。打开 services/store/users.go 文件修改成如下这样。

type User struct {
  Username string`binding:"required,min=5,max=30"`
  Password string`binding:"required,min=7,max=32"`
}

这里接受设置了接手的字段和字段的规则 在这里来找 Go 相关的验证规则 go-playground/validator 验证支持的字段 点击这里

现在可以通过注册页面输入用户名和密码(重启Gin服务),不符合规则的会返回服务端错误。

Screen Shot 2021-12-08 at 4.23.19 PM.png

五、添加数据库

目前为止我们的应用程序运行OK,我么可以创建用户,并且登陆但是我们一旦重启Gin服务,我们的用户数据就会丢失,因为他仅仅在内存中运行。一个完整的应用程序是需要数据库来进行数据的存储的,我们将所有的数据保存到数据库中。PostgreSQL 的具体安装细节我们不做讲解,官网上会有详细的安装步骤,我们现在假设您已经安装和配置了 PostgreSQL 所有例子当中我们使用 postgres 默认的帐号和密码。

首先我们创建一个数据库,如果 PostgreSQL 没有运行,我们需要启动它,然后通过默认的帐号 postgres 来登陆。

sudo service postgresql start
sudo -u postgres psql

链接成功后 可以创建 数据库

CREATE DATABASE pharos;

数据库通信我们使用 go-pg module。您可以通过 go get github.com/go-pg/pg/v10 命令来进行安装。现在我们在 services 文件夹当中新增加一个新的目录文件 database 并且新增一个文件 database.go

package database

import (
  "github.com/go-pg/pg/v10"
)

func NewDBOptions() *pg.Options {
  return &pg.Options{
    Addr:     "localhost:5432",
    Database: "pharos",
    User:     "postgres",
    Password: "postgres",
  }
}

然后在 services/store/store.go 增加数据库的连接。

package store

import (
  “log”

  “github.com/go-pg/pg/v10”
)

// Database connector
var db *pg.DB

func SetDBConnection(dbOpts *pg.Options) {
  if dbOpts == nil {
    log.Panicln(“DB options can’t be nil”)
  } else {
    db = pg.Connect(dbOpts)
  }
}

func GetDBConnection() *pg.DB { return db }

创建一个变量db,然后创建两个方法 Get and Set 数据库连接方法 然后通过 pg.Connect 来连接数据库。然后我们回到 services/server/server.go 增加项目启动后的数据库连接访问

package server

import (
  “pharos/services/database”
  “pharos/services/store”
)

func Start() {
  store.SetDBConnection(database.NewDBOptions())

  router := setRouter()

  // Start listening and serving requests
  router.Run(“:8080”)
}

链接数据库后,用户的信息就可以保存到数据库当中了,并且可以验证一下登陆用户名 密码匹配。services/store/users.go 增加验证相关能力。

package store

import “errors”

type User struct {
  ID       int
  Username string`binding:"required,min=5,max=30"`
  Password string`binding:"required,min=7,max=32"`
}

func AddUser(user *User) error {
  _, err := db.Model(user).Returning(“*”).Insert()
  if err != nil {
    return err
  }
  returnnil
}

func Authenticate(username, password string) (*User, error) {
  user := new(User)
  if err := db.Model(user).Where(
    “username = ?”, username).Select(); err != nil {
    returnnil, err
  }
  if password != user.Password {
    returnnil, errors.New(“Password not valid.”)
  }
  return user, nil
}

相应的 services/server/user.go 也需要进行相关的修改。

package server

import (
  “net/http”
  “pharos/services/store”

  “github.com/gin-gonic/gin”
)

func signUp(ctx *gin.Context) {
  user := new(store.User)
  if err := ctx.Bind(user); err != nil {
    ctx.AbortWithStatusJSON(http.StatusBadRequest, gin.H{“error”: err.Error()})
    return
  }
  if err := store.AddUser(user); err != nil {
    ctx.AbortWithStatusJSON(http.StatusBadRequest, gin.H{“error”: err.Error()})
    return
  }
  ctx.JSON(http.StatusOK, gin.H{
    “msg”: “Signed up successfully.”,
    “jwt”: “123456789”,
  })
}

func signIn(ctx *gin.Context) {
  user := new(store.User)
  if err := ctx.Bind(user); err != nil {
    ctx.AbortWithStatusJSON(http.StatusBadRequest, gin.H{“error”: err.Error()})
    return
  }
  user, err := store.Authenticate(user.Username, user.Password)
  if err != nil {
    ctx.AbortWithStatusJSON(http.StatusUnauthorized, gin.H{“error”: “Sign in failed.”})
    return
  }

  ctx.JSON(http.StatusOK, gin.H{
    “msg”: “Signed in successfully.”,
    “jwt”: “123456789”,
  })
}

数据库连接创建后,我们还需要创建表来存储数据。我们使用 go-pg/migrations 模块来做数据迁移。在Github上您可以找到安装和使用指南这里不做赘述。然后我们在根目录创建一个文件夹 migrations 并在里面创建一个文件 main.go

package main

import (
    "flag"
    "fmt"
    "os"

    "pharos/services/database"
    "pharos/services/store"

    "github.com/go-pg/migrations/v8"
)

const usageText = `This program runs command on the db. Supported commands are:
  - init - creates version info table in the database
  - up - runs all available migrations.
  - up [target] - runs available migrations up to the target one.
  - down - reverts last migration.
  - reset - reverts all migrations.
  - version - prints current db version.
  - set_version [version] - sets db version without running migrations.
Usage:
  go run *.go <command> [args]
`

func main() {
    flag.Usage = usage
    flag.Parse()

    store.SetDBConnection(database.NewDBOptions())
    db := store.GetDBConnection()

    oldVersion, newVersion, err := migrations.Run(db, flag.Args()...)
    if err != nil {
        exitf(err.Error())
    }
    if newVersion != oldVersion {
        fmt.Printf("migrated from version %d to %d\n", oldVersion, newVersion)
    } else {
        fmt.Printf("version is %d\n", oldVersion)
    }
}

func usage() {
    fmt.Print(usageText)
    flag.PrintDefaults()
    os.Exit(2)
}

func errorf(s string, args ...interface{}) {
    fmt.Fprintf(os.Stderr, s+"\n", args...)
}

func exitf(s string, args ...interface{}) {
    errorf(s, args...)
    os.Exit(1)
}

这里新增一个文件 1_addUsersTable.go 这个与官方的例子类似。我们可以用 SetDBConnection()GetDBConnection() 在数据存储包中来定义函数。这是运行数据迁移的主要入口。

package main

import (
  “fmt”

  “github.com/go-pg/migrations/v8”
)

func init() {
  migrations.MustRegisterTx(func(db migrations.DB) error {
    fmt.Println(“creating table users…”)
    _, err := db.Exec(`CREATE TABLE users(
      id SERIAL PRIMARY KEY,
      username TEXT NOT NULL UNIQUE,
      password TEXT NOT NULL,
      created_at TIMESTAMPTZ NOT NULL DEFAULT NOW(),
      modified_at TIMESTAMPTZ NOT NULL DEFAULT NOW()
    )`)
    return err
  }, func(db migrations.DB) error {
    fmt.Println(“dropping table users…”)
    _, err := db.Exec(`DROP TABLE users`)
    return err
  })
}

然后进入 migrations 文件夹来执行相关命令

cd migrations/
go run *.go init
go run *.go up

我们将会为表格创建一个 users 用户表,我们在数据库中添加了 created_at 和 modified_at 列,因此我们还需要将它们添加到 services/store/users.go 中的User数据结构定义当中

type User struct {
  ID         int
  Username   string`binding:"required,min=5,max=30"`
  Password   string`binding:"required,min=7,max=32"`
  CreatedAt  time.Time
  ModifiedAt time.Time
}

现在试着创建一个全新的帐号 然后我们去数据库中观察这个帐号已经被存储到数据库当中。也可以创建一个迁移可执行文件

cd migrations/
go build -o migrations *.go

并且运行它

cd migrations/
go build -o migrations *.go

这里有一个问题,users 表当中我们用纯文本形式存储用户密码,这种事不安全的,我们应该用一个随机的种子来生成密码,为此我们用 golan/crypto库,来扩展我们的用户对象结构。

type User struct {
  ID             int
  Username       string`binding:"required,min=5,max=30"`
  Password       string`pg:"-" binding:"required,min=7,max=32"`
  HashedPassword []byte`json:"-"`
  Salt           []byte`json:"-"`
  CreatedAt      time.Time
  ModifiedAt     time.Time
}

修改 migration 文件

package main

import (
  “fmt”

  “github.com/go-pg/migrations/v8”
)

func init() {
  migrations.MustRegisterTx(func(db migrations.DB) error {
    fmt.Println(“creating table users…”)
    _, err := db.Exec(`CREATE TABLE users(
      id SERIAL PRIMARY KEY,
      username TEXT NOT NULL UNIQUE,
      hashed_password BYTEA NOT NULL,
      salt BYTEA NOT NULL,
      created_at TIMESTAMPTZ NOT NULL DEFAULT CURRENT_TIMESTAMP,
      modified_at TIMESTAMPTZ NOT NULL DEFAULT CURRENT_TIMESTAMP
    )`)
    return err
  }, func(db migrations.DB) error {
    fmt.Println(“dropping table users…”)
    _, err := db.Exec(`DROP TABLE users`)
    return err
  })
}

现在再修改一下 services/store/users.go

package store

import (
  "crypto/rand"
  "time"

  "golang.org/x/crypto/bcrypt"
)

type User struct {
  ID             int
  Username       string`binding:"required,min=5,max=30"`
  Password       string`pg:"-" binding:"required,min=7,max=32"`
  HashedPassword []byte`json:"-"`
  Salt           []byte`json:"-"`
  CreatedAt      time.Time
  ModifiedAt     time.Time
}

func AddUser(user *User) error {
  salt, err := GenerateSalt()
  if err != nil {
    return err
  }
  toHash := append([]byte(user.Password), salt…)
  hashedPassword, err := bcrypt.GenerateFromPassword(toHash, bcrypt.DefaultCost)
  if err != nil {
    return err
  }

  user.Salt = salt
  user.HashedPassword = hashedPassword

  _, err = db.Model(user).Returning(“*”).Insert()
  if err != nil {
    return err
  }
  return err
}

func Authenticate(username, password string) (*User, error) {
  user := new(User)
  if err := db.Model(user).Where(
    “username = ?”, username).Select(); err != nil {
    returnnil, err
  }
  salted := append([]byte(password), user.Salt…)
  if err := bcrypt.CompareHashAndPassword(user.HashedPassword, salted); err != nil {
    returnnil, err
  }
  return user, nil
}

func GenerateSalt() ([]byte, error) {
  salt := make([]byte, 16)
  if _, err := rand.Read(salt); err != nil {
    returnnil, err
  }
  return salt, nil
}

再次更新一下数据库

cd migrations/
go run *.go reset
go run *.go up

E2E8492B-DA4E-40B4-BBF3-98D1B0CA6C67.png

重新在浏览器当中访问页面,并且创建帐号就看到加密后的密码被更新到数据库中了已经。

六、增加配置文件以及增加启动脚本

当前我们吧服务器地址,以及端口等都硬编码到了代码里,数据库相关的选项也是如此。这不是一个优雅的解决方案,所以我们要创建一个 环境变量文件 .env 把相关的配置都从这个文件读取,首先创建一个 services/conf 文件夹 并在里面包含 conf.go 的文件

package conf

import (
  "log"
  "os"
  "strconv"
)

const (
  hostKey       = "PHAROS_HOST"
  portKey       = "PHAROS_PORT"
  dbHostKey     = "PHAROS_DB_HOST"
  dbPortKey     = "PHAROS_DB_PORT"
  dbNameKey     = "PHAROS_DB_NAME"
  dbUserKey     = "PHAROS_DB_USER"
  dbPasswordKey = "PHAROS_DB_PASSWORD"
)

type Config struct {
  Host       string
  Port       string
  DbHost     string
  DbPort     string
  DbName     string
  DbUser     string
  DbPassword string
}

func NewConfig() Config {
  host, ok := os.LookupEnv(hostKey)
  if !ok || host == "" {
    logAndPanic(hostKey)
  }

  port, ok := os.LookupEnv(portKey)
  if !ok || port == “” {
    if _, err := strconv.Atoi(port); err != nil {
      logAndPanic(portKey)
    }
  }

  dbHost, ok := os.LookupEnv(dbHostKey)
  if !ok || dbHost == “” {
    logAndPanic(dbHostKey)
  }

  dbPort, ok := os.LookupEnv(dbPortKey)
  if !ok || dbPort == “” {
    if _, err := strconv.Atoi(dbPort); err != nil {
      logAndPanic(dbPortKey)
    }
  }

  dbName, ok := os.LookupEnv(dbNameKey)
  if !ok || dbName == “” {
    logAndPanic(dbNameKey)
  }

  dbUser, ok := os.LookupEnv(dbUserKey)
  if !ok || dbUser == “” {
    logAndPanic(dbUserKey)
  }

  dbPassword, ok := os.LookupEnv(dbPasswordKey)
  if !ok || dbPassword == “” {
    logAndPanic(dbPasswordKey)
  }

  return Config{
    Host:       host,
    Port:       port,
    DbHost:     dbHost,
    DbPort:     dbPort,
    DbName:     dbName,
    DbUser:     dbUser,
    DbPassword: dbPassword,
  }
}

func logAndPanic(envVar string) {
  log.Println(“ENV variable not set or value not valid: “, envVar)
  panic(envVar)
}

然后相应的修改一下代码引用这些配置的逻辑。

首先修改 services/database/database.go 文件

package database

import (
  “pharos/services/conf”

  “github.com/go-pg/pg/v10”
)

func NewDBOptions(cfg conf.Config) *pg.Options {
  return &pg.Options{
    Addr:     cfg.DbHost + “:” + cfg.DbPort,
    Database: cfg.DbName,
    User:     cfg.DbUser,
    Password: cfg.DbPassword,
  }
}

services/server/server.go 也进行相应的修改

package server

import (
  “pharos/services/conf”
  “pharos/services/database”
  “pharos/services/store”
)

func Start(cfg conf.Config) {
  store.SetDBConnection(database.NewDBOptions(cfg))

  router := setRouter()

  // Start listening and serving requests
  router.Run(“:8080”)
}

main.go 文件

package main

import (
  “pharos/services/conf”
  “pharos/services/server”
)

func main() {
  server.Start(conf.NewConfig())
}

migrations/main.go 文件中还需要进行一项更改。只需导入 pharos/services/conf 包并更改行。

store.SetDBConnection(database.NewDBOptions())
store.SetDBConnection(database.NewDBOptions(conf.NewConfig()))
Enter fullscreen mode

我们现在准备读取配置所需的 ENV 变量。但还缺少一件事。我们需要将这些值提供给 ENV。为此,让我们在名为 .env 的根项目目录中创建新文件:

export PHAROS_HOST=0.0.0.0
export PHAROS_PORT=8080
export PHAROS_DB_HOST=localhost
export PHAROS_DB_PORT=5432
export PHAROS_DB_NAME=pharos
export PHAROS_DB_USER=postgres
export PHAROS_DB_PASSWORD=postgres

上下文的环境变量需要执行 source .env 来改变。

source .env
go run main.go

开发部署的Cli

现在我们有了 .env文件,但是每次项目开始 都要进行一次 source操作 来改变上下问的环境变脸,这样显得很傻,为了更优雅我们还需要创建一系列的shell 脚本,同时开发部署也需要一系列的脚本支持。我们首先在 services/cli创建这个目录,然后 创建一个 cli.go文件。

package cli

import (
  “flag”
  “fmt”
  “os”
)

func usage() {
  fmt.Print(`This program runs Pharos backend server.

Usage:

pharos [arguments]

Supported arguments:

`)
  flag.PrintDefaults()
  os.Exit(1)
}

func Parse() {
  flag.Usage = usage
  env := flag.String(“env”, “dev”, `Sets run environment. Possible values are "dev" and "prod"`)
  flag.Parse()
  fmt.Println(*env)
}

然后修改 main.go 文件来加入引用

package main

import (
  "pharos/services/cli"
  "pharos/services/conf"
  "pharos/services/server"
)

func main() {
  cli.Parse()
  server.Start(conf.NewConfig())
}

现在可以开始编写用于部署和停止我们应用程序的 脚本了。这里不想洗介绍 bash命令的语法,只需要理解就好 具体请参考互联网上的一些教程和指南 我们首先创建一个文件夹 scripts 在跟目录,里面添加第一个脚本。deploy.sh

#! /bin/bash

# default ENV is dev
env=dev

whiletest$# -gt 0; do
  case “$1” in
    -env)
      shift
      iftest$# -gt 0; then
        env=$1
      fi
      # shift
      ;;
    *)
    break
    ;;
  esac
done

cd ../../pharos
source .env
go build -o pharos/pharos pharos/main.go
pharos -env $env &

在上面的脚本中 我们先设置环境为 env=dev 设置成为开发环境。之后我们在为这个脚本传递参数,如果发现 参数我们将把参数传递过去。设置env变量后,我们将目录及切换到项目的根目录,获取 env 变量,然后 我们创建一个文件夹 cmd 并可以把 根目录下的 main.go 文件放到此目录下。运行 go build =o cmd/pharos/pharos cmd/pharos/main.go 这时候我们将创建了一个可执行文件,我们用他来启动我们的服务。构建应用程序是,我们使用 cmd/pharos/pharos -env $env & 启动服务器,它将 env 变量的值作为-env标志传递给我们的服务器。

另外 我们也同时创建一个简单的脚本 stop.sh 放到 scripts/ 文件夹下面。

#! /bin/bash

kill $(pidof pharos)

这个脚本将找到我们 pharos 的进程id,并可以控制结束进程。

在使用脚本前,我们将修改一下相关的权限。

chmod +x deploy.sh
chmod +x stop.sh

现在我们可以控制服务的开始和结束了,scripts/ 下可以执行

./deploy.sh
./stop.sh

七、添加日志记录

日志记录也是大多数 Web 应用程序中非常重要的部分,因为我们通常想知道传入了哪些请求,更重要的是,是否有任何意外错误。因此,正如您可能已经猜到的那样,本节将介绍日志记录,我将向您展示如何设置日志记录,以及如何在开发和生产环境中分离日志记录。现在我们将使用上一节中添加的 -env 标志。

对于日志记录,我们将使用zerolog模块,您可以通过运行 go get github.com/rs/zerolog/log 来获取该模块。

现在我们再创建另一个目录 services/logging 并在其中创建一个 logging.go 文件。

package logging

import (
  "fmt"
  "io"
  "io/ioutil"
  "os"
  "path/filepath"
  "strings"
  "time"

  "github.com/gin-gonic/gin"
  "github.com/rs/zerolog"
  "github.com/rs/zerolog/log"
)

const (
  logsDir = "logs"
  logName = "gin_production.log"
)

var logFilePath = filepath.Join(logsDir, logName)

func SetGinLogToFile() {
  gin.SetMode(gin.ReleaseMode)
  logFile, err := os.Create(logFilePath)
  if err != nil {
    log.Panic().Err(err).Msg("Error opening Gin log file")
  }
  gin.DefaultWriter = io.MultiWriter(logFile)
}

func ConfigureLogger(env string) {
  zerolog.SetGlobalLevel(zerolog.DebugLevel)
  switch env {
  case"dev":
    stdOutWriter := zerolog.ConsoleWriter{Out: os.Stdout, TimeFormat: "15:04:05.000"}
    logger := zerolog.New(stdOutWriter).With().Timestamp().Logger()
    log.Logger = logger
  case"prod":
    createLogDir()
    backupLastLog()
    logFile := openLogFile()
    logFileWriter := zerolog.ConsoleWriter{Out: logFile, NoColor: true, TimeFormat: "15:04:05.000"}
    logger := zerolog.New(logFileWriter).With().Timestamp().Logger()
    log.Logger = logger
  default:
    fmt.Printf("Env not valid: %s\n", env)
    os.Exit(2)
  }
}

func createLogDir() {
  if err := os.Mkdir(logsDir, 0744); err != nil && !os.IsExist(err) {
    log.Fatal().Err(err).Msg("Unable to create logs directory.")
  }
}

func backupLastLog() {
  timeStamp := time.Now().Format("20060201_15_04_05")
  base := strings.TrimSuffix(logName, filepath.Ext(logName))
  bkpLogName := base + "_" + timeStamp + "." + filepath.Ext(logName)
  bkpLogPath := filepath.Join(logsDir, bkpLogName)

  logFile, err := ioutil.ReadFile(logFilePath)
  if err != nil {
    if os.IsNotExist(err) {
      return
    }
    log.Panic().Err(err).Msg(“Error reading log file for backup”)
  }

  if err = ioutil.WriteFile(bkpLogPath, logFile, 0644); err != nil {
    log.Panic().Err(err).Msg(“Error writing backup log file”)
  }
}

func openLogFile() *os.File {
  logFile, err := os.OpenFile(logFilePath, os.O_WRONLY|os.O_CREATE, 0644)
  if err != nil {
    log.Panic().Err(err).Msg(“Error while opening log file”)
  }
  return logFile
}

func curentDir() string {
  path, err := os.Executable()
  if err != nil {
    log.Panic().Err(err).Msg(“Can’t get current directory.”)
  }
  return filepath.Dir(path)
}

然后我们可以更新 services/cli/cli.go 以根据环境配置日志记录,而不是仅仅打印它

package cli

import (
  "flag"
  "fmt"
  “os”

  “pharos/services/logging”
)

func usage() {
  fmt.Print(`This program runs PHAROS backend server.

Usage:

pharos [arguments]

Supported arguments:

`)
  flag.PrintDefaults()
  os.Exit(1)
}

func Parse() {
  flag.Usage = usage
  env := flag.String(“env”, “dev”, `Sets run environment. Possible values are "dev" and "prod"`)
  flag.Parse()
  logging.ConfigureLogger(*env)
  if *env == “prod” {
    logging.SetGinLogToFile()
  }
}

这看起来像很多代码,但它非常简单。首先我们根据环境配置我们的日志。如果 env 是 dev,我们会将所有内容都记录到 stdout,而对于 prod 环境,我们将登录到文件中。登录文件时,我们将首先根据需要创建日志目录并备份以前的日志,因此每次服务器启动时我们都有新的日志。当然,您可以为日志轮换创建不同的逻辑,以更好地满足您的需求。在这种情况下我们需要做的另一件事是告诉 Gin 以发布模式运行,这将减少不必要和干扰的输出,然后还设置默认 Gin writer 写入日志文件。您也可以在 prod case 块中执行此操作,但由于我们实际上有两个不同的记录器(Gin 的附加记录器和我们的 zerolog 记录器),我更倾向于将这两部分代码分开。这只是个人喜好,您可以按照自己的方式进行。有了这个集合,我们现在可以开始记录一些错误。例如,让我们更新 services/conf/conf.go 中的 logAndPanic()函数:

func logAndPanic(envVar string) {
  log.Panic().Str(“envVar”, envVar).Msg(“ENV variable not set or value not valid”)
}

我们可以记录在 services/store/users.go 中生成密钥的时候是否发生错误。

func GenerateSalt() ([]byte, error) {
  salt := make([]byte, 16)
  if _, err := rand.Read(salt); err != nil {
    log.Error().Err(err).Msg(“Unable to create salt”)
    returnnil, err
  }
  return salt, nil
}

八、JWT authentication

身份验证是几乎每个 Web 应用程序中最重要的部分之一。我们必须确保每个用户只能创建、读取、更新和删除其授权的数据。为此,我们将使用 JWT(JSON Web 密钥)。幸运的是,有各种专门用于此的 Golang 模块。本指南中将使用的一个可以在此 GitHub 存储库中找到。当前最新版本是 v3,可以通过运行 go get github.com/cristalhq/jwt/v3 来安装。

由于我们将需要用于生成和验证令牌的密钥,让我们将 export PHAROS_JWT_SECRET=jwtSecret123 添加到我们的 .env文件中。当然,在生产中你会想要使用一些随机生成的长字符串。接下来我们应该做的是在 services/conf/conf.go 中添加新变量。我们将添加常量 jwtSecretKey = "PHAROS_JWT_SECRET" 与我们的其余常量,然后将字符串类型的新字段 JwtSecret添加到配置结构。现在我们可以读取新的 env 变量并将其添加到 NewConfig()函数中:

const (
  hostKey       = "PHAROS_HOST"
  portKey       = "PHAROS_PORT"
  dbHostKey     = "PHAROS_DB_HOST"
  dbPortKey     = "PHAROS_DB_PORT"
  dbNameKey     = "PHAROS_DB_NAME"
  dbUserKey     = "PHAROS_DB_USER"
  dbPasswordKey = "PHAROS_DB_PASSWORD"
  jwtSecretKey  = "PHAROS_JWT_SECRET"
)

type Config struct {
  Host       string
  Port       string
  DbHost     string
  DbPort     string
  DbName     string
  DbUser     string
  DbPassword string
  JwtSecret  string
}

func NewConfig() Config {
  host, ok := os.LookupEnv(hostKey)
  if !ok || host == "" {
    logAndPanic(hostKey)
  }

  port, ok := os.LookupEnv(portKey)
  if !ok || port == "" {
    if _, err := strconv.Atoi(port); err != nil {
      logAndPanic(portKey)
    }
  }

  dbHost, ok := os.LookupEnv(dbHostKey)
  if !ok || dbHost == "" {
    logAndPanic(dbHostKey)
  }

  dbPort, ok := os.LookupEnv(dbPortKey)
  if !ok || dbPort == "" {
    if _, err := strconv.Atoi(dbPort); err != nil {
      logAndPanic(dbPortKey)
    }
  }

  dbName, ok := os.LookupEnv(dbNameKey)
  if !ok || dbName == "" {
    logAndPanic(dbNameKey)
  }

  dbUser, ok := os.LookupEnv(dbUserKey)
  if !ok || dbUser == “” {
    logAndPanic(dbUserKey)
  }

  dbPassword, ok := os.LookupEnv(dbPasswordKey)
  if !ok || dbPassword == “” {
    logAndPanic(dbPasswordKey)
  }

  jwtSecret, ok := os.LookupEnv(jwtSecretKey)
  if !ok || jwtSecret == “” {
    logAndPanic(jwtSecretKey)
  }

  return Config{
    Host:       host,
    Port:       port,
    DbHost:     dbHost,
    DbPort:     dbPort,
    DbName:     dbName,
    DbUser:     dbUser,
    DbPassword: dbPassword,
    JwtSecret:  jwtSecret,
  }
}

我们可以创建一个新的文件 services/server/jwt.go

package server

import (
  “pharos/services/conf”

  “github.com/cristalhq/jwt/v3”
  “github.com/rs/zerolog/log”
)

var (
  jwtSigner   jwt.Signer
  jwtVerifier jwt.Verifier
)

func jwtSetup(conf conf.Config) {
  var err error
  key := []byte(conf.JwtSecret)

  jwtSigner, err = jwt.NewSignerHS(jwt.HS256, key)
  if err != nil {
    log.Panic().Err(err).Msg(“Error creating JWT signer”)
  }

  jwtVerifier, err = jwt.NewVerifierHS(jwt.HS256, key)
  if err != nil {
    log.Panic().Err(err).Msg(“Error creating JWT verifier”)
  }
}

函数 jwtSetup()将只创建稍后将用于身份验证的签名者和验证者。现在我们可以在启动服务器时从 services/server/server/go 调用这个函数:

package server

import (
  “pharos/services/conf”
  “pharos/services/database”
  “pharos/services/store”
)

func Start(cfg conf.Config) {
  jwtSetup(cfg)

  store.SetDBConnection(database.NewDBOptions(cfg))

  router := setRouter()

  // Start listening and serving requests
  router.Run(“:8080”)
}

为了生成密钥,我们将在 services/server/jwt.go 中创建函数:

func generateJWT(user *store.User) string {
  claims := &jwt.RegisteredClaims{
    ID:        fmt.Sprint(user.ID),
    ExpiresAt: jwt.NewNumericDate(time.Now().Add(time.Hour * 24 * 7)),
  }
  builder := jwt.NewBuilder(jwtSigner)
  token, err := builder.Build(claims)
  if err != nil {
    log.Panic().Err(err).Msg(“Error building JWT”)
  }
  return token.String()
}

然后我们将从 services/server/user.go 调用它,而不是我们迄今为止用于测试目的的硬编码字符串:

package server

import (
  “net/http”
  “pharos/services/store”

  “github.com/gin-gonic/gin”
)

func signUp(ctx *gin.Context) {
  user := new(store.User)
  if err := ctx.Bind(user); err != nil {
    ctx.AbortWithStatusJSON(http.StatusBadRequest, gin.H{“error”: err.Error()})
    return
  }
  if err := store.AddUser(user); err != nil {
    ctx.AbortWithStatusJSON(http.StatusBadRequest, gin.H{“error”: err.Error()})
    return
  }
  ctx.JSON(http.StatusOK, gin.H{
    “msg”: “Signed up successfully.”,
    “jwt”: generateJWT(user),
  })
}

func signIn(ctx *gin.Context) {
  user := new(store.User)
  if err := ctx.Bind(user); err != nil {
    ctx.AbortWithStatusJSON(http.StatusBadRequest, gin.H{“error”: err.Error()})
    return
  }
  user, err := store.Authenticate(user.Username, user.Password)
  if err != nil {
    ctx.AbortWithStatusJSON(http.StatusUnauthorized, gin.H{“error”: “Sign in failed.”})
    return
  }

  ctx.JSON(http.StatusOK, gin.H{
    “msg”: “Signed in successfully.”,
    “jwt”: generateJWT(user),
  })
}

让我们通过注册或通过我们的前端登录来测试这一点。打开浏览器开发工具并检查登录或注册响应。您可以看到我们的后端现在生成了随机 JWT:

BF9C0E23-0297-4038-99CC-8F90DDB6EF8F.png

令牌现在在 signIn 和 signUp 处理程序中创建,这意味着我们可以为所有安全路由验证它。为此,我们将首先在 services/server/jwt.go 中实现 verifyJWT()函数。此函数将接收字符串形式的令牌,验证其签名,从声明中提取 ID,如果一切正常,用户 ID 将作为 int 返回:

func verifyJWT(tokenStr string) (int, error) {
  token, err := jwt.Parse([]byte(tokenStr))
  if err != nil {
    log.Error().Err(err).Str(“tokenStr”, tokenStr).Msg(“Error parsing JWT”)
    return0, err
  }

  if err := jwtVerifier.Verify(token.Payload(), token.Signature()); err != nil {
    log.Error().Err(err).Msg(“Error verifying token”)
    return0, err
  }

  var claims jwt.StandardClaims
  if err := json.Unmarshal(token.RawClaims(), &claims); err != nil {
    log.Error().Err(err).Msg(“Error unmarshalling JWT claims”)
    return0, err
  }

  if notExpired := claims.IsValidAt(time.Now()); !notExpired {
    return0, errors.New(“Token expired.”)
  }

  id, err := strconv.Atoi(claims.ID)
  if err != nil {
    log.Error().Err(err).Str(“claims.ID”, claims.ID).Msg(“Error converting claims ID to number”)
    return0, errors.New(“ID in token is not valid”)
  }
  return id, err
}

生成和验证的功能都完成了,到此我们几乎可以编写用于授权的 Gin 中间件了。在此之前,我们将添加根据用户 ID 从数据库中获取用户的函数。在 services/store/users.go 中,添加函数:

func FetchUser(id int) (*User, error) {
  user := new(User)
  user.ID = id
  err := db.Model(user).Returning(“*”).WherePK().Select()
  if err != nil {
    log.Error().Err(err).Msg(“Error fetching user”)
    returnnil, err
  }
  return user, nil
}

现在可以创建一个新文件 services/server/middleware.go

package server

import (
  “net/http”
  “pharos/services/store”
  “strings”

  “github.com/gin-gonic/gin”
)

func authorization(ctx *gin.Context) {
  authHeader := ctx.GetHeader(“Authorization”)
  if authHeader == “” {
    ctx.AbortWithStatusJSON(http.StatusUnauthorized, gin.H{“error”: “Authorization header missing.”})
    return
  }
  headerParts := strings.Split(authHeader, “ “)
  iflen(headerParts) != 2 {
    ctx.AbortWithStatusJSON(http.StatusUnauthorized, gin.H{“error”: “Authorization header format is not valid.”})
    return
  }
  if headerParts[0] != “Bearer” {
    ctx.AbortWithStatusJSON(http.StatusUnauthorized, gin.H{“error”: “Authorization header is missing bearer part.”})
    return
  }
  userID, err := verifyJWT(headerParts[1])
  if err != nil {
    ctx.AbortWithStatusJSON(http.StatusUnauthorized, gin.H{“error”: err.Error()})
    return
  }
  user, err := store.FetchUser(userID)
  if err != nil {
    ctx.AbortWithStatusJSON(http.StatusUnauthorized, gin.H{“error”: err.Error()})
    return
  }
  ctx.Set(“user”, user)
  ctx.Next()
}

授权中间件从授权头中提取令牌。它首先检查标头是否存在,是否为有效格式,然后调用 verifyJWT()函数。如果 JWT 验证通过,则返回用户 ID。从数据库中获取具有该 ID 的用户并将其设置为此上下文的当前用户。

从上下文中获取当前用户是我们经常需要的东西,所以让我们将其提取到辅助函数中:

func currentUser(ctx *gin.Context) (*store.User, error) {
  var err error
  _user, exists := ctx.Get(“user”)
  if !exists {
    err = errors.New(“Current context user not set”)
    log.Error().Err(err).Msg(“”)
    returnnil, err
  }
  user, ok := _user.(*store.User)
  if !ok {
    err = errors.New(“Context user is not valid type”)
    log.Error().Err(err).Msg(“”)
    returnnil, err
  }
  return user, nil
}

首先,我们检查是否为此上下文设置了用户。如果不是,则返回错误。由于 ctx.Get()返回接口,我们必须检查 value 是否为 *store.User 类型。如果不是,则返回错误。当两个检查都通过时,当前用户从上下文返回。

九、增加发帖功能

身份验证到位后,是时候开始使用它了。我们需要身份验证才能创建、阅读、更新和删除用户的博客文章。让我们从添加新的数据库迁移开始,这将创建包含列的所需数据表。创建新的迁移文件 migrations/2_addPostsTable.go:·

package main

import (
  “fmt”

  “github.com/go-pg/migrations/v8”
)

func init() {
  migrations.MustRegisterTx(func(db migrations.DB) error {
    fmt.Println(“creating table posts…”)
    _, err := db.Exec(`CREATE TABLE posts(
      id SERIAL PRIMARY KEY,
      title TEXT NOT NULL,
      content TEXT NOT NULL,
      created_at TIMESTAMPTZ NOT NULL DEFAULT NOW(),
      modified_at TIMESTAMPTZ NOT NULL DEFAULT NOW(),
      user_id INT REFERENCES users ON DELETE CASCADE
    )`)
    return err
  }, func(db migrations.DB) error {
    fmt.Println(“dropping table posts…”)
    _, err := db.Exec(`DROP TABLE posts`)
    return err
  })
}

然后运行 migrations

cd migrations/
go run *.go up

现在我们创建结构来保存帖子数据。我们还将为标题和内容添加字段约束。添加新文件 services/store/posts.go

package store

import “time”

type Post struct {
  ID         int
  Title      string`binding:"required,min=3,max=50"`
  Content    string`binding:"required,min=5,max=5000"`
  CreatedAt  time.Time
  ModifiedAt time.Time
  UserID     int`json:"-"`
}

用户可以有多个博客文章,因此我们必须添加与用户结构的多关系。在 services/store/users.go 中,编辑 User 结构:

type User struct {
  ID             int
  Username       string`binding:"required,min=5,max=30"`
  Password       string`pg:"-" binding:"required,min=7,max=32"`
  HashedPassword []byte`json:"-"`
  Salt           []byte`json:"-"`
  CreatedAt      time.Time
  ModifiedAt     time.Time
  Posts          []*Post `json:"-" pg:"fk:user_id,rel:has-many,on_delete:CASCADE"`
}

可以在数据库中插入新帖子条目的功能将在 services/store/posts.go中实现:

func AddPost(user *User, post *Post) error {
  post.UserID = user.ID
  _, err := db.Model(post).Returning(“*”).Insert()
  if err != nil {
    log.Error().Err(err).Msg(“Error inserting new post”)
  }
  return err
}

要创建帖子,我们将添加新的处理程序,它将调用上面的函数。创建新文件 services/server/post.go

package server

import (
  “net/http”
  “pharos/services/store”

  “github.com/gin-gonic/gin”
)

func createPost(ctx *gin.Context) {
  post := new(store.Post)
  if err := ctx.Bind(post); err != nil {
    ctx.AbortWithStatusJSON(http.StatusBadRequest, gin.H{“error”: err.Error()})
    return
  }
  user, err := currentUser(ctx)
  if err != nil {
    ctx.AbortWithStatusJSON(http.StatusInternalServerError, gin.H{“error”: err.Error()})
    return
  }
  if err := store.AddPost(user, post); err != nil {
    ctx.AbortWithStatusJSON(http.StatusBadRequest, gin.H{“error”: err.Error()})
    return
  }
  ctx.JSON(http.StatusOK, gin.H{
    “msg”:  “Post created successfully.”,
    “data”: post,
  })
}

帖子创建处理程序已准备就绪,让我们为创建帖子添加新的受保护路由。在 services/server/router.go 中,我们将创建新组,该组将使用我们在前一章中实现的授权中间件。我们将使用 HTTP 方法 POST 添加路由 /posts 到该受保护组:

func setRouter() *gin.Engine {
  // Creates default gin router with Logger and Recovery middleware already attached
  router := gin.Default()

  // Enables automatic redirection if the current route can’t be matched but a
  // handler for the path with (without) the trailing slash exists.
  router.RedirectTrailingSlash = true

  // Create API route group
  api := router.Group(“/api”)
  {
    api.POST(“/signup”, signUp)
    api.POST(“/signin”, signIn)
  }

  authorized := api.Group(“/“)
  authorized.Use(authorization)
  {
    authorized.POST(“/posts”, createPost)
  }

  router.NoRoute(func(ctx *gin.Context) { ctx.JSON(http.StatusNotFound, gin.H{}) })

  return router
}

所有其他 CRUD(创建、读取、更新、删除)方法的配方都是相同的:

  1. 实现与数据库通信以执行所需操作的功能
  2. 实现 Gin 处理程序,它将使用步骤 1 中的函数
  3. 将带有处理程序的路由添加到路由器

我们已经涵盖了创建部分,所以让我们继续下一个方法,阅读。我们将实现从 services/store/posts.go 数据库中获取所有用户帖子的函数:

func FetchUserPosts(user *User) error {
  err := db.Model(user).
    Relation(“Posts”, func(q *orm.Query) (*orm.Query, error) {
      return q.Order(“id ASC”), nil
    }).
    Select()
  if err != nil {
    log.Error().Err(err).Msg(“Error fetching user’s posts”)
  }
  return err
}

添加一个文件 services/server/post.go :

func indexPosts(ctx *gin.Context) {
  user, err := currentUser(ctx)
  if err != nil {
    ctx.AbortWithStatusJSON(http.StatusInternalServerError, gin.H{“error”: err.Error()})
    return
  }
  if err := store.FetchUserPosts(user); err != nil {
    ctx.AbortWithStatusJSON(http.StatusInternalServerError, gin.H{“error”: err.Error()})
    return
  }
  ctx.JSON(http.StatusOK, gin.H{
    “msg”:  “Posts fetched successfully.”,
    “data”: user.Posts,
  })
}

要更新帖子,请将这两个函数添加到 services/store/posts.go

func FetchPost(id int) (*Post, error) {
  post := new(Post)
  post.ID = id
  err := db.Model(post).WherePK().Select()
  if err != nil {
    log.Error().Err(err).Msg(“Error fetching post”)
    returnnil, err
  }
  return post, nil
}

func UpdatePost(post *Post) error {
  _, err := db.Model(post).WherePK().UpdateNotZero()
  if err != nil {
    log.Error().Err(err).Msg(“Error updating post”)
  }
  return err
}

修改 services/server/post.go

func updatePost(ctx *gin.Context) {
  jsonPost := new(store.Post)
  if err := ctx.Bind(jsonPost); err != nil {
    ctx.AbortWithStatusJSON(http.StatusBadRequest, gin.H{“error”: err.Error()})
    return
  }
  user, err := currentUser(ctx)
  if err != nil {
    ctx.AbortWithStatusJSON(http.StatusInternalServerError, gin.H{“error”: err.Error()})
    return
  }
  dbPost, err := store.FetchPost(jsonPost.ID)
  if err != nil {
    ctx.AbortWithStatusJSON(http.StatusInternalServerError, gin.H{“error”: err.Error()})
    return
  }
  if user.ID != dbPost.UserID {
    ctx.AbortWithStatusJSON(http.StatusForbidden, gin.H{“error”: “Not authorized.”})
    return
  }
  jsonPost.ModifiedAt = time.Now()
  if err := store.UpdatePost(jsonPost); err != nil {
    ctx.AbortWithStatusJSON(http.StatusInternalServerError, gin.H{“error”: err.Error()})
    return
  }
  ctx.JSON(http.StatusOK, gin.H{
    “msg”:  “Post updated successfully.”,
    “data”: jsonPost,
  })
}

还有一个删除相关 services/store/posts.go

func DeletePost(post *Post) error {
  _, err := db.Model(post).WherePK().Delete()
  if err != nil {
    log.Error().Err(err).Msg(“Error deleting post”)
  }
  return err
}

services/server/post.go

func deletePost(ctx *gin.Context) {
  paramID := ctx.Param(“id”)
  id, err := strconv.Atoi(paramID)
  if err != nil {
    ctx.AbortWithStatusJSON(http.StatusBadRequest, gin.H{“error”: “Not valid ID.”})
    return
  }
  user, err := currentUser(ctx)
  if err != nil {
    ctx.AbortWithStatusJSON(http.StatusInternalServerError, gin.H{“error”: err.Error()})
    return
  }
  post, err := store.FetchPost(id)
  if err != nil {
    ctx.AbortWithStatusJSON(http.StatusInternalServerError, gin.H{“error”: err.Error()})
    return
  }
  if user.ID != post.UserID {
    ctx.AbortWithStatusJSON(http.StatusForbidden, gin.H{“error”: “Not authorized.”})
    return
  }
  if err := store.DeletePost(post); err != nil {
    ctx.AbortWithStatusJSON(http.StatusInternalServerError, gin.H{“error”: err.Error()})
    return
  }
  ctx.JSON(http.StatusOK, gin.H{“msg”: “Post deleted successfully.”})
}

您可以在这里注意到的一项新事物是 paramID := ctx.Param("id")。我们正在使用它从 URL 路径中提取 ID 参数。

让我们将所有这些处理程序添加到路由器:

func setRouter() *gin.Engine {
  // Creates default gin router with Logger and Recovery middleware already attached
  router := gin.Default()

  // Enables automatic redirection if the current route can’t be matched but a
  // handler for the path with (without) the trailing slash exists.
  router.RedirectTrailingSlash = true

  // Create API route group
  api := router.Group(“/api”)
  {
    api.POST(“/signup”, signUp)
    api.POST(“/signin”, signIn)
  }

  authorized := api.Group(“/“)
  authorized.Use(authorization)
  {
    authorized.GET(“/posts”, indexPosts)
    authorized.POST(“/posts”, createPost)
    authorized.PUT(“/posts”, updatePost)
    authorized.DELETE(“/posts/:id”, deletePost)
  }

  router.NoRoute(func(ctx *gin.Context) { ctx.JSON(http.StatusNotFound, gin.H{}) })

  return router
}

如果用户还没有帖子,User.Posts 字段默认为 nil。这使前端的事情变得复杂,因为它必须检查 nil 值,所以最好使用空切片。为此,我们将使用 AfterSelectHook,它会在每次为 User 执行 Select() 后执行。该钩子将被添加到 services/store/users.go

var _ pg.AfterSelectHook = (*User)(nil)

func (user *User) AfterSelect(ctx context.Context) error {
  if user.Posts == nil {
    user.Posts = []*Post{}
  }
  returnnil
}

十、错误异常处理

如果您尝试使用太短的密码创建新帐户,您将收到错误 Key: 'User.Password' Error:Field validation for 'Password' failed on the 'min' 标签。这不是真正好的用户体验,因此应该对其进行更改以获得更好的用户体验。让我们看看如何将其转换为我们自己的自定义错误消息。为此,我们将在 services/server/middleware.go 文件中创建新的 Gin 处理程序函数:

func customErrors(ctx *gin.Context) {
  ctx.Next()
  iflen(ctx.Errors) > 0 {
    for _, err := range ctx.Errors {
      // Check error type
      switch err.Type {
      case gin.ErrorTypePublic:
        // Show public errors only if nothing has been written yet
        if !ctx.Writer.Written() {
          ctx.AbortWithStatusJSON(ctx.Writer.Status(), gin.H{“error”: err.Error()})
        }
      case gin.ErrorTypeBind:
        errMap := make(map[string]string)
        if errs, ok := err.Err.(validator.ValidationErrors); ok {
          for _, fieldErr := range []validator.FieldError(errs) {
            errMap[fieldErr.Field()] = customValidationError(fieldErr)
          }
        }

        status := http.StatusBadRequest
        // Preserve current status
        if ctx.Writer.Status() != http.StatusOK {
          status = ctx.Writer.Status()
        }
        ctx.AbortWithStatusJSON(status, gin.H{“error”: errMap})
      default:
        // Log other errors
        log.Error().Err(err.Err).Msg(“Other error”)
      }
    }

    // If there was no public or bind error, display default 500 message
    if !ctx.Writer.Written() {
      ctx.AbortWithStatusJSON(http.StatusInternalServerError, gin.H{“error”: InternalServerError})
    }
  }
}

func customValidationError(err validator.FieldError) string {
  switch err.Tag() {
  case “required”:
    return fmt.Sprintf(“%s is required.”, err.Field())
  case “min”:
    return fmt.Sprintf(“%s must be longer than or equal %s characters.”, err.Field(), err.Param())
  case “max”:
    return fmt.Sprintf(“%s cannot be longer than %s characters.”, err.Field(), err.Param())
  default:
    return err.Error()
  }
}

internal/server/server.go 中定义常量 InternalServerError


const InternalServerError = “Something went wrong!”

让我们在 services/server/router.go 中使用新的 Gin 中间件:

func setRouter() *gin.Engine {
  // Creates default gin router with Logger and Recovery middleware already attached
  router := gin.Default()

  // Enables automatic redirection if the current route can’t be matched but a
  // handler for the path with (without) the trailing slash exists.
  router.RedirectTrailingSlash = true

  // Create API route group
  api := router.Group(“/api”)
  api.Use(customErrors)
  {
    api.POST(“/signup”, gin.Bind(store.User{}), signUp)
    api.POST(“/signin”, gin.Bind(store.User{}), signIn)
  }

  authorized := api.Group(“/“)
  authorized.Use(authorization)
  {
    authorized.GET(“/posts”, indexPosts)
    authorized.POST(“/posts”, createPost)
    authorized.PUT(“/posts”, updatePost)
    authorized.DELETE(“/posts/:id”, deletePost)
  }

  router.NoRoute(func(ctx *gin.Context) { ctx.JSON(http.StatusNotFound, gin.H{}) })

  return router
}

我们现在在 api 组中使用 customErrors 中间件。但这并不是唯一的变化。注意登录和注册的更新路由:

api.POST(“/signup”, gin.Bind(store.User{}), signUp)
api.POST(“/signin”, gin.Bind(store.User{}), signIn)

通过这些更改,我们甚至会在点击 signUp 和 signIn 处理程序之前尝试绑定请求数据,这意味着只有在表单验证通过时才会到达处理程序。通过这样的设置,处理程序不需要考虑绑定错误,因为如果到达处理程序就没有绑定错误。考虑到这一点,让我们更新这两个处理程序:

func signUp(ctx *gin.Context) {
  user := ctx.MustGet(gin.BindKey).(*store.User)
  if err := store.AddUser(user); err != nil {
    ctx.AbortWithStatusJSON(http.StatusBadRequest, gin.H{“error”: err.Error()})
    return
  }
  ctx.JSON(http.StatusOK, gin.H{
    “msg”: “Signed up successfully.”,
    “jwt”: generateJWT(user),
  })
}

func signIn(ctx *gin.Context) {
  user := ctx.MustGet(gin.BindKey).(*store.User)
  user, err := store.Authenticate(user.Username, user.Password)
  if err != nil {
    ctx.AbortWithStatusJSON(http.StatusUnauthorized, gin.H{“error”: “Sign in failed.”})
    return
  }

  ctx.JSON(http.StatusOK, gin.H{
    “msg”: “Signed in successfully.”,
    “jwt”: generateJWT(user),
  })
}

我们的处理程序现在简单得多,它们只处理数据库错误。如果您再次尝试使用太短的用户名和密码创建帐户,您将看到更具可读性和描述性的错误:

上面提到了页面登陆相关的错误,如果数据库发生错误我们也呀优雅的处理,您尝试使用现有用户名创建帐户,ERROR #23505 duplicate key value violates unique constraint “users_username_key”。不幸的是,这里没有涉及验证器,pg 模块将大部分错误返回为 map[byte]string,因此这可能有点棘手。

一种方法是通过执行数据库查询手动检查每个错误情况。例如,要检查具有给定用户名的用户是否已存在于数据库中,我们可以在尝试创建新用户之前执行此操作:

func AddUser(user *User) error {
  err = db.Model(user).Where(“username = ?”, user.Username).Select()
  if err != nil {
    return errors.New(“Username already exists.”)
  }
  …
}

问题是这会变得非常乏味。需要针对与数据库通信的每个函数中的每个错误情况执行此操作。最重要的是,我们不必要地增加了数据库查询。在这个简单的例子中,对于每个成功的用户创建,现在将有 2 个数据库查询,而不是 1 个。还有一种方法,那就是尝试做一次查询,如果发生错误再解析。这是棘手的部分,因为我们需要使用正则表达式处理每种错误类型,以提取创建更用户友好的自定义错误消息所需的相关数据。那么让我们开始吧。如前所述,pg 错误主要是 map[byte]string 类型,因此当您尝试使用现有用户名创建用户帐户时,对于此特定错误,您将在下图中获得Map对象:

B5566213-AA61-4F8F-8116-C7CC3210C971.png

为了提取相关数据,我们将使用字段 82 和 110。错误类型将从字段 82 中读取,我们将从字段 110 中提取列名。让我们将这些函数添加到 services/store/store.go

func dbError(_err interface{}) error {
  if _err == nil {
    returnnil
  }
  switch _err.(type) {
  case pg.Error:
    err := _err.(pg.Error)
    switch err.Field(82) {
    case “_bt_check_unique”:
      return errors.New(extractColumnName(err.Field(110)) + “ already exists.”)
    }
  case error:
    err := _err.(error)
    switch err.Error() {
    case “pg: no rows in result set”:
      return errors.New(“Not found.”)
    }
    return err
  }
  return errors.New(fmt.Sprint(_err))
}

func extractColumnName(text string) string {
  reg := regexp.MustCompile(`.+_(.+)_.+`)
  if reg.MatchString(text) {
    return strings.Title(reg.FindStringSubmatch(text)[1])
  }
  return “Unknown”
}

有了这个,我们可以从 services/store/users.go 调用这个 dbError()函数:

func AddUser(user *User) error {
  …

  _, err = db.Model(user).Returning(“*”).Insert()
  if err != nil {
    log.Error().Err(err).Msg(“Error inserting new user”)
    return dbError(err)
  }
  returnnil
}

如果我们使用已有的用户名,就可以提示一个更优雅的提示了。

另外还需要优雅的是关闭服务。我们要修改一下 services/server/server.go

package server

import (
  “context”
  “errors”
  “net/http”
  “os”
  “os/signal”
  “pharos/services/conf”
  “pharos/services/database”
  “pharos/services/store”
  “syscall”
  “time”

  “github.com/rs/zerolog/log”
)

const InternalServerError = “Something went wrong!”

func Start(cfg conf.Config) {
  jwtSetup(cfg)

  store.SetDBConnection(database.NewDBOptions(cfg))

  router := setRouter()

  server := &http.Server{
    Addr:    cfg.Host + “:” + cfg.Port,
    Handler: router,
  }

  // Initializing the server in a goroutine so that
  // it won’t block the graceful shutdown handling below
  gofunc() {
    if err := server.ListenAndServe(); err != nil && errors.Is(err, http.ErrServerClosed) {
      log.Error().Err(err).Msg(“Server ListenAndServe error”)
    }
  }()

  // Wait for interrupt signal to gracefully shutdown the server with
  // a timeout of 5 seconds.
  quit := make(chan os.Signal)
  // kill (no param) default send syscall.SIGTERM
  // kill -2 is syscall.SIGINT
  // kill -9 is syscall.SIGKILL but can’t be catch, so don’t need add it
  signal.Notify(quit, syscall.SIGINT, syscall.SIGTERM)
  <-quit
  log.Info().Msg(“Shutting down server…”)

  // The context is used to inform the server it has 5 seconds to finish
  // the request it is currently handling
  ctx, cancel := context.WithTimeout(context.Background(), 5*time.Second)
  defer cancel()

  if err := server.Shutdown(ctx); err != nil {
    log.Fatal().Err(err).Msg(“Server forced to shutdown”)
  }

  log.Info().Msg(“Server exiting.”)
}

十一、测试

编写单元和集成测试是软件开发的重要组成部分,在开始编写测试相关之前需要确保一些基础的应用问题,例如,主要要做的是创建测试数据库。这将通过使用已经创建的开发数据库模式来完成。

我们将从创建新的测试配置开始。将下面的函数添加到 services/conf/conf.go

func NewTestConfig() Config {
  testConfig := NewConfig()
  testConfig.DbName = testConfig.DbName + "_test"
  return testConfig
}

这将创建与通常配置相同的新配置,但将 _test后缀附加到数据库名称。请参考之前添加数据库的例子在数据库中增加新的数据库,测试数据库将被命名为 pharos_test

DROP DATABASE IF EXISTS pharos_test;
CREATE DATABASE pharos_test WITH TEMPLATE pharos;

创建一个叫 pharos_test 的数据库,每次更改开放数据库时候都需要执行此操作。

每次测试用例都必须独立于其他用例,因此我嗯应该为每次测试用例使用新的数据库,所以,将在每个测试用例开始时创建和调用充值的数据库函数,在这个函数中我们将重置所有表名,清除所有表,重置它的计数器,确保所有ID排序从 1 开始,我们可以将函数添加到 services/store/store.go

func ResetTestDatabase() {
  // Connect to test database
  SetDBConnection(database.NewDBOptions(conf.NewTestConfig()))

  // Empty all tables and restart sequence counters
  tables := []string{“users”, “posts”}
  for _, table := range tables {
    _, err := db.Exec(fmt.Sprintf(“DELETE FROM %s;”, table))
    if err != nil {
      log.Panic().Err(err).Str(“table”, table).Msg(“Error clearing test database”)
    }

    _, err = db.Exec(fmt.Sprintf(“ALTER SEQUENCE %s_id_seq RESTART;”, table))
  }
}

在大多数测试中我们需要做的一件事是设置测试环境并创建新用户。我们不想在每个测试用例中重复这一点。让我们创建文件 services/store/main_test.go 并添加辅助函数:

package store

import (
  “pharos/services/conf”
  “pharos/services/store”

  “github.com/gin-gonic/gin”
)

func testSetup() *gin.Engine {
  gin.SetMode(gin.TestMode)
  store.ResetTestDatabase()
  cfg := conf.NewConfig(“dev”)
  jwtSetup(cfg)
  return setRouter(cfg)
}

func addTestUser() (*User, error) {
  user := &User{
    Username: “batman”,
    Password: “secret123”,
  }
  err := AddUser(user)
  return user, err
}

准备工作完成,现在我们可以开始添加测试了。让我们创建新文件 services/store/users_test.go 并创建第一个测试:

package store

import (
  “testing”

  “github.com/stretchr/testify/assert”
)

func TestAddUser(t *testing.T) {
  testSetup()
  user, err := addTestUser()
  assert.NoError(t, err)
  assert.Equal(t, 1, user.ID)
  assert.NotEmpty(t, user.Salt)
  assert.NotEmpty(t, user.HashedPassword)
}

我们可以为用户帐户创建添加的另一个测试是当用户尝试使用现有用户名创建帐户时:

func TestAddUserWithExistingUsername(t *testing.T) {
  testSetup()
  user, err := addTestUser()
  assert.NoError(t, err)
  assert.Equal(t, 1, user.ID)

  user, err = addTestUser()
  assert.Error(t, err)
  assert.Equal(t, “Username already exists.”, err.Error())
}

为了测试 Authenticate()函数,我们将创建 3 个测试:成功的身份验证、使用无效用户名进行身份验证和使用无效密码进行身份验证:

func TestAuthenticateUser(t *testing.T) {
  testSetup()
  user, err := addTestUser()
  assert.NoError(t, err)

  authUser, err := Authenticate(user.Username, user.Password)
  assert.NoError(t, err)
  assert.Equal(t, user.ID, authUser.ID)
  assert.Equal(t, user.Username, authUser.Username)
  assert.Equal(t, user.Salt, authUser.Salt)
  assert.Equal(t, user.HashedPassword, authUser.HashedPassword)
  assert.Empty(t, authUser.Password)
}

func TestAuthenticateUserInvalidUsername(t *testing.T) {
  testSetup()
  user, err := addTestUser()
  assert.NoError(t, err)

  authUser, err := Authenticate(“invalid”, user.Password)
  assert.Error(t, err)
  assert.Nil(t, authUser)
}

func TestAuthenticateUserInvalidPassword(t *testing.T) {
  testSetup()
  user, err := addTestUser()
  assert.NoError(t, err)

  authUser, err := Authenticate(user.Username, “invalid”)
  assert.Error(t, err)
  assert.Nil(t, authUser)
}

最后,我们将使用 2 个测试来测试 FetchUser() 函数:成功获取和获取不存在的用户:

func TestFetchUser(t *testing.T) {
  testSetup()
  user, err := addTestUser()
  assert.NoError(t, err)

  fetchedUser, err := FetchUser(user.ID)
  assert.NoError(t, err)
  assert.Equal(t, user.ID, fetchedUser.ID)
  assert.Equal(t, user.Username, fetchedUser.Username)
  assert.Empty(t, fetchedUser.Password)
  assert.Equal(t, user.Salt, fetchedUser.Salt)
  assert.Equal(t, user.HashedPassword, fetchedUser.HashedPassword)
}

func TestFetchNotExistingUser(t *testing.T) {
  testSetup()

  fetchedUser, err := FetchUser(1)
  assert.Error(t, err)
  assert.Nil(t, fetchedUser)
  assert.Equal(t, “Not found.”, err.Error())
}

上面的测试函数只测试数据库通信,但我们的路由器和处理程序在这里没有测试。为此,我们将需要另一组测试。首先,我们应该创建更多的辅助函数。我们将创建新文件 services/server/main_test.go

package server

import (
  "bytes"
  "encoding/json"
  "net/http"
  "net/http/httptest"
  "pharos/services/store"
  "strings"

  "github.com/gin-gonic/gin"
  "github.com/rs/zerolog/log"
)

func testSetup() *gin.Engine {
  gin.SetMode(gin.TestMode)
  store.ResetTestDatabase()
  jwtSetup()
  return setRouter()
}

func userJSON(user store.User) string {
  body, err := json.Marshal(map[string]interface{}{
    “Username”: user.Username,
    “Password”: user.Password,
  })
  if err != nil {
    log.Panic().Err(err).Msg(“Error marshalling JSON body.”)
  }
  returnstring(body)
}

func jsonRes(body *bytes.Buffer) map[string]interface{} {
  jsonValue := &map[string]interface{}{}
  err := json.Unmarshal(body.Bytes(), jsonValue)
  if err != nil {
    log.Panic().Err(err).Msg(“Error unmarshalling JSON body.”)
  }
  return *jsonValue
}

func performRequest(router *gin.Engine, method, path, body string) *httptest.ResponseRecorder {
  req, err := http.NewRequest(method, path, strings.NewReader(body))
  if err != nil {
    log.Panic().Err(err).Msg(“Error creating new request”)
  }
  rec := httptest.NewRecorder()
  req.Header.Add(“Content-Type”, “application/json”)
  router.ServeHTTP(rec, req)
  return rec
}

除了最后一个,performRequest(),这些函数中的大多数都不是什么新鲜事。在该函数中,我们使用 http 包创建新请求,并使用 httptest 包创建新记录器。我们还需要将值为 application/jsonContent-Type 标头添加到我们的测试请求中。我们现在准备使用传递的路由器来处理该测试请求,并使用记录器记录响应。现在让我们看看如何实际使用这些函数。创建新文件 services/server/user_test.go

package server

import (
  “net/http”
  “pharos/services/store”
  “testing”

  “github.com/stretchr/testify/assert”
)

func TestSignUp(t *testing.T) {
  router := testSetup()

  body := userJSON(store.User{
    Username: “batman”,
    Password: “secret123”,
  })
  rec := performRequest(router, “POST”, “/api/signup”, body)

  assert.Equal(t, http.StatusOK, rec.Code)
  assert.Equal(t, “Signed up successfully.”, jsonRes(rec.Body)[“msg”])
  assert.NotEmpty(t, jsonRes(rec.Body)[“jwt”])
}

需要注意的是测试用例是按顺序运行的,没有并行性。如果同时运行,它们可能会相互影响,因为对于每个测试用例,数据库都是空的。如果您的机器有多个内核,Go 默认使用多个 goroutine 来运行测试。为了确保只使用了 1 个 goroutine,请添加 -p 1 选项。这意味着您应该使用以下命令运行测试:

go test -p 1 ./internal/…

十二、部署

我们的服务器已经完成,几乎可以部署了,这将使用 Docker 完成。请注意,我说它几乎准备好了,所以让我们看看缺少什么。一直以来,我们都使用 React 开发服务器,它监听 8181 端口并将所有请求重定向到我们的后端 8080 端口。这对开发很有意义,因为它可以更轻松地同时开发前端和后端,以及调试前端反应应用程序。但是在生产中我们不需要它,只运行我们的后端服务器并将静态前端文件提供给客户端更有意义。因此,我们不会使用 npm start 命令启动 React 开发服务器,而是使用 app/ 目录中的命令 npm run build 为生产构建优化的前端文件。这将创建新目录 app/build/ 以及生产所需的所有文件。现在我们还必须指示我们的后端在哪里可以找到这些文件以便能够为它们提供服务。这只需使用命令 router.Use(static.Serve("/", static.LocalFile("./app/build", true))) 即可完成。当然,我们希望只有在 prod 环境中启动服务器时才这样做,因此我们需要稍微更新一些文件。

首先,我们将更新 services/cli/cli.go 中的 Parse() 函数以将环境值作为字符串返回:

func Parse() string {
  flag.Usage = usage
  env := flag.String(“env”, “dev”, `Sets run environment. Possible values are "dev" and "prod"`)
  flag.Parse()
  logging.ConfigureLogger(*env)
  if *env == “prod” {
    logging.SetGinLogToFile()
  }
  return *env
}

然后我们将更新 Config struct NewConfig() 函数以能够接收和设置环境值:

pe Config struct {
  Host       string
  Port       string
  DbHost     string
  DbPort     string
  DbName     string
  DbUser     string
  DbPassword string
  JwtSecret  string
  Env        string
}

func NewConfig(env string) Config {
  …
  return Config{
    Host:       host,
    Port:       port,
    DbHost:     dbHost,
    DbPort:     dbPort,
    DbName:     dbName,
    DbUser:     dbUser,
    DbPassword: dbPassword,
    JwtSecret:  jwtSecret,
    Env:        env,
  }
}

现在我们可以更新 services/cli/main.go以接收来自 CLI 的 env 值,并将其发送到将用于启动服务器的新配置创建:

func main() {
  env := cli.Parse()
  server.Start(conf.NewConfig(env))
}

接下来我们要做的是更新路由器以能够接收配置参数,并将其设置为在生产模式下启动时提供静态文件:

package server

import (
  "net/http"
  “pharos/services/conf”
  “pharos/services/store”

  “github.com/gin-contrib/static”
  “github.com/gin-gonic/gin”
)

func setRouter(cfg conf.Config) *gin.Engine {
  // Creates default gin router with Logger and Recovery middleware already attached
  router := gin.Default()

  // Enables automatic redirection if the current route can’t be matched but a
  // handler for the path with (without) the trailing slash exists.
  router.RedirectTrailingSlash = true

  // Serve static files to frontend if server is started in production environment
  if cfg.Env == “prod” {
    router.Use(static.Serve(“/“, static.LocalFile(“./app/build”, true)))
  }

  // Create API route group
  api := router.Group(“/api”)
  api.Use(customErrors)
  {
    api.POST(“/signup”, gin.Bind(store.User{}), signUp)
    api.POST(“/signin”, gin.Bind(store.User{}), signIn)
  }

  authorized := api.Group(“/“)
  authorized.Use(authorization)
  {
    authorized.GET(“/posts”, indexPosts)
    authorized.POST(“/posts”, gin.Bind(store.Post{}), createPost)
    authorized.PUT(“/posts”, gin.Bind(store.Post{}), updatePost)
    authorized.DELETE(“/posts/:id”, deletePost)
  }

  router.NoRoute(func(ctx *gin.Context) { ctx.JSON(http.StatusNotFound, gin.H{}) })

  return router
}

需要更新的最后一行在 migrations/main.go 文件中

store.SetDBConnection(database.NewDBOptions(conf.NewConfig()))

改为

store.SetDBConnection(database.NewDBOptions(conf.NewConfig(“dev”)))

这还没有完成。您还必须更新所有使用配置和路由器设置的测试。

现在一切准备就绪,可以进行 Docker 部署。Docker 不在本指南的范围内,因此我不会详细介绍 Dockerfile.dockerignoredocker-compose.yml 内容。

首先我们将在项目根目录中创建 .dockerignore 文件:

# This file
.dockerignore

# Git files
.git/
.gitignore

# VS Code config dir
.vscode/

# Docker configuration files
docker/

# Assets dependencies and built files
app/build/
app/node_modules/

# Log files
logs/

# Built binary
cmd/pharos/pharos

# ENV file
.env

# Readme file
README.md

现在用两个文件 Dockerfiledocker-compose.yml 创建新目录 docker/Dockerfile 的内容将是:

FROM node:16 AS frontendBuilder

# set app work dir
WORKDIR /pharos

# copy assets files to the container
COPY app/ .

# set app/ as work dir to build frontend static files
WORKDIR /pharos/app
RUN npm install
RUN npm run build

FROM golang:1.16.3 AS backendBuilder

# set app work dir
WORKDIR /go/src/pharos

# copy all files to the container
COPY . .

# build app executable
RUN CGO_ENABLED=0 GOOS=linux go build -o cmd/pharos/pharos cmd/pharos/main.go

# build migrations executable
RUN CGO_ENABLED=0 GOOS=linux go build -o migrations/migrations migrations/*.go

FROM alpine:3.14

# Create a group and user deploy
RUN addgroup -S deploy && adduser -S deploy -G deploy

ARG ROOT_DIR=/home/deploy/pharos

WORKDIR ${ROOT_DIR}

RUN chown deploy:deploy ${ROOT_DIR}

# copy static assets file from frontend build
COPY —from=frontendBuilder —chown=deploy:deploy /pharos/build ./app/build

# copy app and migrations executables from backend builder
COPY —from=backendBuilder —chown=deploy:deploy /go/src/pharos/migrations/migrations ./migrations/
COPY —from=backendBuilder —chown=deploy:deploy /go/src/pharos/cmd/pharos/pharos .

# set user deploy as current user
USER deploy

# start app
CMD [ “./pharos”, “-env”, “prod” ]

docker-compose.yml 的内容是:

version: “3”
services:
  pharos:
    image: kramat/pharos
    env_file:
      - ../.env
    environment:
      PHAROS_DB_HOST: db
    depends_on:
      - db
    ports:
      - ${PHAROS_PORT}:${PHAROS_PORT}
  db:
    image: postgres
    environment:
      POSTGRES_USER: ${PHAROS_DB_USER}
      POSTGRES_PASSWORD: ${PHAROS_DB_PASSWORD}
      POSTGRES_DB: ${PHAROS_DB_NAME}
    ports:
      - ${PHAROS_DB_PORT}:${PHAROS_DB_PORT}
    volumes:
      - postgresql:/var/lib/postgresql/pharos
      - postgresql_data:/var/lib/postgresql/pharos/data
volumes:
  postgresql: {}
  postgresql_data: {}

Docker 部署所需的所有文件现已准备就绪,让我们看看如何构建 Docker 镜像并部署它。首先,我们将从官方 Docker 容器存储库中拉取 postgres 镜像:

docker pull postgres

下一步是构建 pharos 镜像。在项目根目录中运行(使用您自己的 docker ID 更改 DOCKER_ID):

docker build -t DOCKER_ID/pharos -f docker/Dockerfile .

要使用资源创建 pharos 和 db 容器,请运行:

cd docker/
docker-compose up -d

这将启动两个容器,您可以通过运行 docker ps 检查它们的状态。最后,我们需要运行迁移。通过运行在 pharos 容器中打开 shell:

docker-compose run —rm pharos sh

在容器内部,我们可以像以前一样运行迁移:

cd migrations/
./migrations init
./migrations up

我们已经完成了。您可以在浏览器中打开 localhost:8181 以检查一切是否正常,这意味着您应该能够创建帐户并添加新帖子:

要完善一个网站还有很多事情需要做,不仅仅是这些,以上的只是抛砖引玉。

本指北手册,手把手跟大家从头开始构建一个完成一个Go作为服务的Web应用程序 — Blog

完整的应用程序 可以在 [github上下载 ]yuanliang/pharos · GitHub

Go(Golang)是谷歌开发的一种开源语言,更多信息请访问 Go官网

Gin 是一个轻量级的高性能Web框架,支持现代Web应用程序所需的大叔叔基本特性和功能。更多信息、文档访问 Gin官网

React 是Facebook开发的JavaScript框架。React官网

Esbuild 是新一代的JavasScript打包工具 Esbuild官网

Typescript TypeScript 是一种由微软开发的自由和开源的编程语言,它是 JavaScript 的一个超集,扩展了 JavaScript 的语法。TypeScript官网

PostgreSQL 是我们将用于存储数据的数据库,可以到 PostgreSQL官网查看了解更多信息。

相关安装都在官网有详细介绍就不在这里赘述了。

一、启动Gin服务

首先,我们要给我们的Web应用程序取个名字,用作我们Blog程序的服务端。这里我用 Pharos(灯塔)

cd ~/go/src
mkdir pharos
cd pharos

如果还没有安装依赖可以 通过下面命令来下载安装它。

go mod download github.com/gin-gonic/gin

在编写我们的第一个后端源文件之前,我们需要在Golang所需的项目根目录中创建go.mod 文件,以查找和导入所需要的依赖。该文件的内容为:

module pharos

go 1.17

require github.com/gin-gonic/gin v1.7.7

通过如下命令来整理一下go.mod文件

go mod tidy

现在,创建一个入口文件 main.go :


package main

import (
  "github.com/gin-gonic/gin"
)

func main() {
  // 创建默认的 gin 路由器,并且已经附加了 Logger 和 Recovery 中间件
  router := gin.Default()

  // 创建 API 路由组
  api := router.Group("/api")
  {
    // 将 /hello GET 路由添加到路由器并定义路由处理程序
        api.GET("/hello", func(ctx *gin.Context) {
      ctx.JSON(200, gin.H{"msg": "world"})
    })
  }

  router.NoRoute(func(ctx *gin.Context) { ctx.JSON(http.StatusNotFound, gin.H{}) })

  // 开始监听服务请求
  router.Run(":8080")
}

现在我们可以通过如下命令来启动服务器:

go run main.go

然后可以打开浏览器输入 http://localhost:8080/api/hello 来验证服务是否正常启动。如果正常您讲看到 {"msg":"world"}

Screen Shot 2021-12-06 at 7.57.09 PM.png

可以在命令行工具里面看到访问情况。

二、添加React

有了后端服务,现在可以添加前端框架。我们使用 esbuild-create-react-app 来安装react框架

现在根目录下安装React

npx esbuild-create-react-app app
cd app
yarn start | npm run start

过程中您会看到语言的选择,请选择Typescript。


            W  E  L  C  O  M  E      T  O         

      .d88b.  .d8888b       .d8888b 888d888 8888b.  
     d8P  Y8b 88K          d88P"    888P"      "88b 
     88888888 "Y8888b.     888      888    .d888888 
     Y8b.          X88     Y88b.    888    888  888 
      "Y8888   88888P'      "Y8888P 888    "Y888888 


Hello there! esbuild create react app is a minimal replacement for create react app using a truly blazing fast esbuild bundler.
Up and running in less than 1 minute with almost zero configuration needed.

? To get started please choose a template 
  Javascript 
❯ Typescript

安装完成后,我们需要进入 site 中 打开 package.json 文件, 在当中增加 ”proxy”: “http://localhost:8080” 。这个会将React开发的服务将我们所有的请求代理到Gin后端,Gin后端将在端口8080上监听。


{
  "name": "site",
  "version": "1.0.0",
  "main": "builder.js",
  "author": "Yuan Liang",
  "license": "MIT",
  "proxy": "http://localhost:8080",
  "scripts": {
    "pre-commit": "lint-staged",
    "lint": "eslint \"src/**/*.{ts,tsx}\" --max-warnings=0",
    "start": "node builder.js",
    "build": "NODE_ENV=production node builder.js"
  },
  "dependencies": {
    "fs-extra": "^10.0.0",
    "react": "^17.0.2",
    "react-dom": "^17.0.2"
  },
  "devDependencies": {
    "@types/node": "^16.9.1",
    "@types/react": "^17.0.20",
    "@types/react-dom": "^17.0.9",
    "@typescript-eslint/eslint-plugin": "^4.31.0",
    "@typescript-eslint/parser": "^4.31.0",
    "chokidar": "^3.5.2",
    "esbuild": "^0.12.26",
    "eslint": "^7.32.0",
    "eslint-config-airbnb": "^18.2.1",
    "eslint-config-prettier": "^8.3.0",
    "eslint-plugin-import": "^2.24.2",
    "eslint-plugin-jsx-a11y": "^6.4.1",
    "eslint-plugin-react": "^7.25.1",
    "eslint-plugin-react-hooks": "^4.2.0",
    "husky": "^7.0.2",
    "lint-staged": "^11.1.2",
    "prettier": "^2.4.0",
    "server-reload": "^0.0.3",
    "typescript": "^4.4.3"
  },
  "lint-staged": {
    "*.+(js|jsx)": "eslint --fix",
    "*.+(json|css|md)": "prettier --write"
  },
  "husky": {
    "hooks": {
      "pre-commit": "lint-staged"
    }
  }
}

另外 live-server 里面的 proxy 里面有一些 bug 重新搞了一个包 ( server-reload ) ,增加了 POST method 的支持 还有 proxy的传参方式。

npm install server-reload --save-dev

所以 builder.js 的传参也进行了修改。


const serverParams = {
  port: 8181, // Set the server port. Defaults to 8080.
  root: 'dist', // Set root directory that's being served. Defaults to cwd.
  open: false, // When false, it won't load your browser by default.
  cors: true,
  // host: '0.0.0.0', // Set the address to bind to. Defaults to 0.0.0.0 or process.env.IP.
  proxy: {
    path: '/api',
    target: 'http://localhost:8080/api'
  } // Set proxy URLs.
  // ignore: 'scss,my/templates', // comma-separated string for paths to ignore
  // file: 'index.html' // When set, serve this file (server root relative) for every 404 (useful for single-page applications)
  // wait: 1000, // Waits for all changes, before reloading. Defaults to 0 sec.
  // mount: [['/components', './node_modules']], // Mount a directory to a route.
  // logLevel: 2, // 0 = errors only, 1 = some, 2 = lots
  // middleware: [function(req, res, next) { next(); }] // Takes an array of Connect-compatible middleware that are injected into the server middleware stack
}
//

然后 site 文件里面可以执行 yarn start | npm run start 项目启动可以看到 Go 服务的数据已经返回。

Screen Shot 2021-12-07 at 6.28.21 PM.png Screen Shot 2021-12-07 at 6.27.07 PM.png

三、整理目录结构和添加路由

现在我们来重新规划目录结构,感觉比较正规一丢丢,像个架构师一样。代码按照功能来区分是一个比较好的最佳时间。

我们现在创建一个 services 来放置我们的 go 服务应用文件 并在其中添加一个 server 文件夹当中放置 server.go 文件作为Gin的启动文件。然后在 server 文件中添加一个 router.go 来作为项目的路由管理文件。

main.go 文件修改为

package main

import"pharos/services/server"

func main() {
    server.Start()
}

调整后的目录结构是这样的

d90bce43-14ac-478e-be3c-69bf884944de.png

现在再次启动一次服务看看效果:)

四、创建用户对象以及登陆注册方法

接下来作为博客程序,要做的第一件事就是来做用户的创建登陆管理。现在我们来制作一个简单的 User 对象放到文件运行在内存中,稍后我们将链接数据库,可以方便的管理储存数据。

第一步 让我们在 services 当中来创建一个新目录,我们将其命名为 store。这个文件当中将保存我们这个blog的所有的数据逻辑。在我们将程序链接到数据哭之前,我们将用简单的对象来储存用户。在 services/store 目录中,我们来创建一个新文件 users.go


package store

type User struct {
    Username string
    Password string
}

var Users []*User

接下来我们在 router.go 当中删除 /hello 路由,换上新的路由 /signup/signin 注册和登录

package server

import (
  “github.com/gin-gonic/gin”
)

func setRouter() *gin.Engine {
  // Creates default gin router with Logger and Recovery middleware already attached
  router := gin.Default()

  // Enables automatic redirection if the current route can’t be matched but a
  // handler for the path with (without) the trailing slash exists.
  router.RedirectTrailingSlash = true

  // Create API route group
  api := router.Group(“/api”)
  {
    api.POST(“/signup”, signUp)
    api.POST(“/signin”, signIn)
  }

  router.NoRoute(func(ctx *gin.Context) { ctx.JSON(http.StatusNotFound, gin.H{}) })

  return router
}

会发现 缺少 signUp 还有 signIn 两个方法的实现 我们把这两个方法的实现放到 services/server/user.go 当中

package server

import (
  “net/http”
  “pharos/services/store”

  “github.com/gin-gonic/gin”
)

func signUp(ctx *gin.Context) {
  user := new(store.User)
  if err := ctx.Bind(user); err != nil {
    ctx.AbortWithStatusJSON(http.StatusBadRequest, gin.H{“err”: err.Error()})
    return
  }
  store.Users = append(store.Users, user)
  ctx.JSON(http.StatusOK, gin.H{
    “msg”: “Signed up successfully.”,
    “jwt”: “123456789”,
  })
}

func signIn(ctx *gin.Context) {
  user := new(store.User)
  if err := ctx.Bind(user); err != nil {
    ctx.AbortWithStatusJSON(http.StatusBadRequest, gin.H{“err”: err.Error()})
    return
  }
  for _, u := range store.Users {
    if u.Username == user.Username && u.Password == user.Password {
      ctx.JSON(http.StatusOK, gin.H{
        “msg”: “Signed in successfully.”,
        “jwt”: “123456789”,
      })
      return
    }
  }
  ctx.AbortWithStatusJSON(http.StatusUnauthorized, gin.H{“err”: “Sign in failed.”})
}

这段代码的作用是。我们创建一个新的用户类型变量,然后将用户变量存储在前端当中,然后我们调用 bind() 方法将数据绑定到 User 类型变量,如果绑定失败,我们立即设置错误代码和错误消息,并从当前函数返回。如果没有错误,我们将响应代码设置状态为 OK 并返回 JWT 进行身份验证。现在您可以不用关心 JWT 是啥,稍后会详细介绍,也可能不会介绍自行搜索。

现在您可以重启服务然后调用一下两个路由的状态 ,注意是 POST请求 需要专门的工具调试 ,或者暂不调试继续往下。

1C23012A-7952-44F1-BA6E-6A18E82E95C5.png F44D09A5-9A76-4E56-8E5F-4567A2EF9B25.png

接下来是填写React的相关前端的页面 可以直接到github上去下载

有了前端页面就可以利用表单来注册,登陆用户了。在 app/src/components/Auth/AuthForm.tsx 当中为我们的表单提交增加一个 submitHandler() 方法。然后点击提交后就可以看到提交的数据了 。

Screen Shot 2021-12-08 at 4.23.19 PM.png

我们还需要给表单增加前后端的验证规则,这里主要来添加服务端的验证。打开 services/store/users.go 文件修改成如下这样。

type User struct {
  Username string`binding:"required,min=5,max=30"`
  Password string`binding:"required,min=7,max=32"`
}

这里接受设置了接手的字段和字段的规则 在这里来找 Go 相关的验证规则 go-playground/validator 验证支持的字段 点击这里

现在可以通过注册页面输入用户名和密码(重启Gin服务),不符合规则的会返回服务端错误。

Screen Shot 2021-12-08 at 4.23.19 PM.png

五、添加数据库

目前为止我们的应用程序运行OK,我么可以创建用户,并且登陆但是我们一旦重启Gin服务,我们的用户数据就会丢失,因为他仅仅在内存中运行。一个完整的应用程序是需要数据库来进行数据的存储的,我们将所有的数据保存到数据库中。PostgreSQL 的具体安装细节我们不做讲解,官网上会有详细的安装步骤,我们现在假设您已经安装和配置了 PostgreSQL 所有例子当中我们使用 postgres 默认的帐号和密码。

首先我们创建一个数据库,如果 PostgreSQL 没有运行,我们需要启动它,然后通过默认的帐号 postgres 来登陆。

sudo service postgresql start
sudo -u postgres psql

链接成功后 可以创建 数据库

CREATE DATABASE pharos;

数据库通信我们使用 go-pg module。您可以通过 go get github.com/go-pg/pg/v10 命令来进行安装。现在我们在 services 文件夹当中新增加一个新的目录文件 database 并且新增一个文件 database.go

package database

import (
  "github.com/go-pg/pg/v10"
)

func NewDBOptions() *pg.Options {
  return &pg.Options{
    Addr:     "localhost:5432",
    Database: "pharos",
    User:     "postgres",
    Password: "postgres",
  }
}

然后在 services/store/store.go 增加数据库的连接。

package store

import (
  “log”

  “github.com/go-pg/pg/v10”
)

// Database connector
var db *pg.DB

func SetDBConnection(dbOpts *pg.Options) {
  if dbOpts == nil {
    log.Panicln(“DB options can’t be nil”)
  } else {
    db = pg.Connect(dbOpts)
  }
}

func GetDBConnection() *pg.DB { return db }

创建一个变量db,然后创建两个方法 Get and Set 数据库连接方法 然后通过 pg.Connect 来连接数据库。然后我们回到 services/server/server.go 增加项目启动后的数据库连接访问

package server

import (
  “pharos/services/database”
  “pharos/services/store”
)

func Start() {
  store.SetDBConnection(database.NewDBOptions())

  router := setRouter()

  // Start listening and serving requests
  router.Run(“:8080”)
}

链接数据库后,用户的信息就可以保存到数据库当中了,并且可以验证一下登陆用户名 密码匹配。services/store/users.go 增加验证相关能力。

package store

import “errors”

type User struct {
  ID       int
  Username string`binding:"required,min=5,max=30"`
  Password string`binding:"required,min=7,max=32"`
}

func AddUser(user *User) error {
  _, err := db.Model(user).Returning(“*”).Insert()
  if err != nil {
    return err
  }
  returnnil
}

func Authenticate(username, password string) (*User, error) {
  user := new(User)
  if err := db.Model(user).Where(
    “username = ?”, username).Select(); err != nil {
    returnnil, err
  }
  if password != user.Password {
    returnnil, errors.New(“Password not valid.”)
  }
  return user, nil
}

相应的 services/server/user.go 也需要进行相关的修改。

package server

import (
  “net/http”
  “pharos/services/store”

  “github.com/gin-gonic/gin”
)

func signUp(ctx *gin.Context) {
  user := new(store.User)
  if err := ctx.Bind(user); err != nil {
    ctx.AbortWithStatusJSON(http.StatusBadRequest, gin.H{“error”: err.Error()})
    return
  }
  if err := store.AddUser(user); err != nil {
    ctx.AbortWithStatusJSON(http.StatusBadRequest, gin.H{“error”: err.Error()})
    return
  }
  ctx.JSON(http.StatusOK, gin.H{
    “msg”: “Signed up successfully.”,
    “jwt”: “123456789”,
  })
}

func signIn(ctx *gin.Context) {
  user := new(store.User)
  if err := ctx.Bind(user); err != nil {
    ctx.AbortWithStatusJSON(http.StatusBadRequest, gin.H{“error”: err.Error()})
    return
  }
  user, err := store.Authenticate(user.Username, user.Password)
  if err != nil {
    ctx.AbortWithStatusJSON(http.StatusUnauthorized, gin.H{“error”: “Sign in failed.”})
    return
  }

  ctx.JSON(http.StatusOK, gin.H{
    “msg”: “Signed in successfully.”,
    “jwt”: “123456789”,
  })
}

数据库连接创建后,我们还需要创建表来存储数据。我们使用 go-pg/migrations 模块来做数据迁移。在Github上您可以找到安装和使用指南这里不做赘述。然后我们在根目录创建一个文件夹 migrations 并在里面创建一个文件 main.go

package main

import (
    "flag"
    "fmt"
    "os"

    "pharos/services/database"
    "pharos/services/store"

    "github.com/go-pg/migrations/v8"
)

const usageText = `This program runs command on the db. Supported commands are:
  - init - creates version info table in the database
  - up - runs all available migrations.
  - up [target] - runs available migrations up to the target one.
  - down - reverts last migration.
  - reset - reverts all migrations.
  - version - prints current db version.
  - set_version [version] - sets db version without running migrations.
Usage:
  go run *.go <command> [args]
`

func main() {
    flag.Usage = usage
    flag.Parse()

    store.SetDBConnection(database.NewDBOptions())
    db := store.GetDBConnection()

    oldVersion, newVersion, err := migrations.Run(db, flag.Args()...)
    if err != nil {
        exitf(err.Error())
    }
    if newVersion != oldVersion {
        fmt.Printf("migrated from version %d to %d\n", oldVersion, newVersion)
    } else {
        fmt.Printf("version is %d\n", oldVersion)
    }
}

func usage() {
    fmt.Print(usageText)
    flag.PrintDefaults()
    os.Exit(2)
}

func errorf(s string, args ...interface{}) {
    fmt.Fprintf(os.Stderr, s+"\n", args...)
}

func exitf(s string, args ...interface{}) {
    errorf(s, args...)
    os.Exit(1)
}

这里新增一个文件 1_addUsersTable.go 这个与官方的例子类似。我们可以用 SetDBConnection()GetDBConnection() 在数据存储包中来定义函数。这是运行数据迁移的主要入口。

package main

import (
  “fmt”

  “github.com/go-pg/migrations/v8”
)

func init() {
  migrations.MustRegisterTx(func(db migrations.DB) error {
    fmt.Println(“creating table users…”)
    _, err := db.Exec(`CREATE TABLE users(
      id SERIAL PRIMARY KEY,
      username TEXT NOT NULL UNIQUE,
      password TEXT NOT NULL,
      created_at TIMESTAMPTZ NOT NULL DEFAULT NOW(),
      modified_at TIMESTAMPTZ NOT NULL DEFAULT NOW()
    )`)
    return err
  }, func(db migrations.DB) error {
    fmt.Println(“dropping table users…”)
    _, err := db.Exec(`DROP TABLE users`)
    return err
  })
}

然后进入 migrations 文件夹来执行相关命令

cd migrations/
go run *.go init
go run *.go up

我们将会为表格创建一个 users 用户表,我们在数据库中添加了 created_at 和 modified_at 列,因此我们还需要将它们添加到 services/store/users.go 中的User数据结构定义当中

type User struct {
  ID         int
  Username   string`binding:"required,min=5,max=30"`
  Password   string`binding:"required,min=7,max=32"`
  CreatedAt  time.Time
  ModifiedAt time.Time
}

现在试着创建一个全新的帐号 然后我们去数据库中观察这个帐号已经被存储到数据库当中。也可以创建一个迁移可执行文件

cd migrations/
go build -o migrations *.go

并且运行它

cd migrations/
go build -o migrations *.go

这里有一个问题,users 表当中我们用纯文本形式存储用户密码,这种事不安全的,我们应该用一个随机的种子来生成密码,为此我们用 golan/crypto库,来扩展我们的用户对象结构。

type User struct {
  ID             int
  Username       string`binding:"required,min=5,max=30"`
  Password       string`pg:"-" binding:"required,min=7,max=32"`
  HashedPassword []byte`json:"-"`
  Salt           []byte`json:"-"`
  CreatedAt      time.Time
  ModifiedAt     time.Time
}

修改 migration 文件

package main

import (
  “fmt”

  “github.com/go-pg/migrations/v8”
)

func init() {
  migrations.MustRegisterTx(func(db migrations.DB) error {
    fmt.Println(“creating table users…”)
    _, err := db.Exec(`CREATE TABLE users(
      id SERIAL PRIMARY KEY,
      username TEXT NOT NULL UNIQUE,
      hashed_password BYTEA NOT NULL,
      salt BYTEA NOT NULL,
      created_at TIMESTAMPTZ NOT NULL DEFAULT CURRENT_TIMESTAMP,
      modified_at TIMESTAMPTZ NOT NULL DEFAULT CURRENT_TIMESTAMP
    )`)
    return err
  }, func(db migrations.DB) error {
    fmt.Println(“dropping table users…”)
    _, err := db.Exec(`DROP TABLE users`)
    return err
  })
}

现在再修改一下 services/store/users.go

package store

import (
  "crypto/rand"
  "time"

  "golang.org/x/crypto/bcrypt"
)

type User struct {
  ID             int
  Username       string`binding:"required,min=5,max=30"`
  Password       string`pg:"-" binding:"required,min=7,max=32"`
  HashedPassword []byte`json:"-"`
  Salt           []byte`json:"-"`
  CreatedAt      time.Time
  ModifiedAt     time.Time
}

func AddUser(user *User) error {
  salt, err := GenerateSalt()
  if err != nil {
    return err
  }
  toHash := append([]byte(user.Password), salt…)
  hashedPassword, err := bcrypt.GenerateFromPassword(toHash, bcrypt.DefaultCost)
  if err != nil {
    return err
  }

  user.Salt = salt
  user.HashedPassword = hashedPassword

  _, err = db.Model(user).Returning(“*”).Insert()
  if err != nil {
    return err
  }
  return err
}

func Authenticate(username, password string) (*User, error) {
  user := new(User)
  if err := db.Model(user).Where(
    “username = ?”, username).Select(); err != nil {
    returnnil, err
  }
  salted := append([]byte(password), user.Salt…)
  if err := bcrypt.CompareHashAndPassword(user.HashedPassword, salted); err != nil {
    returnnil, err
  }
  return user, nil
}

func GenerateSalt() ([]byte, error) {
  salt := make([]byte, 16)
  if _, err := rand.Read(salt); err != nil {
    returnnil, err
  }
  return salt, nil
}

再次更新一下数据库

cd migrations/
go run *.go reset
go run *.go up

E2E8492B-DA4E-40B4-BBF3-98D1B0CA6C67.png

重新在浏览器当中访问页面,并且创建帐号就看到加密后的密码被更新到数据库中了已经。

六、增加配置文件以及增加启动脚本

当前我们吧服务器地址,以及端口等都硬编码到了代码里,数据库相关的选项也是如此。这不是一个优雅的解决方案,所以我们要创建一个 环境变量文件 .env 把相关的配置都从这个文件读取,首先创建一个 services/conf 文件夹 并在里面包含 conf.go 的文件

package conf

import (
  "log"
  "os"
  "strconv"
)

const (
  hostKey       = "PHAROS_HOST"
  portKey       = "PHAROS_PORT"
  dbHostKey     = "PHAROS_DB_HOST"
  dbPortKey     = "PHAROS_DB_PORT"
  dbNameKey     = "PHAROS_DB_NAME"
  dbUserKey     = "PHAROS_DB_USER"
  dbPasswordKey = "PHAROS_DB_PASSWORD"
)

type Config struct {
  Host       string
  Port       string
  DbHost     string
  DbPort     string
  DbName     string
  DbUser     string
  DbPassword string
}

func NewConfig() Config {
  host, ok := os.LookupEnv(hostKey)
  if !ok || host == "" {
    logAndPanic(hostKey)
  }

  port, ok := os.LookupEnv(portKey)
  if !ok || port == “” {
    if _, err := strconv.Atoi(port); err != nil {
      logAndPanic(portKey)
    }
  }

  dbHost, ok := os.LookupEnv(dbHostKey)
  if !ok || dbHost == “” {
    logAndPanic(dbHostKey)
  }

  dbPort, ok := os.LookupEnv(dbPortKey)
  if !ok || dbPort == “” {
    if _, err := strconv.Atoi(dbPort); err != nil {
      logAndPanic(dbPortKey)
    }
  }

  dbName, ok := os.LookupEnv(dbNameKey)
  if !ok || dbName == “” {
    logAndPanic(dbNameKey)
  }

  dbUser, ok := os.LookupEnv(dbUserKey)
  if !ok || dbUser == “” {
    logAndPanic(dbUserKey)
  }

  dbPassword, ok := os.LookupEnv(dbPasswordKey)
  if !ok || dbPassword == “” {
    logAndPanic(dbPasswordKey)
  }

  return Config{
    Host:       host,
    Port:       port,
    DbHost:     dbHost,
    DbPort:     dbPort,
    DbName:     dbName,
    DbUser:     dbUser,
    DbPassword: dbPassword,
  }
}

func logAndPanic(envVar string) {
  log.Println(“ENV variable not set or value not valid: “, envVar)
  panic(envVar)
}

然后相应的修改一下代码引用这些配置的逻辑。

首先修改 services/database/database.go 文件

package database

import (
  “pharos/services/conf”

  “github.com/go-pg/pg/v10”
)

func NewDBOptions(cfg conf.Config) *pg.Options {
  return &pg.Options{
    Addr:     cfg.DbHost + “:” + cfg.DbPort,
    Database: cfg.DbName,
    User:     cfg.DbUser,
    Password: cfg.DbPassword,
  }
}

services/server/server.go 也进行相应的修改

package server

import (
  “pharos/services/conf”
  “pharos/services/database”
  “pharos/services/store”
)

func Start(cfg conf.Config) {
  store.SetDBConnection(database.NewDBOptions(cfg))

  router := setRouter()

  // Start listening and serving requests
  router.Run(“:8080”)
}

main.go 文件

package main

import (
  “pharos/services/conf”
  “pharos/services/server”
)

func main() {
  server.Start(conf.NewConfig())
}

migrations/main.go 文件中还需要进行一项更改。只需导入 pharos/services/conf 包并更改行。

store.SetDBConnection(database.NewDBOptions())
store.SetDBConnection(database.NewDBOptions(conf.NewConfig()))
Enter fullscreen mode

我们现在准备读取配置所需的 ENV 变量。但还缺少一件事。我们需要将这些值提供给 ENV。为此,让我们在名为 .env 的根项目目录中创建新文件:

export PHAROS_HOST=0.0.0.0
export PHAROS_PORT=8080
export PHAROS_DB_HOST=localhost
export PHAROS_DB_PORT=5432
export PHAROS_DB_NAME=pharos
export PHAROS_DB_USER=postgres
export PHAROS_DB_PASSWORD=postgres

上下文的环境变量需要执行 source .env 来改变。

source .env
go run main.go

开发部署的Cli

现在我们有了 .env文件,但是每次项目开始 都要进行一次 source操作 来改变上下问的环境变脸,这样显得很傻,为了更优雅我们还需要创建一系列的shell 脚本,同时开发部署也需要一系列的脚本支持。我们首先在 services/cli创建这个目录,然后 创建一个 cli.go文件。

package cli

import (
  “flag”
  “fmt”
  “os”
)

func usage() {
  fmt.Print(`This program runs Pharos backend server.

Usage:

pharos [arguments]

Supported arguments:

`)
  flag.PrintDefaults()
  os.Exit(1)
}

func Parse() {
  flag.Usage = usage
  env := flag.String(“env”, “dev”, `Sets run environment. Possible values are "dev" and "prod"`)
  flag.Parse()
  fmt.Println(*env)
}

然后修改 main.go 文件来加入引用

package main

import (
  "pharos/services/cli"
  "pharos/services/conf"
  "pharos/services/server"
)

func main() {
  cli.Parse()
  server.Start(conf.NewConfig())
}

现在可以开始编写用于部署和停止我们应用程序的 脚本了。这里不想洗介绍 bash命令的语法,只需要理解就好 具体请参考互联网上的一些教程和指南 我们首先创建一个文件夹 scripts 在跟目录,里面添加第一个脚本。deploy.sh

#! /bin/bash

# default ENV is dev
env=dev

whiletest$# -gt 0; do
  case “$1” in
    -env)
      shift
      iftest$# -gt 0; then
        env=$1
      fi
      # shift
      ;;
    *)
    break
    ;;
  esac
done

cd ../../pharos
source .env
go build -o pharos/pharos pharos/main.go
pharos -env $env &

在上面的脚本中 我们先设置环境为 env=dev 设置成为开发环境。之后我们在为这个脚本传递参数,如果发现 参数我们将把参数传递过去。设置env变量后,我们将目录及切换到项目的根目录,获取 env 变量,然后 我们创建一个文件夹 cmd 并可以把 根目录下的 main.go 文件放到此目录下。运行 go build =o cmd/pharos/pharos cmd/pharos/main.go 这时候我们将创建了一个可执行文件,我们用他来启动我们的服务。构建应用程序是,我们使用 cmd/pharos/pharos -env $env & 启动服务器,它将 env 变量的值作为-env标志传递给我们的服务器。

另外 我们也同时创建一个简单的脚本 stop.sh 放到 scripts/ 文件夹下面。

#! /bin/bash

kill $(pidof pharos)

这个脚本将找到我们 pharos 的进程id,并可以控制结束进程。

在使用脚本前,我们将修改一下相关的权限。

chmod +x deploy.sh
chmod +x stop.sh

现在我们可以控制服务的开始和结束了,scripts/ 下可以执行

./deploy.sh
./stop.sh

七、添加日志记录

日志记录也是大多数 Web 应用程序中非常重要的部分,因为我们通常想知道传入了哪些请求,更重要的是,是否有任何意外错误。因此,正如您可能已经猜到的那样,本节将介绍日志记录,我将向您展示如何设置日志记录,以及如何在开发和生产环境中分离日志记录。现在我们将使用上一节中添加的 -env 标志。

对于日志记录,我们将使用zerolog模块,您可以通过运行 go get github.com/rs/zerolog/log 来获取该模块。

现在我们再创建另一个目录 services/logging 并在其中创建一个 logging.go 文件。

package logging

import (
  "fmt"
  "io"
  "io/ioutil"
  "os"
  "path/filepath"
  "strings"
  "time"

  "github.com/gin-gonic/gin"
  "github.com/rs/zerolog"
  "github.com/rs/zerolog/log"
)

const (
  logsDir = "logs"
  logName = "gin_production.log"
)

var logFilePath = filepath.Join(logsDir, logName)

func SetGinLogToFile() {
  gin.SetMode(gin.ReleaseMode)
  logFile, err := os.Create(logFilePath)
  if err != nil {
    log.Panic().Err(err).Msg("Error opening Gin log file")
  }
  gin.DefaultWriter = io.MultiWriter(logFile)
}

func ConfigureLogger(env string) {
  zerolog.SetGlobalLevel(zerolog.DebugLevel)
  switch env {
  case"dev":
    stdOutWriter := zerolog.ConsoleWriter{Out: os.Stdout, TimeFormat: "15:04:05.000"}
    logger := zerolog.New(stdOutWriter).With().Timestamp().Logger()
    log.Logger = logger
  case"prod":
    createLogDir()
    backupLastLog()
    logFile := openLogFile()
    logFileWriter := zerolog.ConsoleWriter{Out: logFile, NoColor: true, TimeFormat: "15:04:05.000"}
    logger := zerolog.New(logFileWriter).With().Timestamp().Logger()
    log.Logger = logger
  default:
    fmt.Printf("Env not valid: %s\n", env)
    os.Exit(2)
  }
}

func createLogDir() {
  if err := os.Mkdir(logsDir, 0744); err != nil && !os.IsExist(err) {
    log.Fatal().Err(err).Msg("Unable to create logs directory.")
  }
}

func backupLastLog() {
  timeStamp := time.Now().Format("20060201_15_04_05")
  base := strings.TrimSuffix(logName, filepath.Ext(logName))
  bkpLogName := base + "_" + timeStamp + "." + filepath.Ext(logName)
  bkpLogPath := filepath.Join(logsDir, bkpLogName)

  logFile, err := ioutil.ReadFile(logFilePath)
  if err != nil {
    if os.IsNotExist(err) {
      return
    }
    log.Panic().Err(err).Msg(“Error reading log file for backup”)
  }

  if err = ioutil.WriteFile(bkpLogPath, logFile, 0644); err != nil {
    log.Panic().Err(err).Msg(“Error writing backup log file”)
  }
}

func openLogFile() *os.File {
  logFile, err := os.OpenFile(logFilePath, os.O_WRONLY|os.O_CREATE, 0644)
  if err != nil {
    log.Panic().Err(err).Msg(“Error while opening log file”)
  }
  return logFile
}

func curentDir() string {
  path, err := os.Executable()
  if err != nil {
    log.Panic().Err(err).Msg(“Can’t get current directory.”)
  }
  return filepath.Dir(path)
}

然后我们可以更新 services/cli/cli.go 以根据环境配置日志记录,而不是仅仅打印它

package cli

import (
  "flag"
  "fmt"
  “os”

  “pharos/services/logging”
)

func usage() {
  fmt.Print(`This program runs PHAROS backend server.

Usage:

pharos [arguments]

Supported arguments:

`)
  flag.PrintDefaults()
  os.Exit(1)
}

func Parse() {
  flag.Usage = usage
  env := flag.String(“env”, “dev”, `Sets run environment. Possible values are "dev" and "prod"`)
  flag.Parse()
  logging.ConfigureLogger(*env)
  if *env == “prod” {
    logging.SetGinLogToFile()
  }
}

这看起来像很多代码,但它非常简单。首先我们根据环境配置我们的日志。如果 env 是 dev,我们会将所有内容都记录到 stdout,而对于 prod 环境,我们将登录到文件中。登录文件时,我们将首先根据需要创建日志目录并备份以前的日志,因此每次服务器启动时我们都有新的日志。当然,您可以为日志轮换创建不同的逻辑,以更好地满足您的需求。在这种情况下我们需要做的另一件事是告诉 Gin 以发布模式运行,这将减少不必要和干扰的输出,然后还设置默认 Gin writer 写入日志文件。您也可以在 prod case 块中执行此操作,但由于我们实际上有两个不同的记录器(Gin 的附加记录器和我们的 zerolog 记录器),我更倾向于将这两部分代码分开。这只是个人喜好,您可以按照自己的方式进行。有了这个集合,我们现在可以开始记录一些错误。例如,让我们更新 services/conf/conf.go 中的 logAndPanic()函数:

func logAndPanic(envVar string) {
  log.Panic().Str(“envVar”, envVar).Msg(“ENV variable not set or value not valid”)
}

我们可以记录在 services/store/users.go 中生成密钥的时候是否发生错误。

func GenerateSalt() ([]byte, error) {
  salt := make([]byte, 16)
  if _, err := rand.Read(salt); err != nil {
    log.Error().Err(err).Msg(“Unable to create salt”)
    returnnil, err
  }
  return salt, nil
}

八、JWT authentication

身份验证是几乎每个 Web 应用程序中最重要的部分之一。我们必须确保每个用户只能创建、读取、更新和删除其授权的数据。为此,我们将使用 JWT(JSON Web 密钥)。幸运的是,有各种专门用于此的 Golang 模块。本指南中将使用的一个可以在此 GitHub 存储库中找到。当前最新版本是 v3,可以通过运行 go get github.com/cristalhq/jwt/v3 来安装。

由于我们将需要用于生成和验证令牌的密钥,让我们将 export PHAROS_JWT_SECRET=jwtSecret123 添加到我们的 .env文件中。当然,在生产中你会想要使用一些随机生成的长字符串。接下来我们应该做的是在 services/conf/conf.go 中添加新变量。我们将添加常量 jwtSecretKey = "PHAROS_JWT_SECRET" 与我们的其余常量,然后将字符串类型的新字段 JwtSecret添加到配置结构。现在我们可以读取新的 env 变量并将其添加到 NewConfig()函数中:

const (
  hostKey       = "PHAROS_HOST"
  portKey       = "PHAROS_PORT"
  dbHostKey     = "PHAROS_DB_HOST"
  dbPortKey     = "PHAROS_DB_PORT"
  dbNameKey     = "PHAROS_DB_NAME"
  dbUserKey     = "PHAROS_DB_USER"
  dbPasswordKey = "PHAROS_DB_PASSWORD"
  jwtSecretKey  = "PHAROS_JWT_SECRET"
)

type Config struct {
  Host       string
  Port       string
  DbHost     string
  DbPort     string
  DbName     string
  DbUser     string
  DbPassword string
  JwtSecret  string
}

func NewConfig() Config {
  host, ok := os.LookupEnv(hostKey)
  if !ok || host == "" {
    logAndPanic(hostKey)
  }

  port, ok := os.LookupEnv(portKey)
  if !ok || port == "" {
    if _, err := strconv.Atoi(port); err != nil {
      logAndPanic(portKey)
    }
  }

  dbHost, ok := os.LookupEnv(dbHostKey)
  if !ok || dbHost == "" {
    logAndPanic(dbHostKey)
  }

  dbPort, ok := os.LookupEnv(dbPortKey)
  if !ok || dbPort == "" {
    if _, err := strconv.Atoi(dbPort); err != nil {
      logAndPanic(dbPortKey)
    }
  }

  dbName, ok := os.LookupEnv(dbNameKey)
  if !ok || dbName == "" {
    logAndPanic(dbNameKey)
  }

  dbUser, ok := os.LookupEnv(dbUserKey)
  if !ok || dbUser == “” {
    logAndPanic(dbUserKey)
  }

  dbPassword, ok := os.LookupEnv(dbPasswordKey)
  if !ok || dbPassword == “” {
    logAndPanic(dbPasswordKey)
  }

  jwtSecret, ok := os.LookupEnv(jwtSecretKey)
  if !ok || jwtSecret == “” {
    logAndPanic(jwtSecretKey)
  }

  return Config{
    Host:       host,
    Port:       port,
    DbHost:     dbHost,
    DbPort:     dbPort,
    DbName:     dbName,
    DbUser:     dbUser,
    DbPassword: dbPassword,
    JwtSecret:  jwtSecret,
  }
}

我们可以创建一个新的文件 services/server/jwt.go

package server

import (
  “pharos/services/conf”

  “github.com/cristalhq/jwt/v3”
  “github.com/rs/zerolog/log”
)

var (
  jwtSigner   jwt.Signer
  jwtVerifier jwt.Verifier
)

func jwtSetup(conf conf.Config) {
  var err error
  key := []byte(conf.JwtSecret)

  jwtSigner, err = jwt.NewSignerHS(jwt.HS256, key)
  if err != nil {
    log.Panic().Err(err).Msg(“Error creating JWT signer”)
  }

  jwtVerifier, err = jwt.NewVerifierHS(jwt.HS256, key)
  if err != nil {
    log.Panic().Err(err).Msg(“Error creating JWT verifier”)
  }
}

函数 jwtSetup()将只创建稍后将用于身份验证的签名者和验证者。现在我们可以在启动服务器时从 services/server/server/go 调用这个函数:

package server

import (
  “pharos/services/conf”
  “pharos/services/database”
  “pharos/services/store”
)

func Start(cfg conf.Config) {
  jwtSetup(cfg)

  store.SetDBConnection(database.NewDBOptions(cfg))

  router := setRouter()

  // Start listening and serving requests
  router.Run(“:8080”)
}

为了生成密钥,我们将在 services/server/jwt.go 中创建函数:

func generateJWT(user *store.User) string {
  claims := &jwt.RegisteredClaims{
    ID:        fmt.Sprint(user.ID),
    ExpiresAt: jwt.NewNumericDate(time.Now().Add(time.Hour * 24 * 7)),
  }
  builder := jwt.NewBuilder(jwtSigner)
  token, err := builder.Build(claims)
  if err != nil {
    log.Panic().Err(err).Msg(“Error building JWT”)
  }
  return token.String()
}

然后我们将从 services/server/user.go 调用它,而不是我们迄今为止用于测试目的的硬编码字符串:

package server

import (
  “net/http”
  “pharos/services/store”

  “github.com/gin-gonic/gin”
)

func signUp(ctx *gin.Context) {
  user := new(store.User)
  if err := ctx.Bind(user); err != nil {
    ctx.AbortWithStatusJSON(http.StatusBadRequest, gin.H{“error”: err.Error()})
    return
  }
  if err := store.AddUser(user); err != nil {
    ctx.AbortWithStatusJSON(http.StatusBadRequest, gin.H{“error”: err.Error()})
    return
  }
  ctx.JSON(http.StatusOK, gin.H{
    “msg”: “Signed up successfully.”,
    “jwt”: generateJWT(user),
  })
}

func signIn(ctx *gin.Context) {
  user := new(store.User)
  if err := ctx.Bind(user); err != nil {
    ctx.AbortWithStatusJSON(http.StatusBadRequest, gin.H{“error”: err.Error()})
    return
  }
  user, err := store.Authenticate(user.Username, user.Password)
  if err != nil {
    ctx.AbortWithStatusJSON(http.StatusUnauthorized, gin.H{“error”: “Sign in failed.”})
    return
  }

  ctx.JSON(http.StatusOK, gin.H{
    “msg”: “Signed in successfully.”,
    “jwt”: generateJWT(user),
  })
}

让我们通过注册或通过我们的前端登录来测试这一点。打开浏览器开发工具并检查登录或注册响应。您可以看到我们的后端现在生成了随机 JWT:

BF9C0E23-0297-4038-99CC-8F90DDB6EF8F.png

令牌现在在 signIn 和 signUp 处理程序中创建,这意味着我们可以为所有安全路由验证它。为此,我们将首先在 services/server/jwt.go 中实现 verifyJWT()函数。此函数将接收字符串形式的令牌,验证其签名,从声明中提取 ID,如果一切正常,用户 ID 将作为 int 返回:

func verifyJWT(tokenStr string) (int, error) {
  token, err := jwt.Parse([]byte(tokenStr))
  if err != nil {
    log.Error().Err(err).Str(“tokenStr”, tokenStr).Msg(“Error parsing JWT”)
    return0, err
  }

  if err := jwtVerifier.Verify(token.Payload(), token.Signature()); err != nil {
    log.Error().Err(err).Msg(“Error verifying token”)
    return0, err
  }

  var claims jwt.StandardClaims
  if err := json.Unmarshal(token.RawClaims(), &claims); err != nil {
    log.Error().Err(err).Msg(“Error unmarshalling JWT claims”)
    return0, err
  }

  if notExpired := claims.IsValidAt(time.Now()); !notExpired {
    return0, errors.New(“Token expired.”)
  }

  id, err := strconv.Atoi(claims.ID)
  if err != nil {
    log.Error().Err(err).Str(“claims.ID”, claims.ID).Msg(“Error converting claims ID to number”)
    return0, errors.New(“ID in token is not valid”)
  }
  return id, err
}

生成和验证的功能都完成了,到此我们几乎可以编写用于授权的 Gin 中间件了。在此之前,我们将添加根据用户 ID 从数据库中获取用户的函数。在 services/store/users.go 中,添加函数:

func FetchUser(id int) (*User, error) {
  user := new(User)
  user.ID = id
  err := db.Model(user).Returning(“*”).WherePK().Select()
  if err != nil {
    log.Error().Err(err).Msg(“Error fetching user”)
    returnnil, err
  }
  return user, nil
}

现在可以创建一个新文件 services/server/middleware.go

package server

import (
  “net/http”
  “pharos/services/store”
  “strings”

  “github.com/gin-gonic/gin”
)

func authorization(ctx *gin.Context) {
  authHeader := ctx.GetHeader(“Authorization”)
  if authHeader == “” {
    ctx.AbortWithStatusJSON(http.StatusUnauthorized, gin.H{“error”: “Authorization header missing.”})
    return
  }
  headerParts := strings.Split(authHeader, “ “)
  iflen(headerParts) != 2 {
    ctx.AbortWithStatusJSON(http.StatusUnauthorized, gin.H{“error”: “Authorization header format is not valid.”})
    return
  }
  if headerParts[0] != “Bearer” {
    ctx.AbortWithStatusJSON(http.StatusUnauthorized, gin.H{“error”: “Authorization header is missing bearer part.”})
    return
  }
  userID, err := verifyJWT(headerParts[1])
  if err != nil {
    ctx.AbortWithStatusJSON(http.StatusUnauthorized, gin.H{“error”: err.Error()})
    return
  }
  user, err := store.FetchUser(userID)
  if err != nil {
    ctx.AbortWithStatusJSON(http.StatusUnauthorized, gin.H{“error”: err.Error()})
    return
  }
  ctx.Set(“user”, user)
  ctx.Next()
}

授权中间件从授权头中提取令牌。它首先检查标头是否存在,是否为有效格式,然后调用 verifyJWT()函数。如果 JWT 验证通过,则返回用户 ID。从数据库中获取具有该 ID 的用户并将其设置为此上下文的当前用户。

从上下文中获取当前用户是我们经常需要的东西,所以让我们将其提取到辅助函数中:

func currentUser(ctx *gin.Context) (*store.User, error) {
  var err error
  _user, exists := ctx.Get(“user”)
  if !exists {
    err = errors.New(“Current context user not set”)
    log.Error().Err(err).Msg(“”)
    returnnil, err
  }
  user, ok := _user.(*store.User)
  if !ok {
    err = errors.New(“Context user is not valid type”)
    log.Error().Err(err).Msg(“”)
    returnnil, err
  }
  return user, nil
}

首先,我们检查是否为此上下文设置了用户。如果不是,则返回错误。由于 ctx.Get()返回接口,我们必须检查 value 是否为 *store.User 类型。如果不是,则返回错误。当两个检查都通过时,当前用户从上下文返回。

九、增加发帖功能

身份验证到位后,是时候开始使用它了。我们需要身份验证才能创建、阅读、更新和删除用户的博客文章。让我们从添加新的数据库迁移开始,这将创建包含列的所需数据表。创建新的迁移文件 migrations/2_addPostsTable.go:·

package main

import (
  “fmt”

  “github.com/go-pg/migrations/v8”
)

func init() {
  migrations.MustRegisterTx(func(db migrations.DB) error {
    fmt.Println(“creating table posts…”)
    _, err := db.Exec(`CREATE TABLE posts(
      id SERIAL PRIMARY KEY,
      title TEXT NOT NULL,
      content TEXT NOT NULL,
      created_at TIMESTAMPTZ NOT NULL DEFAULT NOW(),
      modified_at TIMESTAMPTZ NOT NULL DEFAULT NOW(),
      user_id INT REFERENCES users ON DELETE CASCADE
    )`)
    return err
  }, func(db migrations.DB) error {
    fmt.Println(“dropping table posts…”)
    _, err := db.Exec(`DROP TABLE posts`)
    return err
  })
}

然后运行 migrations

cd migrations/
go run *.go up

现在我们创建结构来保存帖子数据。我们还将为标题和内容添加字段约束。添加新文件 services/store/posts.go

package store

import “time”

type Post struct {
  ID         int
  Title      string`binding:"required,min=3,max=50"`
  Content    string`binding:"required,min=5,max=5000"`
  CreatedAt  time.Time
  ModifiedAt time.Time
  UserID     int`json:"-"`
}

用户可以有多个博客文章,因此我们必须添加与用户结构的多关系。在 services/store/users.go 中,编辑 User 结构:

type User struct {
  ID             int
  Username       string`binding:"required,min=5,max=30"`
  Password       string`pg:"-" binding:"required,min=7,max=32"`
  HashedPassword []byte`json:"-"`
  Salt           []byte`json:"-"`
  CreatedAt      time.Time
  ModifiedAt     time.Time
  Posts          []*Post `json:"-" pg:"fk:user_id,rel:has-many,on_delete:CASCADE"`
}

可以在数据库中插入新帖子条目的功能将在 services/store/posts.go中实现:

func AddPost(user *User, post *Post) error {
  post.UserID = user.ID
  _, err := db.Model(post).Returning(“*”).Insert()
  if err != nil {
    log.Error().Err(err).Msg(“Error inserting new post”)
  }
  return err
}

要创建帖子,我们将添加新的处理程序,它将调用上面的函数。创建新文件 services/server/post.go

package server

import (
  “net/http”
  “pharos/services/store”

  “github.com/gin-gonic/gin”
)

func createPost(ctx *gin.Context) {
  post := new(store.Post)
  if err := ctx.Bind(post); err != nil {
    ctx.AbortWithStatusJSON(http.StatusBadRequest, gin.H{“error”: err.Error()})
    return
  }
  user, err := currentUser(ctx)
  if err != nil {
    ctx.AbortWithStatusJSON(http.StatusInternalServerError, gin.H{“error”: err.Error()})
    return
  }
  if err := store.AddPost(user, post); err != nil {
    ctx.AbortWithStatusJSON(http.StatusBadRequest, gin.H{“error”: err.Error()})
    return
  }
  ctx.JSON(http.StatusOK, gin.H{
    “msg”:  “Post created successfully.”,
    “data”: post,
  })
}

帖子创建处理程序已准备就绪,让我们为创建帖子添加新的受保护路由。在 services/server/router.go 中,我们将创建新组,该组将使用我们在前一章中实现的授权中间件。我们将使用 HTTP 方法 POST 添加路由 /posts 到该受保护组:

func setRouter() *gin.Engine {
  // Creates default gin router with Logger and Recovery middleware already attached
  router := gin.Default()

  // Enables automatic redirection if the current route can’t be matched but a
  // handler for the path with (without) the trailing slash exists.
  router.RedirectTrailingSlash = true

  // Create API route group
  api := router.Group(“/api”)
  {
    api.POST(“/signup”, signUp)
    api.POST(“/signin”, signIn)
  }

  authorized := api.Group(“/“)
  authorized.Use(authorization)
  {
    authorized.POST(“/posts”, createPost)
  }

  router.NoRoute(func(ctx *gin.Context) { ctx.JSON(http.StatusNotFound, gin.H{}) })

  return router
}

所有其他 CRUD(创建、读取、更新、删除)方法的配方都是相同的:

  1. 实现与数据库通信以执行所需操作的功能
  2. 实现 Gin 处理程序,它将使用步骤 1 中的函数
  3. 将带有处理程序的路由添加到路由器

我们已经涵盖了创建部分,所以让我们继续下一个方法,阅读。我们将实现从 services/store/posts.go 数据库中获取所有用户帖子的函数:

func FetchUserPosts(user *User) error {
  err := db.Model(user).
    Relation(“Posts”, func(q *orm.Query) (*orm.Query, error) {
      return q.Order(“id ASC”), nil
    }).
    Select()
  if err != nil {
    log.Error().Err(err).Msg(“Error fetching user’s posts”)
  }
  return err
}

添加一个文件 services/server/post.go :

func indexPosts(ctx *gin.Context) {
  user, err := currentUser(ctx)
  if err != nil {
    ctx.AbortWithStatusJSON(http.StatusInternalServerError, gin.H{“error”: err.Error()})
    return
  }
  if err := store.FetchUserPosts(user); err != nil {
    ctx.AbortWithStatusJSON(http.StatusInternalServerError, gin.H{“error”: err.Error()})
    return
  }
  ctx.JSON(http.StatusOK, gin.H{
    “msg”:  “Posts fetched successfully.”,
    “data”: user.Posts,
  })
}

要更新帖子,请将这两个函数添加到 services/store/posts.go

func FetchPost(id int) (*Post, error) {
  post := new(Post)
  post.ID = id
  err := db.Model(post).WherePK().Select()
  if err != nil {
    log.Error().Err(err).Msg(“Error fetching post”)
    returnnil, err
  }
  return post, nil
}

func UpdatePost(post *Post) error {
  _, err := db.Model(post).WherePK().UpdateNotZero()
  if err != nil {
    log.Error().Err(err).Msg(“Error updating post”)
  }
  return err
}

修改 services/server/post.go

func updatePost(ctx *gin.Context) {
  jsonPost := new(store.Post)
  if err := ctx.Bind(jsonPost); err != nil {
    ctx.AbortWithStatusJSON(http.StatusBadRequest, gin.H{“error”: err.Error()})
    return
  }
  user, err := currentUser(ctx)
  if err != nil {
    ctx.AbortWithStatusJSON(http.StatusInternalServerError, gin.H{“error”: err.Error()})
    return
  }
  dbPost, err := store.FetchPost(jsonPost.ID)
  if err != nil {
    ctx.AbortWithStatusJSON(http.StatusInternalServerError, gin.H{“error”: err.Error()})
    return
  }
  if user.ID != dbPost.UserID {
    ctx.AbortWithStatusJSON(http.StatusForbidden, gin.H{“error”: “Not authorized.”})
    return
  }
  jsonPost.ModifiedAt = time.Now()
  if err := store.UpdatePost(jsonPost); err != nil {
    ctx.AbortWithStatusJSON(http.StatusInternalServerError, gin.H{“error”: err.Error()})
    return
  }
  ctx.JSON(http.StatusOK, gin.H{
    “msg”:  “Post updated successfully.”,
    “data”: jsonPost,
  })
}

还有一个删除相关 services/store/posts.go

func DeletePost(post *Post) error {
  _, err := db.Model(post).WherePK().Delete()
  if err != nil {
    log.Error().Err(err).Msg(“Error deleting post”)
  }
  return err
}

services/server/post.go

func deletePost(ctx *gin.Context) {
  paramID := ctx.Param(“id”)
  id, err := strconv.Atoi(paramID)
  if err != nil {
    ctx.AbortWithStatusJSON(http.StatusBadRequest, gin.H{“error”: “Not valid ID.”})
    return
  }
  user, err := currentUser(ctx)
  if err != nil {
    ctx.AbortWithStatusJSON(http.StatusInternalServerError, gin.H{“error”: err.Error()})
    return
  }
  post, err := store.FetchPost(id)
  if err != nil {
    ctx.AbortWithStatusJSON(http.StatusInternalServerError, gin.H{“error”: err.Error()})
    return
  }
  if user.ID != post.UserID {
    ctx.AbortWithStatusJSON(http.StatusForbidden, gin.H{“error”: “Not authorized.”})
    return
  }
  if err := store.DeletePost(post); err != nil {
    ctx.AbortWithStatusJSON(http.StatusInternalServerError, gin.H{“error”: err.Error()})
    return
  }
  ctx.JSON(http.StatusOK, gin.H{“msg”: “Post deleted successfully.”})
}

您可以在这里注意到的一项新事物是 paramID := ctx.Param("id")。我们正在使用它从 URL 路径中提取 ID 参数。

让我们将所有这些处理程序添加到路由器:

func setRouter() *gin.Engine {
  // Creates default gin router with Logger and Recovery middleware already attached
  router := gin.Default()

  // Enables automatic redirection if the current route can’t be matched but a
  // handler for the path with (without) the trailing slash exists.
  router.RedirectTrailingSlash = true

  // Create API route group
  api := router.Group(“/api”)
  {
    api.POST(“/signup”, signUp)
    api.POST(“/signin”, signIn)
  }

  authorized := api.Group(“/“)
  authorized.Use(authorization)
  {
    authorized.GET(“/posts”, indexPosts)
    authorized.POST(“/posts”, createPost)
    authorized.PUT(“/posts”, updatePost)
    authorized.DELETE(“/posts/:id”, deletePost)
  }

  router.NoRoute(func(ctx *gin.Context) { ctx.JSON(http.StatusNotFound, gin.H{}) })

  return router
}

如果用户还没有帖子,User.Posts 字段默认为 nil。这使前端的事情变得复杂,因为它必须检查 nil 值,所以最好使用空切片。为此,我们将使用 AfterSelectHook,它会在每次为 User 执行 Select() 后执行。该钩子将被添加到 services/store/users.go

var _ pg.AfterSelectHook = (*User)(nil)

func (user *User) AfterSelect(ctx context.Context) error {
  if user.Posts == nil {
    user.Posts = []*Post{}
  }
  returnnil
}

十、错误异常处理

如果您尝试使用太短的密码创建新帐户,您将收到错误 Key: 'User.Password' Error:Field validation for 'Password' failed on the 'min' 标签。这不是真正好的用户体验,因此应该对其进行更改以获得更好的用户体验。让我们看看如何将其转换为我们自己的自定义错误消息。为此,我们将在 services/server/middleware.go 文件中创建新的 Gin 处理程序函数:

func customErrors(ctx *gin.Context) {
  ctx.Next()
  iflen(ctx.Errors) > 0 {
    for _, err := range ctx.Errors {
      // Check error type
      switch err.Type {
      case gin.ErrorTypePublic:
        // Show public errors only if nothing has been written yet
        if !ctx.Writer.Written() {
          ctx.AbortWithStatusJSON(ctx.Writer.Status(), gin.H{“error”: err.Error()})
        }
      case gin.ErrorTypeBind:
        errMap := make(map[string]string)
        if errs, ok := err.Err.(validator.ValidationErrors); ok {
          for _, fieldErr := range []validator.FieldError(errs) {
            errMap[fieldErr.Field()] = customValidationError(fieldErr)
          }
        }

        status := http.StatusBadRequest
        // Preserve current status
        if ctx.Writer.Status() != http.StatusOK {
          status = ctx.Writer.Status()
        }
        ctx.AbortWithStatusJSON(status, gin.H{“error”: errMap})
      default:
        // Log other errors
        log.Error().Err(err.Err).Msg(“Other error”)
      }
    }

    // If there was no public or bind error, display default 500 message
    if !ctx.Writer.Written() {
      ctx.AbortWithStatusJSON(http.StatusInternalServerError, gin.H{“error”: InternalServerError})
    }
  }
}

func customValidationError(err validator.FieldError) string {
  switch err.Tag() {
  case “required”:
    return fmt.Sprintf(“%s is required.”, err.Field())
  case “min”:
    return fmt.Sprintf(“%s must be longer than or equal %s characters.”, err.Field(), err.Param())
  case “max”:
    return fmt.Sprintf(“%s cannot be longer than %s characters.”, err.Field(), err.Param())
  default:
    return err.Error()
  }
}

internal/server/server.go 中定义常量 InternalServerError

const InternalServerError = “Something went wrong!”

让我们在 services/server/router.go 中使用新的 Gin 中间件:

func setRouter() *gin.Engine {
  // Creates default gin router with Logger and Recovery middleware already attached
  router := gin.Default()

  // Enables automatic redirection if the current route can’t be matched but a
  // handler for the path with (without) the trailing slash exists.
  router.RedirectTrailingSlash = true

  // Create API route group
  api := router.Group(“/api”)
  api.Use(customErrors)
  {
    api.POST(“/signup”, gin.Bind(store.User{}), signUp)
    api.POST(“/signin”, gin.Bind(store.User{}), signIn)
  }

  authorized := api.Group(“/“)
  authorized.Use(authorization)
  {
    authorized.GET(“/posts”, indexPosts)
    authorized.POST(“/posts”, createPost)
    authorized.PUT(“/posts”, updatePost)
    authorized.DELETE(“/posts/:id”, deletePost)
  }

  router.NoRoute(func(ctx *gin.Context) { ctx.JSON(http.StatusNotFound, gin.H{}) })

  return router
}

我们现在在 api 组中使用 customErrors 中间件。但这并不是唯一的变化。注意登录和注册的更新路由:

api.POST(“/signup”, gin.Bind(store.User{}), signUp)
api.POST(“/signin”, gin.Bind(store.User{}), signIn)

通过这些更改,我们甚至会在点击 signUp 和 signIn 处理程序之前尝试绑定请求数据,这意味着只有在表单验证通过时才会到达处理程序。通过这样的设置,处理程序不需要考虑绑定错误,因为如果到达处理程序就没有绑定错误。考虑到这一点,让我们更新这两个处理程序:

func signUp(ctx *gin.Context) {
  user := ctx.MustGet(gin.BindKey).(*store.User)
  if err := store.AddUser(user); err != nil {
    ctx.AbortWithStatusJSON(http.StatusBadRequest, gin.H{“error”: err.Error()})
    return
  }
  ctx.JSON(http.StatusOK, gin.H{
    “msg”: “Signed up successfully.”,
    “jwt”: generateJWT(user),
  })
}

func signIn(ctx *gin.Context) {
  user := ctx.MustGet(gin.BindKey).(*store.User)
  user, err := store.Authenticate(user.Username, user.Password)
  if err != nil {
    ctx.AbortWithStatusJSON(http.StatusUnauthorized, gin.H{“error”: “Sign in failed.”})
    return
  }

  ctx.JSON(http.StatusOK, gin.H{
    “msg”: “Signed in successfully.”,
    “jwt”: generateJWT(user),
  })
}

我们的处理程序现在简单得多,它们只处理数据库错误。如果您再次尝试使用太短的用户名和密码创建帐户,您将看到更具可读性和描述性的错误:

上面提到了页面登陆相关的错误,如果数据库发生错误我们也呀优雅的处理,您尝试使用现有用户名创建帐户,ERROR #23505 duplicate key value violates unique constraint “users_username_key”。不幸的是,这里没有涉及验证器,pg 模块将大部分错误返回为 map[byte]string,因此这可能有点棘手。

一种方法是通过执行数据库查询手动检查每个错误情况。例如,要检查具有给定用户名的用户是否已存在于数据库中,我们可以在尝试创建新用户之前执行此操作:

func AddUser(user *User) error {
  err = db.Model(user).Where(“username = ?”, user.Username).Select()
  if err != nil {
    return errors.New(“Username already exists.”)
  }
  …
}

问题是这会变得非常乏味。需要针对与数据库通信的每个函数中的每个错误情况执行此操作。最重要的是,我们不必要地增加了数据库查询。在这个简单的例子中,对于每个成功的用户创建,现在将有 2 个数据库查询,而不是 1 个。还有一种方法,那就是尝试做一次查询,如果发生错误再解析。这是棘手的部分,因为我们需要使用正则表达式处理每种错误类型,以提取创建更用户友好的自定义错误消息所需的相关数据。那么让我们开始吧。如前所述,pg 错误主要是 map[byte]string 类型,因此当您尝试使用现有用户名创建用户帐户时,对于此特定错误,您将在下图中获得Map对象:

B5566213-AA61-4F8F-8116-C7CC3210C971.png

为了提取相关数据,我们将使用字段 82 和 110。错误类型将从字段 82 中读取,我们将从字段 110 中提取列名。让我们将这些函数添加到 services/store/store.go

func dbError(_err interface{}) error {
  if _err == nil {
    returnnil
  }
  switch _err.(type) {
  case pg.Error:
    err := _err.(pg.Error)
    switch err.Field(82) {
    case “_bt_check_unique”:
      return errors.New(extractColumnName(err.Field(110)) + “ already exists.”)
    }
  case error:
    err := _err.(error)
    switch err.Error() {
    case “pg: no rows in result set”:
      return errors.New(“Not found.”)
    }
    return err
  }
  return errors.New(fmt.Sprint(_err))
}

func extractColumnName(text string) string {
  reg := regexp.MustCompile(`.+_(.+)_.+`)
  if reg.MatchString(text) {
    return strings.Title(reg.FindStringSubmatch(text)[1])
  }
  return “Unknown”
}

有了这个,我们可以从 services/store/users.go 调用这个 dbError()函数:

func AddUser(user *User) error {
  …

  _, err = db.Model(user).Returning(“*”).Insert()
  if err != nil {
    log.Error().Err(err).Msg(“Error inserting new user”)
    return dbError(err)
  }
  returnnil
}

如果我们使用已有的用户名,就可以提示一个更优雅的提示了。

另外还需要优雅的是关闭服务。我们要修改一下 services/server/server.go

package server

import (
  “context”
  “errors”
  “net/http”
  “os”
  “os/signal”
  “pharos/services/conf”
  “pharos/services/database”
  “pharos/services/store”
  “syscall”
  “time”

  “github.com/rs/zerolog/log”
)

const InternalServerError = “Something went wrong!”

func Start(cfg conf.Config) {
  jwtSetup(cfg)

  store.SetDBConnection(database.NewDBOptions(cfg))

  router := setRouter()

  server := &http.Server{
    Addr:    cfg.Host + “:” + cfg.Port,
    Handler: router,
  }

  // Initializing the server in a goroutine so that
  // it won’t block the graceful shutdown handling below
  gofunc() {
    if err := server.ListenAndServe(); err != nil && errors.Is(err, http.ErrServerClosed) {
      log.Error().Err(err).Msg(“Server ListenAndServe error”)
    }
  }()

  // Wait for interrupt signal to gracefully shutdown the server with
  // a timeout of 5 seconds.
  quit := make(chan os.Signal)
  // kill (no param) default send syscall.SIGTERM
  // kill -2 is syscall.SIGINT
  // kill -9 is syscall.SIGKILL but can’t be catch, so don’t need add it
  signal.Notify(quit, syscall.SIGINT, syscall.SIGTERM)
  <-quit
  log.Info().Msg(“Shutting down server…”)

  // The context is used to inform the server it has 5 seconds to finish
  // the request it is currently handling
  ctx, cancel := context.WithTimeout(context.Background(), 5*time.Second)
  defer cancel()

  if err := server.Shutdown(ctx); err != nil {
    log.Fatal().Err(err).Msg(“Server forced to shutdown”)
  }

  log.Info().Msg(“Server exiting.”)
}

十一、测试

编写单元和集成测试是软件开发的重要组成部分,在开始编写测试相关之前需要确保一些基础的应用问题,例如,主要要做的是创建测试数据库。这将通过使用已经创建的开发数据库模式来完成。

我们将从创建新的测试配置开始。将下面的函数添加到 services/conf/conf.go

func NewTestConfig() Config {
  testConfig := NewConfig()
  testConfig.DbName = testConfig.DbName + "_test"
  return testConfig
}

这将创建与通常配置相同的新配置,但将 _test后缀附加到数据库名称。请参考之前添加数据库的例子在数据库中增加新的数据库,测试数据库将被命名为 pharos_test

DROP DATABASE IF EXISTS pharos_test;
CREATE DATABASE pharos_test WITH TEMPLATE pharos;

创建一个叫 pharos_test 的数据库,每次更改开放数据库时候都需要执行此操作。

每次测试用例都必须独立于其他用例,因此我嗯应该为每次测试用例使用新的数据库,所以,将在每个测试用例开始时创建和调用充值的数据库函数,在这个函数中我们将重置所有表名,清除所有表,重置它的计数器,确保所有ID排序从 1 开始,我们可以将函数添加到 services/store/store.go

func ResetTestDatabase() {
  // Connect to test database
  SetDBConnection(database.NewDBOptions(conf.NewTestConfig()))

  // Empty all tables and restart sequence counters
  tables := []string{“users”, “posts”}
  for _, table := range tables {
    _, err := db.Exec(fmt.Sprintf(“DELETE FROM %s;”, table))
    if err != nil {
      log.Panic().Err(err).Str(“table”, table).Msg(“Error clearing test database”)
    }

    _, err = db.Exec(fmt.Sprintf(“ALTER SEQUENCE %s_id_seq RESTART;”, table))
  }
}

在大多数测试中我们需要做的一件事是设置测试环境并创建新用户。我们不想在每个测试用例中重复这一点。让我们创建文件 services/store/main_test.go 并添加辅助函数:

package store

import (
  “pharos/services/conf”
  “pharos/services/store”

  “github.com/gin-gonic/gin”
)

func testSetup() *gin.Engine {
  gin.SetMode(gin.TestMode)
  store.ResetTestDatabase()
  cfg := conf.NewConfig(“dev”)
  jwtSetup(cfg)
  return setRouter(cfg)
}

func addTestUser() (*User, error) {
  user := &User{
    Username: “batman”,
    Password: “secret123”,
  }
  err := AddUser(user)
  return user, err
}

准备工作完成,现在我们可以开始添加测试了。让我们创建新文件 services/store/users_test.go 并创建第一个测试:

package store

import (
  “testing”

  “github.com/stretchr/testify/assert”
)

func TestAddUser(t *testing.T) {
  testSetup()
  user, err := addTestUser()
  assert.NoError(t, err)
  assert.Equal(t, 1, user.ID)
  assert.NotEmpty(t, user.Salt)
  assert.NotEmpty(t, user.HashedPassword)
}

我们可以为用户帐户创建添加的另一个测试是当用户尝试使用现有用户名创建帐户时:

func TestAddUserWithExistingUsername(t *testing.T) {
  testSetup()
  user, err := addTestUser()
  assert.NoError(t, err)
  assert.Equal(t, 1, user.ID)

  user, err = addTestUser()
  assert.Error(t, err)
  assert.Equal(t, “Username already exists.”, err.Error())
}

为了测试 Authenticate()函数,我们将创建 3 个测试:成功的身份验证、使用无效用户名进行身份验证和使用无效密码进行身份验证:

func TestAuthenticateUser(t *testing.T) {
  testSetup()
  user, err := addTestUser()
  assert.NoError(t, err)

  authUser, err := Authenticate(user.Username, user.Password)
  assert.NoError(t, err)
  assert.Equal(t, user.ID, authUser.ID)
  assert.Equal(t, user.Username, authUser.Username)
  assert.Equal(t, user.Salt, authUser.Salt)
  assert.Equal(t, user.HashedPassword, authUser.HashedPassword)
  assert.Empty(t, authUser.Password)
}

func TestAuthenticateUserInvalidUsername(t *testing.T) {
  testSetup()
  user, err := addTestUser()
  assert.NoError(t, err)

  authUser, err := Authenticate(“invalid”, user.Password)
  assert.Error(t, err)
  assert.Nil(t, authUser)
}

func TestAuthenticateUserInvalidPassword(t *testing.T) {
  testSetup()
  user, err := addTestUser()
  assert.NoError(t, err)

  authUser, err := Authenticate(user.Username, “invalid”)
  assert.Error(t, err)
  assert.Nil(t, authUser)
}

最后,我们将使用 2 个测试来测试 FetchUser() 函数:成功获取和获取不存在的用户:

func TestFetchUser(t *testing.T) {
  testSetup()
  user, err := addTestUser()
  assert.NoError(t, err)

  fetchedUser, err := FetchUser(user.ID)
  assert.NoError(t, err)
  assert.Equal(t, user.ID, fetchedUser.ID)
  assert.Equal(t, user.Username, fetchedUser.Username)
  assert.Empty(t, fetchedUser.Password)
  assert.Equal(t, user.Salt, fetchedUser.Salt)
  assert.Equal(t, user.HashedPassword, fetchedUser.HashedPassword)
}

func TestFetchNotExistingUser(t *testing.T) {
  testSetup()

  fetchedUser, err := FetchUser(1)
  assert.Error(t, err)
  assert.Nil(t, fetchedUser)
  assert.Equal(t, “Not found.”, err.Error())
}

上面的测试函数只测试数据库通信,但我们的路由器和处理程序在这里没有测试。为此,我们将需要另一组测试。首先,我们应该创建更多的辅助函数。我们将创建新文件 services/server/main_test.go

package server

import (
  "bytes"
  "encoding/json"
  "net/http"
  "net/http/httptest"
  "pharos/services/store"
  "strings"

  "github.com/gin-gonic/gin"
  "github.com/rs/zerolog/log"
)

func testSetup() *gin.Engine {
  gin.SetMode(gin.TestMode)
  store.ResetTestDatabase()
  jwtSetup()
  return setRouter()
}

func userJSON(user store.User) string {
  body, err := json.Marshal(map[string]interface{}{
    “Username”: user.Username,
    “Password”: user.Password,
  })
  if err != nil {
    log.Panic().Err(err).Msg(“Error marshalling JSON body.”)
  }
  returnstring(body)
}

func jsonRes(body *bytes.Buffer) map[string]interface{} {
  jsonValue := &map[string]interface{}{}
  err := json.Unmarshal(body.Bytes(), jsonValue)
  if err != nil {
    log.Panic().Err(err).Msg(“Error unmarshalling JSON body.”)
  }
  return *jsonValue
}

func performRequest(router *gin.Engine, method, path, body string) *httptest.ResponseRecorder {
  req, err := http.NewRequest(method, path, strings.NewReader(body))
  if err != nil {
    log.Panic().Err(err).Msg(“Error creating new request”)
  }
  rec := httptest.NewRecorder()
  req.Header.Add(“Content-Type”, “application/json”)
  router.ServeHTTP(rec, req)
  return rec
}

除了最后一个,performRequest(),这些函数中的大多数都不是什么新鲜事。在该函数中,我们使用 http 包创建新请求,并使用 httptest 包创建新记录器。我们还需要将值为 application/jsonContent-Type 标头添加到我们的测试请求中。我们现在准备使用传递的路由器来处理该测试请求,并使用记录器记录响应。现在让我们看看如何实际使用这些函数。创建新文件 services/server/user_test.go

package server

import (
  “net/http”
  “pharos/services/store”
  “testing”

  “github.com/stretchr/testify/assert”
)

func TestSignUp(t *testing.T) {
  router := testSetup()

  body := userJSON(store.User{
    Username: “batman”,
    Password: “secret123”,
  })
  rec := performRequest(router, “POST”, “/api/signup”, body)

  assert.Equal(t, http.StatusOK, rec.Code)
  assert.Equal(t, “Signed up successfully.”, jsonRes(rec.Body)[“msg”])
  assert.NotEmpty(t, jsonRes(rec.Body)[“jwt”])
}

需要注意的是测试用例是按顺序运行的,没有并行性。如果同时运行,它们可能会相互影响,因为对于每个测试用例,数据库都是空的。如果您的机器有多个内核,Go 默认使用多个 goroutine 来运行测试。为了确保只使用了 1 个 goroutine,请添加 -p 1 选项。这意味着您应该使用以下命令运行测试:

go test -p 1 ./internal/…

十二、部署

我们的服务器已经完成,几乎可以部署了,这将使用 Docker 完成。请注意,我说它几乎准备好了,所以让我们看看缺少什么。一直以来,我们都使用 React 开发服务器,它监听 8181 端口并将所有请求重定向到我们的后端 8080 端口。这对开发很有意义,因为它可以更轻松地同时开发前端和后端,以及调试前端反应应用程序。但是在生产中我们不需要它,只运行我们的后端服务器并将静态前端文件提供给客户端更有意义。因此,我们不会使用 npm start 命令启动 React 开发服务器,而是使用 app/ 目录中的命令 npm run build 为生产构建优化的前端文件。这将创建新目录 app/build/ 以及生产所需的所有文件。现在我们还必须指示我们的后端在哪里可以找到这些文件以便能够为它们提供服务。这只需使用命令 router.Use(static.Serve("/", static.LocalFile("./app/build", true))) 即可完成。当然,我们希望只有在 prod 环境中启动服务器时才这样做,因此我们需要稍微更新一些文件。

首先,我们将更新 services/cli/cli.go 中的 Parse() 函数以将环境值作为字符串返回:

func Parse() string {
  flag.Usage = usage
  env := flag.String(“env”, “dev”, `Sets run environment. Possible values are "dev" and "prod"`)
  flag.Parse()
  logging.ConfigureLogger(*env)
  if *env == “prod” {
    logging.SetGinLogToFile()
  }
  return *env
}

然后我们将更新 Config struct NewConfig() 函数以能够接收和设置环境值:

pe Config struct {
  Host       string
  Port       string
  DbHost     string
  DbPort     string
  DbName     string
  DbUser     string
  DbPassword string
  JwtSecret  string
  Env        string
}

func NewConfig(env string) Config {
  …
  return Config{
    Host:       host,
    Port:       port,
    DbHost:     dbHost,
    DbPort:     dbPort,
    DbName:     dbName,
    DbUser:     dbUser,
    DbPassword: dbPassword,
    JwtSecret:  jwtSecret,
    Env:        env,
  }
}

现在我们可以更新 services/cli/main.go以接收来自 CLI 的 env 值,并将其发送到将用于启动服务器的新配置创建:

func main() {
  env := cli.Parse()
  server.Start(conf.NewConfig(env))
}

接下来我们要做的是更新路由器以能够接收配置参数,并将其设置为在生产模式下启动时提供静态文件:

package server

import (
  "net/http"
  “pharos/services/conf”
  “pharos/services/store”

  “github.com/gin-contrib/static”
  “github.com/gin-gonic/gin”
)

func setRouter(cfg conf.Config) *gin.Engine {
  // Creates default gin router with Logger and Recovery middleware already attached
  router := gin.Default()

  // Enables automatic redirection if the current route can’t be matched but a
  // handler for the path with (without) the trailing slash exists.
  router.RedirectTrailingSlash = true

  // Serve static files to frontend if server is started in production environment
  if cfg.Env == “prod” {
    router.Use(static.Serve(“/“, static.LocalFile(“./app/build”, true)))
  }

  // Create API route group
  api := router.Group(“/api”)
  api.Use(customErrors)
  {
    api.POST(“/signup”, gin.Bind(store.User{}), signUp)
    api.POST(“/signin”, gin.Bind(store.User{}), signIn)
  }

  authorized := api.Group(“/“)
  authorized.Use(authorization)
  {
    authorized.GET(“/posts”, indexPosts)
    authorized.POST(“/posts”, gin.Bind(store.Post{}), createPost)
    authorized.PUT(“/posts”, gin.Bind(store.Post{}), updatePost)
    authorized.DELETE(“/posts/:id”, deletePost)
  }

  router.NoRoute(func(ctx *gin.Context) { ctx.JSON(http.StatusNotFound, gin.H{}) })

  return router
}

需要更新的最后一行在 migrations/main.go 文件中

store.SetDBConnection(database.NewDBOptions(conf.NewConfig()))

改为

store.SetDBConnection(database.NewDBOptions(conf.NewConfig(“dev”)))

这还没有完成。您还必须更新所有使用配置和路由器设置的测试。

现在一切准备就绪,可以进行 Docker 部署。Docker 不在本指南的范围内,因此我不会详细介绍 Dockerfile.dockerignoredocker-compose.yml 内容。

首先我们将在项目根目录中创建 .dockerignore 文件:

# This file
.dockerignore

# Git files
.git/
.gitignore

# VS Code config dir
.vscode/

# Docker configuration files
docker/

# Assets dependencies and built files
app/build/
app/node_modules/

# Log files
logs/

# Built binary
cmd/pharos/pharos

# ENV file
.env

# Readme file
README.md

现在用两个文件 Dockerfiledocker-compose.yml 创建新目录 docker/Dockerfile 的内容将是:

FROM node:16 AS frontendBuilder

# set app work dir
WORKDIR /pharos

# copy assets files to the container
COPY app/ .

# set app/ as work dir to build frontend static files
WORKDIR /pharos/app
RUN npm install
RUN npm run build

FROM golang:1.16.3 AS backendBuilder

# set app work dir
WORKDIR /go/src/pharos

# copy all files to the container
COPY . .

# build app executable
RUN CGO_ENABLED=0 GOOS=linux go build -o cmd/pharos/pharos cmd/pharos/main.go

# build migrations executable
RUN CGO_ENABLED=0 GOOS=linux go build -o migrations/migrations migrations/*.go

FROM alpine:3.14

# Create a group and user deploy
RUN addgroup -S deploy && adduser -S deploy -G deploy

ARG ROOT_DIR=/home/deploy/pharos

WORKDIR ${ROOT_DIR}

RUN chown deploy:deploy ${ROOT_DIR}

# copy static assets file from frontend build
COPY —from=frontendBuilder —chown=deploy:deploy /pharos/build ./app/build

# copy app and migrations executables from backend builder
COPY —from=backendBuilder —chown=deploy:deploy /go/src/pharos/migrations/migrations ./migrations/
COPY —from=backendBuilder —chown=deploy:deploy /go/src/pharos/cmd/pharos/pharos .

# set user deploy as current user
USER deploy

# start app
CMD [ “./pharos”, “-env”, “prod” ]

docker-compose.yml 的内容是:

version: “3”
services:
  pharos:
    image: kramat/pharos
    env_file:
      - ../.env
    environment:
      PHAROS_DB_HOST: db
    depends_on:
      - db
    ports:
      - ${PHAROS_PORT}:${PHAROS_PORT}
  db:
    image: postgres
    environment:
      POSTGRES_USER: ${PHAROS_DB_USER}
      POSTGRES_PASSWORD: ${PHAROS_DB_PASSWORD}
      POSTGRES_DB: ${PHAROS_DB_NAME}
    ports:
      - ${PHAROS_DB_PORT}:${PHAROS_DB_PORT}
    volumes:
      - postgresql:/var/lib/postgresql/pharos
      - postgresql_data:/var/lib/postgresql/pharos/data
volumes:
  postgresql: {}
  postgresql_data: {}

Docker 部署所需的所有文件现已准备就绪,让我们看看如何构建 Docker 镜像并部署它。首先,我们将从官方 Docker 容器存储库中拉取 postgres 镜像:

docker pull postgres

下一步是构建 pharos 镜像。在项目根目录中运行(使用您自己的 docker ID 更改 DOCKER_ID):

docker build -t DOCKER_ID/pharos -f docker/Dockerfile .

要使用资源创建 pharos 和 db 容器,请运行:

cd docker/
docker-compose up -d

这将启动两个容器,您可以通过运行 docker ps 检查它们的状态。最后,我们需要运行迁移。通过运行在 pharos 容器中打开 shell:

docker-compose run —rm pharos sh

在容器内部,我们可以像以前一样运行迁移:

cd migrations/
./migrations init
./migrations up

我们已经完成了。您可以在浏览器中打开 localhost:8181 以检查一切是否正常,这意味着您应该能够创建帐户并添加新帖子:

要完善一个网站还有很多事情需要做,不仅仅是这些,以上的只是抛砖引玉。

参考资**料**

[1]github源码地址: https://github.com/yuanliang/pharos/

[2]go官网: https://dev.to/

[3]Gin官网: https://gin-gonic.com/

[4]React官网: https://reactjs.org/

[5]Esbuild官网: https://esbuild.github.io/api/

[6]TypeScript官网: https://www.typescriptlang.org/

[7]PostgreSQL官网: https://www.postgresql.org/

Copyright© 2013-2020

All Rights Reserved 京ICP备2023019179号-8