代码结构和理想中的依赖关系
目录结构解释:
cmd/ :里面主要存放的是main文件,用于运行程序
script/ :里面存放的是相关的脚本文件,例如mysql目录下面存放的就是数据库初始化过程中的创建数据库的脚本文件,正式的项目里面一般不会让我们自己创建数据库,通常就是我们提出建库建表的需求,然后数据库管理员来进行创建。
docker、k8s:这一部分就是我们运行程序所需要的一些中间件的创建
internal/ :这一部分是程序的内部文件,属于是整个项目的主体
着重的聊一下internal这个目录:
1.internal/domain :这个目录里存放的主要是领域对象相关的代码
领域对象(Domain Object)是在领域驱动设计(Domain-Driven Design, DDD)中用来表示业务领域核心概念的对象。它们代表了业务领域的实体和概念,并且通常包含了与业务逻辑紧密相关的状态和行为。领域对象可以分为以下几种类型:
聚合根(Aggregate Root):
聚合根是领域模型中的主要实体,它们是聚合的入口点。聚合是一组相关对象的集合,这些对象在业务规则下作为一个整体进行操作。聚合根负责维护聚合内部的一致性。
领域实体(Domain Entity):
实体具有唯一标识符,即使其属性改变,其身份也不会改变。实体通常包含业务逻辑和规则,以及与业务流程相关的状态。
值对象(Value Object):
值对象没有唯一标识符,它们的相等性基于其属性的值。如果两个值对象的属性值相同,则认为它们是相等的。值对象通常用于描述实体的某些方面,如货币金额、地址等。
除了上述三种主要的领域对象类型之外,还有其他一些常见的对象类型,例如:
视图对象(View Object, VO):
用于展示层,封装特定页面或组件所需的数据。
数据传输对象(Data Transfer Object, DTO):
用于在不同的系统组件之间传输数据,通常在展示层和服务层之间使用。
持久化对象(Persistence Object, PO):
用于数据库交互,通常映射到数据库表的结构。
领域对象的核心在于它们不仅持有数据,还封装了业务逻辑,这与简单的数据访问对象(DAO)或贫血模型(Anemic Domain Model)不同,在贫血模型中,业务逻辑通常被放在服务层,领域对象仅作为数据载体。在领域驱动设计中,领域对象是业务逻辑的核心承载者。
在这个项目里面的domain里面的golang代码主要起到了持久化对象的作用,用于与数据库交互,映射到表结构,具体的代码如下:
package domain
import "time"
type User struct {
Id int64
Email string
Password string
Ctime time.Time
Utime time.Time
}
上面的代码,里面的id,email和password就对应到了数据库上面的几个字段,通常使用。gorm.db 里面的AutoMigrate 方法来初始化数据表,具体数据表的初始化内容在dao目录下面。初始化完成创建出来的表结构如下:
2.internal/repository
internal/repository/user.go里面的代码主要是数据库的抽象并不直接操作数据库,具体的数据库操作在其下面的dao目录里面,具体的解释如下:
repository目录通常用于封装对数据库或外部服务的数据访问逻辑。这个目录下的代码主要负责与数据存储层交互,如执行CRUD(创建、读取、更新、删除)操作,以及更复杂的查询和事务处理。
repository目录内的结构可能包括:
接口定义:
定义数据访问接口,声明所有可能的操作方法。
实现文件:
对应每个接口的具体实现,通常会有一个或多个实现文件,每个文件对应不同的数据源或不同的数据类型。
数据模型:
虽然数据模型(即ORM映射对象)有时放在单独的models或entities目录下,但在一些项目中,repository目录也可能包含这些模型的引用或直接定义。
单元测试:
每个实现文件通常都有对应的单元测试文件,以确保数据访问逻辑的正确性。
repository/user.go目前的主要代码如下,主要定义了一些查询方法,还有进行数据库的访问,具体的调用逻辑也是完全按照开篇图片里面红色箭头进行调用的,代码如下:
package repository
import (
"context"
"git.com/gin_basic/webook/internal/domain"
"git.com/gin_basic/webook/internal/repository/dao"
)
var (
ErrDuplicateEmail = dao.ErrDuplicateEmail
ErrRecordNotFound = dao.ErrRecordNotFound
)
type UserRepository struct {
dao *dao.UserDAO
}
func (repo *UserRepository) Create(ctx context.Context, u domain.User) error {
return repo.dao.Insert(ctx, dao.User{
Email: u.Email,
Password: u.Password,
})
}
func (repo *UserRepository) FindByEmail(ctx context.Context, email string) (domain.User, error) {
u, err := repo.dao.FindByEmail(ctx, email)
if err != nil {
return domain.User{}, err
}
return repo.toDomain(u), err
}
func (repo *UserRepository) toDomain(u dao.User) domain.User {
return domain.User{
Id: u.Id,
Email: u.Email,
Password: u.Password,
}
}
func NewUserRepository(dao *dao.UserDAO) *UserRepository {
return &UserRepository{
dao: dao,
}
}
2.1 internal/repository/cache/
这部分主要是用来存储缓存的,具体的操作还没有用到,后续补充。
2.2 internal/repository/dao/user.go 和init.go
init.go 这部分主要是为了初始化数据表,具体的代码如下:
package dao
import "gorm.io/gorm"
func InitTables(db *gorm.DB) error {
db.AutoMigrate(&User{})
return nil
}
user.go 这部分主要是一些数据库的具体操作,包括数据表里面的一些字段的定义,包括主键和唯一索引,数据库查询操作,虽然repository/user.go里面也有一些查询方法,但是里面的方法主要是通过调用dao/user.go 里面的方法来进行实现的。还有数据表里面包含时间的存储格式,还有具体的错误的定义。具体代码如下:
package dao
import (
"context"
"errors"
"time"
"github.com/go-sql-driver/mysql"
"gorm.io/gorm"
)
// UserDAO 是用户数据访问对象,负责用户相关数据的操作。
type UserDAO struct {
db *gorm.DB
}
// ErrDuplicateEmail 表示邮箱重复的错误。
var ErrDuplicateEmail = errors.New("邮箱冲突")
// ErrRecordNotFound 表示记录未找到的错误。
var ErrRecordNotFound = gorm.ErrRecordNotFound
// Insert 插入一个新用户到数据库。
// ctx: 操作的上下文。
// u: 待插入的用户对象。
// 返回错误,如果插入失败或邮箱已存在。
func (dao *UserDAO) Insert(ctx context.Context, u User) error {
now := time.Now().UnixMilli()
u.Ctime = now
u.Utime = now
err := dao.db.WithContext(ctx).Create(&u).Error
if me, ok := err.(*mysql.MySQLError); ok {
const duplicateErr uint16 = 1062
if me.Number == duplicateErr {
return ErrDuplicateEmail
}
}
return err
}
// FindByEmail 根据邮箱查找用户。
// ctx: 操作的上下文。
// email: 待查找的邮箱。
// 返回用户对象和错误,如果未找到用户或发生其他错误。
func (dao *UserDAO) FindByEmail(ctx context.Context, email string) (User, error) {
var u User
err := dao.db.WithContext(ctx).Where("email=?", email).First(&u).Error
return u, err
}
// NewUserDAO 创建一个新的UserDAO实例。
// db: GORM数据库连接。
// 返回UserDAO实例。
func NewUserDAO(db *gorm.DB) *UserDAO {
return &UserDAO{
db: db,
}
}
// User 是用户实体。
type User struct {
Id int64 `gorm:"primaryKey,autoIncrement"`
Email string `gorm:"unique"`
Password string
Ctime int64 `gorm:"autoCreateTime"`
Utime int64 `gorm:"autoUpdateTime"`
}
3.internal/repository/service
service 目录,里面存放的是主要的业务逻辑代码,用于一些接口的实现和错误的处理。service目录下的具体内容可以包括如下几部分,具体的代码如下:
service目录通常用于封装业务逻辑。这里的业务逻辑是指那些处理数据和业务规则的代码,它们通常独立于HTTP请求和响应的细节。service目录下的文件和包可以包含以下几种类型的代码:
业务逻辑处理:
这些函数或方法负责执行应用程序的核心功能,例如用户认证、购物车管理、订单处理等。
它们可能调用repository层来获取或更新数据,并且可能包含复杂的业务规则和流程控制。
依赖注入:
service层可能会依赖于其他服务或者组件,如数据库访问层、外部API、缓存系统等。
依赖注入模式可以在这里被采用,以提高代码的可测试性和解耦。
接口实现:
可能会定义一些接口,然后在service目录下实现这些接口,以便于代码的重用和替换。
错误处理:
业务逻辑中可能包含错误处理机制,确保在发生问题时能够优雅地处理并返回适当的错误信息。
事务管理:
如果涉及到多个数据库操作,service层可能是处理事务的地方,确保数据的一致性。
在service目录中的代码通常不直接与HTTP请求交互,而是通过controller层调用。controller层负责接收HTTP请求,解析请求参数,调用service层的相应方法,然后将结果转换为HTTP响应发送回客户端。
这样的设计有助于保持代码的清晰和模块化,使得每个部分都有明确的职责,同时也便于单元测试和维护。
插入1个名词解释:
依赖注入:
依赖注入(Dependency Injection,简称DI)是一种设计模式,用于实现控制反转(Inversion of Control, IoC)。在软件工程中,依赖注入允许我们以松耦合的方式编写代码,使得组件之间的依赖关系可以在运行时动态地配置,而不是在编译时硬编码。
依赖注入有三种主要类型:
构造器注入:
依赖项通过构造函数参数传递给对象。这是最推荐的方式,因为它可以确保依赖项的不可变性和对象的初始化状态。
属性注入:
依赖项通过setter方法或公开字段注入到对象中。这种方式不如构造器注入安全,因为依赖项可能在对象的生命周期中被更改。
方法注入:
依赖项通过特定的方法调用注入。这通常用于注入一些策略或行为,而不是核心依赖。
package service
import (
"context"
"errors"
"git.com/gin_basic/webook/internal/domain"
"git.com/gin_basic/webook/internal/repository"
"golang.org/x/crypto/bcrypt"
)
var (
ErrDuplicateEmail = repository.ErrDuplicateEmail
ErrInvalidUserOrPassword = errors.New("用户名称或者密码不对")
)
type UserService struct {
repo *repository.UserRepository
}
func NewUserService(repo *repository.UserRepository) *UserService {
return &UserService{
repo: repo,
}
}
func (svc *UserService) SignUp(ctx context.Context, u domain.User) error {
hash, err := bcrypt.GenerateFromPassword([]byte(u.Password), bcrypt.DefaultCost)
if err != nil {
return err
}
u.Password = string(hash)
return svc.repo.Create(ctx, u)
}
func (svc *UserService) Login(ctx context.Context, email string, password string) (domain.User, error) {
u, err := svc.repo.FindByEmail(ctx, email)
if errors.Is(err, repository.ErrRecordNotFound) {
return domain.User{}, ErrInvalidUserOrPassword
}
if err != nil {
return domain.User{}, err
}
err = bcrypt.CompareHashAndPassword([]byte(u.Password), []byte(password))
if err != nil {
return domain.User{}, ErrInvalidUserOrPassword
}
return u, nil
}
4.internal/repoository/web
web 目录见名知意,主要是用来和http交互的代码,里面定义了路由和访问方式,以及整个代码具体都有哪些实现,主要是组织代码和资源文件,internal的各个目录里面的代码都是基于web里面的定义进行实现的,然后由web代码进行调用。最后实现功能。例如
package web
import (
"errors"
"net/http"
"git.com/gin_basic/webook/internal/domain"
"git.com/gin_basic/webook/internal/service"
regexp "github.com/dlclark/regexp2"
"github.com/gin-gonic/gin"
)
const (
emailRegexPattern = "^\\w+([-+.]\\w+)*@\\w+([-.]\\w+)*\\.\\w+([-.]\\w+)*$"
// 和上面比起来,用 ` 看起来就比较清爽
passwordRegexPattern = `^(?=.*[A-Za-z])(?=.*\d)(?=.*[$@$!%*#?&.])[A-Za-z\d$@$!%*#?&.]{8,72}$`
)
// UserHandlers 处理用户相关请求的结构体
type UserHandlers struct {
emailRegExp *regexp.Regexp
passwordRegExp *regexp.Regexp
svc *service.UserService
}
// RegisterRoutes 注册用户相关路由
// 该方法用于将用户操作的路由绑定到对应的处理函数
func NewUserHandlers(svc *service.UserService) *UserHandlers {
return &UserHandlers{
emailRegExp: regexp.MustCompile(emailRegexPattern, regexp.None),
passwordRegExp: regexp.MustCompile(passwordRegexPattern, regexp.None),
svc: svc,
}
}
func (h *UserHandlers) RegisterRoutes(routers *gin.Engine) {
// 用户路由组
rg := routers.Group("/users")
// 注册路由
rg.POST("/signup", h.SignUp)
// 登录路由
rg.POST("/sign", h.Login)
// 用户资料路由
rg.GET("/profile", h.Profile)
// 编辑资料路由
rg.POST("/edit", h.Edit)
}
// Signup 处理用户注册请求
// 该方法用于处理用户注册流程,包括验证数据,创建用户等
// 它接收一个 gin.Context 类型的参数,用于获取请求数据和发送响应
// 定义几个方法,包含注册、登录、编辑、查看
func (h *UserHandlers) SignUp(ctx *gin.Context) {
// 注册
// 字段标签,固定接收json格式,内部类
type SignUpReq struct {
Email string `json:"email"`
Password string `json:"password"`
ConfirmPassword string `json:"confirmPassword"`
}
//Bind 方法,类似是格式校验的一个方法,如果格式不是上面SignUp设置的格式就会报错。
var req SignUpReq
if err := ctx.Bind(&req); err != nil {
ctx.String(200, "输入的格式不合法")
return
}
// 邮箱格式校验
isEmail, err := h.emailRegExp.MatchString(req.Email)
if err != nil {
ctx.String(http.StatusOK, "系统错误")
return
}
if !isEmail {
ctx.String(http.StatusOK, "邮箱式不对")
return
}
// 密码格式校验
isPassword, err := h.passwordRegExp.MatchString(req.Password)
if err != nil {
ctx.String(http.StatusOK, "系统错误")
return
}
if !isPassword {
ctx.String(http.StatusOK, "密码格式不对,密码长度应该不小于8位,且包含特殊符号")
return
}
// 确认密码校验
if req.Password != req.ConfirmPassword {
ctx.String(200, "两次输入密码不一致")
return
}
err = h.svc.SignUp(ctx, domain.User{
Email: req.Email,
Password: req.Password,
})
switch {
case err == nil:
ctx.String(http.StatusOK, "注册成功,")
case errors.Is(err, service.ErrDuplicateEmail):
ctx.String(http.StatusOK, "邮箱冲突,请换一个")
default:
ctx.String(http.StatusOK, "系统错误")
}
ctx.String(http.StatusOK, "你已经完成注册")
}
// Login 处理用户登录请求
// 该方法用于处理用户登录流程,包括验证身份,生成会话等
// 它接收一个 gin.Context 类型的参数,用于获取请求数据和发送响应
// 定义几个方法,包含注册、登录、编辑、查看
func (h *UserHandlers) Login(ctx *gin.Context) {
type LoginReq struct {
Email string `json:"email"`
Password string `json:"password"`
}
//Bind 方法,类似是格式校验的一个方法,如果格式不是上面SignUp设置的格式就会报错。
var req LoginReq
if err := ctx.Bind(&req); err != nil {
return
}
_, err := h.svc.Login(ctx, req.Email, req.Password)
switch {
case err == nil:
ctx.String(http.StatusOK, "登录成功")
case errors.Is(err, service.ErrInvalidUserOrPassword):
ctx.String(http.StatusOK, "用户名称或者密码不对")
default:
ctx.String(http.StatusOK, "系统错误")
}
}
// Profile 处理用户资料查询请求
// 该方法用于处理用户查询自己资料的请求
// 它接收一个 gin.Context 类型的参数,用于获取请求数据和发送响应
func (h *UserHandlers) Profile(ctx *gin.Context) {
// 查看
}
// Edit 处理用户资料编辑请求
// 该方法用于处理用户编辑自己资料的请求
// 它接收一个 gin.Context 类型的参数,用于获取请求数据和发送响应
func (h *UserHandlers) Edit(ctx *gin.Context) {
// 编辑
}