Go Web 开发 Demo【用户登录、注册、验证】
前言
这篇文章主要是学习怎么用 Go 语言(Gin)开发Web程序,前端太弱了,得好好补补课,完了再来更新。
1、环境准备
新建项目,生成 go.mod 文件:
出现报错:go: modules disabled by GO111MODULE=off; see 'go help modules',说明需要开启:
临时设置环境变量:
set GO111MODULE=on # windows export GO111MODULE=on # linux
永久设置环境变量:
再次生成 go.mod 文件:
执行完毕,发现项目下生成 go.mod 文件:
这里的模块名称是我们自定义的,不是说非得和哪个目录或者项目名对应上!
2、用户注册
2.1、需求
- 用户向地址 /register 发送POST请求(表单携带着 username、password、phone)
- 后端处理请求(检查手机号位数、手机号是否已经被注册、用户名是否为空)
2.2、需求实现
2.2.1、判断手机号是否存在
func isPhoneExist(db gorm.DB, phone string) bool { user := User{} db.Where("phone = ?", phone).First(user) if user.ID != 0 { return true } return false }
2.2.2、生成随机10位的用户名
func RandomString(length int) string { var letters = []byte("qwertyuiopasdfghjklzxcvbnmQWERTYUIOPASDFGHJKLZXCVBNM") result := make([]byte, length) rand.Seed(time.Now().Unix()) for i := range result { result[i] = letters[rand.Intn(len(letters))] } return string(result) }
2.2.3、设置用户表结构体
这个结构体的字段会对应用户表中的每个字段,我们会在初始化数据库的时候,使用 gorm.DB 的 AutoMigrate 方法自动帮我进行创建这个结构体对应的表。
type User struct { gorm.Model Name string `gorm:"type:varchar(20);not null"` Phone string `gorm:"type:varchar(110);not null,unique"` Password string `gorm:"size:255;not null"` }
其中 gorm.Model 的源码如下:
我们通过嵌套 gorm.Model 来给我们的表增加四个字段。
2.2.4、获得数据库连接(gorm)
func InitDB() *gorm.DB { driverName := "mysql" host := "127.0.0.1" port := 3306 database := "go_web" username := "root" password := "xxxxx" charset := "utf8mb4" args := fmt.Sprintf("%s:%s@tcp(%s:%d)/%s?charset=%s&parseTime=true", username, password, host, port, database, charset) db, err := gorm.Open(driverName, args) if err != nil { panic("failed to connect database, err : " + err.Error()) } db.AutoMigrate(&User{}) // 如果表不存在则自动创建 return db }
2.3、完整代码
package main import ( "fmt" "github.com/gin-gonic/gin" _ "github.com/go-sql-driver/mysql" "github.com/jinzhu/gorm" "math/rand" "net/http" "time" ) type User struct { gorm.Model Name string `gorm:"type:varchar(20);not null"` Phone string `gorm:"type:varchar(110);not null,unique"` Password string `gorm:"size:255;not null"` } func main() { db := InitDB() defer db.Close() engine := gin.Default() engine.POST("/register", func(ctx *gin.Context) { // 获取参数 name := ctx.PostForm("username") phone := ctx.PostForm("phone") password := ctx.PostForm("password") // 数据验证 if len(phone) != 11 { ctx.JSON(http.StatusUnprocessableEntity, gin.H{ "code": 422, "msg": "手机号必须为11位!", }) return } if len(password)
2.4、测试
使用规范的用户信息再次注册:
3、项目重构
上面我们把连接数据库和响应的代码都放到了一个文件中,显然后期随着业务代码越来越多开发起来越来越难以管理,所以我们这里需要对项目进行重构:
3.1、util 层
存放工具,比如我们上面的随机生成用户名方法
package util import ( "math/rand" "time" ) func RandomString(length int) string { var letters = []byte("qwertyuiopasdfghjklzxcvbnmQWERTYUIOPASDFGHJKLZXCVBNM") result := make([]byte, length) rand.Seed(time.Now().Unix()) for i := range result { result[i] = letters[rand.Intn(len(letters))] } return string(result) }
3.2、model 层
存放结构体
package model import "github.com/jinzhu/gorm" type User struct { gorm.Model Name string `gorm:"type:varchar(20);not null"` Phone string `gorm:"type:varchar(110);not null,unique"` Password string `gorm:"size:255;not null"` }
3.3、common 层
存放一些公共的方法,比如连接数据库工具
package common import ( "com.lyh/goessential/model" "fmt" "github.com/jinzhu/gorm" ) var DB *gorm.DB func InitDB() *gorm.DB { driverName := "mysql" host := "127.0.0.1" port := 3306 database := "go_web" username := "root" password := "Yan1029." charset := "utf8mb4" args := fmt.Sprintf("%s:%s@tcp(%s:%d)/%s?charset=%s&parseTime=true", username, password, host, port, database, charset) db, err := gorm.Open(driverName, args) if err != nil { panic("failed to connect database, err : " + err.Error()) } db.AutoMigrate(&model.User{}) // 如果表不存在则自动创建 DB = db return db } func GetDB() *gorm.DB { return DB }
3.4、controller 层
存放控制器,因为我们使用的 Gin 框架的请求方法都是函数式编程,它的第二个参数是一个处理请求的函数,所以控制器层我们存放的是业务模块对应的方法:
package controller import ( "com.lyh/goessential/common" "com.lyh/goessential/model" "com.lyh/goessential/util" "fmt" "github.com/gin-gonic/gin" "github.com/jinzhu/gorm" "net/http" ) func Register(ctx *gin.Context) { DB := common.GetDB() // 获取参数 name := ctx.PostForm("username") phone := ctx.PostForm("phone") password := ctx.PostForm("password") // 数据验证 if len(phone) != 11 { ctx.JSON(http.StatusUnprocessableEntity, gin.H{ "code": 422, "msg": "手机号必须为11位!", }) return } if len(password)
注意:这里判断用户是否存在需要传入一个 user 地址,因为 user 是值类型,如果不传入地址,则进入方法后的操作无效。
3.5、routes.go
所有的请求都将通过这个文件中的方法再传递给 main 方法,其实就是为了简化 main 方法所在go文件的代码量,方便管理和维护。所以它的包名也是 main,相当于它俩在一个文件内。
package main import ( "com.lyh/goessential/controller" "github.com/gin-gonic/gin" ) func CollectRoute(engine *gin.Engine) *gin.Engine { engine.POST("/register", controller.Register) return engine }
3.6、main.go
这是程序的入口,现在我们已经把它彻底解脱出来了:
package main import ( "com.lyh/goessential/common" "github.com/gin-gonic/gin" _ "github.com/go-sql-driver/mysql" ) func main() { db := common.InitDB() defer db.Close() engine := gin.Default() engine = CollectRoute(engine) panic(engine.Run()) // 默认端口 8080 }
测试
因为我们有两个文件都是 main 包,所以我们最好使用命令启动:
4、密码加密以及登录测试
4.1、注册加密
在 controller 的注册方法( Register )中修改创建用户的代码,对将要插入数据库中的代码进行加密:
// 创建用户 hasedPassword, err := bcrypt.GenerateFromPassword([]byte(password), bcrypt.DefaultCost) if err != nil { ctx.JSON(http.StatusInternalServerError, gin.H{ "code": 500, "msg": "加密错误", }) return } newUser := model.User{ Name: name, Phone: phone, Password: string(hasedPassword), } DB.Create(&newUser)
4.2、登录方法
func Login(ctx *gin.Context) { DB := common.GetDB() // 获取参数 phone := ctx.PostForm("phone") password := ctx.PostForm("password") // 数据验证 if len(phone) != 11 { ctx.JSON(http.StatusUnprocessableEntity, gin.H{ "code": 422, "msg": "手机号必须为11位!", }) return } if len(password)
4.3、注册请求
把我们的 Login 方法注册到 /login 地址(只需要在 routes.go 文件的 CollectRoute 函数中添加一行即可):
测试
查看数据库:
登录测试
5、jwt 实现用户认证
jwt 地址:github.com/dgrijalva/jwt-go
5.1、发放 token
在 common 包下来创建一个 jwt.go 文件,定义发放 token 的函数:
package common import ( "com.lyh/goessential/model" "github.com/dgrijalva/jwt-go" "time" ) var jwtKey = []byte("a_secret_crect") type Claims struct { UserId uint jwt.StandardClaims } func ReleaseToken(user model.User) (string, error) { expirationTime := time.Now().Add(7 * 24 * time.Hour) // 设置token有效期7天 claims := &Claims{ UserId: user.ID, StandardClaims: jwt.StandardClaims{ ExpiresAt: expirationTime.Unix(), // 过期时间 IssuedAt: time.Now().Unix(), // 发放的时间 Issuer: "lyh", // 发送者 Subject: "user token", // 发送主题 }, } token := jwt.NewWithClaims(jwt.SigningMethodHS256, claims) tokenString, err := token.SignedString(jwtKey) if err != nil { return "", err } return tokenString, nil } // 从 tokenString 中解析出 claims 然后返回 func ParseToken(tokenString string) (*jwt.Token, *Claims, error) { claims := &Claims{} token, err := jwt.ParseWithClaims(tokenString, claims, func(token *jwt.Token) (interface{}, error) { return jwtKey, nil }) return token, claims, err }
5.2、设置返回 token
在之前 controller 层下用户模块中的登录请求(/login)中设置返回 token(之前随便写了个 "11"):
// 返回 token 给前端 token, err := common.ReleaseToken(user) if err != nil { ctx.JSON(http.StatusInternalServerError, gin.H{ "code": 500, "msg": "系统异常", }) log.Printf("token generate error : %v", err) return } // 返回结果 ctx.JSON(200, gin.H{ "code": 200, "data": gin.H{ "token": token, }, "msg": "登录成功", })
测试登录用户,拿到 token :
token 由三部分组成:
- 协议头(token 使用的加密协议)
- 我们给token中存储的信息(解密后是 JSON 格式的数据)
- 前两部分加上key通过hash后的值
5.3、定义用户认证中间件
如果不加中间件的话,前端请求时携带token,返回的 user 是 null ,因为我们没有往上下文存储 user 的信息。
中间件为的是把前端请求时,authorization 中携带的 token 信息解析出来验证并保存到上下文。在 middleware 包下创建 AuthMiddleware.go:
package middleware import ( "com.lyh/goessential/common" "com.lyh/goessential/model" "github.com/gin-gonic/gin" "net/http" "strings" ) // 自定义中间件用于用户验证:相当于SpringBoot中的拦截器 func AuthMiddleware() gin.HandlerFunc { return func(ctx *gin.Context) { // 获取 authorization header tokenString := ctx.GetHeader("Authorization") //eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJVc2VySWQiOjIsImV4cCI6MTcxNTE1MDIyNCwiaWF0IjoxNzE0NTQ1NDI0LCJpc3MiOiJseWgiLCJzdWIiOiJ1c2VyIHRva2VuIn0.C6yH99IZDjj6_FnpHaREVPmoCX82nYWv1OZao171iPg // 验证格式 if tokenString == "" || !strings.HasPrefix(tokenString, "Bearer ") { ctx.JSON(http.StatusUnauthorized, gin.H{ "code": 401, "msg": "权限不足", }) ctx.Abort() return } tokenString = tokenString[7:] // Bearer+' '一共7位 token, claims, err := common.ParseToken(tokenString) if err != nil || !token.Valid { ctx.JSON(http.StatusUnauthorized, gin.H{ "code": 401, "msg": "权限不足", }) ctx.Abort() return } // 验证通过后获取claims 中的 userId userId := claims.UserId DB := common.GetDB() var user model.User DB.First(&user, userId) // 用户不存在 if user.ID == 0 { ctx.JSON(http.StatusUnauthorized, gin.H{"code": 401, "msg": "权限不足"}) ctx.Abort() return } // 用户存在 将user信息写入上下文 ctx.Set("user", user) ctx.Next() } }
添加路由并测试
添加到我们的管理所有路由的文件(routes.go)中:
测试:
6、统一请求返回格式
我们在学习 SpringBoot 项目的时候也进行了请求的统一返回格式:(code,data,other),这里也是一样,为的是简化开发,比如我们前面返回前端需要这样写:
ctx.JSON(200, gin.H{ "code": 200, "msg": "注册成功", })
统一格式后我们只需要写个 200 和 "注册成功" 就可以了。
此外,我们给前端返回的数据还有一些问题:比如把用户的全部信息都返回出去了(包括密码登隐私信息)
6.1、数据传输对象(dto)
创建一个包 dto 并创建 user_dto.go 用来将返回给前端的 user 转换为 userDto:
package dto import ( "com.lyh/goessential/model" ) type UserDto struct { Name string `json:"name"` Phone string `json:"phone"` } // 将 user 转换为 userDto func ToUserDto(user model.User) UserDto { return UserDto{ Name: user.Name, Phone: user.Phone, } }
修改 controller 中的 Info 方法:
func Info(ctx *gin.Context) { // 从上下文中获得用户的信息 user, _ := ctx.Get("user") ctx.JSON(http.StatusOK, gin.H{"code": 200, "data": gin.H{"user": dto.ToUserDto(user.(model.User))}}) }
再次进行用户验证:
可以看到,这次返回的数据没有其它敏感信息。
6.2、封装 HTTP 返回
创建目录 response,并创建 response.go :
package response import ( "github.com/gin-gonic/gin" "net/http" ) // 这里的 code 是我们自定义的业务code func Response(ctx *gin.Context, httpStatus int, code int, data gin.H, msg string) { ctx.JSON(httpStatus, gin.H{"code": code, "date": data, "msg": msg}) } func Success(ctx *gin.Context, data gin.H, msg string) { Response(ctx, http.StatusOK, 200, data, msg) } func Fail(ctx *gin.Context, data gin.H, msg string) { Response(ctx, http.StatusOK, 400, data, msg) }
定义了统一的前端返回类型之后,我们就可以开始修改之前的返回代码了,之前我们的 HTTP 返回都是通过 ctx.JSON(httpStatus,gin.H) 来返回的,现在我们需要都替换为我们自定义的返回格式,比如下面的:
ctx.JSON(http.StatusUnprocessableEntity, gin.H{ "code": 422, "msg": "手机号必须为11位!", })
统一之后就清爽多了,而且不会存在前端拿一些 JSON 的属性却拿不到的情况。
response.Response(ctx, http.StatusUnprocessableEntity, 422, nil, "手机号必须为11位")
7、从文件中读取配置(viper)
上面我们的很多配置信息都是直接定义在代码中的(比如连接数据库需要的参数),这样很不好管理和维护,所以这里我们统一下配置源。
7.1、安装 viper
go get github.com/spf13/viper
如果需要使用旧版本就去 go.mod 取修改版本号重新下载。
7.2、编写配置文件(yml)
在 config 目录下创建 application.yml:
server: port: 1016 datasource: driverName: mysql host: 127.0.0.1 port: 3306 database: go_web username: root password: Yan1029. charset: utf8mb4
7.3、使用 viper 读取配置文件
在 main 方法中添加读取配置文件的函数:
package main import ( "com.lyh/goessential/common" "github.com/gin-gonic/gin" _ "github.com/go-sql-driver/mysql" "github.com/spf13/viper" "os" ) func main() { InitConfig() db := common.InitDB() defer db.Close() engine := gin.Default() engine = CollectRoute(engine) port := viper.GetString("server.port") if port != "" { panic(engine.Run(":" + port)) } panic(engine.Run()) // 默认端口 8080 } func InitConfig() { workDir, _ := os.Getwd() viper.SetConfigName("application") viper.SetConfigType("yml") viper.AddConfigPath(workDir + "/config") err := viper.ReadInConfig() if err != nil { panic(err) } }
修改 databse.go 中的 InitDB 方法:
driverName := viper.GetString("datasource.driverName") host := viper.GetString("datasource.host") port := viper.GetInt("datasource.port") database := viper.GetString("datasource.database") username := viper.GetString("datasource.username") password := viper.GetString("datasource.password") charset := viper.GetString("datasource.charset")
测试
数据库可以查询成功,配置成功。
注意事项
1、gorm 版本问题
最新版 gorm:
使用旧版本的 gorm:
require ( github.com/jinzhu/gorm v1.9.12 )
总结
至此,我大概明白了 Go 语言怎么开发一个 Web 程序,也消除了我的很多疑虑,比如Java一个类就是一个文件,那Go语言怎么对项目进行分层架构等一些简单但又特别重要的内容。
接下来,学学前端,至少了解怎么和后端交互,写一个功能完整的Web程序。