Claran's blog

路漫漫其修远兮

项目结构规范

规范

一个项目/Demo的不同包应该按照其不同功能或实现方法/依赖位置来进行不同结构的划分

例如internal存储项目/Demo内部的所有业务逻辑

二internal内部按照功能可以划分为service(业务逻辑层)、handler(响应处理层)、util(工具层)、middleware(中间件层)、repository(数据仓库层)等

而util内部可按照不同对象的工具分为jwt_util、user_util等

repository内部可以按照不同存储方式分为memory(内存存储)、db(数据库存储)等

handler也可以分为外部处理和保护处理等层

以下为可能出现的结构(部分):

层级/类别 目录/包名 核心职责与内容 关键原则与说明
应用入口层 /cmd/<appname> 应用程序的入口点 (main 包)。每个子目录对应一个可执行文件,如 cmd/api, cmd/cli 保持精简main.go 应只包含初始化、依赖注入和启动逻辑。业务代码应置于 internalpkg
接口层 (API/HTTP) /internal/handler (或 controller) HTTP 请求处理层,负责参数绑定、校验、基本序列化/反序列化 不应包含业务逻辑,通常调用 service
业务逻辑层 /internal/service (或 usecase) 核心业务逻辑和用例流程的实现层。协调多个 repository 或领域对象完成业务操作 基于接口编程。通过依赖注入接收 repository 等依赖,便于测试
数据访问层 /internal/repository (或 dao) 负责与数据源(数据库、缓存、外部 API)交互。对上层提供统一的数据操作接口 基于接口编程service 层依赖 Repository 接口,而非具体实现,实现解耦
领域模型层 /internal/domain (或 model, entity) 定义核心业务数据结构、枚举、领域对象的行为(方法)和业务规则 应保持 高内聚、低耦合,不包含具体技术(如 DB 注解)的依赖
内部共享工具 /internal/pkg 项目内部多个模块共享的、但不想暴露给外部的辅助代码,如项目特定的数据库连接池、内部中间件 internal 目录的 Go 编译器强制保护,外部项目无法导入
公共库代码 /pkg 设计良好、希望被外部项目导入和使用的公共库代码,如 pkg/logger, pkg/errors 需慎重设计公开 API。对于是否使用此目录存在争议,中小型单体项目可酌情简化
接口定义 /api 存放 API 契约文件,如 OpenAPI (Swagger) Spec、Protocol Buffers (.proto) 文件、GraphQL Schema 接口契约与实现分离,便于前后端协作和生成客户端代码
配置模板 /configs 配置文件模板或默认配置(如 config.yaml.tmpl 切勿在此存放含密码、密钥等敏感信息的真实配置文件
部署配置 /deployments (或 /deploy) IaaS、PaaS、容器编排(如 Docker-Compose, Kubernetes/Helm, Terraform)的配置和模板 将部署逻辑与应用程序代码分离
构建与CI /build 打包和持续集成相关的配置和脚本。通常包含 /build/ci (CI 配置) 和 /build/package (系统包配置) Makefilescripts 目录协同工作
脚本库 /scripts 用于执行构建、安装、分析等操作的脚本。这些脚本可被根目录的 Makefile 调用 复杂项目可在其下建立子目录,如 scripts/make-rules, scripts/lib (Shell 库)
项目工具 /tools 存放本项目专用的支持工具,这些工具可以导入 pkginternal 中的代码 将工具代码与应用程序代码分开管理
外部工具与代码 /third_party 外部辅助工具、Fork 的第三方代码或其他第三方应用(如 Swagger UI) 方便清晰地管理自定义修改的第三方依赖
前端资源 /web (或 /assets) Web 前端静态资源,如 CSS、JavaScript 文件、服务端模板和单页应用 (SPA) 主要用于全栈 Web 项目
项目文档 /docs 设计文档、用户手册、开发指南等(非 Godoc 生成的 API 文档)可按语言细分,如 docs/guide/zh-CN 保持文档与代码版本同步
代码示例 /examples 为应用程序或公共库提供的使用示例代码,降低使用者上手门槛 示例应简洁、典型、可运行
网站数据 /website 如果不使用 GitHub Pages,可在此存放项目的网站数据 适用于有独立站点的开源项目
Git钩子 /githooks 项目相关的 Git 钩子脚本,如 commit-msg 钩子 可通过 Git 配置指向此目录来共享钩子
测试相关 /test 额外的外部测试应用和测试数据。用于集成测试、端到端测试。可包含 /test/testdata 单元测试 (_test.go 文件) 应与被测试代码放在同一包内
依赖包 /vendor 项目依赖的第三方库代码(通过 go mod vendor 生成)。用于固定依赖版本或离线构建 现代 Go Modules 下通常无需提交至仓库,但特定场景(如保障确定性构建)仍有用

RESTful API

RESTful API 是一种基于 REST架构风格设计的网络应用程序接口,它通过一系列设计原则和约束条件,让网络服务变得更加清晰、简洁且易于维护

🔑六大核心原则

RESTful API 的设计建立在以下六项架构约束之上:

  • 统一接口

    • 这是REST最核心的约束,它确保与API的交互是标准化的。主要体现在:使用URI唯一地标识每个资源;使用标准的HTTP方法(GET, POST, PUT, DELETE等)来操作资源;返回自描述的消息,通常使用JSON或XML格式
  • 无状态

    • 每个从客户端发往服务器的请求都必须包含理解该请求所需的全部信息。服务器不会存储任何与会话相关的上下文状态。这使得服务器更容易扩展,也简化了系统设计
  • 客户端-服务器分离

    • 关注点分离。客户端负责用户界面和用户体验,服务器负责数据处理和存储。两者可以独立开发和演化,只要它们之间的接口不变
  • 可缓存

    • 服务器返回的响应必须明确表明其是否可以被客户端或中间代理缓存。这可以显著提高性能,减少不必要的网络请求
  • 分层系统

    • 架构可以由多个层次组成(如:负载均衡、应用服务器、数据库)。客户端不需要知道它是在与哪一层交互,这有助于提高系统的可扩展性和安全性
  • 按需代码

    • 这是一个可选的约束。服务器可以临时将可执行代码(如JavaScript脚本)发送给客户端,以扩展客户端的功能

📐设计规范

在实际设计中,上述原则转化为一些非常具体的实践规范

URI 设计

  • 使用名词而非动词:URI应该标识资源本身,而不是对资源的操作。例如,应使用 /users,而不是 /getUsers

  • 使用复数名词:通常建议对资源集合使用复数形式,如 /products比 /product更常见

  • 使用连字符-而非下划线_:这主要是为了提升URI的可读性

  • 体现层级关系:对于有关联的资源,可以使用嵌套路径,例如 /users/123/orders表示用户ID为123的所有订单

HTTP 方法的正确使用

  • GET:检索/获取资源。不应改变资源状态

  • POST:创建新资源

  • PUT:完整更新已存在的资源(客户端提供更新后的完整资源)

  • PATCH:部分更新资源(客户端只提供需要改变的字段)

  • DELETE:删除资源

使用标准的HTTP状态码

API通过状态码告知客户端请求的结果,这构成了通信契约的重要部分

  • 200 OK:请求成功。

  • 201 Created:资源创建成功。

  • 204 No Content:请求成功,但无返回内容(如删除操作后)。

  • 400 Bad Request:客户端请求错误(如参数有误)。

  • 401 Unauthorized:未认证(身份验证失败)。

  • 403 Forbidden:无权限(认证成功,但无权访问)。

  • 404 Not Found:请求的资源不存在。

  • 500 Internal Server Error:服务器内部错误。

返回统一的响应格式

通常使用JSON作为数据交换格式,并保持响应结构的一致性。一个常见的成功响应格式如下

1
2
3
4
5
6
7
8
{
"code": 200,
"status": "success",
"data": {
"id": 1,
"name": "Example"
}
}

而错误响应则可能包含错误信息:

1
2
3
4
5
{
"code": 404,
"status": "error",
"message": "Requested resource not found"
}

流程规范

以handler为例

handler:

创建数据 -> 捕获数据 -> 调用服务层(service)-> 构建响应(model.response) -> 返回响应(response)

. . . . . .

面向接口与依赖注入

  1. 接口定义的位置

在Go中,接口由使用方定义是一种最佳实践。这意味着,操作数据的接口(如 UserRepository)应该在调用它的层中定义,而不是在实现它的层中

业务逻辑层(service)定义它需要什么样的数据存取功能(repository接口)。

数据访问层(repository)负责实现这个接口。

这样做彻底解耦了业务逻辑和具体的技术实现,使得更换数据库(如从MySQL到PostgreSQL)或为测试提供Mock实现变得非常容易

  1. 依赖注入(Dependency Injection)

依赖注入是实现控制反转、连接各层的关键技术。它通常在 main.go或专门的 wire包中完成:

在入口函数中,初始化所有具体的实现(如数据库连接、第三方SDK)。

将具体实现实例,注入到需要它们接口的业务组件中(如将 userRepository实例传递给 userService)。

实例

Demo

基础知识

鉴权

计网和Gin基础知识:Link

我们都知道 HTTP 协议是无状态的,所谓的无状态就是客户端每次想要与服务端通信,都必须重新与服务端链接,意味着请求一次客户端和服务端就连接一次,下一次请求与上一次请求是没有关系

这种无状态的方式就会存在一个问题:如何判断两次请求的是同一个人?就好比用户在页面 A 发起请求获取个人信息,然后在另一个页面同样发起请求获取个人信息,我们如何确定这俩个请求是同一个人发的呢?

为了解决这种问题,我们就迫切需要一种方式知道发起请求的客户端是谁?此时,cookie、token、session 就出现了,它们就可以解决客户端标识的问题,甚至是解决权限问题

通常情况下,cookie 和 session 都是结合着来用

因此,这里我们就将 cookie 和 session 结合着来用

概述

Cookie 是一种在客户端存储数据的技术,它是由服务器发送给客户端的小型文本文件,存储在客户端的浏览器中,大小限制大致在 4KB 左右

在客户端发送请求时,浏览器会自动将相应的 Cookie 信息发送给服务器,服务器通过读取 Cookie 信息,就可以判断该请求来自哪个客户端

因此,Cookie 可以用于存储用户的登录状态、购物车信息等

在以前很多开发人员通常用 cookie 来存储各种数据,后来随着更多浏览器存储方案的出现,cookie 存储数据这种方式逐渐被取代,主要原因有如下:

  1. cookie 有存储大小限制,4KB 左右

  2. 字符编码为 Unicode,不支持直接存储中文

  3. 数据可以被轻易查看

流程图

session

概述

session 由服务端创建

当一个请求发送到服务端时,服务器会检索该请求里面有没有包含 sessionId 标识,如果包含了 sessionId,则代表服务端已经和客户端创建过 session,然后就通过这个 sessionId 去查找真正的 session,如果没找到,则为客户端创建一个新的 session,并生成一个新的 sessionId 与 session 对应,然后在响应的时候将 sessionId 给客户端,通常是存储在 cookie 中。如果在请求中找到了真正的 session,验证通过,正常处理该请求

每一个客户端与服务端连接,服务端都会为该客户端创建一个 session,并将 session 的唯一标识 sessionId 通过设置 Set-Cookie 头的方式响应给客户端,客户端将 sessionId 存到 cookie 中。

流程图

token

概述

Token 是一种在客户端和服务端之间传递身份信息的方式

当用户登录成功后,服务端会生成一个 Token,将其发送给客户端

客户端在后续的请求中,需要将 Token 携带在请求头或请求参数中

服务端通过验证 Token 的合法性,就可以确定该请求来自哪个用户,并且可以根据用户的权限进行相应的操作

组成

Token是一个由一串字符组成的令牌,用于在计算机系统中进行身份验证和授权

token通常由三个部分组成:标头、有效载荷、签名

  1. 标头(Header):包含了加密算法和类型,用于指定如何对有效载荷进行编码和签名。常用的算法有HMAC、RSA、SHA等
  2. 有效载荷(Payload):包含了一些信息,如用户ID、角色、权限等,用于验证身份和授权。有效载荷可以是加密的,也可以是明文的
  3. 签名(Signature):是对标头和有效载荷进行密钥签名后得到的值,用于验证token的完整性和真实性

一个完整的token包含了标头、有效载荷和签名三个部分,它们一起构成了一个安全的令牌,用于进行身份验证和授权。

认证流程(Access token)

  1. 客户端使用用户名跟密码请求登录
  2. 服务端收到请求,去验证用户名与密码
  3. 验证成功后,服务端会签发一个 token 并把这个 token 发送给客户端
  4. 客户端收到 token 以后,会把它存储起来,比如放在 localStorage 里
  5. 客户端每次发起请求的时候需要把 token 放到请求的 Header 里传给服务端
  6. 服务端收到请求,然后去验证客户端请求里面带着的 token ,如果验证成功,就向客户端返回请求的数据

Refresh Token

另外一种 token——refresh token

refresh token 是专用于刷新 access token 的 token。如果没有 refresh token,也可以刷新 access token,但每次刷新都要用户输入登录用户名与密码,会很麻烦。有了 refresh token,可以减少这个麻烦,客户端直接用 refresh token 去更新 access token,无需用户进行额外的操作

Access Token 的有效期比较短,当 Acesss Token 由于过期而失效时,使用 Refresh Token 就可以获取到新的 Token,如果 Refresh Token 也失效了,用户就只能重新登录了

Refresh Token 及过期时间是存储在服务器的数据库中,只有在申请新的 Acesss Token 时才会验证,不会对业务接口响应时间造成影响,也不需要向 Session 一样一直保持在内存中以应对大量的请求

JWT

概述

JSON Web Token(简称 JWT)是目前最流行的跨域认证解决方案,是一种认证授权机制,是token的一种实现形式

JWT 是为了在网络应用环境间传递声明而执行的一种基于 JSON 的开放标准。、

JWT 的声明一般被用来在身份提供者和服务提供者间传递被认证的用户身份信息,以便于从资源服务器获取资源

比如用在用户登录上。 可以使用 HMAC 算法或者是 RSA 的公/私秘钥对 JWT 进行签名

因为数字签名的存在,这些传递的信息是可信的。

流程图

组成

  • JWT 生成示例:

    1
    2
    3
    4
    5
    6
    7
    8
    9
    10
    11
    12
    Header:
    {
    "alg": "HS256",
    "typ": "JWT"
    }

    Payload:
    {
    "user_id": 114514,
    "role": "admin",
    "exp": 1716579600
    }

  • 生成的 JWT:

    1
    2
    3
    4
    5
    eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9
    .
    eyJ1c2VyX2lkIjoxMjMsInJvbGUiOiJhZG1pbiIsImV4cCI6MTcxNjU3OTYwMH0
    .
    abc123signature
  • 客户端:

    • 接收到服务器返回的 JWT,将其存储在客户端(通常是 localStoragesessionStorage也可以是cookie)
    • 后续请求中,客户端会将 JWT 作为凭证发送给服务器
    • 在请求受保护资源时,客户端通过 HTTP 请求头将 JWT 发送到服务器
    • 请求头中通常使用 Authorization: Bearer <token> 格式
  • 服务端

    1.检查请求头中的 Authorization 是否包含 JWT。

    Authorization: Bearer <具体的token字符串>

    2.提取 JWT 并验证:

    • **签名验证:**使用密钥验证签名是否正确,确保 Token 未被篡改。
    • **过期时间验证:**检查 exp 字段是否未过期。
    • **其他验证:**检查 iss(发行者)和 aud(受众)是否匹配。

    验证成功后,解析 Payload,提取用户信息和权限。

    如果 Token 无效或过期,返回 401 Unauthorized 错误

因为 JWT 是自包含的(内部包含了一些会话信息),因此减少了需要查询数据库的需要

因为用户的状态不再存储在服务端的内存中,所以这是一种无状态的认证机制生成

JWT代码实现

依赖:

  • gin框架: go get -u github.com/gin-gonic/gin
  • JWT: go get -u github.com/golang-jwt/jwt/v4
  • time包(存储Expiration过期时间)
  • strings包(分解请求头信息)
  • errors包(返回错误信息)

基础知识

声明(Claims)

JWT声明默认信息如下:

声明简称 英文全称 含义与作用
iss Issuer JWT的签发者,标识创建并签发该令牌的实体(例如某个认证服务器或服务)。
sub Subject JWT的主题,标识该令牌所指向的主体(通常是用户ID),即令牌是关于谁的。
aud Audience JWT的受众,指定该令牌意图提供给哪个或哪些接收方使用,接收方需验证自身是否在受众列表中。
exp Expiration Time JWT的过期时间,一个Unix时间戳,超过此时间后令牌应被视为无效。
nbf Not Before JWT的生效时间,一个Unix时间戳,在此时间之前令牌应被视为无效。
iat Issued At JWT的签发时间,标识令牌是何时被创建的。
jti JWT ID JWT的唯一标识符,为令牌提供一个唯一标识,常用于防止同一令牌被重复使用(重放攻击)。

type token:

在jwt中,jwt.token是一种数据类型,用于储存token信息,详细数据如下:

1
2
3
4
5
6
7
8
type Token struct {
Raw string // 原始 Token 字符串
Method SigningMethod // 签名方法
Header map[string]interface{} // 头部信息
Claims Claims // 声明信息
Signature []byte // 签名部分
Valid bool // 是否验证通过
}

实现流程

1. 设置JWT默认配置

config包下:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
package config

import (
"time"
)

// JWTConfig JWT配置
type JWTConfig struct {
SecretKey string
Issuer string
ExpirationTime time.Duration
}

// DefaultJWTConfig 默认的JWT配置
var DefaultJWTConfig = JWTConfig{
SecretKey: "this_is_secret_key", // 秘钥,一般不保存在源代码中
Issuer: "jwt_study_demo", // 签发者
ExpirationTime: time.Hour * 24 * 7, // 七天有效期
}

以上代码设置了JWT的默认配置DefaultJWTConfig,用于后续JWT配置的使用

2. 设置JWT具体方法

util包下:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
package utils

import (
"GoGin/JWT/config"
"time"

"github.com/golang-jwt/jwt/v5"
)

// GenerateToken 生成 tokenString
func GenerateToken(userID string, username string) (string, error) {
//获取默认JWT配置
jwtConfig := config.DefaultJWTConfig

//创建JWT声明
claims := jwt.MapClaims{
"iss": jwtConfig.Issuer, // 签发者
"sub": userID, // 指向主体
"iat": time.Now().Unix(), // issued At -- 签发时间
"exp": time.Now().Add(jwtConfig.ExpirationTime).Unix(), // 有效期
"nbf": time.Now().Unix(),
"username": username, "user_id": userID,
//"aud" : ... // 受众
//"jti" : ... // 唯一标识符
}

// 生成Token,算法:HS256
token := jwt.NewWithClaims(jwt.SigningMethodHS256, claims) // jwt.NewWithClaims - 利用某算法为claims生成token
// 对token签名
tokenString, err := token.SignedString([]byte(jwtConfig.SecretKey)) // token.SignedString(key) - 为token进行密钥key签名
if err != nil {
return "", err
}
return tokenString, nil
}

// ValidateTokenString 验证 JWT Token
func ValidateTokenString(tokenString string) (*jwt.Token, error) { // *jwt.Token - 一种数据类型,表示token类型
// 获取token
token, err := jwt.Parse(tokenString, func(token *jwt.Token) (interface{}, error) { // jwt.Parse(tokenString,key) - 用密钥key解析并验证JWT,此处func为验证获取密钥
// 验证签名方式是否正确
if _, ok := token.Method.(*jwt.SigningMethodHMAC); !ok { // Method为类型token的内部变量,表示签名方法 此处为类型断言,判断是否为HMAC方法
return nil, jwt.ErrSignatureInvalid // 返回错误:签名验证失败
}

// 返回密钥
return []byte(config.DefaultJWTConfig.SecretKey), nil
})

if err != nil {
return nil, err
}

return token, nil
}

// ExtractClaims 提取 JWT 声明信息
func ExtractClaims(token *jwt.Token) (jwt.MapClaims, error) {
if claims, ok := token.Claims.(jwt.MapClaims); ok && token.Valid { // token.Claims.(jwt.MapClaims) 表示对token的声明信息进行类型断言; token.Valid 表示验证是否通过
return claims, nil // 通过
} else {
return nil, jwt.ErrTokenInvalidClaims // 返回错误:jwt声明信息无效
}
}

以上代码创建了三个JWT方法:生成 tokenString、验证 JWT Token和提取 JWT 声明信息,注意这三个方法的参数以及返回值

3. 创建JWT认证中间件

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
package middleware

import (
"GoGin/JWT/utils"

"errors"

"github.com/gin-gonic/gin"

"github.com/golang-jwt/jwt/v5"

"net/http"

"strings"
)

func JWTAuthMiddleware() gin.HandlerFunc {
return func(c *gin.Context) {
// 获取请求头中的Authorization字段,err:无该字段
authHeader := c.GetHeader("Authorization") // 从请求头获取Authorization字段
if authHeader == "" { // 无该字段
c.JSON(http.StatusUnauthorized, gin.H{
"error": "Authorization required",
})
c.Abort()
return
}

// 解析,获取token,err:格式不合法
parts := strings.SplitN(authHeader, " ", 2) // 将Authorization: Bearer <token> 提取为[0]{Bearer}和[1]{<token>}
if parts[0] != "Bearer" || len(parts) != 2 { // Authorization 信息格式不合法
c.JSON(http.StatusUnauthorized, gin.H{
"error": "invalid authorization header format",
})
c.Abort()
return
}
tokenString := parts[1]

// 验证token,err:过期 / 非法
token, err := utils.ValidateTokenString(tokenString) // 验证tokenString
if err != nil {
if errors.Is(err, jwt.ErrTokenExpired) { // 错误:过期
c.JSON(http.StatusUnauthorized, gin.H{
"error": "token is expired",
})
c.Abort()
return
}
c.JSON(http.StatusUnauthorized, gin.H{ // 错误:非法
"error": "invalid token",
})
c.Abort()
return
}

//提取声明信息,err:claims失败
claims, err := utils.ExtractClaims(token)
if err != nil {
c.JSON(http.StatusInternalServerError, gin.H{
"error": "extract claims failed",
})
c.Abort()
return
}

// 保存claims到上下文
c.Set("user_id", claims["user_id"])
c.Set("username", claims["username"])

//c.nest
c.Next()
}
}

以上代码创建了一个JWT认证中间件JWTAuthMiddleware,路由注册该中间件后,对该路由的请求会流过该中间件,验证请求头中是否含有token以及token是否合法

4. 为受保护的路由注册中间件

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
package routes

import (
"GoGin/JWT/middleware"

"github.com/gin-gonic/gin"

"GoGin/JWT/handler"
)

func InitRouter() {
r := gin.Default()

// 登录路由
r.POST("/login", handler.LoginHandler)

// 受保护的路由组
protected := r.Group("/api")
protected.Use(middleware.JWTAuthMiddleware())
{
protected.GET("/info", handler.InfoHandler)
}
}

现在访问http://localhost:8080/api/info 会验证JWT

实现效果

Demo:Github

Article:My-Blog

使用Postman测试api后:

0%