Golang包维护者应如何正确弃用接口?

Golang包维护者应如何正确弃用接口? 假设我创建了一个供任何人使用的公共库/包。在这个库中,我导出了一个服务,如下所示。

package myLib

type LibService struct {
}

func (l LibService) GetThingName(id string) string {
  ... Some implementation ...
}

作为这个库的用户,我应该定义自己的接口(如此处所建议的),以减少与依赖项的耦合,便于测试时进行模拟等。

import "codeplace.com/someone/myLib"
import "codeplace.com/someone/otherpackage"

package myProject

type ThingNameGetter interface {
  GetThingName(id string) string
}

func MyProjectFunction(getter ThingNameGetter) string {
  return getter.GetThingName("my id")
}

现在,一段时间后,库的概念领域发生了变化,使得 GetThingName 函数不再适用。我很想进行如下更改,并让我的所有库使用者都知道该函数已弃用。

// Deprecated: Use GetNamespacedThingName
// This function will start panicking on Dec 1, 20XX
func (l LibService) GetThingName(id string) string {
  ... Some implementation ...
}

func (l LibService) GetNamespacedThingName(
  namespace string, id string,
) string {
  ... Some implementation ...
}

问题是:因为 myProject 定义了自己的接口,他们不会在 IDE 中看到高亮显示的弃用警告(我认为 linter 也不会捕获它)。

因此,他们将没有时间对即将到来的函数移除做出反应。相反,它只会在 20XX 年 12 月 1 日突然“发生”,他们将不得不匆忙更新他们的使用方式。

当使用者定义自己的接口时,处理弃用的正确方法是什么?


更多关于Golang包维护者应如何正确弃用接口?的实战教程也可以访问 https://www.itying.com/category-94-b0.html

7 回复

为了确认一下。您是说,将一个函数标记为弃用(而不是移除它)也算是一种破坏性变更吗?

我有点喜欢这种做法,尽管这取决于更新库的人是否会去查找该函数的使用情况。在一个多人协作的项目中,他们可能只是执行更新,并想着“我们可以以后再处理这个问题”,然后这个函数在之后(尽管是在又一次主版本更新中)就消失了。

更多关于Golang包维护者应如何正确弃用接口?的实战系列教程也可以访问 https://www.itying.com/category-94-b0.html


正如 Christoph Berger 所说:如果你的项目当前版本是 3.5.2,并且你想弃用一个函数,那么在 4.0.0 版本之前你都不能真正弃用它。如果用户依赖的是版本 3,那么他们就不会失去该功能。当他们有意识地将对你的项目的依赖升级到版本 4 时,他们才会在那时看到错误。

我不知道最佳实践是什么,但我认为在你的项目版本 3 中为函数添加弃用注释是有意义的,只要你在 v4 版本之前不实际删除它们。

弃用功能的目的,正是为了在重大/破坏性版本发布之前告知用户计划移除的内容……

1.0.0 版本有一个函数 Foo,后来考虑到使用该函数会导致严重的性能下降,应该改用 BetterFoo,后者需要一个额外的参数才能实现其如此快速的性能。因此,随着 BetterFoo 的引入,Foo 在 1.1.0 版本中被弃用。在假设遵循语义化版本控制的前提下,Foo 在 2.0.0 版本之前仍然不允许被移除。

是的,我在这里纠结的并不是语义化版本控制的实践问题。问题在于,依赖倒置(以及库使用者定义自己的接口)似乎被这门语言所鼓励。然而,使用这个特性和代码风格似乎使得 deprecated 标签变得价值很低。

我在网上遇到的一个解决方案是,在库中将结构体标记为废弃,并导出一个不同的结构体,该结构体排除了不再支持的旧函数。

使用这个解决方案,客户端定义的接口可以继续使用,并且客户端(在它们实例化结构体的地方)可以看到废弃提示。

唯一的问题是,这有点更繁琐,而且……嗯……不太常见。

正如Norbert所说:我认为在同一个主版本中添加弃用警告是可以的,但在下一个主版本之前,你实际上不能移除已弃用的函数。

这仍然意味着,如果使用你包的人只是将其版本依赖更新到@latest或下一个主版本,并且他们通过你提到的接口使用那段代码,那么他们的代码会突然崩溃,而他们可能对此一无所知。话虽如此,这是他们的责任。当一个包的主版本号增加时,意味着引入了破坏性的API变更。我不能依赖一个包,然后将该依赖的版本升级到该包的下一个主版本,还指望一切都能正常工作。

据我理解,这也是为什么Go语言添加泛型时,只是将次版本号从1.17增加到1.18:泛型是以向后兼容的方式添加的。如果添加泛型破坏了现有的非泛型代码,那么它就会是Go 2.0。

两点想法:

1 - “我应该定义自己的接口(如此处所建议的)以减少与依赖项的耦合”

链接后的文档指出,接口应由包的使用者创建,而不是由包的提供者创建。它似乎并没有说(如果我错了请纠正我)使用库时,使用者总是应该创建接口。

可以在需要时创建接口,例如为了模拟测试。(并非所有测试都需要模拟库的API。) 或者当你出于解耦的原因,例如,在功能相似的包之间进行选择,并且你希望能够轻松切换时。

在具体需求出现之前就预先创建接口可能有些过度,因为YAGNI

话虽如此,即使使用者用接口包装了一个包函数,对包的依赖也并未消失。在某个时刻,接口必须被实现,而如果该实现使用了已弃用的包函数,弃用信息就不应被忽视。

2 - 弃用与破坏客户端代码

当一个函数从包API中移除时,这是一个破坏性变更,需要更新主版本号,以符合语义化版本控制原则和Go模块的版本控制规则。

如果客户端想要使用模块的更高主版本,他们必须有意识地更新其依赖项(并相应地更改导入路径)。如果他们不这样做,他们将继续使用仍包含已弃用函数的旧版本,对他们来说一切照旧。

这就引出了一个有趣的问题——模块的语义化版本控制规则是否使得函数级别的//Deprecated:注释变得过时?如果破坏性变更无论如何都需要新的主版本,那么函数级别的弃用信息似乎就没多大必要了。

当库维护者需要弃用接口方法时,用户自定义接口确实会导致弃用警告无法传递。以下是几种专业处理方案:

1. 提供官方接口(推荐)

在库中同时提供具体类型和接口定义:

package myLib

// LibService 具体类型
type LibService struct{}

// Service 官方接口
type Service interface {
    GetThingName(id string) string
    GetNamespacedThingName(namespace, id string) string
}

// 确保 LibService 实现 Service 接口
var _ Service = (*LibService)(nil)

// Deprecated: Use GetNamespacedThingName
// This function will be removed in v2.0.0
func (l LibService) GetThingName(id string) string {
    // 实现
}

func (l LibService) GetNamespacedThingName(namespace, id string) string {
    // 实现
}

2. 使用编译时检查

在库中添加接口兼容性检查:

package myLib

// DeprecatedInterface 标记已弃用的接口方法集合
type DeprecatedInterface interface {
    GetThingName(id string) string
}

// 在测试或初始化时检查
func init() {
    var s LibService
    var _ DeprecatedInterface = s // 编译时检查
}

// Deprecated: Use GetNamespacedThingName
func (l LibService) GetThingName(id string) string {
    // 实现
}

3. 运行时警告

在已弃用的方法中添加日志输出:

package myLib

import (
    "log"
    "runtime"
)

// Deprecated: Use GetNamespacedThingName
func (l LibService) GetThingName(id string) string {
    // 输出调用栈信息
    pc := make([]uintptr, 10)
    n := runtime.Callers(2, pc)
    frames := runtime.CallersFrames(pc[:n])
    
    log.Printf("WARNING: GetThingName is deprecated and will be removed. Called from:")
    for {
        frame, more := frames.Next()
        log.Printf("  %s:%d %s", frame.File, frame.Line, frame.Function)
        if !more {
            break
        }
    }
    
    // 原有实现
    return l.getThingNameImpl(id)
}

4. 版本化弃用策略

使用语义化版本和文档明确弃用时间线:

package myLib

import "time"

// v1.2.0: 添加弃用警告
// v1.3.0: 函数返回错误
// v2.0.0: 移除函数

var deprecationDeadline = time.Date(2024, 12, 1, 0, 0, 0, 0, time.UTC)

// Deprecated: Use GetNamespacedThingName
// This function will return an error after Dec 1, 2024
func (l LibService) GetThingName(id string) (string, error) {
    if time.Now().After(deprecationDeadline) {
        return "", errors.New("GetThingName is deprecated, use GetNamespacedThingName")
    }
    return l.getThingNameImpl(id), nil
}

5. 提供迁移辅助工具

创建检测已弃用用法的工具:

// tools/deprecation_check/main.go
package main

import (
    "go/ast"
    "go/parser"
    "go/token"
    "log"
)

func checkDeprecatedUsage(filename string) {
    fset := token.NewFileSet()
    node, err := parser.ParseFile(fset, filename, nil, parser.ParseComments)
    if err != nil {
        log.Fatal(err)
    }
    
    ast.Inspect(node, func(n ast.Node) bool {
        switch x := n.(type) {
        case *ast.CallExpr:
            if sel, ok := x.Fun.(*ast.SelectorExpr); ok {
                if sel.Sel.Name == "GetThingName" {
                    log.Printf("Deprecated usage found at %s:%d", 
                        filename, fset.Position(sel.Pos()).Line)
                }
            }
        }
        return true
    })
}

6. 接口适配器模式

提供向后兼容的适配器:

package myLib

// NamespacedService 新接口
type NamespacedService interface {
    GetNamespacedThingName(namespace, id string) string
}

// DeprecatedAdapter 适配旧接口
type DeprecatedAdapter struct {
    Service NamespacedService
    DefaultNamespace string
}

func (a DeprecatedAdapter) GetThingName(id string) string {
    return a.Service.GetNamespacedThingName(a.DefaultNamespace, id)
}

实际示例

综合应用上述策略:

package myLib

import (
    "fmt"
    "time"
)

// Service 官方接口定义
type Service interface {
    // Deprecated: Use GetNamespacedThingName
    // Will be removed in v2.0.0
    GetThingName(id string) string
    
    GetNamespacedThingName(namespace, id string) string
}

type LibService struct {
    defaultNamespace string
}

var _ Service = (*LibService)(nil)

func (l LibService) GetThingName(id string) string {
    // 输出弃用警告
    if os.Getenv("MYLIB_STRICT") == "1" {
        panic("GetThingName is deprecated")
    }
    
    fmt.Fprintf(os.Stderr, 
        "WARNING: GetThingName is deprecated, use GetNamespacedThingName\n")
    
    return l.GetNamespacedThingName(l.defaultNamespace, id)
}

func (l LibService) GetNamespacedThingName(namespace, id string) string {
    // 新实现
    return fmt.Sprintf("%s/%s", namespace, id)
}

// 提供迁移函数
func MigrateToNamespaced(getter interface {
    GetThingName(id string) string
}, namespace string) Service {
    return &migratedService{
        oldGetter: getter,
        namespace: namespace,
    }
}

type migratedService struct {
    oldGetter interface {
        GetThingName(id string) string
    }
    namespace string
}

func (m *migratedService) GetThingName(id string) string {
    return m.oldGetter.GetThingName(id)
}

func (m *migratedService) GetNamespacedThingName(namespace, id string) string {
    // 模拟旧行为
    return m.oldGetter.GetThingName(id)
}

关键点:

  1. 在库中提供官方接口定义
  2. 使用编译时和运行时检查
  3. 明确的版本弃用策略
  4. 提供迁移路径和工具
  5. 通过环境变量控制严格模式

这样即使使用者定义了自定义接口,也能通过多种渠道获知弃用信息,并有充足时间进行迁移。

回到顶部