本指北手册,手把手跟大家从头开始构建一个完成一个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
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

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
package.json”proxy”: “http://localhost: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
}
//
siteyarn start | npm run start
Screen Shot 2021-12-07 at 6.28.21 PM.png
Screen Shot 2021-12-07 at 6.27.07 PM.png
三、整理目录结构和添加路由

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

servicesserverserver.goserverrouter.go
main.go
package main

import "pharos/services/server"

func main() {
	server.Start()
}

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

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

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

四、创建用户对象以及登陆注册方法
User
servicesstoreservices/storeusers.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
}
signUpsignInservices/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
POST
1C23012A-7952-44F1-BA6E-6A18E82E95C5.png
F44D09A5-9A76-4E56-8E5F-4567A2EF9B25.png

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

app/src/components/Auth/AuthForm.tsxsubmitHandler()
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
五、添加数据库
PostgreSQLPostgreSQL
PostgreSQL
sudo service postgresql start
sudo -u postgres psql

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

CREATE DATABASE pharos;
go get github.com/go-pg/pg/v10servicesdatabasedatabase.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 }
pg.Connectservices/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
  }
  return nil
}

func Authenticate(username, password string) (*User, error) {
  user := new(User)
  if err := db.Model(user).Where(
    “username = ?”, username).Select(); err != nil {
    return nil, err
  }
  if password != user.Password {
    return nil, 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/migrationsmigrationsmain.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.goSetDBConnection()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
services/store/users.go
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
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 {
    return nil, err
  }
  salted := append([]byte(password), user.Salt…)
  if err := bcrypt.CompareHashAndPassword(user.HashedPassword, salted); err != nil {
    return nil, err
  }
  return user, nil
}

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

再次更新一下数据库

cd migrations/
go run *.go reset
go run *.go up
E2E8492B-DA4E-40B4-BBF3-98D1B0CA6C67.png

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

六、增加配置文件以及增加启动脚本
.envservices/confconf.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.gopharos/services/conf
store.SetDBConnection(database.NewDBOptions())
store.SetDBConnection(database.NewDBOptions(conf.NewConfig()))
Enter fullscreen mode
.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

.envservices/clicli.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())
}
scriptsdeploy.sh
#! /bin/bash

# default ENV is dev
env=dev

while test $# -gt 0; do
  case “$1” in
    -env)
      shift
      if test $# -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=devenvcmdmain.gogo build =o cmd/pharos/pharos cmd/pharos/main.gocmd/pharos/pharos -env $env &env
stop.shscripts/
#! /bin/bash

kill $(pidof pharos)
pharos

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

chmod +x deploy.sh
chmod +x stop.sh
scripts/
./deploy.sh
./stop.sh
七、添加日志记录

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

go get github.com/rs/zerolog/log
services/logginglogging.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()
  }
}
prod caseservices/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”)
    return nil, err
  }
  return salt, nil
}
八、JWT authentication
go get github.com/cristalhq/jwt/v3
export PHAROS_JWT_SECRET=jwtSecret123.env services/conf/conf.gojwtSecretKey = "PHAROS_JWT_SECRET"JwtSecret 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
services/server/jwt.goverifyJWT()
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”)
    return 0, err
  }

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

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

  if notExpired := claims.IsValidAt(time.Now()); !notExpired {
    return 0, 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”)
    return 0, errors.New(“ID in token is not valid”)
  }
  return id, err
}
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”)
    return nil, 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, “ “)
  if len(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()

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

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(“”)
    return nil, err
  }
  user, ok := _user.(*store.User)
  if !ok {
    err = errors.New(“Context user is not valid type”)
    log.Error().Err(err).Msg(“”)
    return nil, err
  }
  return user, nil
}
ctx.Get()*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
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/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”)
    return nil, 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")

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

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.PostsAfterSelectHookSelect()services/store/users.go
var _ pg.AfterSelectHook = (*User)(nil)

func (user *User) AfterSelect(ctx context.Context) error {
  if user.Posts == nil {
    user.Posts = []*Post{}
  }
  return nil
}
十、错误异常处理
Key: 'User.Password' Error:Field validation for 'Password' failed on the 'min'services/server/middleware.go
func customErrors(ctx *gin.Context) {
  ctx.Next()
  if len(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.goInternalServerError
const InternalServerError = “Something went wrong!”
services/server/router.go
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
}
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”pgmap[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
services/store/store.go
func dbError(_err interface{}) error {
  if _err == nil {
    return nil
  }
  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.godbError()
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)
  }
  return nil
}

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

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
  go func() {
    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
}
_testpharos_test
DROP DATABASE IF EXISTS pharos_test;
CREATE DATABASE pharos_test WITH TEMPLATE pharos;
pharos_test
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()
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)
}
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.”)
  }
  return string(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()application/jsonContent-Typeservices/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/…
十二、部署
npm startapp/npm run buildrouter.Use(static.Serve("/", static.LocalFile("./app/build", true)))
services/cli/cli.go
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 
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”)))

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

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.ymldocker/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
cd docker/
docker-compose up -d
docker pspharos
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
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

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
package.json”proxy”: “http://localhost: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
}
//
siteyarn start | npm run start
Screen Shot 2021-12-07 at 6.28.21 PM.png
Screen Shot 2021-12-07 at 6.27.07 PM.png
三、整理目录结构和添加路由

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

servicesserverserver.goserverrouter.go
main.go
package main

import "pharos/services/server"

func main() {
	server.Start()
}

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

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

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

四、创建用户对象以及登陆注册方法
User
servicesstoreservices/storeusers.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
}
signUpsignInservices/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
POST
1C23012A-7952-44F1-BA6E-6A18E82E95C5.png
F44D09A5-9A76-4E56-8E5F-4567A2EF9B25.png

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

app/src/components/Auth/AuthForm.tsxsubmitHandler()
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
五、添加数据库
PostgreSQLPostgreSQL
PostgreSQL
sudo service postgresql start
sudo -u postgres psql

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

CREATE DATABASE pharos;
go get github.com/go-pg/pg/v10servicesdatabasedatabase.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 }
pg.Connectservices/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
  }
  return nil
}

func Authenticate(username, password string) (*User, error) {
  user := new(User)
  if err := db.Model(user).Where(
    “username = ?”, username).Select(); err != nil {
    return nil, err
  }
  if password != user.Password {
    return nil, 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/migrationsmigrationsmain.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.goSetDBConnection()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
services/store/users.go
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
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 {
    return nil, err
  }
  salted := append([]byte(password), user.Salt…)
  if err := bcrypt.CompareHashAndPassword(user.HashedPassword, salted); err != nil {
    return nil, err
  }
  return user, nil
}

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

再次更新一下数据库

cd migrations/
go run *.go reset
go run *.go up
E2E8492B-DA4E-40B4-BBF3-98D1B0CA6C67.png

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

六、增加配置文件以及增加启动脚本
.envservices/confconf.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.gopharos/services/conf
store.SetDBConnection(database.NewDBOptions())
store.SetDBConnection(database.NewDBOptions(conf.NewConfig()))
Enter fullscreen mode
.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

.envservices/clicli.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())
}
scriptsdeploy.sh
#! /bin/bash

# default ENV is dev
env=dev

while test $# -gt 0; do
  case “$1” in
    -env)
      shift
      if test $# -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=devenvcmdmain.gogo build =o cmd/pharos/pharos cmd/pharos/main.gocmd/pharos/pharos -env $env &env
stop.shscripts/
#! /bin/bash

kill $(pidof pharos)
pharos

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

chmod +x deploy.sh
chmod +x stop.sh
scripts/
./deploy.sh
./stop.sh
七、添加日志记录

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

go get github.com/rs/zerolog/log
services/logginglogging.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()
  }
}
prod caseservices/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”)
    return nil, err
  }
  return salt, nil
}
八、JWT authentication
go get github.com/cristalhq/jwt/v3
export PHAROS_JWT_SECRET=jwtSecret123.env services/conf/conf.gojwtSecretKey = "PHAROS_JWT_SECRET"JwtSecret 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
services/server/jwt.goverifyJWT()
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”)
    return 0, err
  }

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

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

  if notExpired := claims.IsValidAt(time.Now()); !notExpired {
    return 0, 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”)
    return 0, errors.New(“ID in token is not valid”)
  }
  return id, err
}
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”)
    return nil, 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, “ “)
  if len(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()

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

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(“”)
    return nil, err
  }
  user, ok := _user.(*store.User)
  if !ok {
    err = errors.New(“Context user is not valid type”)
    log.Error().Err(err).Msg(“”)
    return nil, err
  }
  return user, nil
}
ctx.Get()*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
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/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”)
    return nil, 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")

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

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.PostsAfterSelectHookSelect()services/store/users.go
var _ pg.AfterSelectHook = (*User)(nil)

func (user *User) AfterSelect(ctx context.Context) error {
  if user.Posts == nil {
    user.Posts = []*Post{}
  }
  return nil
}
十、错误异常处理
Key: 'User.Password' Error:Field validation for 'Password' failed on the 'min'services/server/middleware.go
func customErrors(ctx *gin.Context) {
  ctx.Next()
  if len(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.goInternalServerError
const InternalServerError = “Something went wrong!”
services/server/router.go
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
}
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”pgmap[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
services/store/store.go
func dbError(_err interface{}) error {
  if _err == nil {
    return nil
  }
  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.godbError()
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)
  }
  return nil
}

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

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
  go func() {
    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
}
_testpharos_test
DROP DATABASE IF EXISTS pharos_test;
CREATE DATABASE pharos_test WITH TEMPLATE pharos;
pharos_test
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()
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)
}
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.”)
  }
  return string(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()application/jsonContent-Typeservices/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/…
十二、部署
npm startapp/npm run buildrouter.Use(static.Serve("/", static.LocalFile("./app/build", true)))
services/cli/cli.go
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 
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”)))

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

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.ymldocker/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
cd docker/
docker-compose up -d
docker pspharos
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/

- END -

关于奇舞团

奇舞团是 360 集团最大的大前端团队,代表集团参与 W3C 和 ECMA 会员(TC39)工作。奇舞团非常重视人才培养,有工程师、讲师、翻译官、业务接口人、团队 Leader 等多种发展方向供员工选择,并辅以提供相应的技术力、专业力、通用力、领导力等培训课程。奇舞团以开放和求贤的心态欢迎各种优秀人才关注和加入奇舞团。