设计原则
面向接口编程
所谓的面向接口编程,是指:如果你使用到了别的类型,那么用的一定是接口。
在计算机科学中,接口是计算机系统中多个组件共享的边界,不同的组件能够在边界上交换信息。如下图所示,接口的本质是引入一个新的中间层,调用方可以通过接口与具体实现分离,解除上下游的耦合,上层的模块不再需要依赖下层的具体模块,只需要依赖一个约定好的接口。
Go语言的主要设计者之一罗布·派克曾经说过:如果只能选择一个Go语言的特性移植到其他语言中,我会选择接口。(Rob Pike)
接口在Go语言有着至关重要的地位。如果说goroutine和channel 是支撑起Go语言的并发模型的基石,让Go语言在如今集群化与多核化的时代成为一道极为亮丽的风景,那么接口是Go语言整个类型系统的基石,让Go语言在基础编程哲学的探索上达到前所未有的高度。
Go语言中Interface淡化了面向对象中接口应具有的象征意义,接口在Go语言中仅仅只是“表现形式”上相同的一类事物的抽象概念。在Go语言中只要是具有相同“表现形式”的“类型”都具有相同的Interface,而不需要考虑这个Interface在具体的使用中应具有的实际意义。
面向接口编程思想
- 模块之间依赖接口以实现继承和多态特性。
- 继承和多态是面向对象设计一个非常好的特性,它可以更好的抽象框架,让模块之间依赖于接口,而不是依赖于具体实现
- 依赖于接口来实现方法函数,只要实现了这个接口就可以认为赋值给这个口,实现动态绑定。
这里有一些争议的点:
- 如果我当下只有一个实现,我需要定义一个接口吗?
答案是,如果你预期你负责维护这段代码的时间内,有可能有新的实现,那你就要定义接口
- 什么时候不用接口,而是用具体实现呢?
答案是你依赖于具体实现细节的时候
。例如说在 Repository里面,正常读写数据都是用接口方法,但是如果你要依赖于这个 Repository 内部控制缓存预加载的特性,那么就应该用具体的实现。
设计原则:为什么要面向接口编程?
面向接口编程核心就是为了:扩展性。也就是,在使用接口的时候,
我们可以根据需要随时切换不同的实现,而不需要修改已有的代码
。
除此以外,面向接口编程还是别的优秀实践的基础:
- 是坚持开闭原则的基础。
- 是装饰器模式等设计模式的基础。
依赖注入
依赖注入是常见的解耦方式之一。如果系统没有解耦,单元测试就无从谈起。依赖注入指的是显式指定它所需要的功能来执行其任务。早在 1996 年,Robert Martin 就写了一篇文章,名为The Dependency Inversion Principle依赖转置原则
依赖注入是一种设计模式,用于管理对象之间的依赖关系。依赖注入的核心思想是将对象的依赖关系从代码中分离出来,从而使代码更加灵活和可维护。在依赖注入中,对象不再负责创建它所依赖的对象,而是由外部容器来负责创建和管理对象之间的依赖关系。
简单来说所谓的依赖注入就是:
- 如果 A 用到了 B,那么 B 一定是 A 的字段。
- 如果 B 是 A 的字段,那么 B 一定是在构造 A 的时候从外部传入进来的。
如果叠加面向接口编程,那么就再加上一条:
- 如果 A 用到了 B,那么 B 一定是一个接口。也就是,在初始化 A 的时候,传入 B 的一个具体实现。
在 Golang 中,依赖注入主要有三种实现方式:构造函数注入、属性注入和方法注入。
- 构造函数注入:这是最常见的依赖注入方式。在构造函数中,依赖对象作为参数传递给对象的构造函数。例如,如果有一个 UserService 类,它依赖于 UserRepository 类,那么在创建 UserService 对象时,需要将 UserRepository 对象作为参数传递给 UserService 的构造函数。代码示例如下:
type UserService struct {
userRepository *UserRepository
}
func NewUserService(userRepository *UserRepository) *UserService {
return &UserService{userRepository: userRepository}
}
在这个例子中,NewUserService 函数接受一个 UserRepository 参数,并将其传递给 UserService 的构造函数,然后保存在 UserService 结构体中。
- 属性注入:这种方式是将依赖对象作为对象属性进行注入。可以通过一个方法UserRepository 注入到 UserService 中。代码示例如下:
type UserService struct {
userRepository *UserRepository
}
func (u *UserService) SetUserRepository(userRepository *UserRepository) {
u.userRepository = userRepository
}
在这个例子中,UserService 类有一个 SetUserRepository 方法,该方法接受一个 UserRepository 参数,并将其设置为 UserService 的属性。
- 方法注入:这种方式是将依赖对象作为方法参数进行注入。例如,在 UserService 类的 SaveUser 方法中,UserRepository 作为参数被注入。代码示例如下:
type UserService struct {}
func (u *UserService) SaveUser(user *User, userRepository *UserRepository) error {
return userRepository.SaveUser(user)
}
在这个例子中,SaveUser方法接受一个UserRepository参数,这样 UserService就可以使用 UserRepository来保存用户信息。
这三种方式各有优缺点,选择哪一种取决于具体的应用场景和个人偏好。构造函数注入通常被认为是最推荐的依赖注入方式,因为它使得依赖关系更加明确,并且有助于保持对象的不可变性。属性注入和方法注入则提供了更多的灵活性,但可能会使得依赖关系不那么明显,在go语言中构造函数使用的非常之多。
Go 的隐式接口相比于 Java 的显式接口更容易解耦。如果不想手动编写注入的过程,推荐使用 Google 的 wire 自动组装代码。
设计原则:单一职责原则
单一职责原则,就是指一个接口只干一件事(或者有联系的几件事)。最直观的说法,就是你的接口不能有很多方法,应该只有寥寥几个方法,而且这些方法应该是类似的。
type SearchService interface {
Search(ctx context.Context, uid int64, expression string) (domain.SearchResult, error)
}
type SyncService interface {
InputArticle(ctx context.Context, article domain.Article) error
InputUser(ctx context.Context, user domain.User) error
InputAny(ctx context.Context, index, docID, data string) error
}
设计原则:CQRS
CQRS(Command Query Responsibility Segregation),命令-查询分离。
换一句更加通俗易懂的话来说,就是读写方法,你要分散到不同的接口里面去
。
前面的搜索服务拆分成两个接口,也可以看做是这种原则的体现。
读写方法分离到不同的接口之后,还有一个最大的好处:分别治理:读写接口降级写接口,限流的时候可以读接口设置更高的阈值,写接口设置更加低的阈值。
设计原则:开闭原则
开闭原则强调的是:对修改闭合,对扩展开放。
简单来说,就是如果在发生变更的时候,已有的实现不需要改,而是考虑提供新的实现
。
最典型的例子就是我们使用装饰器来为已有功能添加新的功能。
如下图 sms 的例子,我们在 sms 的基础上尝试引入容错、可观测性的时候,并没有修改已有的代码,而是提供了新的实现。所以,往往你加一个 if-else 分支的时候,就意味着你没有坚持这条原则。
type Service struct {
svc sms.Service // 已有实现
tracer trace.Tracer
}
// 新实现
func (s *Service) Send(ctx context.Context,
tplId string,
args []string, numbers ...string) error {
ctx, span := s.tracer.Start(ctx, "sms_send")
defer span.End()
// 你也可以考虑拼接进去 span name 里面
span.SetAttributes(attribute.String("tplId", tplId))
err := s.svc.Send(ctx, tplId, args, numbers...)
if err != nil {
span.RecordError(err)
}
return err
}
func NewService(svc sms.Service) *Service {
return &Service{
svc: svc,
tracer: otel.GetTracerProvider().Tracer("sms_service"),
}
}
设计原则:超前设计,但是不超前实现
很多时候,为了方便后期维护或者扩展,
在设计的时候,需要超前设计
。第一条要记住的就是:超前设计,但是不要超前实现。也就是说,即便你预期到将来会有很多的变化,但是你不要提早解决应对这些变化,而是通过留出接口的形式来保留应对的可能性。
那么超前多少才算是超前太多?这要根据具体的现实情况来判定:
- 先将所有自己能够预想到的、需求分析里面讨论到的都纳入考虑范围内。
- 正常你考虑的越多,设计的复杂度就会越高,需要的时间、人力就越多。
- 将接下来两三个迭代内会遇到的变更,在设计的时候留出口子。
- 如果是后续更长时间内可能出现的变更,此时纳入设计范围的代价并不高,就考虑进来;代价高就放弃。
设计原则:异步优先
常规情况很多人的思维是优先考虑同步调用。
但是在系统规模开始变大,稳定性、可用性和性能这些问题日益突出的时候,你需要转变为异步优先
。具体来说,就是不到逼不得已,不用同步调用。
也就是但凡能够借助 Kafka 等来完成的事情,就不要借助同步的 gRPC 或者 HTTP 来完成。
设计模式
装饰器模式
装饰器模式主要用于在已有实现的基础上,无侵入式地增加新的功能。
package main
type IPizza interface {
getPrice() int
}
package main
type VeggieMania struct {
}
func (p *VeggieMania) getPrice() int {
return 15
}
package main
type TomatoTopping struct {
pizza IPizza
}
func (c *TomatoTopping) getPrice() int {
pizzaPrice := c.pizza.getPrice()
return pizzaPrice + 7
}
package main
type CheeseTopping struct {
pizza IPizza
}
func (c *CheeseTopping) getPrice() int {
pizzaPrice := c.pizza.getPrice()
return pizzaPrice + 10
}
package main
import "fmt"
func main() {
pizza := &VeggieMania{}
//Add cheese topping
pizzaWithCheese := &CheeseTopping{
pizza: pizza,
}
//Add tomato topping
pizzaWithCheeseAndTomato := &TomatoTopping{
pizza: pizzaWithCheese,
}
fmt.Printf("Price of veggeMania with tomato and cheese topping is %d\n", pizzaWithCheeseAndTomato.getPrice())
// Price of veggeMania with tomato and cheese topping is 32
}
洋葱模式
装饰器模式不断叠加,就可以变成洋葱模式。
洋葱模式的特性就是不断在已有功能的基础上叠加新功能。一般来说,越是靠近洋葱内核,就越是核心的功能。
洋葱模式完美坚持了开闭原则,并且扩展性极佳。大部分中间件的设计核心都是一个洋葱。
不仅仅是go中,node的koa也是典型的洋葱模型:
Builder 模式
Builder 模式在构建各种 middlware和插件的时候用得多。Builder 模式一般用于构造复杂的对象,或者说用来构造你预期会有很多变化的对象。Builder 模式一般会结合链式调用来设计,也就是说Builder 模式会返回 Builder 本身,直到完成整个构建。
type MiddlewareBuilder struct {
logFunc func(ctx context.Context, al AccessLog)
allowReqBody bool
allowRespBody bool
}
func NewMiddlewareBuilder(fn func(ctx context.Context, al AccessLog)) *MiddlewareBuilder {
return &MiddlewareBuilder{
logFunc: fn,
// 默认不打印
allowReqBody: false,
}
}
func (b *MiddlewareBuilder) AllowReqBody() *MiddlewareBuilder {
b.allowReqBody = true
return b
}
func (b *MiddlewareBuilder) AllowRespBody() *MiddlewareBuilder {
b.allowRespBody = true
return b
}
Option 模式:接口实现与函数式实现
最典型的就是在 gRPC 里面,大量使用了 Option模式。Option 模式通常有两种实现方式。
- 接口实现
- 函数式实现
如果你有 C++ 或者 Java 的编程基础,你的第一反应应该是函数重载。但是因为 Golang 语言不像 C++ 一样支持重载函数,所以,你得用不同的函数名来应对不同的配置选项。
type User struct {
ID string
Name string
Age int
Email string
Phone string
Gender string
}
type Option func(*User)
func WithAge(age int) Option {
return func(u *User) {
u.Age = age
}
}
func WithEmail(email string) Option {
return func(u *User) {
u.Email = email
}
}
func WithPhone(phone string) Option {
return func(u *User) {
u.Phone = phone
}
}
func WithGender(gender string) Option {
return func(u *User) {
u.Gender = gender
}
}
func NewUser(id string, name string, options ...func(*User)) (*User, error) {
user := User{
ID: id,
Name: name,
Age: 0,
Email: "",
Phone: "",
Gender: "female",
}
for _, option := range options {
option(&user)
}
//...
return &user, nil
}
func main() {
user, err := NewUser("1", "Ada", WithAge(18), WithPhone("123456"))
if err != nil {
fmt.Printf("NewUser: err:%v", err)
}
fmt.Printf("NewUser Success")
}
适配器模式
适配器模式一般用于将一个接口适配到另外一个接口
。适配器经常用于,版本升级的时候还要保持向后兼容性的场景中。
package adaptor
import "fmt"
// 我们的接口(新接口)——音乐播放
type MusicPlayer interface {
play(fileType string, fileName string)
}
// 在网上找的已实现好的库 音乐播放
// ( 旧接口)
type ExistPlayer struct {
}
func (*ExistPlayer) playMp3(fileName string) {
fmt.Println("play mp3 :", fileName)
}
func (*ExistPlayer) playWma(fileName string) {
fmt.Println("play wma :", fileName)
}
// 适配器
type PlayerAdaptor struct {
// 持有一个旧接口
existPlayer ExistPlayer
}
// 实现新接口
func (player *PlayerAdaptor) play(fileType string, fileName string) {
switch fileType {
case "mp3":
player.existPlayer.playMp3(fileName)
case "wma":
player.existPlayer.playWma(fileName)
default:
fmt.Println("暂时不支持此类型文件播放")
}
}
组合模式
组合模式是 Go 本身就支持的,也就是我们惯常用的组合语法而已。
如下示例:
type SmsServiceServer struct {
smsv1.UnimplementedSmsServiceServer
svc service.Service
}
我们利用组合模式来实现的装饰器,这样可以控制住我们只需要装饰必要的方法,而别的方法我们可以不用管。这样就可以保持 gRPC 接口的向后兼容。即便是Protobuf 增加了新的方法,这里依旧可以编译通过。
责任链模式
最为典型的责任链模式是 Gin 里面接入middleware 的方式。可以认为每一个 middleware 都是责任链上的一环。
func (p *PrometheusBuilder) BuildResponseTime() gin.HandlerFunc {
labels := []string{"method", "pattern", "status"}
vector := prometheus.NewSummaryVec(prometheus.SummaryOpts{
Namespace: p.Namespace,
Subsystem: p.Subsystem,
Name: p.Name + "_resp_time",
Help: p.Help,
ConstLabels: map[string]string{
"instance_id": p.InstanceID,
},
Objectives: map[float64]float64{
0.5: 0.01,
0.75: 0.01,
0.90: 0.01,
0.99: 0.001,
0.999: 0.0001,
},
}, labels)
prometheus.MustRegister(vector)
return func(ctx *gin.Context) {
method := ctx.Request.Method
start := time.Now() // 执行当前
defer func() {
// 执行下一环后执行
vector.WithLabelValues(method, ctx.FullPath(),
strconv.Itoa(ctx.Writer.Status())).
Observe(float64(time.Since(start).Milliseconds()))
}()
ctx.Next() // 执行下一环
}
}