基于gin的golang web开发:访问mysql数据库

web开发基本都离不开访问数据库,在Gin中使用mysql数据库需要依赖mysql的驱动。直接使用驱动提供的API就要写很多样板代码。你可以找到很多扩展包这里介绍的是jmoiron/sqlx。另外还有一个用来处理空值的包guregu/null。

1
2
3
go get github.com/go-sql-driver/mysql
go get gopkg.in/guregu/null.v4
go get github.com/jmoiron/sqlx

连接数据库

jmoiron/sqlx包为database/sql提供了很多扩展方法,例如Select可以直接把查询结果映射为结构体,不在需要对每一列进行绑定。使用jmoiron/sqlx连接数据库的方法和mysql驱动提供的方法是一样的,可以直接调用sqlx.Connect并传入连接字符串。这里使用go语言init机制初始化数据库连接。

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

import (
_ "github.com/go-sql-driver/mysql"
"github.com/jmoiron/sqlx"
"log"
)

var Db *sqlx.DB

func init() {
db, err := sqlx.Connect("mysql", "...?parseTime=true")
if err != nil {
log.Panicln("db err: ", err.Error())
}
db.SetMaxOpenConns(20)
db.SetMaxIdleConns(20)
Db = db
}

连接字符串中设置parseTime=true是为了解析mysql中日期时间类型。

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
type SysRole struct {
Id int64 `json:"id"`
Name sql.NullString `json:"name"` // 角色名
Description sql.NullString `json:"description"`
Available sql.NullInt32 `json:"available"`
CreateTime sql.NullTime `json:"create_time" db:"create_time"` // 添加时间
UpdateTime sql.NullTime `json:"update_time" db:"update_time"` // 更新时间
}

func main() {
r := gin.Default()
r.GET("/test", func(c *gin.Context) {
var sysRole []SysRole

dataSql := `
select id, name, description, available, create_time, update_time
from sys_role
`
err := db.Db.Select(&sysRole, dataSql)
if err != nil {
panic(`select sys_role err: ` + err.Error())
}

c.JSON(200, gin.H{
"data": sysRole,
})
})
r.Run(":9001")
}

在go语言中int、string之类的类型是不可以为空的,sql.NullXXX类型代表了数据库中的可空类型。还要注意一下CreateTime字段的标签,如果表的列名和结构体字段名不一样的话就要添加db标签db:"create_time"

访问一下接口你会发现结果可能并不是你想要的,每一个可空类型的字段都变成了对象。

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
{
"data": [
{
"id": 1,
"name": {
"String": "role:root",
"Valid": true
},
"description": {
"String": "超级管理员",
"Valid": true
},
"available": {
"Int32": 1,
"Valid": true
},
"create_time": {
"Time": "2020-10-25T03:13:12Z",
"Valid": true
},
"update_time": {
"Time": "2020-10-25T03:13:12Z",
"Valid": true
}
}
...
]
}

解决空值的问题

使用guregu/null包可以解决空值的问题,guregu/null为数据库和JSON提供了可空的数据类型,可以替换掉所有sql.NullXXX类型。更新后的结构体如下:

1
2
3
4
5
6
7
8
type SysRole struct {
Id int64 `json:"id"`
Name null.String `json:"name"` // 角色名
Description null.String `json:"description"`
Available null.Int `json:"available"`
CreateTime null.Time `json:"create_time" db:"create_time"` // 添加时间
UpdateTime null.Time `json:"update_time" db:"update_time"` // 更新时间
}

再次访问接口会看到熟悉的JSON结果,并且当数据为空时也能返回正确的值。

1
2
3
4
5
6
7
8
9
10
11
12
13
{
"data": [
{
"id": 1,
"name": "role:root",
"description": "超级管理员",
"available": 1,
"create_time": "2020-10-25T03:13:12Z",
"update_time": null
},
...
]
}

基于gin的golang web开发:模型验证

Gin除了模型绑定还提供了模型验证功能。你可以给字段指定特定的规则标签,如果一个字段用binding:”required”标签修饰,在绑定时该字段的值为空,那么将返回一个错误。开发web api的时候大部分参数都是需要验证的,比如email参数要验证是否是邮箱格式、phone参数要验证是否是手机号格式等等,使用模型验证方法可以将验证过程隔离在业务之外。

内置的验证标签

Gin通过集成go-playground/validator提供模型验证功能,并提供了很多常用验证规则可以满足我们大部分的开发需求。我们通过一个例子看一下怎么使用这些验证标签。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
type AddUserRequest struct {
Username string `json:"username" binding:"required"`
Password string `json:"password" binding:"required"` // 登录密码
Nickname string `json:"nickname" binding:"required"` // 昵称
Mobile string `json:"mobile"` // 手机号
Email string `json:"email" binding:"required,email"` // 邮箱地址
}

func AddUser(c *gin.Context) {
req := sysUser.AddUserRequest{}
if err := c.ShouldBind(&req); err != nil {
c.JSON(http.StatusBadRequest, gin.H{"error": err.Error()})
return
}
...
c.String(http.StatusOK, "")
}

在Username字段上增加了binding:”required”标签,代表gin会验证参数为必填项,如果没有为Username字段提供值ShouldBind返回的err中会包含相应的错误内容。Email字段增加了binding:”required,email”标签,这是一个组合验证,代表Email是必填项的同时还要是一个正确的邮箱格式的字符串。

下面例子可以看到在未通过模型验证时,接口返回的错误信息。

请求:

1
2
3
4
5
6
7
8
{
"username":"",
"password":"123qwe",
"nickname": "昵称",
"mobile": "13322323232",
"email": "",
"qq": "234123412312"
}

响应:

1
2
3
{
"error": "Key: 'AddUserRequest.Username' Error:Field validation for 'Username' failed on the 'required' tag\nKey: 'AddUserRequest.Email' Error:Field validation for 'Email' failed on the 'required' tag"
}

自定义验证

有时候内置的验证规则可能不能满足业务需求,这样就需要自定义验证规则。大致两个步骤,1.定义一个验证方法。2.把这个方法注册为验证规则。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
type Booking struct {
CheckIn time.Time `form:"check_in" binding:"required,bookabledate" time_format:"2006-01-02"`
CheckOut time.Time `form:"check_out" binding:"required,gtfield=CheckIn" time_format:"2006-01-02"`
}

var bookableDate validator.Func = func(fl validator.FieldLevel) bool {
date, ok := fl.Field().Interface().(time.Time)
if ok {
today := time.Now()
if today.After(date) {
return false
}
}
return true
}

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

if v, ok := binding.Validator.Engine().(*validator.Validate); ok {
v.RegisterValidation("bookabledate", bookableDate)
}
...
}

这里添加了一个叫做bookabledate的验证规则,验证一下参数是否大于今天。使用RegisterValidation(“bookabledate”, bookableDate)方法注册为验证规则,并且在CheckIn字段上增加了binding:”required,bookabledate”标签。如果验证失败会返回错误信息:

1
Key: 'Booking.CheckIn' Error:Field validation for 'CheckIn' failed on the 'bookabledate' tag"

自定义错误消息

目前错误消息还是英文的,对于国内用户很不友好,接口报错的时候基本不可能把这种错误消息返回给用户看。go-playground/validator提供了错误信息的翻译,至少先解决英文错误的问题。在项目下新增validator/init.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
package validator

import (
"github.com/gin-gonic/gin/binding"
"github.com/go-playground/locales/zh"
ut "github.com/go-playground/universal-translator"
"github.com/go-playground/validator/v10"
zh_translations "github.com/go-playground/validator/v10/translations/zh"
)

var (
uni *ut.UniversalTranslator
validate *validator.Validate
trans ut.Translator
)

func init() {
translator := zh.New()
uni = ut.New(translator, translator)
trans, _ = uni.GetTranslator("zh")
validate := binding.Validator.Engine().(*validator.Validate)
_ = zh_translations.RegisterDefaultTranslations(validate, trans)
}

func Translate(err error) string {
var result string

errors := err.(validator.ValidationErrors)

for _, err := range errors {
result += err.Translate(trans) + ";"
}
return result
}

在handler中调用validator.Translate方法获取错误消息的中文翻译。

1
2
3
4
5
6
7
8
9
func AddUser(c *gin.Context) {
req := sysUser.AddUserRequest{}
if err := c.ShouldBind(&req); err != nil {
c.JSON(http.StatusBadRequest, gin.H{"error": validator.Translate(err)})
return
}
...
c.String(http.StatusOK, "")
}

这样访问接口时会看到中文的错误信息:

1
2
3
{
"error": "Username为必填字段;"
}

文本加密:常见的密码加密解密方法

前言

几年前国内一家大型的开发者论坛数据泄露,其中用户密码竟然是以明文方式保存的,很不幸我的常用密码在那时候被泄露了。这件事足以证明我们在保存用户密码的时候是不能用明文保存的。那么问题来了,用什么样的方法加密密码才安全呢?下面介绍一些常见的加密用户密码的算法。

散列函数 HASH

你可能不止一次听到过类似“MD5加密密码”或者“SHA1加密密码”等说法。这里面其实有一个误区MD5之类的算法是一种不可逆的算法,也就是说没有办法解密。这种算法有个统一的名字:散列函数。维基百科给出了解释:

散列函数(英语:Hash function)又称散列算法、哈希函数,是一种从任何一种数据中创建小的数字“指纹”的方法。散列函数把消息或数据压缩成摘要,使得数据量变小,将数据的格式固定下来。该函数将数据打乱混合,重新创建一个叫做散列值(hash values,hash codes,hash sums,或hashes)的指纹。散列值通常用一个短的随机字母和数字组成的字符串来代表。好的散列函数在输入域中很少出现散列冲突。在散列表和数据处理中,不抑制冲突来区别数据,会使得数据库记录更难找到。

散列函数有如下特性:不可以从消息摘要中复原信息;两个不同的消息不会产生同样的消息摘要。

MD5 SHA1 SHA256这些都是散列函数。由于现代计算机算力越来越强大对MD5进行碰撞试验的成本越来越低,目前来说直接保存密码的MD5值已经不再安全了,但并不是说明MD5本身有问题,如果使用MD5保存用户密码的话可以对密码进行多次MD5,这样我们的程序依然是安全的。还有MD5在验证数据完整性的应用上也具有一定的优势。

SHA1和SHA256属于一类算法SHA的不同版本,SHA1是SHA的较旧版本,可生成160位哈希值,而SHA256是SHA2的一种类型,可生成256位哈希值。更推荐使用SHA256,毕竟新版本安全性更高一些。

加密解密

DES算法:数据加密标准(英语:Data Encryption Standard,缩写为 DES)是一种对称密钥加密块密码算法,1976年被美国联邦政府的国家标准局确定为联邦资料处理标准(FIPS),随后在国际上广泛流传开来。它基于使用56位密钥的对称算法。这个算法因为包含一些机密设计元素,相对短的密钥长度以及怀疑内含美国国家安全局(NSA)的后门而在开始时有争议,DES因此受到了强烈的学院派式的审查,并以此推动了现代的块密码及其密码分析的发展。

AES算法: 高级加密标准(英语:Advanced Encryption Standard,缩写:AES),在密码学中又称Rijndael加密法,是美国联邦政府采用的一种区块加密标准。这个标准用来替代原先的DES,已经被多方分析且广为全世界所使用。经过五年的甄选流程,高级加密标准由美国国家标准与技术研究院(NIST)于2001年11月26日发布于FIPS PUB 197,并在2002年5月26日成为有效的标准。现在,高级加密标准已然成为对称密钥加密中最流行的算法之一。

RSA算法:RSA加密算法是一种非对称加密算法,在公开密钥加密和电子商业中被广泛使用。RSA是由罗纳德·李维斯特(Ron Rivest)、阿迪·萨莫尔(Adi Shamir)和伦纳德·阿德曼(Leonard Adleman)在1977年一起提出的。当时他们三人都在麻省理工学院工作。RSA 就是他们三人姓氏开头字母拼在一起组成的。

这几种算法有个特点就是可以被解密,在实践过程中使用这种算法对项目的后期维护会增加很多便利。要注意DES算法已经被证明不是一种安全的方法,尽量不要在新项目中使用。

编码

类似base64、base62这种算法是对数据进行编码的方式,只是修改了数据表现方式,并没有加密功能。一般用来配合其他算法使用。

最后还是要说安全无小事,我们在项目开发中还是要尽量的注意数据安全的问题,避免造成不必要的麻烦。

基于gin的golang web开发:模型绑定

在前两篇文章介绍路由的时候,我们了解到gin可用通过类似DefaultQuery或DefaultPostForm等方法获取到前端提交过来的参数。参数不多的情况下也很好用,但是想想看,如果接口有很多个参数的时候再用这种方法就要调用很多次获取参数的方法,本文将介绍一种新的接收参数的方法来解决这个问题:模型绑定。

gin中的模型绑定可以理解为:把请求的参数映射为一个具体的类型。gin支持JSON,XML,YAML和表单参数等多种参数格式,只需要在对应的字段上声明标签。

绑定表单或者查询字符串

1
2
3
4
5
6
7
8
9
10
11
12
13
type Person struct {
Name string `form:"name"`
Address string `form:"address"`
}

func startPage(c *gin.Context) {
var person Person
if c.ShouldBindQuery(&person) == nil {
log.Println(person.Name)
log.Println(person.Address)
}
c.String(200, "Success")
}

在结构体Name字段声明form标签,并调用ShouldBindQuery方法,gin会为我们绑定查询字符串中的name和address两个参数。注意虽然我们声明了form标签,ShouldBindQuery只绑定查询字符串中的参数。

如果你想绑定表单中的参数的话结构体不用改变,需要把ShouldBindQuery方更改为ShouldBind方法。ShouldBind方法会区分GET和POST请求,如果是GET请求绑定查询字符串中的参数,如果是POST请求绑定表单参数中的内容,但是不能同时绑定两种参数。

绑定json参数

1
2
3
4
5
6
7
8
9
10
11
12
13
type Person struct {
Name string `json:"name"`
Address string `json:"address"`
}

func startPage(c *gin.Context) {
var person Person
if c.ShouldBind(&person) == nil {
log.Println(person.Name)
log.Println(person.Address)
}
c.String(200, "Success")
}

json是一种常用的数据交换格式,尤其是在和web前端页面交互的时候,似乎已经成为了一种事实标准。gin绑定json格式数据方法很简单,只需要设置字段的标签为json并且调用ShouldBind方法。

其他类型参数绑定

路由参数在绑定时设置标签为uri,并调用ShouldBindUri方法。

1
2
3
4
5
6
7
8
9
10
11
type Person struct {
Id string `uri:"id"`
}

func startPage(c *gin.Context) {
var person Person
if c.ShouldBindUri(&person) == nil {
log.Println(person.Id)
}
c.String(200, "Success")
}

绑定在HTTP Header中的参数,字段的标签设置为header,调用方法为ShouldBindHeader。

还有不太常用的数组参数是字段标签设置为form:”colors[]”,结构体例子如下:

1
2
3
type myForm struct {
Colors []string `form:"colors[]"`
}

文件上传这种场景我很少用模型绑定的方式获取参数,在gin中对于这种场景也提供了模型绑定支持。

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
type ProfileForm struct {
Name string `form:"name"`
Avatar *multipart.FileHeader `form:"avatar"`
// Avatars []*multipart.FileHeader `form:"avatar"` 多文件上传
}

func main() {
router := gin.Default()
router.POST("/profile", func(c *gin.Context) {
var form ProfileForm
if err := c.ShouldBind(&form); err != nil {
c.String(http.StatusBadRequest, "bad request")
return
}

err := c.SaveUploadedFile(form.Avatar, form.Avatar.Filename)
if err != nil {
c.String(http.StatusInternalServerError, "unknown error")
return
}

c.String(http.StatusOK, "ok")
})
router.Run(":8080")
}

多种类型的模型绑定

如果我们有一个UpdateUser接口,PUT /user/:id,参数是{“nickname”: “nickname…”,”mobile”: “13322323232”}。代码如下:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
type ProfileForm struct {
Id int `uri:"id"`
Nickname string `json:"nickname"` // 昵称
Mobile string `json:"mobile"` // 手机号
}

func main() {
router := gin.Default()
router.GET("/user/:id", func(c *gin.Context) {
var form ProfileForm
if err := c.ShouldBindUri(&form); err != nil {
c.JSON(http.StatusBadRequest, gin.H{"error": err.Error()})
return
}

if err := c.ShouldBindJSON(&form); err != nil {
c.JSON(http.StatusBadRequest, gin.H{"error": err.Error()})
return
}

c.String(http.StatusOK, "ok")
})
router.Run(":8080")
}

代码里调用了两次bind方法才获取到全部的参数。和gin社区沟通之后发现目前还不能调用一个方法同时绑定多个参数来源,当前gin版本为1.6.x,不知道未来会不会提供这种功能。

基于gin的golang web开发:路由二

基于gin的golang web开发:路由中我们介绍了Gin的路由和一些获取链接中参数的方法,本文继续介绍其他获取参数的方法。

文件上传

在web开发中文件上传是一个很常见的需求,下面我们来看一下基于Gin的文件上传。

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
func main() {
router := gin.Default()
router.MaxMultipartMemory = 8 << 20 // 8 MiB
router.POST("/upload", func(c *gin.Context) {
file, _ := c.FormFile("file")
log.Println(file.Filename)

c.SaveUploadedFile(file, dst)

c.String(http.StatusOK, fmt.Sprintf("'%s' uploaded!", file.Filename))
})

router.POST("/multiple_upload", func(c *gin.Context) {
form, _ := c.MultipartForm()
files := form.File["upload[]"]

for _, file := range files {
log.Println(file.Filename)

c.SaveUploadedFile(file, dst)
}
c.String(http.StatusOK, fmt.Sprintf("%d files uploaded!", len(files)))
})

router.Run(":8080")
}

router.MaxMultipartMemory用于限制上传文件的大小,默认大小为32MiB。这个值可以根据你的业务设置具体的值,尽量不要设置的太大。

在本例中可以看到单文件上传和多文件上传的处理方式是不一样的。

单文件上传时使用file, _ := c.FormFile(“file”)获取客户端传过来的文件。这里使用 _ 忽略了错误,在生产环境你可能需要处理一下错误。file.Filename可以获取到文件名。注意:file.Filename是一个可选的参数,不要使用这个参数保存文件,保存文件时最好自己生成一个新的文件名。

c.SaveUploadedFile保存文件到文件系统,第一个参数传入获取到的文件,第二个参数输入文件路径。由于Go语言跨平台的特性,在传入文件路径参数的时候你可能要考虑到生产环境服务器的操作系统。例如windows操作系统的文件路径可能是”c:\uploadfiles\1.png”,linux操作系统的文件路径可能是”/var/uploadfiles/1.png”。

多文件上传时先获取到表单form, _ := c.MultipartForm(),然后获取到文件数组files := form.File[“upload[]”],最后循环操作文件数组中的每个文件。

在本例中直接保存文件到文件系统了,业务系统中可能会把上传的文件保存到阿里云的OSS或者七牛云等文件系统,替换c.SaveUploadedFile为不同文件系统保存文件的方法就可以了。

映射参数为Map

文件参数是数组的时候,Gin可以把参数映射为Map类型。

1
2
3
4
5
6
7
8
9
10
11
12
func main() {
router := gin.Default()

router.POST("/post", func(c *gin.Context) {

ids := c.QueryMap("ids")
names := c.PostFormMap("names")

fmt.Printf("ids: %v; names: %v", ids, names)
})
router.Run(":8080")
}

c.QueryMap可以获取到查询字符串中的数组,c.PostFormMap可以获取到表单参数中的数组。向/post?ids[a]=1234&ids[b]=hello post提交数据 names[first]=thinkerou&names[second]=tianou,会看到输出ids: map[b:hello a:1234]; names: map[second:tianou first:thinkerou]。

还有另外一种处理数组参数的方法。路径是这样的/post?ids=1234,hello,这种情况可以用基于gin的golang web开发:路由中提到的获取查询字符串的方法:DefaultQuery或者Query,然后分割字符串。

基于gin的golang web开发:路由

Gin是一个用Golang编写的HTTP网络框架。它的特点是类似于Martini的API,性能更好。在golang web开发领域是一个非常热门的web框架。

启动一个Gin web服务器

使用下面的命令安装Gin

1
go get -u github.com/gin-gonic/gin

在代码里添加依赖

1
import "github.com/gin-gonic/gin"

快速启动一个Gin服务器的代码如下

1
2
3
4
5
6
7
8
9
10
11
12
13
package main

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

func main() {
r := gin.Default()
r.GET("/ping", func(c *gin.Context) {
c.JSON(200, gin.H{
"message": "pong",
})
})
r.Run()
}

核心api gin.Default()返回一个Engine对象,调用Engine对象的Run方法会在本机8080端口启动web服务器。如果不想绑定8080端口或者8080端口已被占用的话,可以给Run方法传递你要绑定的端口r.Run(“:8081”)。代码中的r.GET就是本文要讲解核心内容:路由。

路由

Gin支持http方法: GET, POST, PUT, PATCH, DELETE,HEAD, OPTIONS。分别对应了不同的方法。

1
2
3
4
5
6
7
8
9
10
11
12
13
func main() {
router := gin.Default()

router.GET("/someGet", getting)
router.POST("/somePost", posting)
router.PUT("/somePut", putting)
router.DELETE("/someDelete", deleting)
router.PATCH("/somePatch", patching)
router.HEAD("/someHead", head)
router.OPTIONS("/someOptions", options)

router.Run()
}

这些路由方法的第一个参数设置相对地址,第二个方法是访问该地址时执行的方法。在Gin中叫做handler。handler方法的原型如下。

1
type HandlerFunc func(*Context)

Gin还可以设置路由前缀。例如有v1/login,v1/logout两个地址可以使用Gin的Grouping routes功能设置路由前缀。

1
2
3
4
5
6
7
8
9
10
11
func main() {
router := gin.Default()

v1 := router.Group("/v1")
{
v1.POST("/login", loginEndpoint)
v1.POST("/logout", logoutEndpoint)
}

router.Run()
}

获取参数

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
func main() {
router := gin.Default()

router.GET("/user/:name", func(c *gin.Context) {
name := c.Param("name")
c.String(http.StatusOK, "Hello %s", name)
})

router.GET("/user/:name/*action", func(c *gin.Context) {
name := c.Param("name")
action := c.Param("action")
message := name + " is " + action
c.String(http.StatusOK, message)
})

router.GET("/welcome", func(c *gin.Context) {
firstname := c.DefaultQuery("firstname", "Guest")
lastname := c.Query("lastname")

c.String(http.StatusOK, "Hello %s %s", firstname, lastname)
})

router.POST("/form_post", func(c *gin.Context) {
message := c.PostForm("message")
nick := c.DefaultPostForm("nick", "anonymous")

c.JSON(200, gin.H{
"status": "posted",
"message": message,
"nick": nick,
})
})

router.Run(":8080")
}

可以看到获取参数的方法可以分为路由参数、查询字符串和表单。

路由参数使用c.Param(“name”)取值。访问/user/john时会调用/user/:name对应的handler,根据规则当访问/user/或者/user并不会调用这个hanlder。

访问/user/john/或者/user/john/send时会调用/user/:name/*action对应的handler。如果没有对/user/john设置过路由的话,访问/user/john会重定向到/user/john/。

使用DefaultQuery或者Query获取查询字符串中的参数,DefaultQuery在没有获取到参数时可以设置一个默认值。在本例中访问/welcome?firstname=Jane&lastname=Doe会调用/welcome对应的handler。

获取表单参数Gin同样也为我们提供了两种方法PostForm和DefaultPostForm。和获取查询字符串的方法一样,DefaultPostForm也可以在没有获取到参数时设置一个默认值。

Gin的Api总体来说还是很直观的,例如上文中没有提到的c.JSON从命名就可以看出会输出一段JSON。c.String直接输出字符串。http.StatusOK定义在http包中,这是一个值为200的常量。gin.H不太一样,这是一个自定义的数据类型map[string]interface{}可以用于返回JSON。

在windows上安装pm2并设置开机启动

pm2是一个node.js的进程管理工具,pm2还能够提供性能监控、进程保活功能。node和相关工具在Linux系统上使用非常简单而且一般也不会出什么问题,然而在Windows上设置起来就没那么简单了,还可能会遇到各种各样的问题。本文将介绍pm2的安装和启动过程并解决一些常见问题。

各软件版本

软件 版本
Windows 2019
node v12.18.3
npm 6.14.6
pm2 4.4.1

在Windows上安装node还是比较简单的,下载安装包一路点击下一步就可以了。安装完成之后系统已经包含了node和npm,国内使用npm如果网络不是很好的话要设置一下国内的镜像源。npm国内源还是挺多的腾讯、清华大学等都提供了npm的镜像源。

验证npm没问题之后就可以安装pm2,这里应该不会出现什么问题。

1
npm install pm2 -g

下一步启动项目。这里可能会遇到第一个问题:项目启动报错。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
pm2 start npm -- start

(function (exports, require, module, __filename, __dirname) { :: Created by npm, please don't edit manually.
^
SyntaxError: Unexpected token :
at Object.exports.runInThisContext (vm.js:73:16)
at Module._compile (module.js:543:28)
at Object.Module._extensions..js (module.js:580:10)
at Module.load (module.js:488:32)
at tryModuleLoad (module.js:447:12)
at Function.Module._load (module.js:439:3)
at Object.<anonymous> (C:\Users\nitin.mukesh\AppData\Roaming\npm\node_modules\pm2\lib\ProcessContainerFork.js:53:21)
at Module._compile (module.js:571:32)
at Object.Module._extensions..js (module.js:580:10)
at Module.load (module.js:488:32)

这个问题似乎是因为没有执行cmd的权限我没有深入研究。有两个解决方案。第一个安装node-cmd包,使用这个包的api编写一个启动脚本,这个脚本调用npm start。项目启动之后会在桌面留下一个控制台窗口,这个窗口不能关。

1
npm install node-cmd --save;

startscript.js

1
var cmd=require('node-cmd'); cmd.run('npm start');

启动

1
pm2 start startscript.js

第二个解决方案原理也差不多,不用安装第三方包,还可以把控制台窗口隐藏起来。编辑startscript.js

1
2
var exec = require('child_process').exec;
exec('npm start', {windowsHide: true});

再次启动就不会出现控制台窗口了。

下面设置开机启动,可能会遇到第二个问题

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
pm2 save
pm2 startup

[PM2][ERROR] Init system not found
C:\Users\Administrator\AppData\Roaming\npm\node_modules\pm2\lib\API\Startup.js:209
throw new Error('Init system not found');
^

Error: Init system not found
at API.CLI.startup (C:\Users\Administrator\AppData\Roaming\npm\node_modules\pm2\lib\API\Startup.js:209:13)
at Command.<anonymous> (C:\Users\Administrator\AppData\Roaming\npm\node_modules\pm2\lib\binaries\CLI.js:707:9)
at Command.listener (C:\Users\Administrator\AppData\Roaming\npm\node_modules\pm2\node_modules\commander\index.js:315:8)
at Command.emit (events.js:315:20)
at Command.parseArgs (C:\Users\Administrator\AppData\Roaming\npm\node_modules\pm2\node_modules\commander\index.js:651:12)
at Command.parse (C:\Users\Administrator\AppData\Roaming\npm\node_modules\pm2\node_modules\commander\index.js:474:21)
at Timeout._onTimeout (C:\Users\Administrator\AppData\Roaming\npm\node_modules\pm2\lib\binaries\CLI.js:204:15)
at listOnTimeout (internal/timers.js:549:17)
at processTimers (internal/timers.js:492:7)

查看pm2的官方文档,提示我们要安装pm2-windows-service包。参照提示安装的时候还是会遇到问题。

1
2
npm i pm2-windows-service -g
pm2-service-install

这里提示Perform environment setup…选择y或者n都会卡住,原因似乎是依赖项版本太低造成的。在pm2-windows-service作者更新项目之前我们要自己解决这个问题。

1
2
3
4
5
npm install -g npm-check-updates

cd %USERPROFILE%\AppData\Roaming\npm\node_modules\pm2-windows-service
ncu inquirer -u
npm install

再次执行安装服务的命令,可以在服务中看到安装成功的PM2服务。

1
pm2-service-install

这里一定要设置系统级别的PM2_HOME环境变量,不然会出现一些意想不到的问题。这一点pm2-windows-service项目的文档也给出了警告,一定要严格执行。

这些问题解决完之后就可以启动你的项目了。pm2 list查看你的项目正在运行,pm2 save记录一下pm2的状态,这是为了在服务器重启之后pm2能自动启动你的项目。重启一下试试吧。

可以看到在windows上使用pm2还是挺麻烦的,要解决各种问题。pm2-windows-service这么重要的项目竟然已经3年没有更新了,如果有条件的话服务器环境还是选择Linux吧。

asp.net core自定义tag实现页面按钮权限

一般我们在开发后台系统的时候都会有权限验证的功能,有菜单权限和按钮权限也许还有数据权限。最近做一个按钮权限发现asp.net core增加了TagHelper,相比HtmlHelper来说可读性更好。微软为我们提供了很多默认实现,这些tag一般是以asp-开头。比如超链接就可以用asp-controller和asp-action来设置要跳转到的action,Razor在渲染的时候替换为a标签的href属性。这种方法更贴近html相对于HtmlHelper来说也更直观一些。

1
<a asp-controller="Speaker" asp-action="Evaluations">Speaker Evaluations</a>

以往我们做按钮权限的时候会扩展一下HtmlHelper,增加一个类似HasPermission的方法判断有没有权限。在没有权限的时候就不渲染按钮。

1
2
3
4
@if (Html.HasPermission("/demo/list:delete"))
{
<button type="button" class="layui-btn">删除</button>
}

现在我们有了一个新的选择,我们可以在button中增加一个新的属性来判断权限。TagHelper可以通过扩展html标签或者属性来改变输出的html内容。我们的按钮有两种样式a标签和button标签,所以使用了扩展HTML属性的方式。[HtmlTargetElement(Attributes = “tg-permission”)]让我们在Razor页面中任何标签都可以使用tg-permission属性。output.SuppressOutput()的意思是不输出内容,这里会把整个标签从HTML内容中去除。

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
[HtmlTargetElement(Attributes = "tg-permission")]
public class PermissionCheckerTagHelper : TagHelper
{
private readonly SideMenuProvider _sideMenuProvider;
private readonly IHttpContextAccessor _httpContextAccessor;

public PermissionCheckerTagHelper(SideMenuProvider sideMenuProvider, IHttpContextAccessor httpContextAccessor)
{
_sideMenuProvider = sideMenuProvider;
_httpContextAccessor = httpContextAccessor;
}

public override void Process(TagHelperContext context, TagHelperOutput output)
{
var permissionAttribute = context.AllAttributes
.FirstOrDefault(m => m.Name == "tg-permission");
if (permissionAttribute == null)
{
output.SuppressOutput();
return;
}

var user = _httpContextAccessor.HttpContext.User;

if (!_sideMenuProvider.IsHavePermission(permissionAttribute.Value.ToString(), user.GetUserId()))
{
output.SuppressOutput();
}
}
}

写完扩展类之后并不能直接使用,还需要在_ViewImports.cshtml中使用@addTagHelper *, Demo.Web注册一下。最后需要判断权限的按钮上只需要增加tg-permission属性即可。

1
<button type="button" class="layui-btn" tg-permission="/demo/list:delete">标签</button>

相比扩展HtmlHelper这种方式代码更少,也更贴近HTML的语法。

asp.net core 3.1 更换依赖注入容器

asp.net core的内置容器有时候并不能满足我们所有的需求,比如微软官方就给出了一些内置容器没有提供的功能。我们如果需要这些高级功能就需要替换内置的依赖注入容器。

- 属性注入
- 基于名称的注入
- 子容器
- 自定义生存期管理
- 对迟缓初始化的 Func<T> 支持
- 基于约定的注册

asp.net core在2.x版本中的方法是修改Startup类中的ConfigureServices方法,把返回值从void改成IServiceProvider并在方法结尾返回新的容器。然而在升级到3.1后,以前替换内置依赖容器的方法是不生效的。我们知道在dotnet core 3.x退出了一个新的通用主机。默认情况下我们新创建项目的启动方法配置了这个通用主机。

1
2
3
4
5
6
7
public static IHostBuilder CreateHostBuilder(string[] args) =>
Host.CreateDefaultBuilder(args)
.UseNLog()
.ConfigureWebHostDefaults(webBuilder =>
{
webBuilder.UseStartup<Startup>();
});

一个简单的方法可以是用2.x版本中创建主机的方法,就是直接修改这个CreateHostBuilder为CreateWebHostBuilder。代码如下

1
2
3
public static IWebHostBuilder CreateWebHostBuilder(string[] args) =>
WebHost.CreateDefaultBuilder(args)
.UseStartup<Startup>();

另一个方法可以使用新的通用主机,但是需要容器支持。微软在文档中列出了几个提供支持的容器比如Autofac、Unity。今天是2020年8月31日Castle Windsor对通用主机的支持还停在预览版本。但是我不太熟悉Unity,下面讨论一下Autofac和Castle Windsor的集成方法。

总体来说两个容器的集成方法其实差别不大,在CreateHostBuilder方法用调用use容器的方法。Castle Windsor调用UseWindsorContainerServiceProvider(),Autofac调用UseServiceProviderFactory(new AutofacServiceProviderFactory())。

前面说过不能从ConfigureServices中返回IServiceProvider,但我们还需要一个可以操作容器的入口。在Startup中dotnet core为我们提供了ConfigureContainer方法操作容器,只不过不同容器这个方法的参数不同。

1
2
3
4
5
6
7
8
9
10
11
12
// Castle Windsor使用IWindsorContainer
public void ConfigureContainer (IWindsorContainer container)
{
container.Install(new MyInstaller());
}

// Autofac使用ContainerBuilder
public void ConfigureContainer(ContainerBuilder builder)
{
// Register your own things directly with Autofac, like:
builder.RegisterModule(new MyApplicationModule());
}

最后还有一个需要注意的是Controller。现在Controller的生命周期都不归第三方依赖注入容器管理,它归asp.net core管理,包括你可能需要的属性注入都不会生效。Autofac和Castle Windsor都提供了AddControllersAsServices()方法来改变这个行为。

这就是更换依赖注入容器的一般过程。感觉dotnet core进步还是挺快的,希望未来dotnet core 5不要有太多的重大更改吧。

Kubernetes使用私有仓库

在使用Kubernetes部署应用程序时,大概率会使用私有docker仓库。其实使用私有仓库的逻辑还是比较简单的,只需要登录私有仓库并pull镜像即可。但是在操作上还是不那么直观的。


设置私有仓库登录信息的方式有两种,导入配置文件docker/config.json和使用命令生成登录信息。这些信息记录在secrets中。


导入配置文件的方式你需要使用docker login命令登录你的私有仓库,登录成功之后会在~/.docker/config.json找到登录成功的配置文件。使用命令行导入配置文件即可

1
2
3
kubectl create secret generic regcred \
--from-file=.dockerconfigjson=<path/to/.docker/config.json> \
--type=kubernetes.io/dockerconfigjson

命令行生成登录信息的方式就比较简单了。直接执行命令,kubernetes会自动生成登录信息。

1
kubectl create secret docker-registry regcred --docker-server=<your-registry-server> --docker-username=<your-name> --docker-password=<your-pword> --docker-email=<your-email>

使用时在pod的yaml中指定imagePullSecrets。

1
2
3
4
5
6
7
8
9
10
apiVersion: v1
kind: Pod
metadata:
name: private-reg
spec:
containers:
- name: private-reg-container
image: <your-private-image>
imagePullSecrets:
- name: regcred

这里有一个问题是以后每次新增pod的时候都要配置这个属性,而且这个属性可能会随着环境变化而变化。查看文档可知,在sa上也有一个imagePullSecrets,kubernetes在拉取镜像时会读取这里的值。这样就可以不在pod中配置imagePullSecrets。

1
kubectl patch serviceaccount default -p '{"imagePullSecrets": [{"name": "regcred"}]}'

在登录信息发生变更的时候只要删除并重新创建secrets,保持名称一致。sa中记录的imagePullSecrets不需要改变。

1
2
kubectl delete secrets regcred
kubectl create secret docker-registry regcred --docker-server=<your-registry-server> --docker-username=<your-name> --docker-password=<your-pword> --docker-email=<your-email>