本指北手册,手把手跟大家从头开始构建一个完成一个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]查看了解更多信息。
相关安装都在官网有详细介绍就不在这里赘述了。
首先,我们要给我们的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"}
可以在命令行工具里面看到访问情况。
二、添加Reactesbuild-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
现在我们来重新规划目录结构,感觉比较正规一丢丢,像个架构师一样。代码按照功能来区分是一个比较好的最佳时间。
servicesserverserver.goserverrouter.go
main.go
package main
import "pharos/services/server"
func main() {
server.Start()
}
调整后的目录结构是这样的
现在再次启动一次服务看看效果:)
四、创建用户对象以及登陆注册方法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
接下来是填写React的相关前端的页面 可以直接到github上去下载
app/src/components/Auth/AuthForm.tsxsubmitHandler()
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服务),不符合规则的会返回服务端错误。
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
重新在浏览器当中访问页面,并且创建帐号就看到加密后的密码被更新到数据库中了已经。
六、增加配置文件以及增加启动脚本.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:
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(创建、读取、更新、删除)方法的配方都是相同的:
实现与数据库通信以执行所需操作的功能
实现 Gin 处理程序,它将使用步骤 1 中的函数
将带有处理程序的路由添加到路由器
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对象:
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"}
可以在命令行工具里面看到访问情况。
二、添加Reactesbuild-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
现在我们来重新规划目录结构,感觉比较正规一丢丢,像个架构师一样。代码按照功能来区分是一个比较好的最佳时间。
servicesserverserver.goserverrouter.go
main.go
package main
import "pharos/services/server"
func main() {
server.Start()
}
调整后的目录结构是这样的
现在再次启动一次服务看看效果:)
四、创建用户对象以及登陆注册方法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
接下来是填写React的相关前端的页面 可以直接到github上去下载
app/src/components/Auth/AuthForm.tsxsubmitHandler()
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服务),不符合规则的会返回服务端错误。
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
重新在浏览器当中访问页面,并且创建帐号就看到加密后的密码被更新到数据库中了已经。
六、增加配置文件以及增加启动脚本.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:
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(创建、读取、更新、删除)方法的配方都是相同的:
实现与数据库通信以执行所需操作的功能
实现 Gin 处理程序,它将使用步骤 1 中的函数
将带有处理程序的路由添加到路由器
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对象:
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 以检查一切是否正常,这意味着您应该能够创建帐户并添加新帖子:
要完善一个网站还有很多事情需要做,不仅仅是这些,以上的只是抛砖引玉。
参考资料
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 等多种发展方向供员工选择,并辅以提供相应的技术力、专业力、通用力、领导力等培训课程。奇舞团以开放和求贤的心态欢迎各种优秀人才关注和加入奇舞团。