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@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
本节使用 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