• 作者:老汪软件技巧
  • 发表时间:2024-09-07 00:04
  • 浏览量:

看 Kubernetes 代码的过程中,不断回忆起之前看的代码封装相关的书籍,比如 《重构》、《代码整洁之道》和《设计模式》等,发现在 Kubernetes 不断在践行这些书籍里面的理论和技巧。

也正因如此,在读代码的过程中能通过符合直觉的方式去推导想要了解的内容,也能快速了解到代码的意图。Kubernetes 源码里面,优秀的注释和变量命名也是帮助开发者更好了解代码设计的意图。那 Kubernetes 源码中注释和变量命名有哪些值得我们学习的呢?

变量篇变量名不是越详细越好

如果变量名要精确表达具体的意思,势必会遇到一个问题,就是长度会太长。 但是,当一个非常长的变量名反复出现在代码中的时候,这种感觉就像是 别洛焦尔斯基 和 特维尔斯基 这么多个“司机”,我们看起来也会十分的头疼。

为了避免这种过分精确的重复命名带来的困惑,我们可以通过上下文的语义,帮助短小精悍的变量名表达更多的含义。

func (q *graceTerminateRSList) remove(rs *listItem) bool{
		//...
}

在 Kubernetes 的 graceTerminateRSList 结构体的定义,我们就不需要写graceTerminateRealServerList ,因为对应上下文的 listItem 的时候内部已经用了全称定义。

type listItem struct {
	VirtualServer *utilipvs.VirtualServer
	RealServer    *utilipvs.RealServer
}

所以在这个语境下面, rs 对应的只会是 realServer 而不会是 replicaSet 或者是其他的,如果有这种歧义的可能性,那么就不能进行这种缩写。

还有graceTerminateRSList 的移除 rs 方法,我们不需要写 removeRS 或者是 removeRealServer ,在传入的参数签名就已经有了 rs *listItem ,所以这个方法移除的只能是 rs ,在方法名加上 rs 反而显得冗余了。

在起名字的时候,尽可能短的命名承载更多的意思。

func CountNumber(nums []int, n int) (count int) {
	for i := 0; i < len(nums); i++ {
		// 如果要赋值 则 v := nums[i]
		if nums[i] == n {
			count++
		}
	}
	return
}
func CountNumberBad(nums []int, n int) (count int) {
	for index := 0; index < len(nums); index++ {
		value := nums[index]
		if value == n {
			count++
		}
	}
	return
}

index并不比 i 承载了更多信息, value 也不比 v 更好,所以,在这个例子中,是可以使用缩写来代替的。但是,缩写也并不完全带来好处,需要看具体的场景是否会产生歧义而确定。

变量名需要避免理解歧义

想要表达参加活动的用户数( int 类型),那么用 userCount 比用 user 或者 users 更好。因为 user 可以表示用户信息的对象,而 users 可能是用户信息的切片,如果使用这两个会给用户带来歧义。

再看一个例子。 min 在某些情况下可以表示最小值(minimum),也可以表示分钟(minutes),如果我们在一些比较容易混淆的场景下,我们就用全拼来代替缩写。

// 计算最小价格和促销活动的剩余时间
func main() {
	// 商品价格列表
	prices := []float64{12.99, 9.99, 15.99, 8.49}
	// 各个商品促销剩余时间(分钟)
	remainingMinutes := []int{30, 45, 10, 20}
	// min := findMinPrice(prices) // 变量名 "min":表示最小价格
	minPrice := findMinPrice(prices) 
	fmt.Printf("商品的最低价格: $%.2f\n", min)
	// min = findMinTime(remainingMinutes) // 变量名 "min":表示剩余的最短时间
	remainingMinute := findMinTime(remainingMinutes) 
	fmt.Printf("促销活动的最短剩余时间: %d minutes\n", min)
}

在这个例子中, min 不仅能表示商品最低价格,还能表示活动剩余的最小分钟数,所以在这种情况下我们就不要使用缩写。这样我们就能明确的区分出找到的是最小价格还是最小分钟数。

相同含义的变量需保持一致

整个项目中代表相同含义的变量名字应该尽量保持一致,如果项目里把用户 id 写成 UserId ,那么在其他地方进行复制的时候,就不要把他改成 Uid ,这样我们就会疑惑 UserId 和 Uid 是否是同个东西。

不要小看这种情况,有的时候因为多个系统都存在用户id,我们可能都需要进行存储,如果不加前缀进行区分,那么在需要使用的时候会无从下手。

比如用户A作为买家的角色,买了卖家的某个商品,并且由骑手进行派送。

这里就出现了三个用户id:买家、卖家和骑手。

这个时候我们可以通过增加模块前缀的方式来区分:BuyerId 、SellerId和DriverId。

这个时候我们也尽量不要进行缩写,因为这三个已经足够简短,如果我们把函数入参的 SellerId 缩写成 Sid 的话,当后面的需求有一个商铺id(ShopId)的概念,这个时候我们就会疑惑,Sid 是对应的 SellerId 还是 ShopId 。 如果有个人卖家就是通过 SellerId 填充了 ShopId ,那这个时候就会造成线上的 BUG。

注释篇注释应说明代码不能表达的信息

当函数内部逻辑过于复杂,我们可以用这种方式来避免阅读代码的人需要钻进函数的细节中,从而节省了阅读代码的时间,起到代码导读的作用。

Kubernetes 同步 Pod 循环比较复杂,所以通过函数注释的方式对方法进行说明


// syncLoopIteration reads from various channels and dispatches pods to the
// given handler.
//
// ......
// 
// With that in mind, in truly no particular order, the different channels
// are handled as follows:
//
//   - configCh: dispatch the pods for the config change to the appropriate
//     handler callback for the event type
//   - plegCh: update the runtime cache; sync pod
//   - syncCh: sync all pods waiting for sync
//   - housekeepingCh: trigger cleanup of pods
//   - health manager: sync pods that have failed or in which one or more
//     containers have failed health checks
func (kl *Kubelet) syncLoopIteration(ctx context.Context, configCh <-chan kubetypes.PodUpdate, handler SyncHandler,
	syncCh <-chan time.Time, housekeepingCh <-chan time.Time, plegCh <-chan *pleg.PodLifecycleEvent) bool {
}

注释最开头就解释了处理从各种渠道获取到的Pod信息并交给对应的处理逻辑,从注释中也总结了每个 channel 的大致处理逻辑。

如果代码不是特别复杂,意思也可以通过代码表达出来,没必要写在注释上。

下面的例子,用户签到后普通VIP则加基础的10分,是VIP用户则额外增加100分。

const (
    basePoints  = 10
    vipBonus    = 100
)
type User struct {
    IsVIP    bool
    Points   int
}
// SignIn 处理用户签到并根据是否是 VIP 增加积分
func (u *User) SignIn() {
    pointsToAdd := basePoints
    if u.IsVIP {
        pointsToAdd += vipBonus
    }
    u.Points += pointsToAdd
}

在代码中已经可以看到函数的意图,且代码逻辑比较简单,无需在上面添加注释。

同时,在业务尚未稳定时,尽量只给关键的、可能会产生歧义的操作添加注释即可。因为在业务频繁变动的过程中,如果内部逻辑做了修改,注释却没有同步修改,那么就会给人造成误导。

但是如果代码逻辑过分复杂,我们也 可以通过将代码抽层拆解来替代指引性注释。 我们来看一个 Kubelet 处理配置信号( configCh )的例子:

func (kl *Kubelet) syncLoopIteration(...) bool {
	select {
	case u, open := <-configCh:
		switch u.Op {
		case kubetypes.ADD:
			klog.V(2).InfoS("SyncLoop ADD", "source", u.Source, "pods", klog.KObjSlice(u.Pods))
			handler.HandlePodAdditions(u.Pods)
		case kubetypes.UPDATE:
			klog.V(2).InfoS("SyncLoop UPDATE", "source", u.Source, "pods", klog.KObjSlice(u.Pods))
			handler.HandlePodUpdates(u.Pods)
		case kubetypes.REMOVE:
			klog.V(2).InfoS("SyncLoop REMOVE", "source", u.Source, "pods", klog.KObjSlice(u.Pods))
			handler.HandlePodRemoves(u.Pods)
		case kubetypes.RECONCILE:
			klog.V(4).InfoS("SyncLoop RECONCILE", "source", u.Source, "pods", klog.KObjSlice(u.Pods))
			handler.HandlePodReconcile(u.Pods)
		case kubetypes.DELETE:
			klog.V(2).InfoS("SyncLoop DELETE", "source", u.Source, "pods", klog.KObjSlice(u.Pods))
			handler.HandlePodUpdates(u.Pods)
		case kubetypes.SET:
			// TODO: Do we want to support this?
			klog.ErrorS(nil, "Kubelet does not support snapshot update")
		default:
			klog.ErrorS(nil, "Invalid operation type received", "operation", u.Op)
		}
	}
}
	

通过将每种事件的操作抽象成一个方法,可以避免平铺在 switch 逻辑分支下。

_源程序中注释部分是否参加编译_源代码注释

例如将 kubetypes.ADD 则对应的操作封装在 HandlePodAdditions 方法里面,让整体的代码看起来更加像一个目录。

如果我们想了解添加 Pod 的流程,则直接跳进 HandlePodAdditions 方法即可。

我们再看一个 Kubernetes 中 BoundedFrequencyRunner 的 Run 方法注释的例子:

// Run the function as soon as possible.  If this is called while Loop is not
// running, the call may be deferred indefinitely.
// If there is already a queued request to call the underlying function, it
// may be dropped - it is just guaranteed that we will try calling the
// underlying function as soon as possible starting from now.
func (bfr *BoundedFrequencyRunner) Run() {
	select {
	case bfr.run <- struct{}{}:
	default:
	}
}

这里通过注释告诉了我们在这个方法本身看不到的点

如果 Loop 没有被调用,那么这个执行的信号将会无限期的被延期,因为并没有消费者能够去处理信号如果已经有另一个地方调用了 Run ,我们的执行信号可能会被丢失,这个方法是尽可能现在开始运行一次任务

这两个信息都是我们单独看函数内容没办法直接看到的,作者将这些隐藏的内容通过注释的方式告诉我们,让我们能够快速了解这个方法使用需要注意的事项。

我们看一个正则表达式编译的例子:

func ExecRegex(value string, regex string) bool {
	regex, err := decodeUnicode(regex)
	if err != nil {
		return false
	}
	if regex == "" {
		return true
	}
	
	rx := regexp.MustCompile(regex)
	return rx.MatchString(value)
}

这里我们可以看到传入的正则表达式还被进行了一次 decodeUnicode 的处理,我们来看下具体的方法。

func decodeUnicode(inputString string) (string, error) {
	re := regexp.MustCompile(`\\u[0-9a-fA-F]{4}`)
	matches := re.FindAllString(inputString, -1)
	for _, match := range matches {
		unquoted, err := strconv.Unquote(`"` + match + `"`)
		if err != nil {
			return "", err
		}
		inputString = strings.Replace(inputString, match, unquoted, -1)
	}
	return inputString, nil
}

单看这个方法,我们只能知道这个方法对传入的表达式进行了转义,但是却不知道为什么需要转义,不转义会出现什么情况,令后来者一头雾水。我们在这个方法上加上对应的注释重新来看一下。


// decodeUnicode 对正则字符串进行转义,避免传入识别中文的正则[\\u4e00-\\u9fa5]时出现panic。
func decodeUnicode(inputString string) (string, error) {
	//...
}

这个时候我们就一目了然了,因为 Go 在解析中文正则表达式的时候,如果不对正则字符串进行转义,会在传入识别中文的正则时出现 panic 。

这一行注释不仅让看代码的人马上了解了 decode 的意图,还让后来的人不会随意对转义后的字符串随意进行处理引入新的BUG。

在代码中,变量名和注释是最接近自然语言的内容,所以这部分也是最容易理解的,我们对这部分的内容如果认真斟酌后再去写,可读性自然会有立竿见影的提升。

空行实际上也是一种注释,它对代码进行了逻辑上的分割,相当于告诉读代码的人,这里的逻辑已经告一段落。

在上述decodeUnicode 方法中,我们也用了空行将需要预处理的匹配正则、具体的处理循环和最终的返回语句,在视觉上分割成了三段,使得代码更加直观清晰。

我们来看 kubernetes 中 graceTerminateRSList 判断 RS 是否存在的例子:

func (q *graceTerminateRSList) exist(uniqueRS string) (*listItem, bool) {
	q.lock.Lock()
	defer q.lock.Unlock()
	if rs, ok := q.list[uniqueRS]; ok {
		return rs, true
	}
	return nil, false
}

这里在加锁和解锁的逻辑结束的时候就增加了空行,表名加锁的动作告一段落,接下来是判断是否存在的逻辑,让代码逻辑从视觉上就隔开了,我们需要关心的更多是下半部分的逻辑判断,能帮助我们快速抓住代码重点。

func (q *graceTerminateRSList) exist(uniqueRS string) (*listItem, bool) {
	q.lock.Lock()
	defer q.lock.Unlock()
	if rs, ok := q.list[uniqueRS]; ok {
		return rs, true
	}
	return nil, false
}

如果将空行去掉,跳进代码之后,我们很难就一眼看到这个方法的重点。

不用的代码直接删掉,而不是注释掉

我们大部分时候注释掉是希望未来还能再去掉注释用起来方便。

但还有另外一种情况,在未来某一个时间需要用,先暂时注释,等过段时间恢复之后,发现跟原来的代码不兼容,反而产生了BUG ,还需要重新去编写。

这个时候我们不如一开始就把代码删掉,如果未来某个时刻需要重新用的话,可以通过 git commit 记录来找到以前被改掉的代码,重新进行编写。这个时候我们进行测试,也能对之前的代码进行优化,而不是被一大堆无用的注释代码干扰我们的阅读。

这个原则在编写 Kubernetes 的yaml文件的时候同样适用

spec:
    spec:
      # ...
			# 挂载一个名为server-conf的configMap卷
      #  - name: server-conf-map
      #    configMap:
      #      name: server-conf-map
      #      items:
      #        - key: k8s-conf.yml
      #          path: k8s-conf.yml
      #      defaultMode: 511

这种在看 yaml 的定义时,就会被大段的无用注释给打断,如果 server-conf-map 已经被删除了的话,那这个注释就更加令人疑惑了。所以我们自己项目中没有被外部依赖的的代码,则直接删掉,等有需要的时候在借助 git 工具去找回。

当我们项目中,有被第三方依赖的代码时,增加「弃用」注释可能是比删除更好的方法。有一些时候我们提供了一些包给第三方使用,如果我们直接删掉代码的话,调用方一旦升级了新的代码包时,就会产生大量报错,这个时候就需要先引导使用方去使用最新的代码。

所以我们可以给方法加上 Deprecated 注释,注明用什么来替代,传入什么参数。我们来看 gprc 中 WithInsecure 方法的弃用注释:

// Deprecated: use WithTransportCredentials and insecure.NewCredentials()
// instead. Will be supported throughout 1.x.
func WithInsecure() DialOption {
	return newFuncDialOption(func(o *dialOptions) {
		o.copts.TransportCredentials = insecure.NewCredentials()
	})
}

可以看到这个注释明确告诉我们要用什么方法进行代替,并且由于 WithTransportCredentials 的方法是需要传入参数的,作者将对应的参数也告诉我们该如何传入。

这样我们在替换新方法的时候就会非常方便,也能更好的引导使用方用上我们新增加的特性。

写在最后

我们来回顾一下在这篇文章中学到的内容:

如何通过上下文合适的语境对变量名和函数名进行合理的缩写。注释要表达代码看不到的东西。可以用适当的指引性注释来帮助后来的项目参与者阅读,但是更好的替代方式是通过抽取方法来进行自表达。可以删掉的代码不要注释掉,不能注释的代码通过合理的弃用注释来帮助使用者快速切换到新方法上。

感谢你读到这里,我是蔡蔡蔡,如果喜欢可以关注我,会持续分享云原生、Go和个人成长的内容。