最近一个项目要适配其他机型,在上一机型开发时,很有可能没有之后的机型会在某个功能实现方面有差异。这涉及到代码重构的一系列知识。这里记录一些重构、扩展时遇见的场景。

不同寄存器操作

在最开始写的时候只实现了一种寄存器操作,但是实际上可能还存在其他代际机型寄存器要映射的地址不一样的情况。将每个寄存器都要重新实现的操作定义为抽象接口。

1.定义抽象接口实现工厂模式

type RegisterOperator interface {
    FeatureSupported() bool
}

type IceLakeXeon struct {
    // 特定字段
}

func (op *IceLakeXeon) FeatureSupported() bool {
    // 检查特性是否支持
}

这样的抽象接口让调用不同寄存器都是一样的操作:RegisterOperator实例.FeatureSupported()
这个RegisterOperator实例具体是什么类型,要在工厂里做判断:

func NewRegisterOperator() (RegisterOperator, error) {
    switch {
    case isIntelIceLakeXeon():
        return intel.NewIceLakeXeonOperator(), nil
    default:
        return nil, ErrUnsupportedPlatform
    }
}

2.是否需要将不同寄存器放在不同包里

没必要,主要是这个场景下,所有寄存器共用读写寄存器文件的函数;如果将各种寄存器有建一个包,那么这些包都需要import当前包。
而且要注意:

  • Go的可见性规则决定了小写开头的函数/变量仅对当前包(package)内可见,其他包(即使是父包或子包)都无法直接访问它们。
  • Go的internal目录机制允许特定范围内的包访问内部代码(任何放在 internal 目录下的包,仅能被 internal 的父目录的直接子包或其子目录的包导入)。

3.物理分目录但逻辑同包

如果寄存器文件太多了,而且每个还有对应的单元测试文件。可以不改变这些go文件的package,但是把他们放在不同子目录下。逻辑上仍属于同一个包,没什么不一样,但是结构会清晰一点。

读写文件替代命令行操作

很多Linux命令本质上是对文件的读写。这里的场景是依赖外部工具(rdmsr/wrmsr)读写寄存器,需要改为直接读写/dev/cpu/%d/msr:

  • 性能更高(减少进程启动和调用的开销)。
  • 更灵活(可以直接处理二进制数据,无需解析文本输出)。

对于这部分的单元测试,感觉用处比较少。
如果你依然是使用命令行,那么需要将操作抽象为接口,方便测试时替换为模拟实现。
如果已经改为读写文件了,测试时换一个文件路径,用模拟文件来测试(但是寄存器操作的二进制文件还是不太好模拟啊~)。

管理结构体之间的依赖关系

这部分在做欧拉社区etmem工具的新功能扩展时体验尤其深刻。一般和操作系统相关的工具在创建结构体时要么和操作系统里的基本一致,要么按照文件夹里的内容层层抽象。

这个过程中就会遇到一个问题,抽象出来大概是:A管理一个B,B管理多个C,扩展新功能时发现C需要用到A的参数f。一般有以下3种选择:

1.将共用参数设置为全局变量

把需要用到的参数f在B,C共同所在的包里设置为全局变量。但是全局变量增加了耦合度,测试和维护更困难(测试时需要mock这些依赖)。而且这个变量到底是否满足全局变量的定义,如果他本来的作用不是包里的全局可用的一个参数,未来涉及到对其的读写的扩展会很丑陋。

2.形成链式引用

让B持有A的指针,C持有B的指针。这个考虑是出现了B需要用到A的方法的情况。但是链式引用可能导致循环依赖问题,结构体之间紧密耦合,难以单独测试或重用。这个时候写单元测试就会很痛苦。

3.增加结构体字段并传递参数

通过New函数在初始化时传递相关值,这实际上是一种显式依赖注入的方式。这种做法比全局变量更清晰,比链式指针引用(C->B->A)更解耦。但是也会存在一个有点尴尬的情况NewB和NewC的参数列表会变长(看不惯的话就封装成结构体再传递)。还有一种情况下参数传递也会出现问题,如果A.字段y会变化,而B和C需要实时获取最新值。

4.依赖注入

type AInterface interface {
    GetF() someType
    NeededFunc() someOtherType
} // 这里还可以进一步做接口隔离,没必要

// A实现AInterface
type A struct {
    y int
}

func (a *A) GetF() int {
    return a.y
}

func (a *A) NeededFunc() string {
    return fmt.Sprintf("A.NeededFunc called, y=%d", a.y)
}

// B结构体,依赖AInterface
type B struct {
    ainf  AInterface  // 持有接口
    cs []*C
}

func NewB(ainf AInterface) *B {
    return &B{ainf: ainf}
}

// B的方法中调用A的函数
func (b *B) DoSomething() {
    result := b.ainf.NeededFunc()  // 调用A的NeededFunc
    fmt.Println("B.DoSomething:", result)
}

func (b *B) AddC(c *C) {
    b.cs = append(b.cs, c)
}

// C结构体
type C struct {
    cy int
}

func NewC(cy int) *C {
    return &C{cy: cy}
}

func main() {
    // 初始化
    ainf := &A{y: 123}          // A实现了AInterface
    b := NewB(ainf)              // 注入A的实例
    c := NewC(ainf.GetF())       // C只依赖y
    b.AddC(c)

    // B调用A的方法
    b.DoSomething() 
}

这样写测试时不用构造真实的A:

type MockA struct{}  // 测试用的Mock
func (m *MockA) GetF() int { return 999 }
func (m *MockA) NeededFunc() string { return "mock" }

func TestB(t *testing.T) {
    mockA := &MockA{}
    b := NewB(mockA)  // 注入Mock
    b.DoSomething()
}

文章作者: 易百分
版权声明: 本博客所有文章除特別声明外,均采用 CC BY 4.0 许可协议。转载请注明来源 易百分 !
  目录