Gin blog II

Kesa2021年12月1日...大约 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

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';

使用新用户登录并查看数据库

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"`
}

创建 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
)

创建 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
}

简单流程如下:

创建入口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)
}

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()
}

流程如下:

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
	}
}

创建 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
}

修改 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 :

引入 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"`
}

数据表通用模型:

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"`
}

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
}

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)
}

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
}

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
}

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)
}

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()
	}
}

引入中间件,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