Gin blog II
本节将完成 blog application 后端功能的实现:
- 文章列表查询
 - 文章的新增,修改和删除
 - 用户名查询
 
1. 初始化
 创建新的 github 仓库 gin-blog-server ,clone 至本地
gh repo clone dreamjz/gin-blog-server
初始化 go module
go mod init gin-blog-server
1.1 目录结构
gin-blog-server
├── api
│   └── v1
├── config
├── dao
├── global
├── initialize
├── models
├── routers
├── middleware
├── service
├── utils
├── go.mod
├── main.go
└── README.md
api/v1: 服务端 api ,v1 表示第一个版本config: 配置文件dao: 数据库访问global: 全局变量initialize: 应用初始化models: 数据模型service: 服务routers: 路由utils: 工具包middleware: 中间件
1.2 数据库
本节使用的数据库为
Server version: 10.6.5-MariaDB Arch Linux
项目根目录创建 blog.sql:
-- 若不存在则创建数据库 gin-blog ,字符集 utf8 ,校对规则 utf8_general_ci 不区分大小写
CREATE DATABASE IF NOT EXISTS gin_blog CHARSET utf8 COLLATE utf8_general_ci;
-- User Auth Table
CREATE TABLE IF NOT EXISTS `blog_user` (
  `id` INT(10) UNSIGNED NOT NULL AUTO_INCREMENT,
  `username` VARCHAR(100) DEFAULT '' COMMENT 'Username',
  `password` VARCHAR(100) DEFAULT '' COMMENT 'Password',
  `created_by` VARCHAR(100) DEFAULT '' COMMENT 'Username created by',
  `updated_by` VARCHAR(100) DEFAULT '' COMMENT 'Username updated by',
  `created_at` datatime COMMENT 'Created time',
  `updated_at` datetime COMMENT 'updated time',
  `deleted_at` datetime COMMENT 'deleted time',
  PRIMARY KEY (id)
)ENGINE=InnoDB DEFAULT CHARSET=utf8 COMMENT 'User auth table';
-- Create user admin
INSERT IGNORE INTO `blog_user` (`username`,`password`,`created_at`) VALUES ();
-- Article Table
CREATE TABLE IF NOT EXISTS `blog_article` (
    `id` INT(10) USIGNED NOT NULL AUTO_INCREMENT,
    `author` VARCHAR(100) NOT NULL COMMENT 'author',
    `title` VARCHAR(120) NOT NULL COMMENT 'title',
    `summary` VARCHAR(120) COMMENT 'summary',
    `content` TEXT NOT NULL COMMENT 'article content',
    `importance` TINYINT DEFAULT 0 COMMENT 'importance',
    `status` TINYINT NOT NULL COMMENT 'status 0 draft 1 published',
    `created_by` VARCHAR(100) DEFAULT '' COMMENT 'Article created by',
    `updated_by` VARCHAR(100) DEFAULT '' COMMENT 'Article updated by',
    `created_at` datatime COMMENT 'Created time',
    `updated_at` datetime COMMENT 'updated time',
    `deleted_at` datetime COMMENT 'deleted time',
    PRIMARY KEY (id)
)ENGINE=InnoDB DEFAULT CHARSET=utf8 COMMENT 'Article table';
执行
mysql -u root -p < ./blog.sql
进入数据查看表是否创建成功
1.2.1 创建普通用户
前面创建数据库和表都使用的 root 用户,为了避免滥用 root 用户的风险,创建一个普通用户 blog_admin 来管理 gin_blog
GRANT ALL ON gin_blog.* TO blog_admin@localhost IDENTIFIED BY 'admin';
ALL: 赋予所有的权限gin_blog.*: 权限范围为gin_blog下的所有表blog_admin@localhost: 格式user@hostIDENTIFIED BY: 设置密码
使用新用户登录并查看数据库
mysql -u blog_admin -ppass
> show databses;
+--------------------+
| Database           |
+--------------------+
| gin_blog           |
| information_schema |
| test               |
+--------------------+
> use mysql;
ERROR 1044 (42000): Access denied for user 'blog_admin'@'localhost' to database 'mysql'
当我们尝试访问 mysql 数据库时,会提示 Acccess denied
Tips
TINYINT(M)
TINYINT 默认为 TINYINT(4) ,即 M 为 4. 此处的 M 用于 mysql 展示列时的宽度,不会影响其实际能够存储的数据范围
TINYINT( 有符号位 ) 范围为 [-2^7-2^7-1], TINYINT UNSIGNED( 无符号位 ) 范围为 [0-2^8-1]
1.3 应用配置
应用中配置使用 hard code 形式不利于配置和扩展,因此我们将需要配置的内容提取出来放入配置文件中
并通过设置环境变量根据环境不同切换不同的配置,
1.3.1 Viper
本节使用 viper 进行配置管理,首先引入 viper
go get -u github.com/spf13/viper
1.3.2 配置文件
创建配置文件 config/config.yaml
mysql:
  host: localhost
  port: 3306
  user: user
  pass: pass
  db: gin_blog
  
1.3.3 数据模型
创建 models/config/mysql.go
package config
type MysqlCfg struct {
	Host     string `mapstructure:"host"`
	Port     int    `mapstructrue:"port"`
	Username string `mapstructure:"username"`
	Password string `mapstructure:"password"`
	Database string `mapstructure:"database"`
    CharSet  string `mapstructure:"charset"`
}
- 字段必须为导出的
 mapstructure:"host": viper 使用了mapstructure来解析字段
创建 models/config/app.go
package config
type AppCfg struct {
	Mysql MysqlCfg `mapstructure:"mysql"`
}
1.3.4 初始化 viper
创建 global/global.go
package global
import (
	"gin-blog-server/models/config"
	"github.com/spf13/viper"
)
var (
	AppCfg   config.AppCfg
	AppViper *viper.Viper
)
AppCfg: 将配置数据作为全局变量,方便其他包进行调用AppViper: 将viper作为全局变量,方便后续扩展,比如根据用户的输入来写入配置等
创建 initialize/viper.go
package initialize
import (
	"fmt"
	"gin-blog-server/global"
	"log"
	"os"
	"github.com/fsnotify/fsnotify"
	"github.com/spf13/viper"
)
func InitViper() *viper.Viper {
	v := viper.New()
	// Get APP_ENV, default dev
	env := os.Getenv("APP_ENV")
	if env == "" {
		env = "dev"
	}
	cfgName := fmt.Sprintf("config.%s", env)
	v.SetConfigName(cfgName)
	v.SetConfigType("yaml")
	v.AddConfigPath("./config")
	if err := v.ReadInConfig(); err != nil {
		log.Fatal(fmt.Sprintf("Read config: %s failed, %s", cfgName, err.Error()))
	}
	// Unmarshal config
	if err := v.Unmarshal(&global.AppCfg); err != nil {
		log.Fatal("Unmarshal config failed: ", err.Error())
	}
	// Watching and re-reading
	v.WatchConfig()
	v.OnConfigChange(func(e fsnotify.Event) {
		log.Printf("Config file: %s changed, Operation: %s", e.Name, e.Op)
		// re-reading
		if err := v.Unmarshal(&global.AppCfg); err != nil {
			log.Print("Reload config failed")
			return
		}
		log.Print("Reloaded config")
	})
	return v
}
简单流程如下:
- 根据当前环境变量
APP_ENV(默认dev)来读取不同的配置文件 - 将配置文件解析至结构体
AppCfg中 - 开启配置文件监听,在配置文件发生变化时重新读取配置文件
 
创建入口main.go:
package main
import (
	"fmt"
	"gin-blog-server/global"
	"gin-blog-server/initialize"
)
func main() {
	global.AppViper = initialize.InitViper()
	fmt.Printf("%+v", global.AppCfg)
}
简单测试下
$ APP_ENV=dev go run ./main.go
{Mysql:{Host:localhost Port:3306 Username:user Password:pass Database:gin_blog}}
当前目录结构
gin-blog-server
├── api
│   └── v1
├── config
│   └── config.dev.yaml
├── dao
├── global
│   └── global.go
├── initialize
│   └── viper.go
├── models
│   └── config
│       ├── app.go
│       └── mysql.go
├── routers
├── service
├── utils
├── blog.sql
├── go.mod
├── go.sum
├── main.go
└── README.md
1.4 Router
接下来完善服务端的 RESTFul API
在配置文件中添加服务启动端口, config.dev.yaml
server:
  port: 9090
  readTimeout: 10s
  readHeaderTimeout: 10ms
  writeTimeout: 10s
新增model/config/server.go
type ServerCfg struct {
	Port              int           `mapstructure:"port"`
	ReadTimeout       time.Duration `mapstructure:"readTimeout"`
	ReadHeaderTimeout time.Duration `mapstructure:"readHeaderTimeout"`
	WriteTimeout      time.Duration `mapstructure:"writeTimeout"`
}
修改model/config/app.go,新增 server配置
Server ServerCfg `mapstructure:"server"`
1.4.1 Gin 和 Endless
本节使用 gin 框架来进行构建,首先引入 gin
go get -u github.com/gin-gonic/gin
使用 endless 实现优雅启动和停止服务
go get -u github.com/fvbock/endless
1.4.2 定义通用 Response
将应用的响应数据设置为统一格式
model/response/response.go
package response
import (
	"net/http"
	"github.com/gin-gonic/gin"
)
const (
	SUCCESS = 2000
	ERROR   = 2001
)
var (
	CodeMsgMap = map[int]string{
		SUCCESS: "Success",
		ERROR:   "Error",
	}
)
type Response struct {
	Code int         `json:"code"`
	Data interface{} `json:"data"`
	Msg  string      `json:"msg"`
}
func GetCodeMsg(code int) string {
	if msg, ok := CodeMsgMap[code]; ok {
		return msg
	}
	return ""
}
func Result(code int, data interface{}, msg string, c *gin.Context) {
	c.JSON(http.StatusOK, Response{
		Code: code,
		Data: data,
		Msg:  msg,
	})
}
func OK(c *gin.Context) {
	Result(SUCCESS, "", GetCodeMsg(SUCCESS), c)
}
func OKWithData(data interface{}, c *gin.Context) {
	Result(SUCCESS, data, GetCodeMsg(SUCCESS), c)
}
func Fail(c *gin.Context) {
	Result(ERROR, "", GetCodeMsg(ERROR), c)
}
func FailWithMsg(msg string, c *gin.Context) {
	Result(ERROR, "", msg, c)
}
func FailWithCode(code int, c *gin.Context) {
	Result(code, "", GetCodeMsg(code), c)
}
- 定义错误码及对应的错误信息, 响应码要响应的在前端同步修改
 - 将设置响应数据结构
 - 封装响应方法 
Result, 并提供预设的方法 
1.4.3 初始化 Gin
创建initialize/server.go
package initialize
import (
	"fmt"
	"gin-blog-server/global"
	"gin-blog-server/routers"
	"github.com/fvbock/endless"
	"github.com/gin-gonic/gin"
)
func initRouter() *gin.Engine {
	router := gin.Default()
	publicGroup := router.Group("/")
	{
		routers.InitPublicRouter(publicGroup)
	}
	return router
}
func Run() error {
	router := initRouter()
	addr := fmt.Sprintf(":%d", global.AppCfg.Server.Port)
	server := endless.NewServer(addr, router)
	server.BeforeBegin = func(addr string) {
		log.Printf("Actual PID: %d,Addr: %s", syscall.Getpid(), addr)
	}
	srvCfg := global.AppCfg.Server
	server.ReadTimeout = srvCfg.ReadTimeout
	server.ReadHeaderTimeout = srvCfg.ReadTimeout
	server.WriteTimeout = srvCfg.WriteTimeout
	server.MaxHeaderBytes = 1 << 20
	return server.ListenAndServe()
}
流程如下:
- 初始化路由,新增公共路由组(无需鉴权),后续会增加需要鉴权的路由组
 - 创建
enlessServer对象,实际上其内部嵌套了http.Server结构,并设置相关参数:ReadTimeout: 读取的整个 request 的最大时间ReadHeaderTimeout: 读取 request header 的最大时间WriteTimeout: 写入 response 的最大时间MaxHeaderBytes: request header 的最大容量, 单位 byte
 - 注册
BeforeBegin: 在启动服务前打印 PID 和 ADDR - 启动服务,
ListenAndServe实际上调用的是底层http.Server的同名方法 
1.4.4 路由分组
创建api/v1/public.go,设置 api
package v1
import (
	"gin-blog-server/models/response"
	"github.com/gin-gonic/gin"
)
func Ping(c *gin.Context) {
	response.OKWithData("pong", c)
}
创建routers/pulic.go, 设置公共组路由处理逻辑
package routers
import (
	v1 "gin-blog-server/api/v1"
	"github.com/gin-gonic/gin"
)
func InitPublicRouter(routerGrp *gin.RouterGroup) {
	publicRouter := routerGrp.Group("/public")
	{
		publicRouter.GET("ping", v1.Ping)
	}
}
修改 main.go
package main
import (
	"fmt"
	"gin-blog-server/global"
	"gin-blog-server/initialize"
	"log"
)
func main() {
	global.AppViper = initialize.InitViper()
	err := initialize.Run()
	if err != nil {
		log.Fatal("Listen and serve error: ", err.Error())
	}
}
Run and test
$ go run ./main.go
[GIN-debug] [WARNING] Creating an Engine instance with the Logger and Recovery middleware already attached.
[GIN-debug] [WARNING] Running in "debug" mode. Switch to "release" mode in production.
 - using env:   export GIN_MODE=release
 - using code:  gin.SetMode(gin.ReleaseMode)
[GIN-debug] GET    /public/ping              --> gin-blog-server/api/v1.Ping (3 handlers)
2021/12/11 21:38:13 15212 :9090
$ curl 'http://localhost:9090/public/ping'
{"code":2000,"data":"pong","msg":"Success"}
当前目录结构
gin-blog-server
├── api
│   └── v1
│       └── public.go
├── config
│   ├── config.dev.yml
│   └── config.sample.yaml
├── dao
├── global
│   └── global.go
├── initialize
│   ├── logger.go
│   ├── server.go
│   └── viper.go
├── models
│   ├── config
│   │   ├── app.go
│   │   ├── mysql.go
│   │   └── server.go
│   └── response
│       └── response.go
├── routers
│   └── public.go
├── service
├── utils
├── blog.sql
├── go.mod
├── go.sum
├── main.go
└── README.md
1.5 数据库连接
本节使用 gorm 框架访问数据库,引入 gorm 和 mysql 驱动
go get -u gorm.io/gorm gorm.io/driver/mysql
1.5.1 初始化 Gorm
新增 gorm 的配置
config/config.dev.yaml:
gorm:
  tablePrefix: blog_
  maxIdleConns: 10
  maxOpenConns: 100
  logLevel: info
model/config/gorm.go
package config
type GormCfg struct {
	TablePrefix  string `mapstructure:"tablePrefix"`
	MaxIdleConns int    `mapstructure:"maxIdleConns"`
	MaxOpenConns int    `mapstructure:"maxOpenConns"`
	LogLevel     string `mapstructure:"logLevel"`
}
model/config/app.go
GormCfg GormCfg   `mapstructure:"gorm"`
创建 initialize/gorm.go
package initialize
import (
	"fmt"
	"gin-blog-server/dao"
	"gin-blog-server/global"
	"log"
	"gorm.io/gorm/logger"
	"gorm.io/gorm/schema"
	"gorm.io/driver/mysql"
	"gorm.io/gorm"
)
func InitGorm() {
	cfg := global.AppCfg.Mysql
	gormCfg := global.AppCfg.GormCfg
	dsn := fmt.Sprintf(
		"%s:%s@tcp(%s:%d)/%s?charset=%s&parseTime=True&loc=Local",
		cfg.Username, cfg.Password, cfg.Host, cfg.Port, cfg.Database, cfg.CharSet,
	)
	db, err := gorm.Open(mysql.Open(dsn), &gorm.Config{
		Logger: logger.Default.LogMode(logLevel()),
		NamingStrategy: schema.NamingStrategy{
			TablePrefix:   gormCfg.TablePrefix,
			SingularTable: true,
		},
	})
	if err != nil {
		log.Fatal("Connect to db failed: ", err.Error())
	}
	sqlDB, _ := db.DB()
	sqlDB.SetMaxIdleConns(gormCfg.MaxIdleConns)
	sqlDB.SetMaxOpenConns(gormCfg.MaxOpenConns)
	dao.Init(db)
}
func logLevel() logger.LogLevel {
	lvl := global.AppCfg.GormCfg.LogLevel
	switch lvl {
	case "silent":
		return logger.Silent
	case "error":
		return logger.Error
	case "warn":
		return logger.Warn
	case "info":
		return logger.Info
	default:
		return logger.Info
	}
}
- 使用 mysql 的配置连接数据库
 Logger: 根据配置文件设置 log level- 配置 gorm NamingStrategy: 
TablePrefix设置表名前缀,SingularTable设置表名为单数(gorm默认表为蛇形复数)
 - 设置连接池参数: 
MaxIdleConns: 最大空闲数MaxOpenConns: 最大连接数,当 MaxIdleConns > MaxOpenConns 是会将 MaxIdleConns = MaxOpenConns
 
创建 dao/gorm.go
package dao
import (
	"database/sql"
	"gorm.io/gorm"
)
var (
	db *gorm.DB
	customSession *gorm.Session
)
func Init(gormDB *gorm.DB) {
	db = gormDB
	customSession = &gorm.Session{
		QueryFields: true,
	}
}
func GormDB() *gorm.DB {
	return db
}
func SqlDB() *sql.DB {
	sqlDB, _ := db.DB()
	return sqlDB
}
- 维护
gorm.DB对象,Init&GormDB设置和返回 SqlDB: 返回*sql.DB对象customSession: 自定义 gorm session,QueryFields为true 将在查询时使用字段名而不是*
修改 main.go
package main
import (
	"gin-blog-server/dao"
	"gin-blog-server/global"
	"gin-blog-server/initialize"
	"log"
)
func main() {
	global.AppViper = initialize.InitViper()
	initialize.InitGorm()
	sqlDB := dao.SqlDB()
	defer sqlDB.Close()
	err := initialize.Run()
	if err != nil {
		log.Fatal("Listen and serve error: ", err.Error())
	}
}
在程序结束前关闭数据库连接
当前目录结构
gin-blog-server
├── api
│   └── v1
│       └── public.go
├── config
│   ├── config.dev.yml
│   └── config.sample.yaml
├── dao
│   └── gorm.go
├── global
│   └── global.go
├── initialize
│   ├── gorm.go
│   ├── logger.go
│   ├── server.go
│   └── viper.go
├── models
│   ├── config
│   │   ├── app.go
│   │   ├── gorm.go
│   │   ├── mysql.go
│   │   └── server.go
│   └── response
│       └── response.go
├── routers
│   └── public.go
├── service
├── utils
├── blog.sql
├── go.mod
├── go.sum
├── main.go
└── README.md
2. RESTFul API
初始化各个模块之后,接下来就来编写相关的 API :
AddArticle: 新增文章EditArticle: 更新文章QueryArticles: 查询文章列表QueryArticleContentByID: 根据 ID 获取文章内容DeleteArticle: 删除文章
引入 cast 用于类型转换
go get -u github.com/spf13/cast
2.1 路由分组
创建 privateGroup 作为私有路由(需要鉴权,现在暂时关注文章相关后续会进行完善),将文章 api 添加至此
修改initialize/server.go
func initRouter() *gin.Engine {
	// ...
	privateGroup := router.Group("/")
	{
		routers.InitArticleRouter(privateGroup)
	}
	// ...
}
新增 routers/article
package routers
import "github.com/gin-gonic/gin"
func InitArticleRouter(routerGrp *gin.RouterGroup) {
	articleRouter := routerGrp.Group("/article")
	{
		//TODO: article api
	}
}
2.2 Validation
验证用户的输入是非常重要的,本节采用 go-playground/validator (gin 默认采用的验证方式) 进行参数验证
创建 utils/validation/article.go
package validation
import (
	"gin-blog-server/models"
	"github.com/go-playground/validator/v10"
)
func ArticleStructLevelValidation(sl validator.StructLevel) {
	article := sl.Current().Interface().(models.Article)
	if article.Status < 0 || article.Status > 1 {
		sl.ReportError(article.Status, "Status", "Status", "status", "")
	}
	if article.Importance < 0 || article.Importance > 3 {
		sl.ReportError(article.Importance, "Importance", "Importance", "importance", "")
	}
}
utils/validation/common.go
package validation
import (
	"gin-blog-server/models"
	"github.com/gin-gonic/gin/binding"
	"github.com/go-playground/validator/v10"
)
func RegisterStructValidators() {
	if v, ok := binding.Validator.Engine().(*validator.Validate); ok {
		v.RegisterStructValidation(ArticleStructLevelValidation, models.Article{})
	}
}
initialize/server.go
func initRouter() *gin.Engine {
	// ...
	validation.RegisterStructValidators()
	// ...
}
2.3 Models
2.3.1 新建数据模型
models/model.go
package models
import (
	"time"
	"gorm.io/gorm"
)
type Model struct {
	ID        uint           `gorm:"primarykey" json:"id"`
	CreatedAt time.Time      `json:"createdAt"`
	UpdatedAt time.Time      `json:"updatedAt"`
	DeletedAt gorm.DeletedAt `gorm:"index" json:"deletedAt"`
}
数据表通用模型:
ID: 主键,gorm:"primarykey表示将此字段为主键CreatedAt: 创建时间UpdatedAt: 更新时间DeletedAt: 删除时间,这里使用软删除,gorm:"index"将此字段设置为数据库索引
models/article.go
package models
type Article struct {
	Model
	Author     string `json:"author"    binding:"required"`
	Title      string `json:"title"     binding:"required"`
    Summary    string `json:"summary"`
	Content    string `json:"content"   binding:"required"`
	Importance int    `json:"importance"`
	Status     *int    `json:"status"    binding:"required"`
	CreatedBy  string `json:"createdBy" binding:"required"`
	UpdatedBy  string `json:"UpdatedBy"`
}
Article 数据模型, binding:"required" 表示字段为必须的,否则在绑定数据时会报错
Status *int: status 的零值是有意义的,使用指针类型防止字段验证失败
models/user.go, 用户表模型
type User struct {
	Model
	Username  string `json:"username"`
	Password  string `json:"password"`
	CreatedBy string `json:"createdBy"`
	UpdatedBy string `json:"updatedBy"`
}
2.3.2 定义 Request 结构
 models/request/common.go
package request
type Pagination struct {
	Page     int `form:"page" json:"page"`
	PageSize int `form:"pageSize" json:"pageSize"`
}
PageNo: 页码PageSize: 每页数量
2.4 Create Article
2.4.1 Dao
新增 dao/article.go
package dao
import "gin-blog-server/models"
func CreateArticle(article *models.Article) error {
	return db.Create(article).Error
}
2.4.2 Service
新增 service/article.go
package service
import (
	"errors"
	"gin-blog-server/dao"
	"gin-blog-server/models"
	"log"
)
var (
	ErrCreateArticle = errors.New("create article error")
)
func CreateArticle(article *models.Article) error {
	err := dao.CreateArticle(article)
	if err != nil {
		log.Print("Create article error: ", err.Error())
		return ErrCreateArticle
	}
	return nil
}
ErrCreateArticle: 自定义错误- 拦截 dao 的错误,将其记录在日志中并返回自定义的错误
 
2.4.3 Api
新增 api/v1/article.go
package v1
import (
	"gin-blog-server/models"
	"gin-blog-server/models/response"
	"gin-blog-server/service"
	"log"
	"github.com/gin-gonic/gin"
)
func CreateArticle(c *gin.Context) {
	var article models.Article
	if err := c.ShouldBindJSON(&article); err != nil {
		log.Println("Bind data error: ", err.Error())
		response.FailWithMsg(err.Error(), c)
		return
	}
	if err := service.CreateArticle(&article); err != nil {
		response.FailWithMsg(err.Error(), c)
		return
	}
	response.OK(c)
}
ShouldBindJSON: 绑定 JSON 类型的数据,若绑定失败则返回错误``
routers/article.go注册路由
func InitArticleRouter(routerGrp *gin.RouterGroup) {
	articleRouter := routerGrp.Group("/article")
	{
		articleRouter.POST("/create", v1.CreateArticle)
	}
}
2.5 Query Article
2.5.1 定义 Response
新增 models/response/article.go
type ArticleListResult struct {
	ID         uint      `json:"id"`
	CreatedAt  time.Time `json:"createdAt"`
	UpdatedAt  time.Time `json:"updatedAt"`
	Author     string    `json:"author"`
	Title      string    `json:"title" `
	Importance int       `json:"importance"`
	Status     int       `json:"status"`
}
type ArticleDetail struct {
	ID         uint      `json:"id"`
	CreatedAt  time.Time `json:"createdAt"`
	UpdatedAt  time.Time `json:"updatedAt"`
	Author     string    `json:"author"`
	Title      string    `json:"title" `
    Summary    string    `json:"summary"`
	Importance int       `json:"importance"`
	Status     int       `json:"status"`
	Content    string    `json:"content"`
}
ArticleListResult 将作为 QueryArticleList的数据结构返回
models/response/common.go
package response
type PageResult struct {
	List     interface{} `json:"list"`
	Total    int64       `json:"total"`
	Page     int         `json:"page"`
	PageSize int         `json:"pageSize"`
}
分页数据使用统一的数据结构
2.5.1 Dao
func FindArticleList(offset, limit int) ([]response.ArticleListResult, error) {
	var articleList []response.ArticleListResult
	err := db.Model(&models.Article{}).Offset(offset).Limit(limit).Find(&articleList).Error
	return articleList, err
}
func FindArticleByID(id uint) (*response.ArticleDetail, error) {
	var content response.ArticleDetail
	err := db.Model(&models.Article{}).Where("id = ?", id).Take(&content).Error
	return &content, err
}
func CountArticle() (int64, error) {
	var count int64
	err := db.Model(&models.Article{}).Count(&count).Error
	return count, err
}
FindArticleList: 获取文章列表FindArticleContentByID:通过文章 ID 获取文章内容CountArticle: 统计文章数量
2.5.2 Service
var (
	// ...
	ErrQueryArticle = errors.New("query article list error")
	ErrArticleNotFound  = errors.New("article not found")
)
func QueryArticleList(pagination request.Pagination) (response.PageResult, error) {
	var result response.PageResult
	limit := pagination.PageSize
	offset := (pagination.Page - 1) * limit
	total, err := dao.CountArticle()
	if err != nil {
		log.Print("Count article error: ", err.Error())
		return result, ErrQueryArticle
	}
	if total < 1 {
		log.Print("No article found")
		return result, ErrArticleNotFound
	}
	articleList, err := dao.FindArticleList(offset, limit)
	if err != nil {
		log.Print("No article found")
		return result, nil
	}
	result = response.PageResult{
		List:     articleList,
		Total:    total,
		Page:     pagination.Page,
		PageSize: pagination.PageSize,
	}
	return result, nil
}
func QueryArticleByID(id uint) (*response.ArticleDetail, error) {
	content, err := dao.FindArticleByID(id)
	if err != nil {
		if errors.Is(err, gorm.ErrRecordNotFound) {
			log.Print("Article not found")
			return nil, ErrArticleNotFound
		}
		return nil, ErrQueryArticle
	}
	return content, nil
}
- 获取所有文章的数量,若小于 1 打印日志直接返回
 - 根据分页信息查询文章列表,返回错误则结束查询
 
2.5.3 Api
func QueryArticleList(c *gin.Context) {
	var pagination request.Pagination
	if err := c.ShouldBindQuery(&pagination); err != nil {
		log.Print("Bind pagination error: ", err.Error())
		response.FailWithMsg(err.Error(), c)
		return
	}
	list, err := service.QueryArticleList(pagination)
	if err != nil {
		response.FailWithMsg(err.Error(), c)
		return
	}
	response.OKWithData(list, c)
}
func QueryArticleByID(c *gin.Context) {
	id, err := cast.ToUintE(c.Query("id"))
	if err != nil {
		log.Print("Get article id error: ", err.Error())
		response.FailWithMsg(err.Error(), c)
		return
	}
	article, err := service.QueryArticleByID(id)
	if err != nil {
		response.FailWithMsg(err.Error(), c)
		return
	}
	response.OKWithData(article, c)
}
cast.ToUintE: 将字符串转为 uint ,若失败则返回错误
2.6 Update Article
2.6.1 Dao
func UpdateArticleByID(article *models.Article) error {
   return db.Save(article).Error
}
2.6.2 Service
var (
    // ...
	ErrUpdateArticle   = errors.New("update article error")
)
func UpdateArticleByID(article *models.Article) error {
	if err := dao.UpdateArticleByID(article); err != nil {
		log.Print("Update article error: ", err.Error())
		return ErrUpdateArticle
	}
	return nil
}
2.6.3 Api
func EditArticleByID(c *gin.Context) {
	var article models.Article
	if err := c.ShouldBindJSON(&article); err != nil {
		log.Print("Bind article data error: ", err.Error())
		response.FailWithMsg(err.Error(), c)
		return
	}
	if err := service.UpdateArticleByID(&article); err != nil {
		response.FailWithMsg(err.Error(), c)
		return
	}
	response.OK(c)
}
2.7 Delete Article
2.7.1 Dao
func DeleteArticleByID(id uint) error {
	return db.Where("id = ?", id).Delete(&models.Article{}).Error
}
2.7.2 Service
var (
    // ...
	ErrDeleteArticle   = errors.New("delete article error")
)
func DeleteArticleByID(id uint) error {
	if err := dao.DeleteArticleByID(id); err != nil {
		log.Print("Delete article error: ", err.Error())
		if errors.Is(err, gorm.ErrRecordNotFound) {
			return ErrArticleNotFound
		}
		return ErrDeleteArticle
	}
	return nil
}
2.7.3 Api
func DeleteArticleByID(c *gin.Context) {
	id, err := cast.ToUintE(c.Query("id"))
	if err != nil {
		log.Print("Get id error: ", err.Error())
		response.FailWithMsg(err.Error(), c)
		return
	}
	if err := service.DeleteArticleByID(id); err != nil {
		response.FailWithMsg(err.Error(), c)
		return
	}
	response.OK(c)
}
2.8 注册 Article 路由
完善 article 的路由,routers/article.go
func InitArticleRouter(routerGrp *gin.RouterGroup) {
	articleRouter := routerGrp.Group("/article")
	{
		articleRouter.POST("/create", v1.CreateArticle)
		articleRouter.GET("/list", v1.QueryArticleList)
		articleRouter.GET("/detail", v1.QueryArticleByID)
		articleRouter.PUT("/edit", v1.EditArticleByID)
		articleRouter.DELETE("/delete", v1.DeleteArticleByID)
	}
}
2.9 Search Username
在创建文章时文章作者需要从用户名中选择,admin 可以实时搜索用户名进行选择
2.9.1 定义 Response
创建 models/response/user.go
type SearchUsername struct {
	Username string `json:"username"`
}
2.9.2 Dao
创建 dao/user.go
func FindUsername(keywords string) ([]string, error) {
	var names []string
	err := db.Model(&models.User{}).Select("username").
		Where("username REGEXP ?", keywords).Find(&names).Error
	return names, err
}
- 通过正则表达式搜索符合条件的用户
 
2.9.3 Service
创建 service/user.go
package service
import (
	"errors"
	"gin-blog-server/dao"
	"log"
	"gorm.io/gorm"
)
var (
	ErrUserNotFound = errors.New("user not found")
	ErrQueryUser    = errors.New("query user error")
)
func SearchUsername(keywords string) ([]string, error) {
	names, err := dao.FindUsername(keywords)
	if err != nil {
		log.Print("Search username error: ", err.Error())
		if errors.Is(err, gorm.ErrRecordNotFound) {
			return nil, ErrUserNotFound
		}
		return nil, ErrQueryUser
	}
	return names, nil
}
2.9.4 Api
创建 api/v1/user.go
func SearchUsername(c *gin.Context) {
	keywords := c.Query("name")
	if keywords == "" {
		response.FailWithMsg("search name cannot be empty", c)
		return
	}
	names, err := service.SearchUsername(keywords)
	if err != nil {
		response.FailWithMsg(err.Error(), c)
		return
	}
	response.OKWithData(gin.H{
		"list": names,
	}, c)
}
2.10 注册 User 路由
创建 routers/user.go
func InitUserRouter(routerGrp *gin.RouterGroup) {
	userRouter := routerGrp.Group("user")
	{
		userRouter.GET("/name", v1.SearchUsername)
	}
}
initialize/server.go
func initRouter() *gin.Engine {
	// ...
	privateGroup := router.Group("/")
	{
		// ...
		routers.InitUserRouter(privateGroup)
	}
	// ...
}
3. CORS
gin-blog 是前后端分离项目,前端调用后端服务会存在跨域问题,本节通过自定 gin middleware 在服务端解决跨域问题
创建 middleware/cors.go
package middleware
import "github.com/gin-gonic/gin"
func Cors() gin.HandlerFunc {
	return func(c *gin.Context) {
		method := c.Request.Method
		origin := c.Request.Header.Get("Origin")
		c.Header("Access-Control-Allow-Origin", origin)
		c.Header("Access-Control-Allow-Headers", "Content-Type")
		c.Header("Access-Control-Allow-Methods", "GET,POST,OPTIONS,DELETE,PUT")
		c.Header("Access-Control-Expose-Headers", "Content-Length, Access-Control-Allow-Origin, Access-Control-Allow-Headers, Content-Type")
		c.Header("Access-Control-Allow-Credentials", "true")
		if method == "OPTIONS" {
			c.AbortWithStatus(http.StatusOK)
		}
		c.Next()
	}
}
Access-Control-Allow-Origin: 允许来自 oringin 的请求访问Access-Control-Allow-Headers: 告知服务器,表明服务器允许请求中 Header 携带字段Access-Control-Allow-Methods: 表明服务器允许客户端的方法Access-Control-Expose-Headers:让服务器把允许浏览器访问的头放入白名单Access-Control-Allow-Credentials: 指定了当浏览器的credentials设置为true时是否允许浏览器读取response的内容c.AbortWithStatus(http.StatusOK): 中断后续 handler 的调用,并设置状态码c.Next(): 调用后续的 handler
引入中间件,initialize/server.go
func initRouter() *gin.Engine {
	router := gin.Default()
	router.Use(middleware.Cors())
    // ...
	return router
}
至此,文章相关的 API 就完成了
当前目录结构
gin-blog-server
├── api
│   └── v1
│       ├── article.go
│       └── public.go
├── config
│   ├── config.dev.yml
│   └── config.sample.yaml
├── dao
│   ├── article.go
│   └── gorm.go
├── global
│   └── global.go
├── initialize
│   ├── gorm.go
│   ├── server.go
│   └── viper.go
├── middleware
│   └── cors.go
├── models
│   ├── config
│   │   ├── app.go
│   │   ├── gorm.go
│   │   ├── mysql.go
│   │   └── server.go
│   ├── request
│   │   └── common.go
│   ├── response
│   │   ├── article.go
│   │   ├── common.go
│   │   └── response.go
│   ├── article.go
│   └── model.go
├── routers
│   ├── article.go
│   └── public.go
├── service
│   └── article.go
├── utils
│   └── validation
│       ├── article.go
│       └── common.go
├── blog.sql
├── go.mod
├── go.sum
├── main.go
└── README.md
4. 修改前端配置
4.1 配置文件
添加服务端环境
.env.development
# server 
SERVER_HOST = 'localhost'
SERVER_PORT = 9090
添加跨域配置, vue.config.js
// ...
const api = process.env.VUE_APP_BASE_API
const serverHost = process.env.SERVER_HOST
const serverPort = process.env.SERVER_PORT
console.log('Server: ' + serverHost + ':' + serverPort)
module.exports = {
	// ...
      devServer: {
    // ...
    // 请求代理
    proxy: {
      // 将 对应的路径代理到target 位置
      api: {
        target: `http://${serverHost}:${serverPort}`,
        changeOrigin: true,
        // 重写 URL
        pathRewrite: {
          ['^' + api]: ''
        }
      }
    },
    // ...
  },
    // ...
}
当同时使用 mock 和 server 时,会有服务端无法获取 request body 的bug, 参见#3020
4.2 Api
修改 api 的请求 URL, src/api/article.js
import request from '@/utils/request'
export function fetchList(query) {
  return request({
    url: '/article/list',
    method: 'get',
    params: query
  })
}
export function fetchArticle(id) {
  return request({
    url: '/article/detail',
    method: 'get',
    params: { id }
  })
}
export function fetchPv(pv) {
  return request({
    url: '/article/pv',
    method: 'get',
    params: { pv }
  })
}
export function createArticle(data) {
  return request({
    url: '/article/create',
    method: 'post',
    data
  })
}
export function updateArticle(data) {
  return request({
    url: '/article/edit',
    method: 'post',
    data
  })
}
4.3 Page
4.3.1 Article List
src/views/article/list, 修改分页数据和响应数据结构, 具体参见list.vue
4.3.2 ArticleDetail.vue
src/views/article/components/ArticleDetail.vue, 修改分页数据和响应数据结构,具体参见ArticleDetail.vue
至此,基于文章的增删改查功能就完成了
Reference
- 煎鱼 blog 煎鱼 blog
 - mysql-doc mysql 8.0 docs
 - MySql: Tinyint (2) vs tinyint(1) - what is the difference? stackoverflow
 - viper github repo
 - SetMaxOpenConns and SetMaxIdleConns stackoverflow
 - CORS MDN docs
 
