Go+React实现一个基础的前后端分离问卷功能

效果图

后端

前期准备

取一个随便点的名字

1
2
3
4
mkdir goquestionapi
cd goquestionapi
git init
go mod init github.com/linweiyuan/goquestionapi

这里和数据库相关的操作会使用两个工具,migratesqlc,可以用 go 的方式安装,但这边习惯用 pacman,因为包升级好管理

1
2
sudo pacman -S sqlc
yay -S migrate

sqlc 是一个代码生成工具,生成的代码中连接数据库的部分用的是 Golang 原生 sql 库,性能极高(官方是这样说的)

为什么不用 GORM,因为由于多年 Java 开发经验先入为主,看到 GORM 的 API 好多时候会觉得莫名其妙,尝试过几遍,都弃坑了,并且据说有性能问题

sqlc 可以通过编写简单的 SQL 语句,来自动生成模板代码,那么只需要关注具体的业务逻辑即可,并且可以通过 sqlc.arg() 的方式给占位符起别名,使用起来比较顺手

数据库用的是 Postgres

对于这种一个问题多个答案,一对多的结构,其实我自己比较喜欢用 JSON 字段

为什么不直接用 MongoDB?其实工作多年接触的项目,只用过一次,不是很熟悉

为什么不用 MySQL?

1
2
3
4
CONTAINER ID   NAME                 CPU %     MEM USAGE / LIMIT     MEM %     NET I/O          BLOCK I/O        PIDS
2057f98237b3 postgres 0.02% 28.57MiB / 15.49GiB 0.18% 3.03kB / 0B 0B / 43.4MB 7
25959b7d7133 mysql 0.27% 159.5MiB / 15.49GiB 1.01% 6.85kB / 0B 115kB / 259MB 39
ef2ecd22745f mongo 0.30% 68.66MiB / 15.49GiB 0.43% 8.25kB / 0B 4.1kB / 1.29MB 33

如果开了 performance_schema 呢(默认是开)

1
2
CONTAINER ID   NAME                 CPU %     MEM USAGE / LIMIT     MEM %     NET I/O          BLOCK I/O        PIDS
bb4ba1c34299 mysql 0.26% 383MiB / 15.49GiB 2.42% 2.5kB / 0B 115kB / 247MB 39

练手而已

目前所在公司,新项目也纷纷上 Postgres,不涉及到那些储存过程函数之类,转换下 database schema 概念,其实用起来都没什么区别

准备数据,数据是从网上拿的,主要是一些问题,然后对应的选项写死了分值,最后根据总分来判断性格(性格测试这东西我本人觉得极为扯淡,有些公司面试还要做,莫名其妙,如果我所在公司也要做,那我前面什么都没说,这个测试好得很,必须做)

数据准备好,就可以导入数据库,这里用的是 migrate,所以先准备第一版的 SQL

sqlc 配置文件

1
2
3
4
5
6
7
8
9
version: "2"
sql:
- engine: "postgresql"
schema: "sqlc/migration"
queries: "sqlc/query"
gen:
go:
package: "sqlc"
out: "sqlc"

schema 是数据库建表、删表语句,queries 是业务 SQL 的编写地方,然后 package 是生成的 go 代码的包名,out 就是代码生成的文件夹

1
migrate create -ext sql -dir sqlc/migration -seq init

migrate 参数:

  • -ext: 数据脚本扩展名(后缀名)
  • -dir: 脚本文件所在地,配合上面 sqlc 的配置文件就是在 sqlc/migration 里
  • -seq: 就是一个 sequence,自增
  • 最后是文件名字

上面的命令就会生成 000001_init.down.sql 和 000001_init.up.sql 这两个文件,up 就是新改动,down 就是回滚,和别的数据库 migration 一样

首先需要先创建 schema,这里 schema 叫 question,表也叫 question(表则可以在 migration sql 里创建)

创建好 schema 之后 migrate 和 sqlc 才会好使,不然会有先有鸡还是先有蛋的问题

1
CREATE SCHEMA question;

准备 up sql

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
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
254
255
CREATE TABLE
question (
id SERIAL PRIMARY KEY NOT NULL,
title VARCHAR NOT NULL,
answer JSON NOT NULL,
score JSON NOT NULL
);

INSERT INTO
question (title, answer, score)
VALUES (
'你更喜欢吃那种水果',
'{
"A": "草莓",
"B": "苹果",
"C": "西瓜",
"D": "菠萝",
"E": "橘子"
}',
'{
"A": 2,
"B": 3,
"C": 5,
"D": 10,
"E": 15
}'
), (
'你平时休闲经常去的地方',
'{
"A": "郊外",
"B": "电影院",
"C": "公园",
"D": "商场",
"E": "酒吧",
"F": "练歌房"
}',
'{
"A": 2,
"B": 3,
"C": 5,
"D": 10,
"E": 15,
"F": 20
}'
), (
'你认为容易吸引你的人是',
'{
"A": "有才气的人",
"B": "依赖你的人",
"C": "优雅的人",
"D": "善良的人",
"E": "性情豪放的人"
}',
'{
"A": 2,
"B": 3,
"C": 5,
"D": 10,
"E": 15
}'
), (
'如果你可以成为一种动物,你希望自己是哪种',
'{
"A": "猫",
"B": "马",
"C": "大象",
"D": "猴子",
"E": "狗",
"F": "狮子"
}',
'{
"A": 2,
"B": 3,
"C": 5,
"D": 10,
"E": 15,
"F": 20
}'
), (
'天气很热,你更愿意选择什么方式解暑',
'{
"A": "游泳",
"B": "喝冷饮",
"C": "开空调"
}',
'{
"A": 5,
"B": 10,
"C": 15
}'
), (
'如果必须与一个你讨厌的动物或昆虫在一起生活,你能容忍哪一个',
'{
"A": "蛇",
"B": "猪",
"C": "老鼠",
"D": "苍蝇"
}',
'{
"A": 2,
"B": 5,
"C": 10,
"D": 15
}'
), (
'你喜欢看哪类电影、电视剧',
'{
"A": "悬疑推理类",
"B": "童话神话类",
"C": "自然科学类",
"D": "伦理道德类",
"E": "战争枪战类"
}',
'{
"A": 2,
"B": 3,
"C": 5,
"D": 10,
"E": 15
}'
), (
'以下哪个是你身边必带的物品',
'{
"A": "打火机",
"B": "口红",
"C": "记事本",
"D": "纸巾",
"E": "手机"
}',
'{
"A": 2,
"B": 2,
"C": 3,
"D": 5,
"E": 10
}'
), (
'你出行时喜欢坐什么交通工具',
'{
"A": "火车",
"B": "自行车",
"C": "汽车",
"D": "飞机",
"E": "步行"
}',
'{
"A": 2,
"B": 3,
"C": 5,
"D": 10,
"E": 15
}'
), (
'以下颜色你更喜欢哪种',
'{
"A": "紫",
"B": "黑",
"C": "蓝",
"D": "白",
"E": "黄",
"F": "红"
}',
'{
"A": 2,
"B": 3,
"C": 5,
"D": 8,
"E": 12,
"F": 15
}'
), (
'下列运动中挑选一个你最喜欢的(不一定擅长)',
'{
"A": "瑜珈",
"B": "自行车",
"C": "乒乓球",
"D": "拳击",
"E": "足球",
"F": "蹦极"
}',
'{
"A": 2,
"B": 3,
"C": 5,
"D": 8,
"E": 10,
"F": 15
}'
), (
'如果你拥有一座别墅,你认为它应当建立在哪里',
'{
"A": "湖边",
"B": "草原",
"C": "海边",
"D": "森林",
"E": "城中区"
}',
'{
"A": 2,
"B": 3,
"C": 5,
"D": 10,
"E": 15
}'
), (
'你更喜欢以下哪种天气现象',
'{
"A": "雪",
"B": "风",
"C": "雨",
"D": "雾",
"E": "雷电"
}',
'{
"A": 2,
"B": 3,
"C": 5,
"D": 10,
"E": 15
}'
), (
'你希望自己的窗口在一座30层大楼的第几层',
'{
"A": "七层",
"B": "一层",
"C": "二十三层",
"D": "十八层",
"E": "三十层"
}',
'{
"A": 2,
"B": 3,
"C": 5,
"D": 10,
"E": 15
}'
), (
'你认为自己更喜欢在以下哪一个城市中生活',
'{
"A": "丽江",
"B": "拉萨",
"C": "昆明",
"D": "西安",
"E": "杭州",
"F": "北京"
}',
'{
"A": 1,
"B": 3,
"C": 5,
"D": 8,
"E": 10,
"F": 15
}'
);
1
migrate -path sqlc/migration -database "postgresql://postgres:postgres@localhost:5432/postgres?sslmode=disable&search_path=question" -verbose up

down sql

1
DROP TABLE question;

同理

1
migrate -path sqlc/migration -database "postgresql://postgres:postgres@localhost:5432/postgres?sslmode=disable&search_path=question" -verbose down

接着就是编写获取问题列表的 SQL

1
2
3
-- name: GetQuestions :many

SELECT q.id, q.title, q.answer FROM question q ORDER BY q.id;

根据 sqlc 的规范,-- name: 方法名,这些空格都是必须的,如果返回一条数据就 :one,多条则 :many,如果只是增删,可以用 :exec

执行 sqlc generate

这样就会生成好几个 go 文件,其中生成的方法类似

1
2
3
func (q *Queries) GetQuestions(ctx context.Context) ([]GetQuestionsRow, error) {
...
}

但是每次都要输入这么长的命令,虽然可以键盘上下,但是有更好的方法,就是 Makefile

1
2
3
4
5
6
7
8
9
10
up:
migrate -path sqlc/migration -database "postgresql://postgres:postgres@localhost:5432/postgres?sslmode=disable&search_path=question" -verbose up

down:
migrate -path sqlc/migration -database "postgresql://postgres:postgres@localhost:5432/postgres?sslmode=disable&search_path=question" -verbose down

gen:
sqlc generate

.PHONY: up down gen

.PHONY 的作用就是确保执行的是这里定义的命令,比如 Makefile 里定义个 ls,如果不加到 .PHONY 里,则执行的是系统的 ls

编写业务逻辑

定义配置类,用到了 viper,有一个好处就是 AutomaticEnv() 能够将环境变量覆盖掉代码配置,这样在部署的时候非常方便,日志则用了 logrus,可以设置不同的级别,还有颜色输出,Java 过来表示很亲切

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
package util

import (
log "github.com/sirupsen/logrus"
"github.com/spf13/viper"
)

const (
configFileName = "app"
configFileSuffix = "env"
)

type Config struct {
ServerPort string `mapstructure:"SERVER_PORT"`

DBDriver string `mapstructure:"DB_DRIVER"`
DBSource string `mapstructure:"DB_SOURCE"`

LogLevel string `mapstructure:"LOG_LEVEL"`
}

func LoadConfig(path string) (config Config) {
viper.AddConfigPath(path)
viper.SetConfigName(configFileName)
viper.SetConfigType(configFileSuffix)
viper.AutomaticEnv()

if err := viper.ReadInConfig(); err != nil {
log.Fatalf("failed to load config file: [%v]", err)
}

if err := viper.Unmarshal(&config); err != nil {
log.Fatalf("failed to parse config file: [%v]", err)
}

logLevel, err := log.ParseLevel(config.LogLevel)
if err != nil {
log.Fatalf("failed to get log level: [%v]", err)
}

log.SetLevel(logLevel)

return
}

然后就可以开启调试了,可以 build 或者直接 run,或者用其他热加载的工具,这里用到了 gowatch

直接执行 gowatch 就可以将终端晾在一边了,和前端开发的体验没什么区别,Java 就要重启,没用过 JRebel

此时访问本地 8080 就能得到 404 的正确响应了

接着加一个 CORS 的中间件,这里不加,前端就要 proxy

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

import (
"net/http"

"github.com/gin-gonic/gin"
log "github.com/sirupsen/logrus"
)

func CORS() gin.HandlerFunc {
return func(ctx *gin.Context) {
log.Debug("handle cors...")

ctx.Writer.Header().Set("Access-Control-Allow-Origin", "*")
ctx.Writer.Header().Set("Access-Control-Allow-Headers", "*")

if ctx.Request.Method == http.MethodOptions {
ctx.AbortWithStatus(http.StatusNoContent)
return
}

ctx.Next()
}
}

给 server 加上

1
server.Use(middleware.CORS())

API

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
package api

import (
"net/http"

"github.com/gin-gonic/gin"
"github.com/linweiyuan/goquestionapi/sqlc"
log "github.com/sirupsen/logrus"
)

type QuestionAPI struct {
db *sqlc.Queries
}

func NewQuestionAPI(db *sqlc.Queries) *QuestionAPI {
return &QuestionAPI{db}
}

func (api *QuestionAPI) GetQuestions(ctx *gin.Context) {
questions, err := api.db.GetQuestions(ctx)
if err != nil {
log.Errorf("failed to get questions: [%v]", err)
ctx.JSON(http.StatusInternalServerError, gin.H{
"error": err.Error(),
})
return
}

ctx.JSON(http.StatusOK, gin.H{
"questions": questions,
})
}

路由配置

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

import (
"github.com/gin-gonic/gin"
"github.com/linweiyuan/goquestionapi/api"
)

type QuestionRouter struct {
api *api.QuestionAPI
}

func NewQuestionRouter(api *api.QuestionAPI) *QuestionRouter {
return &QuestionRouter{api}
}

func (router *QuestionRouter) Setup(routerGroup *gin.RouterGroup) {
group := routerGroup.Group("/questions")
group.GET("", router.api.GetQuestions)
}

给 server 加上,并且让其具有查询 db 的功能

1
2
3
4
5
6
7
8
9
sqlDB, err := sql.Open(config.DBDriver, config.DBSource)
if err != nil {
log.Fatalf("failed to connect to DB: [%v]", err)
}

db := sqlc.New(sqlDB)

rg := server.Group("/")
router.NewQuestionRouter(api.NewQuestionAPI(db)).Setup(rg)

需要注意一点就是,要让 db 的驱动执行初始化动作

1
import _ "github.com/lib/pq"

接着再访问 http://localhost:8080/questions,就能看到数据

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
{
"questions": [
{
"ID": 1,
"Title": "你更喜欢吃那种水果",
"Answer": {
"A": "草莓",
"B": "苹果",
"C": "西瓜",
"D": "菠萝",
"E": "橘子"
}
},
...
{
"ID": 15,
"Title": "你认为自己更喜欢在以下哪一个城市中生活",
"Answer": {
"A": "丽江",
"B": "拉萨",
"C": "昆明",
"D": "西安",
"E": "杭州",
"F": "北京"
}
}
]
}

但是 json 的 key 全部大写了,可以在 sqlc 上配置 emit_json_tags,修改后的配置

1
2
3
4
5
6
7
8
9
10
version: "2"
sql:
- engine: "postgresql"
schema: "sqlc/migration"
queries: "sqlc/query"
gen:
go:
package: "sqlc"
out: "sqlc"
emit_json_tags: true

再重新跑一遍 make gen,再刷新页面,就能拿到小写的 key,比较符合习惯

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
{
"questions": [
{
"id": 1,
"title": "你更喜欢吃那种水果",
"answer": {
"A": "草莓",
"B": "苹果",
"C": "西瓜",
"D": "菠萝",
"E": "橘子"
}
},
...
{
"id": 15,
"title": "你认为自己更喜欢在以下哪一个城市中生活",
"answer": {
"A": "丽江",
"B": "拉萨",
"C": "昆明",
"D": "西安",
"E": "杭州",
"F": "北京"
}
}
]
}

至此获取问题列表 API 完成,接着是答题然后计算分数

这里用到下面的数据结构进行提交,思路还是逐条拿分数出来再叠加,没有太多花里胡哨的 SQL

1
2
3
4
5
6
7
8
9
{
"answers": {
"1": "A",
"2": "B",
...
"15": "C"
}
}

于是根据题目拿分数的 SQL 就会是

1
2
3
4
5
6
7
-- name: GetQuestionScoreByID :one

SELECT (
q.score ->> sqlc.arg(answer) :: TEXT
) :: INT AS score
FROM question q
WHERE q.id = sqlc.arg(id);

这里可以用 $1$2 等的占位符,不过用 sqlc.arg() 就会更加顾名思义一些

再执行 make gen,生成新代码

接着编写完答题 API (练手而已,没有太多封装)

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
type answersRequest struct {
Answers json.RawMessage `json:"answers"`
}

func (api *QuestionAPI) HandleAnswers(ctx *gin.Context) {
var req answersRequest
err := ctx.ShouldBindJSON(&req)
if err != nil {
log.Errorf("failed to bind answers json: [%v]", err)
ctx.JSON(http.StatusBadRequest, util.HandleError(err))
return
}

totalScore := 0

questionAnswerMap := make(map[int]string)
json.Unmarshal(req.Answers, &questionAnswerMap)
for questionID, answer := range questionAnswerMap {
getAnswerMappingParams := sqlc.GetQuestionScoreByIDParams{
Answer: answer,
ID: int32(questionID),
}
score, err := api.db.GetQuestionScoreByID(ctx, getAnswerMappingParams)
if err != nil {
log.Errorf("failed to get answer score, questionID: [%d], answer: [%s]", questionID, answer)
ctx.JSON(http.StatusInternalServerError, util.HandleError(err))
return
}

totalScore += int(score)
}

var result string
switch {
case totalScore >= 180:
result = "意志力强,头脑冷静,有较强的领导欲,事业心强,不达目的不罢休。外表和善,内心自傲,对有利于自己的人际关系比较看重,有时显得性格急噪,咄咄逼人,得理不饶人,不利于自己时顽强抗争,不轻易认输。思维理性,对爱情和婚姻的看法很现实,对金钱的欲望一般。"
case totalScore >= 140 && totalScore < 179:
result = "聪明,性格活泼,人缘好,善于交朋友,心机较深。事业心强,渴望成功。思维较理性,崇尚爱情,但当爱情与婚姻发生冲突时会选择有利于自己的婚姻。金钱欲望强烈。"
case totalScore >= 100 && totalScore < 139:
result = "爱幻想,思维较感性,以是否与自己投缘为标准来选择朋友。性格显得较孤傲,有时较急噪,有时优柔寡断。事业心较强,喜欢有创造性的工作,不喜欢按常规办事。性格倔强,言语犀利,不善于妥协。崇尚浪漫的爱情,但想法往往不切合实际。金钱欲望一般。"
case totalScore >= 70 && totalScore < 99:
result = "好奇心强,喜欢冒险,人缘较好。事业心一般,对待工作,随遇而安,善于妥协。善于发现有趣的事情,但耐心较差,敢于冒险,但有时较胆小。渴望浪漫的爱情,但对婚姻的要求比较现实。不善理财。"
case totalScore >= 40 && totalScore < 69:
result = "性情温良,重友谊,性格塌实稳重,但有时也比较狡黠。事业心一般,对本职工作能认真对待,但对自己专业以外事物没有太大兴趣,喜欢有规律的工作和生活,不喜欢冒险,家庭观念强,比较善于理财。"
default:
result = "散漫,爱玩,富于幻想。聪明机灵,待人热情,爱交朋友,但对朋友没有严格的选择标准。事业心较差,更善于享受生活,意志力和耐心都较差,我行我素。有较好的异性缘,但对爱情不够坚持认真,容易妥协。没有财产观念。"
}

ctx.JSON(http.StatusOK, gin.H{
"totalScore": totalScore,
"result": result,
})
}

配置下路由

1
group.POST("", router.api.HandleAnswers)

简单测试能得到结果

1
2
3
4
{
"result": "散漫,爱玩,富于幻想。聪明机灵,待人热情,爱交朋友,但对朋友没有严格的选择标准。事业心较差,更善于享受生活,意志力和耐心都较差,我行我素。有较好的异性缘,但对爱情不够坚持认真,容易妥协。没有财产观念。",
"totalScore": 32
}

此时 API 就完成了,接着就是部署

部署

如果按照其他语言,一般都是 FROM 一个官方的基础镜像,然后在上面再进行构建,这样就有第一种

1
2
FROM FROM golang:alpine
...

这样出来的镜像是很大的,因为 golang 的 alpine 镜像本身就很大了

1
2
REPOSITORY           TAG       IMAGE ID       CREATED          SIZE
golang alpine 5dd973625d31 4 weeks ago 352MB

而由于 go 本身特点,可以编译出来一个摆脱其他束缚的二进制文件,因此可以直接上 alpine

1
2
3
4
5
6
FROM alpine
WORKDIR /app
COPY goquestionapi .

EXPOSE 8080
CMD [ "/app/goquestionapi" ]
1
2
3
CGO_ENABLED=0 GOOS=linux GOARCH=amd64 go build .

docker build -t goquestionapi .

这样 build 出来的程序有 13M,还可以再优化下,-s 可以去掉符号表,-w 可以去掉调试信息

1
CGO_ENABLED=0 GOOS=linux GOARCH=amd64 go build -ldflags="-w -s" .

这样 build 出来的程序只有 8.5M

跑下镜像测试下

1
docker run --rm -p 8080:8080 -e DB_SOURCE="postgresql://postgres:postgres@192.168.31.60:5432/postgres?sslmode=disable&search_path=question" -d goquestionapi

访问正常拿到数据

此时 docker 镜像大小为 14.4M

1
2
REPOSITORY           TAG       IMAGE ID       CREATED          SIZE
goquestionapi latest 109360c6fafd 6 minutes ago 14.4MB

上面说了,由于 go 自身的特性,如果没有额外的需求,还可以用 scratch 镜像,只是这样就不能 sh 进去容器,如果有访问外网的需求,也会报 SSL 证书错误,又或者时区的问题,因为里面确实什么都没有

这个 scratch 感觉更像是一个概念,不是实际可以摸到的东西,因为如果像其他镜像那样 pull 的话,会报错

1
2
3
4
docker pull scratch

Using default tag: latest
Error response from daemon: 'scratch' is a reserved name

这样 build 出来的镜像大小就又小了些

1
2
REPOSITORY           TAG       IMAGE ID       CREATED          SIZE
goquestionapi latest 7ac80b2a1ad0 3 seconds ago 8.86MB

但是为了后续方便 sh 进去,还是习惯用 alpine,也不差那几兆硬盘

现如今都在谈 DevOps,谈云原生,一般 cicd 都在云端了,因此会有第二种,分阶段构建

这个时候就需要一个 go 环境,所以要利用 golang 镜像来做编译,完了再把编译出来的程序,再复制到基础的 alpine 镜像上

1
2
3
4
5
6
7
8
9
10
11
12
FROM golang:alpine AS builder
WORKDIR /app
COPY . .
RUN CGO_ENABLED=0 GOOS=linux GOARCH=amd64 go build -ldflags="-w -s" .

FROM alpine
WORKDIR /app
COPY --from=builder /app/goquestionapi .
COPY app.env .

EXPOSE 8080
CMD [ "/app/goquestionapi" ]

此时编译正常,镜像小,也能正常运行

docker build 的方式会产生很多 <none> 的没用的镜像,每次还要跑一大串命令,这有点不方便,因此可以用 Docker Compose

1
2
3
4
5
6
7
8
9
10
11
12
13
services:
goquestionapi:
container_name: goquestionapi
image: goquestionapi
ports:
- 8080:8080
build: .
environment:
- TZ=Asia/Shanghai
- GIN_MODE=release
- DB_SOURCE=postgresql://postgres:postgres@192.168.31.60:5432/postgres?sslmode=disable&search_path=question
- LOG_LEVEL=info
restart: unless-stopped

只要 docker-compose build 就可以把镜像 build 出来,并且可以灵活修改配置,命令不变,和 Makefile 的思想一样

还有一个好处就是没有 <none> 镜像,代码洁癖+强迫症患者表示很舒服

此时跑 docker-compose up,也能正常看到数据

以上是本地部署,线上大多是 Kubernetes 环境

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
apiVersion: apps/v1
kind: Deployment
metadata:
name: goquestionapi
spec:
selector:
matchLabels:
app: goquestionapi
template:
metadata:
labels:
app: goquestionapi
spec:
containers:
- name: goquestionapi
image: goquestionapi
imagePullPolicy: IfNotPresent
resources:
limits:
memory: "20Mi"
cpu: "500m"
ports:
- containerPort: 8080
env:
- name: GIN_MODE
value: release
- name: DB_SOURCE
value: postgresql://postgres:postgres@postgres-postgresql.default.svc.cluster.local:5432/postgres?sslmode=disable&search_path=question
---
apiVersion: v1
kind: Service
metadata:
name: goquestionapi
spec:
selector:
app: goquestionapi
ports:
- port: 8080
targetPort: 8080
---
apiVersion: networking.k8s.io/v1
kind: Ingress
metadata:
name: goquestionapi
labels:
name: goquestionapi
annotations:
nginx.ingress.kubernetes.io/rewrite-target: /$2
spec:
rules:
- http:
paths:
- pathType: Prefix
path: "/api(/|$)(.*)"
backend:
service:
name: goquestionapi
port:
number: 8080

由于我本地 K8s 环境是 kind,所以要把镜像 load 进去

1
kind load docker-image goquestionapi

有两点需要注意,第一就是确保能拿到镜像,要么给个 tag 不用 latest 再通过加载镜像的方式,要么修改 imagePullPolicy 为 IfNotPresent 或者 Never,要么正确配置仓库地址,无论用 Docker Hub 还是自建

还有一点就是 postgres 要用 K8s 内部的连接,因为我的 postgres 是用 helm 安装的,所以可以通过 helm status postgres 查看到集群内部链接

apply 后访问 http://localhost/api/questions,也能正常看到数据

VSCode 或者 Goland 装好 Kubernetes 插件后,都能很方便直接 apply

VSCode 的话,配置写在一起会有提示无法对比,只能对比单个的,但是单个的话,已经 apply 了的配置会自动带上时间之类的额外的东西,对比起来也不是很直接,所以这里还是写在一起

至此,API 部分完

前端

前期准备

利用 create-react-app 来创建 React 项目

1
yarn create react-app reactquestionweb

接着集成第三方组件,axios 刚发布了 1.0.0 版本,会有兼容问题,因此还是用回老版本 0.27.2 版本

1
2
3
yarn add antd
yarn add axios@0.27.2
yarn add use-axios-react

编写业务逻辑

这里没什么好说的了,用函数式组件 + hook 完成网络请求、条件渲染,其他的就按照后端接口约定的参数提交

不会 CSS,所以没有太多华丽的辞藻显示在页面上

部署

部署好多人都喜欢用 node 的镜像然后用 npm start,但其实我这边习惯用 nginx,除了 yarn build 会有优化外,你都 build 出来静态文件了,还要 node 干啥

Dockerfile

1
2
3
4
5
6
7
8
9
FROM node:alpine AS builder
WORKDIR /app
COPY . .
RUN yarn && yarn build

FROM nginx:alpine
COPY --from=builder /app/build /usr/share/nginx/html

EXPOSE 80

compose.yaml

1
2
3
4
5
6
7
8
services:
reactquestionweb:
container_name: reactquestionweb
image: reactquestionweb
ports:
- 3000:80
build: .
restart: unless-stopped

k8s.yaml

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
apiVersion: apps/v1
kind: Deployment
metadata:
name: reactquestionweb
spec:
selector:
matchLabels:
app: reactquestionweb
template:
metadata:
labels:
app: reactquestionweb
spec:
containers:
- name: reactquestionweb
image: reactquestionweb
resources:
limits:
memory: "20Mi"
cpu: "500m"
ports:
- containerPort: 80
---
apiVersion: v1
kind: Service
metadata:
name: reactquestionweb
spec:
selector:
app: reactquestionweb
ports:
- port: 80
targetPort: 80
---
apiVersion: networking.k8s.io/v1
kind: Ingress
metadata:
name: reactquestionweb
labels:
name: reactquestionweb
spec:
rules:
- http:
paths:
- pathType: Prefix
path: "/"
backend:
service:
name: reactquestionweb
port:
number: 80

然后访问 http://localhost,一切正常

到这里就完了,附上项目地址

API

WEB


纯属抛砖玉玉,如果有不同的意见,以你的为准