0%

从零到一建站(2)

逛 github 的时候发现这个项目了,提供了一个不错的 spec,后续的工作围绕它的 spec 来做:realworld

Fork form realworld

这是官方提供的 spec

现在 fork 这个项目,然后重命名成 golang-gin-starter-kit,然后参考上一篇文章,建立 go 的项目。

做一些小的实验

所有的实验我的是先在单个文件中实现,然后将他们的逻辑分散到各个不同的文件里面去。

建立DB连接池

我依旧打算使用 gorm,关于连接池的说法 GORM Pooling

先在单个文件里面使用中间件,中间件来添加数据库连接:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50

type Todo struct {
Title string `form:"title" json:"title" binding:"exists,required"`
}

func ApiMiddleware() gin.HandlerFunc {
db, err := gorm.Open("sqlite3", "gorm.db")
if err != nil {
fmt.Println("db err: ",err)
}
//defer db.Close()
return func(c *gin.Context) {
c.Set("DB", db)
c.Next()
}
}

func InitDB() {
db, err := gorm.Open("sqlite3", "gorm.db")
if err != nil {
log.Fatal(err)
}
defer db.Close()

db.AutoMigrate(&Todo{})
//todo := Todo{
// Title: "heheda",
//}
//db.Save(&todo)
//db.First(&todo)
//fmt.Println("db: ",todo)
}

func main() {
r := gin.Default()
InitDB()

r.Use(ApiMiddleware())

r.GET("/ping", func(c *gin.Context) {
db := c.MustGet("DB").(*gorm.DB) // MustGet returns an interface so you have to type cast it first :)
var todo Todo
db.First(&todo)
c.JSON(200, gin.H{
"message": "pong",
"todo": todo,
})
})
r.Run() // listen and serve on 0.0.0.0:8080
}

完成之后拆分成两个文件:
文件1:
storage/database.go

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
package storage

import (
"gopkg.in/gin-gonic/gin.v1"
"github.com/jinzhu/gorm"
"fmt"
)

func DatabaseConnection() *gorm.DB {
db, err := gorm.Open("sqlite3", "gorm.db")
if err != nil {
fmt.Println("db err: ",err)
}
return db
}

func ApiMiddleware(db *gorm.DB) gin.HandlerFunc {
return func(c *gin.Context) {
c.Set("DB", db)
c.Next()
}
}

文件2:
hello.go

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
package main

import (
"gopkg.in/gin-gonic/gin.v1"
"github.com/jinzhu/gorm"
"golang-gin-starter-kit/storage"
_ "github.com/jinzhu/gorm/dialects/sqlite"
"fmt"
)

type Todo struct {
gorm.Model
Title string `form:"title" json:"title" binding:"exists,required"`
}

func main() {
r := gin.Default()

db := storage.DatabaseConnection()
defer db.Close()
r.Use(storage.ApiMiddleware(db))


db.AutoMigrate(&Todo{})
todo := Todo{
Title: "heheda2",
}
db.Save(&todo)
fmt.Println("db: ",todo)

r.GET("/ping", func(c *gin.Context) {
db := c.MustGet("DB").(*gorm.DB) // MustGet returns an interface so you have to type cast it first :)
var todo Todo
db.First(&todo)
c.JSON(200, gin.H{
"message": "pong",
"todo": todo,
})
})
r.Run() // listen and serve on 0.0.0.0:8080
}

此时控制台可以看到数据 heheda2
fresh 启动命令server之后 curl http://localhost:8080/ping可以看到东西,说明中间件正常运行。

数据绑定

这个部分我看了一下gin的反射实现机制,具体的文档在这里:
go-playground/validator

gin 本身用的就是validation.v8做校验器,然后 golang 反射包对应的 struct tag 改成了binding

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40

type Todo struct {
ID int
Title string `form:"title" json:"title" binding:"exists,alphanum,min=8,max=255"`
Email string `form:"email" json:"email" binding:"exists,email"`
}


//main:

func (todo Todo) Response() (interface{}){
return map[string]interface{}{
"id": todo.ID,
"title": todo.Title,
}
}

r.POST("/ping", func(c *gin.Context) {
db := c.MustGet("DB").(*gorm.DB) // MustGet returns an interface so you have to type cast it first :)
var todo Todo
if err := c.Bind(&todo); err != nil {
fmt.Println("before bind",err)
errs := err.(validator.ValidationErrors)
var res []interface{}
for _, v := range errs {
// can translate each error one at a time.
fmt.Println(v.Value)
res = append(res, v.Field)
}
c.JSON(http.StatusBadRequest, gin.H{"Data invalid" : res})
return

}
fmt.Println("after bind",todo)
if err := db.Save(&todo).Error; err != nil {
c.JSON(http.StatusBadRequest, gin.H{"Database error" : err})
return
}
c.JSON(http.StatusCreated, todo.Response())
})

alphanum 这个 tag 的意思是数字和字母,这个时候用 postman 测试,如果输入不是字母和数字就会返回StatusBadRequest。

整体源代码

此时已经可以正常运行

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
//column对应数据库存的数据的名称
type User struct {
gorm.Model
Username string `gorm:"column:username"`
Email string `gorm:"column:email"`
Bio string `gorm:"column:bio"`
Image string `gorm:"column:image"`
Salt string `gorm:"column:salt"`
PasswordHash string `gorm:"column:password"`
}

//password加盐加密之后存数据库,校验的时候直接对比加密后的值
func (u *User) setPassword(password string){
passwordHash := password + "salt"
u.PasswordHash = passwordHash
}

func (u *User) checkPassword(password string) bool{
passwordHash := password + "salt"
return passwordHash == u.PasswordHash
}

// 需求里面的 spec 非常有奇怪的嵌套,这里进行一些 binding 的测试
type RegistrationForm struct {
User struct {
Username string `json:"username"`
Email string `json:"email"`
Password string `json:"password"`
} `json:"user"`
}

type LoginForm struct {
User struct {
Email string `json:"email"`
Password string `json:"password"`
} `json:"user"`
}

// 大致测试一下整个流程是不是通的
func (User) Registration(c *gin.Context) {
db := c.MustGet("DB").(*gorm.DB)
var form RegistrationForm
if err := c.Bind(&form); err != nil {
c.JSON(http.StatusBadRequest, gin.H{"Data binding error" : err})
return
}
var user User
user.Username = form.User.Username
user.Email = form.User.Email
user.setPassword(form.User.Password)
if err := db.Save(&user).Error; err != nil {
c.JSON(http.StatusBadRequest, gin.H{"Database error" : err})
return
}
c.JSON(http.StatusCreated, gin.H{"user":user})
}

func (User) Login(c *gin.Context) {
db := c.MustGet("DB").(*gorm.DB)
var form LoginForm
if err := c.Bind(&form); err != nil {
c.JSON(http.StatusBadRequest, gin.H{"Data binding error" : err})
return
}
var user User

if err := db.Where(&User{ Email: form.User.Email}).First(&user).Error; err != nil {
c.JSON(http.StatusForbidden, gin.H{"Database error" : err})
return
}
fmt.Println("user from DB: ",user)

if valid := user.checkPassword(form.User.Password); !valid {
c.JSON(http.StatusForbidden, gin.H{"Error" : "password error!"})
return
}
c.JSON(http.StatusOK, gin.H{"user":user})
}

// main.go

v1 := r.Group("/api/v1/users")
{
user := new(User)
v1.POST("/", user.Registration)
v1.POST("/login", user.Login)
}

此时应该有的效果

POST http://localhost:8080/api/v1/users

1
2
3
4
5
6
7
{
"user":{
"username": "Jacob",
"email": "wzt@gg.cn",
"password": "jakejake"
}
}

返回:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
{
"user": {
"ID": 1,
"CreatedAt": "2017-07-16T14:02:37.381339679+08:00",
"UpdatedAt": "2017-07-16T14:02:37.381339679+08:00",
"DeletedAt": null,
"Username": "Jacob",
"Email": "wzt@gg.cn",
"Bio": "",
"Image": "",
"Salt": "",
"PasswordHash": "jakejakesalt"
}
}

POST http://localhost:8080/api/v1/users/login

1
2
3
4
5
6
{
"user":{
"email": "wzt@gg.cn",
"password": "jakejake"
}
}

返回:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
{
"user": {
"ID": 1,
"CreatedAt": "2017-07-16T14:02:37.381339679+08:00",
"UpdatedAt": "2017-07-16T14:02:37.381339679+08:00",
"DeletedAt": null,
"Username": "Jacob",
"Email": "wzt@gg.cn",
"Bio": "",
"Image": "",
"Salt": "",
"PasswordHash": "jakejakesalt"
}
}

POST http://localhost:8080/api/v1/users/login

1
2
3
4
5
6
{
"user":{
"email": "wzt@gg.cn",
"password": "jakejakex"
}
}

返回:

1
2
3
{
"Error": "password error!"
}

模块划分

模块划分一般来说有两种模式,按照功能划分,按照应用结构划分

  • 按照功能划分

    • 建立的文件夹叫 routers models views
    • 每个文件夹内用应用的名字命名文件 users.go articles.go
    • 定义结构体、对象、接口的时候,使用功能.应用-模块
    • 访问的时候 models.User routers.userLogin
  • 按照应用结构划分

    • 建立的文件夹叫 users articles
    • 每个文件夹内用应用的名字命名文件 routers.go models.go views.go
    • 定义结构体、对象、接口的时候,使用应用.模块-功能
    • 访问的时候 users.UserModel users.Login
    • 中间件之类单独放一个文件夹

我使用第二种划分方法

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
.
├── BACKEND_INSTRUCTIONS.md
├── FRONTEND_INSTRUCTIONS.md
├── MOBILE_INSTRUCTIONS.md
├── bak.hello.gogo
├── gorm.db
├── hello.go
├── logo.png
├── readme.md
├── storage
│   └── database.go
├── tmp
│   └── runner-build
└── users
├── models.go
├── routers.go
└── validators.go

models.go
放入 orm 模型定义相关的东西
routers.go
放入处理的逻辑,路由绑定关系
validators.go
表单、json 数据校验器
serializers.go
将来可能再加上,放入序列化的定义

此时,代码大致变成了这样: github

实现 SPEC

有了刚才的若干试验,此时可以用刚才的手脚架正式开始加入 feature

数据库模型生成与密码保存

常识部分我搜了一个介绍如何安全的存储用户的密码

这里选择 bcrypt,它本身就是一个加了盐的算法,然后我们重写之前的生成密码和校验密码的方法。

另外,给模型加入tag json:"balabala"json:"-",这样默认的序列化会给对象加上相关的属性。

image 的 string变成一个指针,这样生成的字符串就可以是null,而不是一个
字符串;marshal null

此时 users/models.go变成:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
package users

import (
"golang.org/x/crypto/bcrypt"
"golang-gin-starter-kit/common"
)

type UserModel struct {
ID uint `json:"id" gorm:"primary_key"`
Username string `json:"username" gorm:"column:username"`
Email string `json:"email" gorm:"column:email;unique_index"`
Bio string `json:"bio" gorm:"column:bio"`
Image *string `json:"image" gorm:"column:image"`
PasswordHash string `json:"-" gorm:"column:password"`
JWT string `json:"jwt" gorm:"column:-"`
}

func (u *UserModel) setJWT()error{
token, err := common.GenToken(u.ID)
u.JWT = token
return err
}

func (u *UserModel) setPassword(password string) error{
bytePassword := []byte(password)
passwordHash, err := bcrypt.GenerateFromPassword(bytePassword, bcrypt.DefaultCost)
if err!=nil {
return err
}
u.PasswordHash = string(passwordHash)
err = u.setJWT()
return err
}

func (u *UserModel) checkPassword(password string) error{
bytePassword := []byte(password)
byteHashedPassword := []byte(u.PasswordHash)
err := u.setJWT()
if err!=nil {
return err
}
return bcrypt.CompareHashAndPassword(byteHashedPassword, bytePassword)
}

Auth 中间件

这里的选型是 jwt,介绍可以看这个文章JSON Web Token,以及 oauth 的介绍理解OAuth 2.0

具体实现参考这个中间件gin-jwt,但是这个地方有一些坑,他的header引入的是这一个gin"github.com/gin-gonic/gin",而我使用的是发布版"gopkg.in/gin-gonic/gin.v1"

common/utils.go

1
2
3
4
5
6
7
8
9
10
11
12
const NBSecretPassword = "heheda";

func GenToken(id uint) (string, error){
token := jwt.New(jwt.GetSigningMethod("HS256"))
// Set some claims
token.Claims = jwt.MapClaims{
"Id": id,
"exp": time.Now().Add(time.Hour * 24).Unix(),
}
// Sign and get the complete encoded token as a string
return token.SignedString([]byte(NBSecretPassword))
}

直接在models的checkPassword里边加入生成JWT token的代码,之所以把生成的逻辑添加在这个地方,是因为每次登陆的时候都需要运行这段逻辑,另外,由于中间件可以从token里面直接解析出过期的时间,重复生成token是没有关系的。

1
2
3
4
5
6
7
8
9
10
func (u *UserModel) checkPassword(password string) error{
bytePassword := []byte(password)
byteHashedPassword := []byte(u.PasswordHash)
token, err := common.GenToken(u.ID)
if err!=nil {
return err
}
u.JWT = token
return bcrypt.CompareHashAndPassword(byteHashedPassword, bytePassword)
}

middlewares/auth-jwt.go

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
package middlewares


import (
"gopkg.in/gin-gonic/gin.v1"
"github.com/dgrijalva/jwt-go"
"github.com/dgrijalva/jwt-go/request"
)

func Auth(secret string) gin.HandlerFunc {
return func(c *gin.Context) {
_, err := request.ParseFromRequest(c.Request, request.OAuth2Extractor, func(token *jwt.Token) (interface{}, error) {
b := ([]byte(secret))
return b, nil
})

if err != nil {
c.AbortWithError(401, err)
}
}
}

hello.go

1
2
3
4
5
6
7
8
9
10
11
12
// main:

r := gin.Default()
testAuth := r.Group("/api/v1/ping")
testAuth.Use(middlewares.Auth(common.NBSecretPassword))

testAuth.GET("/", func(c *gin.Context) {
c.JSON(200, gin.H{
"message": "pong",
})
})

接着使用 postman 测试,过期时间设置为半分钟,先使用login接口登陆,此时的返回值应该长成这样:

1
2
3
4
5
6
7
8
9
10
{
"user": {
"id": 1,
"username": "Jacob",
"email": "wzt@gg.cn",
"bio": "",
"image": null,
"jwt": "eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJJZCI6MSwiZXhwIjoxNTAwMjE3NzQzfQ.gJHBLwohyfPMub7_gbOE_jI_y9hYej4NauJKO-Gelio"
}
}

header 中加入这个键值对 Authorization: Bearer eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJJZCI6MSwiZXhwIjoxNTAwMjE3NzQzfQ.gJHBLwohyfPMub7_gbOE_jI_y9hYej4NauJKO-Gelio

此时应该能够看到一个返回值pong,我们正在半分钟之后再进行尝试,此时的返回值是401。

中间件完成

测试

基本介绍+通用教程

gin testing docs

测试文件结构

首先介绍m *testing.M,这是整个文件的入口,如果我们需要 setup 和 teardown,我们可以把相关的东西放到这个里面。m.Run()用来运行其他的测试t *testing.T

1
2
3
4
5
6
7
8
9
10
11
12
func TestMain(m *testing.M) {

test_db, _ = gorm.Open("sqlite3", "./../gorm_test.db")
test_db.AutoMigrate(&UserModel{})
//fmt.Println("before")
exitVal := m.Run()
//fmt.Println("after")
test_db.Close()
os.Remove("./../gorm_test.db")

os.Exit(exitVal)
}

最佳实践

个人认为的最佳实践是把相关参数和正则放到一个数字/dict 里面,然后用一个函数去依次执行相关的函数。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
var routerRegistrationTests = []struct {
bodyData string
expectedCode int
responseRegexg string
msg string
}{
{
`{"user":{"username": "wangzitian0","email": "wzt@gg.cn","password": "jakejxke"}}`,
http.StatusCreated,
`{"user":{"id":1,"username":"wangzitian0","email":"wzt@gg.cn","bio":"","image":null,"token":"([a-zA-Z0-9-_.]{115})"}}`,
"valid data and should return 200",
},
{
`{"user":{"username": "u","email": "wzt@gg.cn","password": "jakejxke"}}`,
http.StatusUnprocessableEntity,
`{"errors":{"Username":"{min: 8}"}}`,
"short username should return error",
},
{
`{"user":{"username": "wangzitian0","email": "wzt@gg.cn","password": "j"}}`,
http.StatusUnprocessableEntity,
`{"errors":{"Password":"{min: 8}"}}`,
"short password should return error",
},
{
`{"user":{"username": "wangzitian0","email": "wztgg.cn","password": "jakejxke"}}`,
http.StatusUnprocessableEntity,
`{"errors":{"Email":"{key: email}"}}`,
"email invalid should return error",
},
}

func TestRouter_Registration(t *testing.T) {
assert := assert.New(t)

r := gin.New()
usersGroup := r.Group("/p")
usersGroup.Use(middlewares.DatabaseMiddleware(test_db))
UsersRegister(usersGroup)
for _, testData := range routerRegistrationTests{
bodyData := testData.bodyData
req, err := http.NewRequest("POST", "/p/", bytes.NewBufferString(bodyData))
req.Header.Set("Content-Type", "application/json")

assert.NoError(err)
w := httptest.NewRecorder()
r.ServeHTTP(w, req)

assert.Equal(testData.expectedCode, w.Code, "code - " + testData.msg)
assert.Regexp(testData.responseRegexg, w.Body.String(),"regexp - %v\n " + testData.msg)
}
}

解释

核心是这一段代码,req 是一个 net/http 包生成的请求,现在模拟用户的请求,把它放到 req 里面,r 可以是一个 gin的路由,httptest.NewRecorder()包装了一个虚拟的请求,只要运行r.ServeHTTP(w, req),就相当于在浏览器 curl 了相关地址。
后续我们可以 assert 相关的变量参数。

1
2
3
4
5
req, err := http.NewRequest()

w := httptest.NewRecorder()

r.ServeHTTP(w, req)

杂七杂八

assert

github.com/stretchr/testify/这个库封装了很多东西,我这边主要是它用 assert。具体来说,t *testing.T 包含了报错的要素,现在assert := assert.New(t)把它传递给了这个库,然后后续用的时候可以少打参数。
最好用还是这个 assert:

1
assert.Regexp(x, y, msg)

os

想用golang来删除文件,查到了这个:

1
2
3
os.Remove("./../gorm_test.db")

test_db, _ = gorm.Open("sqlite3", "./../gorm_test.db")

发现一个问题,在根目录和子目录它对应的文件不同,后续需要找一个 config 的方案,给工程指定一个 env 变量。

Bearer Token

jwt-go 提供的 token 形式是 Authorization: Bearer <token>,但是 spec 里面需要的形式是Authorization: Token <token>。另外,我希望从 jwt-token 中读出 user_id 放进中间件,我选择仿照 oauth2 的接口直接重写中间件:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
package middlewares


import (
"net/http"
"gopkg.in/gin-gonic/gin.v1"
"github.com/dgrijalva/jwt-go"
"github.com/dgrijalva/jwt-go/request"
"golang-gin-starter-kit/common"
"strings"
)
// Strips 'Bearer ' prefix from bearer token string
func stripBearerPrefixFromTokenString(tok string) (string, error) {
// Should be a bearer token
if len(tok) > 5 && strings.ToUpper(tok[0:6]) == "TOKEN " {
return tok[6:], nil
}
return tok, nil
}

// Extract bearer token from Authorization header
// Uses PostExtractionFilter to strip "Bearer " prefix from header
var AuthorizationHeaderExtractor = &request.PostExtractionFilter{
request.HeaderExtractor{"Authorization"},
stripBearerPrefixFromTokenString,
}

// Extractor for OAuth2 access tokens. Looks in 'Authorization'
// header then 'access_token' argument for a token.
var MyAuth2Extractor = &request.MultiExtractor{
AuthorizationHeaderExtractor,
request.ArgumentExtractor{"access_token"},
}



func Auth() gin.HandlerFunc {
return func(c *gin.Context) {
token, err := request.ParseFromRequest(c.Request, MyAuth2Extractor, func(token *jwt.Token) (interface{}, error) {
b := ([]byte(common.NBSecretPassword))
return b, nil
})
if err != nil {
c.AbortWithError(http.StatusUnauthorized, err)
return
}
if claims, ok := token.Claims.(jwt.MapClaims); ok && token.Valid {
my_user_id := uint64(claims["id"].(float64))
//fmt.Println(my_user_id,claims["id"])
c.Set("my_user_id", my_user_id)
} else {
c.Set("my_user_id", uint64(0))
}
}
}

这里单独提一下c.AbortWithError这个函数。一旦使用它就应该马上 return,如果再使用其他 response 类函数如c.JSON()会panic。

bind error

这个查了很久的文档和源码,gin 本身使用的 validater 是 go-playground/validator.v8,它本身的实现是基于反射,给了很多的域,但是这些域是没法拿到其他 struct tag的,比如json:"field0",我们没法从它的信息中得到field0这个字符串。如果非要拿,可以用reflect.Type得到名称之后再去取完整的 struct tag,然后再从中 Get 相关映射值。

1
2
3
4
5
6
7
8
9
10
11
12
type FieldError struct {
FieldNamespace string
NameNamespace string
Field string
Name string
Tag string
ActualTag string
Kind reflect.Kind
Type reflect.Type
Param string
Value interface{}
}

另外是我希望嵌套 json 直接 bind 到结构体,事实证明可以嵌套,对应域的后面加上 tag 即可。

1
2
3
4
5
6
7
8
9
{"user":{"email":"john@jacob.com", "password":"johnnyjacob"}}

type LoginValidator struct {
User struct {
Email string `form:"email" json:"email" binding:"exists,email"`
Password string `form:"password"json:"password" binding:"exists,min=8,max=255"`
} `json:"user"`
}

spec 表单错误要求返回422,但是 bind 错误之后直接返回400了,还不能改写,所以我重写了这个函数,把 must 变成 should,自己接管错误检测。

1
2
3
4
func Bind(c *gin.Context, obj interface{}) error {
b := binding.Default(c.Request.Method, c.ContentType())
return c.ShouldBindWith(obj, b)
}

巧用 struct tag

1
2
PasswordHash  string      `json:"-" gorm:"column:password;not null"`
Token string `json:"token" gorm:"column:-"`

一部分不希望被序列化,一部分不希望持久化,可以用减号来避免操作。

1
Image         *string     `json:"image" gorm:"column:image"`

如果希望字符串出现类似于 “hehe”:null 的空值,那么需要使用指针。指针的 nil 和 “” 是两个不同的值。

1
Image         string      `form:"image" json:"image" binding:"omitempty,url"`

omitempty 是个特殊值,可以让你为空,或者为某种特定串。
具体可以仔细研究下文档golang Marshal

状态码

长知识了,状态码很多,表单错误最标准的返回应该是422
具体可以参考 net/http/status.go 里面的完整描述。

postman 真好用

spec of realworld这个文档里面给了一份脚本,这才是 postman 的正确姿势:

  • 可以加入、根据 response 来修改环境的变量,如得到 token 之后写到一个环境变量里面
  • 可以自动化跑一个组的 request
  • 可以在返回值里面加入 assert,这样基本上不用肉眼看。
  • 以前我用的是假的 postman。

总结

截止到目前,没有任何的数据库关系。restful level2的部分已经基本上和我想要的差不多了。因为要和 spec 完全对齐,所以这篇文章的代码和最终的有很多地方有差别,正式的代码可以看下面的github链接。
还差的东西:

  • 加上全局的 config
  • 多对多关系,外键,等等还没有使用
  • github 的 badge
  • commit 之后自动 build
  • 缓存系统、session 之类
  • 没有文档

至此 v0.04