通过 Kubernetes 看 Go 接口设计之道
原创 蔡蔡蔡菜 蔡蔡蔡云原生Go 2024年10月01日 08:30 广东解耦依赖底层
在 Kubernetes
中能看到非常多通过接口对具体实现的封装。
Kubelet
实现了非常多复杂的功能,我们可以看到它实现了各种各样的接口,上层代码在使用的时候并不会直接实现 Kubelet
这个具体的结构体,是为了让上层和下层解耦,这样下层 Kubelet
发生改变的时候,接口只要保持稳定,就不需要改动上层的逻辑。
比如 Bootstrap
就是 Kubelet
实现的其中一个接口
type Bootstrap interface {
GetConfiguration() kubeletconfiginternal.KubeletConfiguration
BirthCry()
StartGarbageCollection()
ListenAndServe(kubeCfg *kubeletconfiginternal.KubeletConfiguration, tlsOptions *server.TLSOptions, auth server.AuthInterface, tp trace.TracerProvider)
ListenAndServeReadOnly(address net.IP, port uint, tp trace.TracerProvider)
ListenAndServePodResources()
Run(<-chan kubetypes.PodUpdate)
RunOnce(<-chan kubetypes.PodUpdate) ([]RunPodResult, error)
}
在启动 Kubelet
时用的并不是 Kubelet
这个结构体,而是转换成了 Bootstrap
接口。
func createAndInitKubelet() (k kubelet.Bootstrap, err error) {
k, err = kubelet.NewMainKubelet(...)
k.BirthCry()
k.StartGarbageCollection()
return k, nil
}
也通过接口定义的分离,让外部启动的时候不会看到 Kubelet
过多的方法细节。
内部去调用时会看到入参过多的细节,将入参隐式转成结构,让内部只看到需要的方法。
这在日常业务研发中是使用的最多的,但是使用的时候也没办法一蹴而就,我们在第一次接到产品需求的时候,会将逻辑封装成 service
的结构体。
type UserService struct{}
func (*UserService) Login(ctx context.Context. req LoginRequest)(User, error){}
最开始我们会直接依赖具体的实现,并且可能也只支持一种登录,比如手机号验证码登录。
产品迭代后需要接入多种登录方式,比如微信扫码登录。这个时候我们可以将原有的 Service
抽象成接口。
type UserService interface {
Login(ctx context.Context. req LoginRequest)(User, error)
}
然后根据传入参数请求的不同来选择不同的登录逻辑,外面则只需要用接口进行调用即可。
type wxLogin struct{}
func (*wxLogin) Login(ctx context.Context. req LoginRequest)(User, error){}
type phoneLogin struct{}
func (*phoneLogin) Login(ctx context.Context. req LoginRequest)(User, error){}
这样对于调用方来说也不需要复杂的逻辑来选择看使用的是哪一种登录,直接通过 NewUserService
获取 UserService
这个接口,然后调用 Login
方法即可。
func NewUserService() (UserService) {
// 通过单例只初始化一个
initOnce.Do(func(){
})
return userService
}
等到后面 User
模块需要单独拆分服务的时候,只需要提供一个 RPC
的 SDK
实现进行替换,这样调用方就能够无感知的.
func NewUserService() (UserService) {
initOnce.Do(func(){
// 通过 RPC 来初始化 userService
})
return userService
}
隐藏细节
我们看 Kubelet
实现的另一个接口 SyncHandler
.
type SyncHandler interface {
HandlePodAdditions(pods []*v1.Pod)
HandlePodUpdates(pods []*v1.Pod)
HandlePodRemoves(pods []*v1.Pod)
HandlePodReconcile(pods []*v1.Pod)
HandlePodSyncs(pods []*v1.Pod)
HandlePodCleanups(ctx context.Context) error
}
这里我们可以看到 Kubelet
本身有比较多的方法:
syncLoop
同步状态的循环Run
用来启动监听循环HandlePodAdditions
处理Pod增加的逻辑
type Kubelet struct{}
func (kl *Kubelet) HandlePodAdditions(pods []*Pod) {
for _, pod := range pods {
fmt.Printf("create pods : %s\n", pod.Status)
}
}
func (kl *Kubelet) Run(updates <-chan Pod) {
fmt.Println(" run kubelet")
go kl.syncLoop(updates, kl)
}
func (kl *Kubelet) syncLoop(updates <-chan Pod, handler SyncHandler) {
for {
select {
case pod := <-updates:
handler.HandlePodAdditions([]*Pod{&pod})
}
}
}
由于 syncLoop
其实并不需要知道 kubelet
上其他的方法,所以通过 SyncHandler
接口的定义,让 kubelet
实现该接口后,外面作为参数传入给 syncLoop
,它就会将类型转换为 SyncHandler
。
经过转换后 kubelet
上其他的方法在入参里面就看不到了,编码时就可以更加专注在 syncLoop
本身逻辑的编写。
但是这样做同样会带来一些问题,第一次研发的需求肯定是能满足我们的抽象,但是随着需求的增加和迭代,我们在内部需要使用 kubelet
其他未封装成接口的方法时,我们就需要额外传入 kubelet
或者是增加接口的封装,这都会增加我们的编码工作,也破坏了我们最开始的封装,所以这个方法要辩证的去使用,如果本身逻辑并没有复杂到需要隐藏的程度,那就没有必要额外用接口来入参。
分层隐藏设计是我们设计的最终目的,在代码设计的过程中让一个局部关注到它需要关注的东西即可,但是在兼顾隐藏的同时也要考虑需求变动后,代码变动的复杂度。
有些相对稳定的逻辑代码则可以通过该方法进行抽象,也能让局部的方法变的更灵活,能够帮助我们解耦业务逻辑和函数的具体实现,类似于模板方法。
促销的业务实现
我们看一个商城促销的例子,有多种促销策略:满减、打折和赠品,我们希望经过各种促销策略后算出我们购物车需要支付的总金额。
我们先定义购物车和促销接口。
type Cart struct {
Total float64
Items []string
}
type Promotion interface {
// 在购物车上应用促销策略,返回的是新的购物车实体
Apply(cart Cart) Cart
}
然后不同促销策略都实现 Promotion
接口
// 满减促销
type DiscountByAmount struct {
Threshold float64
Discount float64
}
func (d DiscountByAmount) Apply(cart Cart) Cart {
if cart.Total >= d.Threshold {
cart.Total -= d.Discount
}
return cart
}
// 打折促销
type DiscountByPercentage struct {
Percentage float64
}
func (d DiscountByPercentage) Apply(cart Cart) Cart {
cart.Total *= (1 - d.Percentage)
return cart
}
// 赠品促销
type FreeGift struct {
Gift string
}
func (g FreeGift) Apply(cart Cart) Cart {
cart.Items = append(cart.Items, g.Gift)
return cart
}
使用所有策略则通过 GetEffectPromotion
获得所有仍然在进行的活动,然后依次应用到购物车上
// ApplyPromotion 应用在活动期限的促销策略
func ApplyPromotion(cart Cart) Cart {
promotions := GetEffectPromotion()
for _, promotion := range promotions {
cart = promotion.Apply(cart)
}
return cart
}
func GetEffectPromotion() []Promotion {
promotions := make([]Promotion, 0)
// 满减促销
discountByAmount := DiscountByAmount{Threshold: 100, Discount: 20}
promotions = append(promotions, discountByAmount)
// 打折促销
discountByPercentage := DiscountByPercentage{Percentage: 0.1}
promotions = append(promotions, discountByPercentage)
// 赠品促销
freeGift := FreeGift{Gift: "Free Mug"}
promotions = append(promotions, freeGift)
// 如果有其他策略可以在这里初始化
return promotions
}
如果不通过接口入参的方式实现,我们也可以用函数进行实现。
package main
// 满减促销
type DiscountByAmount struct {
Threshold float64
Discount float64
}
// 应用满减促销
func ApplyDiscountByAmount(cart Cart, promo DiscountByAmount) Cart {
if cart.Total >= promo.Threshold {
cart.Total -= promo.Discount
}
return cart
}
// 打折促销
type DiscountByPercentage struct {
Percentage float64
}
// 应用打折促销
func ApplyDiscountByPercentage(cart Cart, promo DiscountByPercentage) Cart {
cart.Total *= (1 - promo.Percentage)
return cart
}
// 赠品促销
type FreeGift struct {
Gift string
}
// 应用赠品促销
func ApplyFreeGift(cart Cart, promo FreeGift) Cart {
cart.Items = append(cart.Items, promo.Gift)
return cart
}
func ApplyPromotion(cart Cart) Cart {
// 满减促销
discountByAmount := GetDiscountByAmountRules()
cart = ApplyDiscountByAmount(cart, discountByAmount)
// 打折促销
discountByPercentage := GetDiscountByPercentage()
cart = ApplyDiscountByPercentage(cart, discountByPercentage)
// 赠品促销
freeGift := GetFreeGift()
cart = ApplyFreeGift(cart, freeGift)
return cart
}
如果后期增加新的促销策略,则需要增加对应的函数或逻辑块。
而抽象出接口之后,可以将具体的促销逻辑和应用到购物车的逻辑拆开。这样做的好处是 ApplyPromotion
使用逻辑可以相对稳定,编写单元测试也更加简单,如果增加策略,我们只需要单独测试新增的促销策略逻辑是否正确和获取有效的促销策略是否正确即可,而不需要再测试整个 ApplyPromotion
逻辑。
转换类型
go
中 http
的 Handle
接口的定义后,将HandlerFunc
函数作为一个类型,再实现 ServeHTTP
接口
type Handler interface {
ServeHTTP(ResponseWriter, *Request)
}
type HandlerFunc func(ResponseWriter, *Request)
func (f HandlerFunc) ServeHTTP(w ResponseWriter, r *Request) {
f(w, r)
}
我们有了方法后直接使用 HandlerFunc
进行类型转换,则就实现了 Handle
接口,我们看具体使用的例子
func greeting(w http.ResponseWriter, r *http.Request) {
// 处理HTTP请求的逻辑
}
// 然后使用的时候只需要这样进行转换即可
http.Handle("/greeting", http.HandlerFunc(greeting))
http.HandlerFunc
的真正含义是将函数greeting显式转换为 HandlerFunc
类型。
转成这种接口类型有什么实际的意义?
通过这种转换,我们可以轻易的实现处理方法灵活替换,根据不同的 Path
选择不同的处理方法,同时注册到 HTTP server
上。
并且也不需要通过再次写一个 struct
来实现对应的 Handler
接口, 如果不支持这样的写法,我们需要怎么写?可以看下面这个例子
func greeting(w http.ResponseWriter, r *http.Request) {
// 处理HTTP请求的逻辑
}
// 手动实现http.Handler接口
type GreetingHandler struct{}
func (h GreetingHandler) ServeHTTP(w http.ResponseWriter, r *http.Request) {
greeting(w, r)
}
// 然后在main函数中这样使用:
http.Handle("/greeting", GreetingHandler{})
可以看到我们如果要实现 Handler
这个接口的话,要自己手动去实现 ServeHTTP
。
那这么封装有什么实际的作用,能给我们的编码带来什么样的遍历呢?
由于都实现了 Handler
接口,所以能够方便的实现链式调用,形成复杂的调用链,下面是日志中间件的写法。
// 日志中间件
func loggingMiddleware(next http.Handler) http.Handler {
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
start := time.Now()
next.ServeHTTP(w, r)
log.Printf("%s %s %s", r.Method, r.URL.Path, time.Since(start))
})
}
// 实际的处理函数
func greetingHandler(w http.ResponseWriter, r *http.Request) {}
func main() {
// 使用loggingMiddleware包装greetingHandler
handler := loggingMiddleware(http.HandlerFunc(greetingHandler))
http.Handle("/greeting", handler)
}
go
中 io
的处理同样也是用了类似方式来实现链式调用。
k8s
中将 context
封装进了 Reader
中。
// readerCtx is the interface that wraps io.Reader with a context
type readerCtx struct {
ctx context.Context
io.Reader
}
然后在每次 Read
之前都会先判断 context
是否有 err
,有的话则直接返回错误。
func (r *readerCtx) Read(p []byte) (n int, err error) {
if err := r.ctx.Err(); err != nil {
return 0, err
}
return r.Reader.Read(p)
}
然后使用的时候就通过 new
来将 context
和 Reader
封装在一起,返回的类型仍然是 io.Reader
// newReaderCtx gets a context-aware io.Reader
func newReaderCtx(ctx context.Context, r io.Reader) io.Reader {
return &readerCtx{
ctx: ctx,
Reader: r,
}
}
小结
读到这里,我们来对整篇文章做一个小结
- 抽象封装是一种简化认知的手段,避免我们掉落进代码的细节内而不知道整体功能需要做什么.
- 本文以
Kubernetes
中的Kubelet
为例,我们看到接口封装的使用使得上层代码不必直接依赖Kubelet
的具体实现,从而在Kubelet
改动时无需修改上层逻辑。 - 通过接口的方式也可以减少入参的复杂度,让代码更加专注于核心逻辑。
- 接口的使用也需谨慎。如果需求或逻辑较为简单,引入过多接口反而会增加开发和维护的复杂度,设计时应权衡隐藏细节和代码变动的复杂性,确保接口抽象既能解耦业务逻辑,也能在需求变动时提供足够的灵活性。
标签:促销,封装,认知,cart,Cart,接口,func,go,http From: https://www.cnblogs.com/cheyunhua/p/18453859