Gin blog II

Kesa...大约 15 分钟golangginvipergorm

本节将完成 blog application 后端功能的实现:

  • 文章列表查询
  • 文章的新增,修改和删除
  • 用户名查询

1. 初始化

​ 创建新的 github 仓库 gin-blog-serveropen in new window ,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@host
  • IDENTIFIED 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

本节使用 viperopen in new window 进行配置管理,首先引入 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

本节使用 ginopen in new window 框架来进行构建,首先引入 gin

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

使用 endlessopen in new window 实现优雅启动和停止服务

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 数据库连接

本节使用 gormopen in new window 框架访问数据库,引入 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: 删除文章

引入 castopen in new window 用于类型转换

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, 参见#3020open in new window

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.vueopen in new window

4.3.2 ArticleDetail.vue

src/views/article/components/ArticleDetail.vue, 修改分页数据和响应数据结构,具体参见ArticleDetail.vueopen in new window

至此,基于文章的增删改查功能就完成了

Reference

  1. 煎鱼 blogopen in new window 煎鱼 blog
  2. mysql-docopen in new window mysql 8.0 docs
  3. MySql: Tinyint (2) vs tinyint(1) - what is the difference?open in new window stackoverflow
  4. viperopen in new window github repo
  5. SetMaxOpenConns and SetMaxIdleConnsopen in new window stackoverflow
  6. CORSopen in new window MDN docs
上次编辑于:
评论
  • 按正序
  • 按倒序
  • 按热度
Powered by Waline v2.15.2