Go从入门到精通(20)-一个简单web项目-服务搭建
文章目录
前言
Api比较多,没有文档不能清晰的知道每个文档的参数。下面我们引入swagger文档来解决这一个问题
前期准备
上一版我们所有的文件都在main文的main包下,这期我们简单分一下包,这样项目结构更加清晰。
go-web-demo
├───app
│ ├───api
│ │ └───handler
│ ├───constant
│ ├───dto
│ └───utils
├───config
├───discovery
├───docs
├───global
├───logger
└───tracing
简单说明一下
- app:业务模块
- api: Api的入口,类似controller层
- handler Api实现,类似service层
- dto 定义请求响应dto
- constants 常量和枚举类
- utils 工具类
- api: Api的入口,类似controller层
- config:配置模块
- discovery:服务注册发现模块
- logger:日志模块
- tracing:链路追踪模块
- docs:文档比如swagger文档
- global:读取一些全局配置参数
大概项目结构如下
为API 添加 Swagger 文档
我将使用swaggo/swag自动生成文档,并通过swaggo/gin-swagger在浏览器中展示。
1.安装依赖
go get -u github.com/swaggo/swag/cmd/swag
go get -u github.com/swaggo/gin-swagger
go get -u github.com/swaggo/files
2.添加 Swagger 注释
main.go
这里看main()函数本身已经很简单了,主要的工作都是在下面的包完成的
package main
import (
"fmt"
"go-web-demo/app"
)
// @title Gin API Example
// @version 1.0
// @description This is a sample server for a web application.
// @termsOfService http://swagger.io/terms/
// @contact.name API Support
// @contact.url http://www.swagger.io/support
// @contact.email support@swagger.io
// @license.name Apache 2.0
// @license.url http://www.apache.org/licenses/LICENSE-2.0.html
// @host localhost:8082
// @BasePath /api
func main() {
err := app.StartApp()
if err != nil {
fmt.Println("Failed to start app:", err)
return
}
}
app.go
这个里包主要完成gin的初始化和启动
注意这里添加的swagger配置
// Swagger文档路由
docs.Init(“localhost:8082”)
router.GET(“/swagger/*any”,ginSwagger.WrapHandler(swaggerFiles.Handler))
以及引入的swagger包
import swaggerFiles “github.com/swaggo/files”
import ginSwagger “github.com/swaggo/gin-swagger”
package app
import (
"fmt"
"github.com/gin-contrib/cors"
"github.com/gin-gonic/gin"
swaggerFiles "github.com/swaggo/files"
ginSwagger "github.com/swaggo/gin-swagger"
"go-web-demo/app/api"
"go-web-demo/docs"
)
func StartApp() error {
// 设置为生产模式
// gin.SetMode(gin.ReleaseMode)
// 创建默认引擎,包含日志和恢复中间件
router := gin.Default()
// 配置CORS
router.Use(cors.Default())
// Swagger文档路由
docs.Init("localhost:8082")
router.GET("/swagger/*any", ginSwagger.WrapHandler(swaggerFiles.Handler))
err := api.InitRouters(router)
if err != nil {
return err
}
// 启动服务器
fmt.Println("Server started at :8082")
if err := router.Run(":8082"); err != nil {
fmt.Println("Failed to start server:", err)
}
return nil
}
api.go
这里主要是分包,不需要额外的注释
package api
import (
"github.com/gin-gonic/gin"
"go-web-demo/app/api/handler"
"go-web-demo/app/utils"
)
// http接口映射
func InitRouters(router *gin.Engine) error {
// 公共路由
public := router.Group("/api/public")
{
public.POST("/register", handler.RegisterHandler)
public.POST("/login", handler.LoginHandler)
public.GET("/health", handler.HealthHandler)
}
// 认证路由
auth := router.Group("/api/v1/auth")
auth.Use(utils.AuthMiddleware())
{
auth.GET("/users/me", handler.GetCurrentUserHandler)
auth.GET("/users", handler.GetUsersHandler)
auth.GET("/users/:id", handler.GetUserHandler)
auth.PUT("/users/:id", handler.UpdateUserHandler)
auth.DELETE("/users/:id", handler.DeleteUserHandler)
}
return nil
}
public_handler.go
这里主要拆分Api实现,注意函数上面的注释就是生成swagger的文档的来源
// @Param user body dto.RegisterRequest true "用户注册信息" 引用参数如果在包下面也必须带上包,比如这里要用 dto.RegisterReques,直接使用RegisterRequest会提示找不到
package handler
import (
"fmt"
"github.com/gin-gonic/gin"
"go-web-demo/app/dto"
"go-web-demo/app/utils"
"go-web-demo/logger"
"golang.org/x/crypto/bcrypt"
"net/http"
)
// 模拟数据库
var users = make(map[string]dto.User)
var nextUserID = 1
// 健康检查
// HealthHandler 健康检查
// @Summary 测试API连通性
// @Description 简单的测试接口,success
// @Tags 通用
// @Produce json
// @Success 200 {object} map[string]string "success"
// @Router /public/health [get]
func HealthHandler(c *gin.Context) {
c.JSON(http.StatusOK, gin.H{"message": "success"})
}
// 注册处理
// RegisterHandler 注册新用户
// @Summary 注册新用户
// @Description 创建一个新用户账户
// @Tags 开放接口
// @Accept json
// @Produce json
// @Param user body dto.RegisterRequest true "用户注册信息"
// @Success 201 {object} dto.TokenResponse "注册成功,返回JWT令牌"
// @Failure 400 {object} dto.ErrorResponse "参数错误"
// @Failure 409 {object} dto.ErrorResponse "用户名已存在"
// @Failure 500 {object} dto.ErrorResponse "服务器内部错误"
// @Router /public/register [post]
func RegisterHandler(c *gin.Context) {
var request dto.RegisterRequest
// 绑定并验证请求
if err := c.ShouldBindJSON(&request); err != nil {
c.JSON(http.StatusBadRequest, gin.H{"error": err.Error()})
return
}
// 检查用户名是否已存在
for _, user := range users {
if user.Username == request.Username {
logger.Sugar.Warnw("用户名已存在", "username", request.Username)
c.JSON(http.StatusConflict, gin.H{"error": "Username already exists"})
return
}
}
// 哈希密码
hashedPassword, err := bcrypt.GenerateFromPassword([]byte(request.Password), bcrypt.DefaultCost)
if err != nil {
logger.Sugar.Errorw("密码哈希失败", "error", err)
c.JSON(http.StatusInternalServerError, gin.H{"error": "Failed to hash password"})
return
}
// 创建新用户
userID := fmt.Sprintf("%d", nextUserID)
nextUserID++
user := dto.User{
ID: userID,
Username: request.Username,
Password: string(hashedPassword),
Email: request.Email,
}
// 保存用户
users[userID] = user
// 生成令牌
token, err := utils.GenerateToken(userID)
if err != nil {
logger.Sugar.Errorw("生成令牌失败", "error", err)
c.JSON(http.StatusInternalServerError, gin.H{"error": "Failed to generate token"})
return
}
c.JSON(http.StatusCreated, dto.TokenResponse{Token: token})
}
// 登录处理
// LoginHandler 用户登录
// @Summary 用户登录
// @Description 使用用户名和密码进行登录
// @Tags 开放接口
// @Accept json
// @Produce json
// @Param credentials body dto.LoginRequest true "登录凭证"
// @Success 200 {object} dto.TokenResponse "登录成功,返回JWT令牌"
// @Failure 400 {object} dto.ErrorResponse "参数错误"
// @Failure 401 {object} dto.ErrorResponse "认证失败"
// @Router /public/login [post]
func LoginHandler(c *gin.Context) {
var request dto.LoginRequest
// 绑定并验证请求
if err := c.ShouldBindJSON(&request); err != nil {
c.JSON(http.StatusBadRequest, gin.H{"error": err.Error()})
return
}
// 查找用户
var user dto.User
for _, u := range users {
if u.Username == request.Username {
user = u
break
}
}
// 验证用户
if user.ID == "" {
c.JSON(http.StatusUnauthorized, gin.H{"error": "Invalid credentials"})
return
}
// 验证密码
if err := bcrypt.CompareHashAndPassword([]byte(user.Password), []byte(request.Password)); err != nil {
c.JSON(http.StatusUnauthorized, gin.H{"error": "Invalid credentials"})
return
}
// 生成令牌
token, err := utils.GenerateToken(user.ID)
if err != nil {
c.JSON(http.StatusInternalServerError, gin.H{"error": "Failed to generate token"})
return
}
c.JSON(http.StatusOK, dto.TokenResponse{Token: token})
}
auth_handler.go
package handler
import (
"github.com/gin-gonic/gin"
"go-web-demo/app/dto"
"golang.org/x/crypto/bcrypt"
"net/http"
)
// GetCurrentUserHandler 获取当前用户信息
// @Summary 获取当前用户信息
// @Description 获取已登录用户的详细信息
// @Tags 用户(需要认证)
// @Accept json
// @Produce json
// @Security BearerAuth
// @Success 200 {object} dto.User "成功返回用户信息"
// @Failure 401 {object} dto.ErrorResponse "未授权"
// @Failure 404 {object} dto.ErrorResponse "用户不存在"
// @Router /v1/auth/users/me [get]
func GetCurrentUserHandler(c *gin.Context) {
userID := c.MustGet("user_id").(string)
user, exists := users[userID]
if !exists {
c.JSON(http.StatusNotFound, gin.H{"error": "User not found"})
return
}
// 不返回密码
user.Password = ""
c.JSON(http.StatusOK, user)
}
// GetUsersHandler 获取所有用户
// @Summary 获取所有用户列表
// @Description 获取系统中所有用户的信息(需管理员权限)
// @Tags 用户(需要认证)
// @Accept json
// @Produce json
// @Security BearerAuth
// @Success 200 {array} dto.User "成功返回用户列表"
// @Failure 401 {object} dto.ErrorResponse "未授权"
// @Router /v1/auth/users [get]
func GetUsersHandler(c *gin.Context) {
var userList []dto.User
for _, user := range users {
// 不返回密码
user.Password = ""
userList = append(userList, user)
}
c.JSON(http.StatusOK, userList)
}
// GetUserHandler 获取单个用户
// @Summary 获取单个用户信息
// @Description 根据用户ID获取用户详细信息
// @Tags 用户(需要认证)
// @Accept json
// @Produce json
// @Security BearerAuth
// @Param id path string true "用户ID"
// @Success 200 {object} dto.User "成功返回用户信息"
// @Failure 401 {object} dto.ErrorResponse "未授权"
// @Failure 404 {object} dto.ErrorResponse "用户不存在"
// @Router /v1/auth/users/{id} [get]
func GetUserHandler(c *gin.Context) {
userID := c.Param("id")
user, exists := users[userID]
if !exists {
c.JSON(http.StatusNotFound, gin.H{"error": "User not found"})
return
}
// 不返回密码
user.Password = ""
c.JSON(http.StatusOK, user)
}
// UpdateUserHandler 更新用户信息
// @Summary 更新用户信息
// @Description 更新当前用户的信息
// @Tags 用户(需要认证)
// @Accept json
// @Produce json
// @Security BearerAuth
// @Param id path string true "用户ID"
// @Param user body dto.User true "要更新的用户信息"
// @Success 200 {object} dto.User "成功返回更新后的用户信息"
// @Failure 400 {object} dto.ErrorResponse "参数错误"
// @Failure 401 {object} dto.ErrorResponse "未授权"
// @Failure 403 {object} dto.ErrorResponse "权限不足"
// @Failure 404 {object} dto.ErrorResponse "用户不存在"
// @Router /v1/auth/users/{id} [put]
func UpdateUserHandler(c *gin.Context) {
userID := c.Param("id")
currentUserID := c.MustGet("user_id").(string)
// 只能更新自己的信息
if userID != currentUserID {
c.JSON(http.StatusForbidden, gin.H{"error": "Permission denied"})
return
}
user, exists := users[userID]
if !exists {
c.JSON(http.StatusNotFound, gin.H{"error": "User not found"})
return
}
var updateData struct {
Username string `json:"username"`
Email string `json:"email"`
Password string `json:"password"`
}
if err := c.ShouldBindJSON(&updateData); err != nil {
c.JSON(http.StatusBadRequest, gin.H{"error": err.Error()})
return
}
// 更新字段
if updateData.Username != "" {
user.Username = updateData.Username
}
if updateData.Email != "" {
user.Email = updateData.Email
}
if updateData.Password != "" {
// 哈希新密码
hashedPassword, err := bcrypt.GenerateFromPassword([]byte(updateData.Password), bcrypt.DefaultCost)
if err != nil {
c.JSON(http.StatusInternalServerError, gin.H{"error": "Failed to hash password"})
return
}
user.Password = string(hashedPassword)
}
// 保存更新
users[userID] = user
// 不返回密码
user.Password = ""
c.JSON(http.StatusOK, user)
}
// DeleteUserHandler 删除用户
// @Summary 删除用户
// @Description 删除当前用户账户
// @Tags 用户(需要认证)
// @Accept json
// @Produce json
// @Security BearerAuth
// @Param id path string true "用户ID"
// @Success 204 "成功删除"
// @Failure 401 {object} dto.ErrorResponse "未授权"
// @Failure 403 {object} dto.ErrorResponse "权限不足"
// @Failure 404 {object} dto.ErrorResponse "用户不存在"
// @Router /v1/auth/users/{id} [delete]
func DeleteUserHandler(c *gin.Context) {
userID := c.Param("id")
currentUserID := c.MustGet("user_id").(string)
// 只能删除自己的账户
if userID != currentUserID {
c.JSON(http.StatusForbidden, gin.H{"error": "Permission denied"})
return
}
_, exists := users[userID]
if !exists {
c.JSON(http.StatusNotFound, gin.H{"error": "User not found"})
return
}
// 删除用户
delete(users, userID)
c.JSON(http.StatusNoContent, nil)
}
common_constant.go
package constant
import "time"
// 配置信息
const (
SecretKey = "your-secret-key"
TokenExpiration = 24 * time.Hour
)
common_dto.go
这里其实应该按业务拆分为多个go文件,数量不多就先放一起吧
package dto
// User 用户模型
// @Description 用户信息的完整表示
type User struct {
// 用户唯一标识,系统自动生成的UUID
ID string `json:"id" binding:"required" example:"5f8d4e1c-3b9d-4c9d-8e1c-3b9d4c9d8e1c"`
// 用户名,用于登录,必须全局唯一
Username string `json:"username" binding:"required,min=3,max=20" example:"john_doe"`
// 用户密码(哈希后),登录时验证,不返回给客户端
Password string `json:"password,omitempty" example:"$2a$10$Z1JzJzJzJzJzJzJzJzJzJzJzJzJzJzJzJzJzJzJzJ"`
// 用户邮箱地址,用于接收通知和密码重置
Email string `json:"email" binding:"required,email" example:"john@example.com"`
}
// LoginRequest 登录请求
// @Description 用户登录时提交的凭证
type LoginRequest struct {
// 登录用户名
Username string `json:"username" binding:"required" example:"john_doe"`
// 登录密码
Password string `json:"password" binding:"required" example:"SecurePass123"`
}
// RegisterRequest 注册请求
// @Description 新用户注册时提交的信息
type RegisterRequest struct {
// 注册用户名,3-20个字符,只能包含字母、数字和下划线
Username string `json:"username" binding:"required,min=3,max=20,alphanum" example:"new_user123"`
// 注册密码,至少6个字符,需包含大小写字母和数字
Password string `json:"password" binding:"required,min=6" example:"Passw0rd!"`
// 注册邮箱,必须为有效的邮箱格式
Email string `json:"email" binding:"required,email" example:"user@example.com"`
}
// TokenResponse 令牌响应
// @Description 登录或注册成功后返回的认证令牌
type TokenResponse struct {
// JWT认证令牌,用于后续请求的Authorization头
Token string `json:"token" example:"eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJ1c2VyX2lkIjoiMSIsImV4cCI6MTY5MzkxMDQwMH0._..."`
}
// ErrorResponse 错误响应
// @Description API请求失败时返回的错误信息
type ErrorResponse struct {
// 错误代码,用于客户端识别具体错误类型
Code int `json:"code" example:"400"`
// 错误消息,简要描述错误原因
Message string `json:"message" example:"Invalid request parameters"`
// 错误详情,包含具体字段的错误信息(可选)
Details map[string]string `json:"details,omitempty" example:"{'username': 'Username already exists'}"`
}
token_utils.go
package utils
import (
"fmt"
"github.com/dgrijalva/jwt-go"
"github.com/gin-gonic/gin"
"go-web-demo/app/constant"
"net/http"
"time"
)
// 生成JWT令牌
func GenerateToken(userID string) (string, error) {
// 创建令牌
token := jwt.New(jwt.SigningMethodHS256)
// 设置声明
claims := token.Claims.(jwt.MapClaims)
claims["id"] = userID
claims["exp"] = time.Now().Add(constant.TokenExpiration).Unix()
// 生成签名字符串
return token.SignedString([]byte(constant.SecretKey))
}
// 认证中间件
func AuthMiddleware() gin.HandlerFunc {
return func(c *gin.Context) {
// 获取授权头
authHeader := c.GetHeader("Authorization")
if authHeader == "" {
c.JSON(http.StatusUnauthorized, gin.H{"error": "Authorization header missing"})
c.Abort()
return
}
// 验证授权头格式
if len(authHeader) < 7 || authHeader[:7] != "Bearer " {
c.JSON(http.StatusUnauthorized, gin.H{"error": "Invalid authorization header format"})
c.Abort()
return
}
// 提取令牌
tokenString := authHeader[7:]
// 解析令牌
token, err := jwt.Parse(tokenString, func(token *jwt.Token) (interface{}, error) {
// 验证签名方法
if _, ok := token.Method.(*jwt.SigningMethodHMAC); !ok {
return nil, fmt.Errorf("unexpected signing method: %v", token.Header["alg"])
}
return []byte(constant.SecretKey), nil
})
if err != nil {
c.JSON(http.StatusUnauthorized, gin.H{"error": "Invalid token"})
c.Abort()
return
}
// 验证令牌有效性
if !token.Valid {
c.JSON(http.StatusUnauthorized, gin.H{"error": "Invalid token"})
c.Abort()
return
}
// 提取用户ID
claims, ok := token.Claims.(jwt.MapClaims)
if !ok {
c.JSON(http.StatusUnauthorized, gin.H{"error": "Invalid token claims"})
c.Abort()
return
}
userID, ok := claims["id"].(string)
if !ok {
c.JSON(http.StatusUnauthorized, gin.H{"error": "Invalid user ID in token"})
c.Abort()
return
}
// 将用户ID添加到上下文
c.Set("user_id", userID)
// 继续处理请求
c.Next()
}
}
3.生成swagger文档
在项目根目录下执行:
swag init
这将自动解析代码中的注释,生成docs目录,包含docs.go、swagger.json和swagger.yaml文件。
swgger.go
这段代码读取文件内容,放入swaggerDoc 变量
//go:embed swagger.json
var swaggerDoc []byte
因为生成的swagger文档是静态内容,下面的代码展示怎么动态注入变更swagger文档内容
package docs
import (
_ "embed"
"encoding/json"
"fmt"
)
//go:embed swagger.json
var swaggerDoc []byte
func Init(swaggerHost string) {
SwaggerInfo.Host = swaggerHost
swaggerMap := make(map[string]interface{})
err := json.Unmarshal(swaggerDoc, &swaggerMap)
if err != nil {
return
}
swaggerMapServer := make([]map[string]string, 0)
swaggerMapServer = append(swaggerMapServer, map[string]string{
"url": fmt.Sprintf("http://%s/api/**", swaggerHost),
})
swaggerMap["servers"] = swaggerMapServer
swaggerJson, err := json.Marshal(swaggerMap)
if err != nil {
return
}
swaggerJsonString := string(swaggerJson)
SwaggerInfo.SwaggerTemplate = swaggerJsonString
}
4.访问 Swagger UI
启动服务器后,访问:
http://localhost:8082/swagger/index.html
就能看到类似下面的页面了
swagger注释说明
1.全局信息
// @title Gin API Example
// @version 1.0
// @description This is a sample server for a web application.
// @host localhost:8080
// @BasePath /api
2 API 操作注释
// @Summary 注册新用户
// @Description 创建一个新用户账户
// @Tags 用户
// @Accept json
// @Produce json
// @Param user body RegisterRequest true “用户注册信息”
// @Success 201 {object} TokenResponse “注册成功,返回JWT令牌”
// @Failure 400 {object} ErrorResponse “参数错误”
// @Router /register [post]
3.模型定义
// User 用户模型
// @Description 用户信息
type User struct {
ID stringjson:"id" binding:"required"
Username stringjson:"username" binding:"required"
Password stringjson:"password,omitempty"
Email stringjson:"email" binding:"required,email"
}
4.认证说明
Security
swagger2openapi3
部分Api网关可能要去OpenAPI 3.0.1格式。Swagger2openapi3 提供了一个包,可以将Swagger 2.0规范的JSON和YAML转换为OpenAPI 3.0.1。它还提供了一个工具叫做swag2op,它集成了Swagger 2.0的生成,并支持转换为OpenAPI 3.0.3。
安装
go install github.com/zxmfke/swagger2openapi3/cmd/swag2op@latest
执行
swag2op init
生成的swagger.json和swagger.yaml文档替换前面的即可
注意事项
- 每次修改 API 注释后,需要重新运行swag init生成文档
- 生产环境中建议限制 Swagger UI 的访问权限
- 可以通过swag init -g main.go指定入口文件
更多 Swagger 注释选项,请参考: swag官网
现在你的 API 已经有了完整的文档,前端开发人员或 API 使用者可以通过 Swagger UI 直观地了解和测试所有 API 端点。`