框架结构
https://go-kratos.dev/blog/go-project-layout/
个人理解:
- Data 层
数据层,类似 DDD 的 Repo 层,主要包含数据操作(如 DB、外部接口 等),实现 Biz 的 Repo 接口。
这里去掉了 DDD 的 防腐层,而内化到了 Data 层。 - Biz 层
业务逻辑层,类似 DDD 的 Service 层,完成 User Case。
Biz 层实际上已经内化了 DDD 的 Domain 层(值对象、实体和聚合根)。 - Service 层
服务层,类似 DDD 的 Application 层,实现API的定义(跟 Server 对接,实现服务暴露),主要处理 DTO 转 DO,不应包含复杂的业务逻辑。 - Server 层
把 Service 注入到 HTTP 或 RPC 实例。
Server -> Service -> Biz -> Data,每一层的依赖都通过 Wire 自动注入。
顺便贴一张之前做 Kratos 整合 Nacos 时梳理的图以供参照:
国际化
错误的国际化已经封装在了 error_reason.proto 里,但很多时候,一些正常的输出也需要做国际化,这时可以用 https://github.com/nicksnyder/go-i18n 来处理。
用法:
# common/i18n/zh/zh.go 里定义翻译内容
var TranslateToml = map[string]string{
...
"time_duration": "{{.d}}天{{.h}}时{{.m}}分{{.s}}秒",
}
# 逻辑里使用
i18n.Localize(ctx, "time_duration", func(option *i18n.LocalizeOptions) {
option.MsgParams = map[string]interface{}{
"d": days,
"h": hours,
"m": minutes,
"s": seconds,
}
})
使用 Validator 做入参校验
入参校验可以做在 biz 层,可以是 Do 里,也可以是在函数里定义的局部 struct,纯粹用于入参校验。
部分疑难杂症
关于错误处理
因为 Proto 里已经提供了 Error 封装,所以,哪一层抛出的 Error,最好在那一层及时封装处理。
而实际上,大部分 Error 封装都应该发生在 Biz 层。
Xorm更新时忽略结构体中零值字段的问题
比如,费用中心中,在余额支付时,如果扣款刚好为0,则 balance=0 放在 Account 结构体,再执行 session.Update 时是会被忽略的。
这时候,只能传递 Map 进去。
用JSON序列化和反序列化实现对象数据拷贝时时间类型转字符串的处理
当原类型中有字段类型为 time.Time 时,反序列化为 string 时会因不匹配而丢失,比如:
src 为指向 struct 的指针类型,其中 struct 的 CreatedAt 字段为 time.Time 类型;dst 为指向 struct 的指针类型,其中 struct 的 CreatedAt 字段为 string 类型。
可通过反射分别判断原类型和目标类型,再进行转换时的处理:
func Copy(src interface{}, dst interface{}) error {
var json = jsoniter.ConfigCompatibleWithStandardLibrary
marshal, err := json.Marshal(src)
if err != nil {
return err
}
err = json.Unmarshal(marshal, &dst)
if err != nil {
return err
}
// 通过反射设置类型不相同的字段
srcVal, dstVal := reflect.ValueOf(src), reflect.ValueOf(dst)
srcValKind, dstValKind := srcVal.Kind(), dstVal.Kind()
srcKey, dstKey := reflect.TypeOf(src), reflect.TypeOf(dst)
// 如果是指针 则钻取指向的数据类型
if srcValKind == reflect.Ptr {
srcVal = srcVal.Elem()
srcValKind = srcVal.Kind()
}
if dstValKind == reflect.Ptr {
dstVal = dstVal.Elem()
dstValKind = dstVal.Kind()
}
if srcKey.Kind() == reflect.Ptr {
srcKey = srcKey.Elem()
}
if dstKey.Kind() == reflect.Ptr {
dstKey = dstKey.Elem()
}
if srcValKind == reflect.Struct && dstValKind == reflect.Struct {
for i := 0; i < srcVal.NumField(); i++ {
// src 和 dst 的字段一样
dstField, ok := dstKey.FieldByName(srcKey.Field(i).Name)
// time.Time 转 string
if ok && srcKey.Field(i).Type.String() == "time.Time" && dstField.Type.String() == "string" {
dstVal.FieldByIndex(dstField.Index).SetString(srcVal.Field(i).Interface().(time.Time).Format("2006-01-02 15:04:05"))
} else {
continue
}
}
}
return nil
}
注:reflect.Elem() 为通过反射获取指针指向的元素类型。
Proto定义中支持传入不同类型的入参 - Any类型 & Struct类型
为了兼容前端传入和数据库中可能存在的多种类型数据(整型、字符串、浮点型等),考虑在 proto 文件中使用一个类似 go interface{} 的类型。
google 的 protobuf 内置了一个 anypb.Any 的 struct,于是 proto 文件中这样设置:
import "google/protobuf/any.proto";
message CommodityAttr {
...
map<string, google.protobuf.Any> props = 5; // 配置项
}
因为通过 json.Marshal/Unmarshal 进行数据拷贝,所以,需要实现 Any 类型的 Unmarshal 自定义:
import (
"google.golang.org/protobuf/types/known/anypb"
"google.golang.org/protobuf/types/known/wrapperspb"
)
type any anypb.Any
func (x *any) UnmarshalJSON(b []byte) error {
value := wrapperspb.StringValue{Value: string(b)}
data, err := anypb.New(&value)
if err != nil {
return err
}
*x = any{
TypeUrl: data.TypeUrl,
Value: data.Value,
}
return nil
}
type CommodityAttr struct {
...
Props map[string]*any `json:"props"` // 配置项
}
但因为 Any.Value 是 []byte 类型,后续想把 Any.Value 的值转换为 int 或 string 时发现有个奇怪的字符而导致转换失败。。。
如果参数是一个 Map,则可以用 Struct:
import "google/protobuf/struct.proto";
message CalculateRequest {
...
google.protobuf.Struct opts = 2;
}
go 中:
func (s *CommodityService) Calculate(ctx context.Context, req *pb.CalculateRequest) (*pb.CalculateReply, error) {
calculate, _, err := s.CommodityUcase.CalculateFee(ctx, req.Id, req.Opts.AsMap()) // 通过 AsMap 方法把 Struct 转换为 map[string]interface{}
...
}
关于类型转换
很多时候,我们需要把 interface{} 要转换成 int、bool、string、float...,虽然可以通过类型断言判断进行特定类型的转换处理,但断言需要判断成功与否,否则失败时会抛异常。
后发现,Xorm 中有个 convert 包,提供了很多类型转换方法(实现原理同上,也用到了内置的 strconv 包)。
具体用法比如:
import "xorm.io/xorm/convert"
valInt, valErr := convert.AsUint64(value)
# 而对于 interface{} 转 string,可以直接 convert.AsString,且此方法没有错误返回
str := convert.AsString(data)
# convert.AsString 比类型断言精简多了
str, ok := data.(string)
if ok {
fmt.Println(str)
}
Xorm 自定义实现 time.Time 空值JSON的问题
空的 time.Time 在 JSON 时会输出 0001-01-01 00:00:00,所以在 Format 之前需要先做空值判断,可以是这样:
// 时间格式化
func TimeFormat(t time.Time, layout string) string {
if t.IsZero() {
return ""
} else {
return t.Format(layout)
}
}
但可以尝试更优雅的实现:通过设定一个新的 struct,定义 MarshalJSON 来实现,比如:
import "time"
type CustomTime struct {
time.Time
}
func (t CustomTime) MarshalJSON() ([]byte, error) {
if time.Time(t).IsZero() {
return []byte(`""`), nil
}
return []byte(`"` + time.Time(t).Format("2006-01-02 15:04:05") + `"`), nil
}
Xorm 中 Update 更新的问题
在操作数据时需注意指定主键,否则可能会全表更新:
_, err := session.ID(order.Id).Update(order)
获取当前时间下个月1日及解析时间字符串的问题
虽然可以巴拉巴拉一通代码,但,也可以有很优雅的方式:
time.Now().AddDate(0, 1, -time.Now().Day()+1)
另外,解析时间需注意用 time.ParseInLocation 并加上 time.Local:
time.ParseInLocation("2006-01-02 15:04:05", "2021-11-29 16:33:55", time.Local)
Validate 中 gtefield 不能用于字符串类型的数值对比
怎么办呢,没办法,只能自己扩展。举个例子:
type req struct {
StartMonth string `validate:"required,datetime=200601" label:"开始月份"`
EndMonth string `validate:"required,datetime=200601,gtefield=StartMonth" label:"结束月份"`
}
想要实现 EndMonth >= StartMonth,但 Validator 内置的 gtefield 对字符串类型,实际上是比较长度。。。
这种情况只能自定义校验实现:
import (
ut "github.com/go-playground/universal-translator"
v10 "github.com/go-playground/validator/v10"
)
// 定义一个结构体用于校验请求参数
type req struct {
StartMonth string `validate:"required,datetime=200601" label:"开始月份"`
EndMonth string `validate:"required,datetime=200601,gteField=StartMonth" label:"结束月份"`
}
err := validator.ValidateData(ctx, &req{
StartMonth: startMonth,
EndMonth: endMonth,
}, func(validate *v10.Validate, trans ut.Translator) {
// 结束月份大于等于开始月份
gteField := func(fl v10.FieldLevel) bool {
// 获取当前字段信息(结束月份)
field := fl.Field()
kind := field.Kind()
// 获取关联字段信息(开始月份)
currentField, currentKind, ok := fl.GetStructFieldOK()
if !ok || currentKind != kind {
return false
}
// 字符串转换为整数并进行比较
end, _ := strconv.Atoi(field.String())
start, _ := strconv.Atoi(currentField.String())
return end >= start
}
// 注册校验器
_ = validate.RegisterValidation("gteField", gteField)
// 注册翻译器
_ = validate.RegisterTranslation("gteField", trans, func(ut ut.Translator) error {
var text string
if trans.Locale() == "en" {
text = "{0} cannot be earlier than the StartMonth"
} else {
text = "{0}不能早于开始月份"
}
return ut.Add("gteField", text, true)
}, func(ut ut.Translator, fe v10.FieldError) string {
t, _ := ut.T("gteField", fe.Field())
return t
})
})
if err != nil {
return nil, err
}
http.Client 发送 HTTPS 请求时报 x509: certificate... 错误
跟HTTPS请求的证书信任有关,可以配置成跳过证书认证:
// 跳过证书认证
transport := &http.Transport{
TLSClientConfig: &tls.Config{InsecureSkipVerify: true},
}
httpClient := http.Client{Timeout: 30 * time.Second, Transport: transport}
resp, respErr := httpClient.Do(req)
不过这种做法有待商榷,最好是本地有HTTPS证书可以用于请求认证。
相关资料
https://go-kratos.dev/docs/getting-started/examples/
框架文档
【必选】ProtoBuf 官方文档,https://developers.google.com/protocol-buffers/docs/proto3
【必选】Kratos 官方文档,https://go-kratos.dev/docs/
基于 Kratos 的基础框架,Canvas-Kratos-V0.2.x设计与实施
【可选】Gin 官方文档,https://gin-gonic.com/zh-cn/docs/
【可选】gRPC 官方文档,https://www.grpc.io/docs/
开发组件
【必选】Xorm 中文文档,https://gobook.io/read/gitea.com/xorm/manual-zh-CN/
【必选】Validator API 文档,https://pkg.go.dev/github.com/go-playground/validator/v10