Go学习第五阶段:接口、泛型与包设计,实现可替换的通讯录存储层

上一阶段把联系人从松散的 Map 重构成结构体,并使用方法、指针和错误值组织业务逻辑。但通讯录数据仍直接保存在 Book 内部的 Map 中,程序退出后数据就会丢失,业务逻辑也与具体存储方式绑定。

本阶段将使用接口把"业务规则"和"数据存储"分离,并分别实现内存存储与 JSON 文件存储。同时学习类型断言、类型选择、泛型和包设计,建立 Go 项目中非常重要的抽象能力。

一、本阶段学习目标

完成本阶段后,你应该能够:

  • 定义并使用小而清晰的接口;
  • 理解 Go 接口的隐式实现机制;
  • 解释接口值由动态类型和动态值组成;
  • 识别"接口不为 nil,但内部指针为 nil"的陷阱;
  • 使用类型断言和类型选择安全识别动态类型;
  • 正确理解空接口 any 的适用边界;
  • 根据使用方需求设计接口,而不是机械抽象;
  • 使用泛型类型参数、约束和类型推断;
  • 判断何时使用泛型、接口或普通函数;
  • 设计清晰的包职责与依赖方向;
  • 使用 internal 包限制实现细节;
  • 为通讯录实现内存仓储和 JSON 文件仓储;
  • 使用接口替身编写不依赖文件系统的业务测试。

二、接口解决什么问题

接口描述一组行为,而不是一组字段:

go 复制代码
type Reader interface {
	Read(p []byte) (n int, err error)
}

任何类型只要具有完全匹配的 Read 方法,就实现了 Reader。类型无需显式声明 implements Reader

接口的主要价值是让调用方只依赖自己需要的能力:

text 复制代码
通讯录业务 → ContactRepository 接口 ← 内存存储
                                  ← JSON 文件存储
                                  ← 未来的数据库存储

业务层只关心新增、查询、更新、删除和列表,不需要知道数据最终存放在 Map、文件还是数据库中。

三、定义与实现接口

1. 定义接口

go 复制代码
type ContactRepository interface {
	Save(contact Contact) error
	FindByPhone(phone string) (Contact, error)
	Delete(phone string) error
	List() ([]Contact, error)
}

2. 隐式实现

go 复制代码
type MemoryRepository struct {
	items map[string]Contact
}

func (repository *MemoryRepository) Save(contact Contact) error {
	repository.items[contact.Phone] = contact
	return nil
}

只要方法集完整匹配,*MemoryRepository 就实现了 ContactRepository

编译期断言可以提前暴露实现缺失:

go 复制代码
var _ ContactRepository = (*MemoryRepository)(nil)

这行代码不会创建对象,也没有运行时成本。它只是要求编译器验证接口关系。

四、接口应该由使用方定义

假设业务服务只需要查询联系人:

go 复制代码
type ContactFinder interface {
	FindByPhone(phone string) (Contact, error)
}

这个接口更适合放在使用它的业务包,而不是由底层存储包提前定义一个包含十几个方法的"大接口"。

推荐原则:

  • 接口表达调用方真正需要的最小能力;
  • 接口通常在使用方一侧定义;
  • 一个方法的接口在 Go 中很常见;
  • 不要为了"面向接口"给每个结构体机械创建同名接口;
  • 只有存在替换、隔离、组合或测试需求时再抽象。

五、方法集与接口实现

值接收者方法同时属于 T*T 的方法集;指针接收者方法只属于 *T 的方法集。

go 复制代码
type Counter struct {
	value int
}

func (counter Counter) Value() int {
	return counter.value
}

func (counter *Counter) Increment() {
	counter.value++
}

如果接口要求 Increment,只有 *Counter 实现接口:

go 复制代码
type Incrementer interface {
	Increment()
}

var _ Incrementer = (*Counter)(nil)
// var _ Incrementer = Counter{} // 编译错误

平时调用变量的方法时,编译器可能自动完成取地址或解引用;接口赋值则必须满足真实方法集,不能依赖这种语法便利。

六、接口值的内部语义

一个接口值可以理解为包含两部分:

  1. 动态类型:当前保存的具体类型;
  2. 动态值:该具体类型对应的值。
go 复制代码
var value any = 100
fmt.Printf("动态类型:%T,动态值:%v\n", value, value)

接口只有在动态类型和动态值都不存在时才等于 nil

典型陷阱:带 nil 指针的非 nil 接口

go 复制代码
type AppError struct{}

func (*AppError) Error() string {
	return "应用错误"
}

func execute() error {
	var err *AppError = nil
	return err
}

func main() {
	err := execute()
	fmt.Println(err == nil) // false
}

返回的接口包含动态类型 *AppError,虽然动态值是 nil,整个接口仍不等于 nil

正确做法是在没有错误时直接返回 nil

go 复制代码
func execute() error {
	return nil
}

七、接口组合

小接口可以组合成更大的接口:

go 复制代码
type Reader interface {
	Read([]byte) (int, error)
}

type Writer interface {
	Write([]byte) (int, error)
}

type ReadWriter interface {
	Reader
	Writer
}

标准库大量使用这种方式,例如 io.ReadWriterio.ReadCloser

组合应该表达真实能力关系,不要只是为了减少复制而无意义嵌入接口。

八、空接口 any

anyinterface{} 的别名,可以保存任意类型:

go 复制代码
var value any
value = 100
value = "Go"
value = Contact{Name: "张三"}

常见合理场景:

  • JSON 等动态格式的未知结构;
  • 日志字段值;
  • 框架边界或通用容器;
  • 需要接纳任意类型的格式化 API。

业务模型字段如果类型明确,不应为了省事全部使用 any。过度使用会丢失编译期检查,并把错误推迟到运行时。

九、类型断言

类型断言从接口值中获取具体值:

go 复制代码
value := any("Go语言")
text, ok := value.(string)
if !ok {
	fmt.Println("不是字符串")
	return
}
fmt.Println(text)

不接收 ok 时,断言失败会触发 panic:

go 复制代码
text := value.(string)

只有程序逻辑能够严格保证动态类型时才考虑单返回值形式。面对外部输入或开放接口,优先使用 comma ok。

断言接口能力

类型断言不仅可以断言具体类型,也可以断言另一个接口:

go 复制代码
type Flusher interface {
	Flush() error
}

if flusher, ok := writer.(Flusher); ok {
	if err := flusher.Flush(); err != nil {
		return err
	}
}

这种方式可以检测可选能力,但若某能力是正确运行的必要条件,应直接写进函数参数接口中。

十、类型选择 type switch

需要处理多种动态类型时使用类型选择:

go 复制代码
func describe(value any) string {
	switch typed := value.(type) {
	case nil:
		return "空值"
	case int:
		return fmt.Sprintf("整数:%d", typed)
	case string:
		return "字符串:" + typed
	case Contact:
		return "联系人:" + typed.Name
	default:
		return fmt.Sprintf("未知类型:%T", typed)
	}
}

typed 在每个分支中具有对应的静态类型。

如果类型选择分支不断增加,通常意味着设计过度依赖具体类型。可以考虑把变化行为提取成接口方法。

十一、泛型解决什么问题

接口擅长抽象行为,泛型擅长为一组类型复用同一种算法,同时保留具体类型信息。

没有泛型时,为 []int[]string 分别实现查找会产生重复:

go 复制代码
func IndexOf[T comparable](items []T, target T) int {
	for index, item := range items {
		if item == target {
			return index
		}
	}
	return -1
}

调用时通常可以自动推断类型:

go 复制代码
fmt.Println(IndexOf([]int{10, 20, 30}, 20))
fmt.Println(IndexOf([]string{"Go", "Java"}, "Go"))

十二、类型参数与约束

go 复制代码
func Max[T cmp.Ordered](a, b T) T {
	if a > b {
		return a
	}
	return b
}

T 是类型参数,cmp.Ordered 是约束。约束决定泛型函数内部允许执行哪些操作。

常见预定义约束:

  • any:允许任意类型;
  • comparable:允许使用 ==!=
  • cmp.Ordered:标准库中支持 <<=>>= 的有序类型约束。

自定义约束

go 复制代码
type Number interface {
	~int | ~int64 | ~float32 | ~float64
}

func Sum[T Number](values []T) T {
	var total T
	for _, value := range values {
		total += value
	}
	return total
}

~int 表示底层类型为 int 的所有类型,因此自定义类型也能满足约束:

go 复制代码
type Score int

如果只写 intScore 不会被包含。

十三、泛型切片工具

1. Map 转换

go 复制代码
func Map[T, R any](items []T, transform func(T) R) []R {
	result := make([]R, len(items))
	for index, item := range items {
		result[index] = transform(item)
	}
	return result
}

使用:

go 复制代码
names := Map(contacts, func(contact Contact) string {
	return contact.Name
})

2. Filter 筛选

go 复制代码
func Filter[T any](items []T, keep func(T) bool) []T {
	result := make([]T, 0, len(items))
	for _, item := range items {
		if keep(item) {
			result = append(result, item)
		}
	}
	return result
}

3. GroupBy 分组

go 复制代码
func GroupBy[T any, K comparable](items []T, key func(T) K) map[K][]T {
	result := make(map[K][]T)
	for _, item := range items {
		groupKey := key(item)
		result[groupKey] = append(result[groupKey], item)
	}
	return result
}

泛型能减少类型转换和重复实现,但通用工具应有真实复用需求。只有一个调用点时,普通循环通常更直接。

十四、泛型类型

类型也可以带类型参数:

go 复制代码
type Result[T any] struct {
	Value T
	Err   error
}

简单栈:

go 复制代码
type Stack[T any] struct {
	items []T
}

func (stack *Stack[T]) Push(value T) {
	stack.items = append(stack.items, value)
}

func (stack *Stack[T]) Pop() (T, bool) {
	if len(stack.items) == 0 {
		var zero T
		return zero, false
	}

	last := len(stack.items) - 1
	value := stack.items[last]
	var zero T
	stack.items[last] = zero
	stack.items = stack.items[:last]
	return value, true
}

var zero T 用来得到任意类型参数的零值。

十五、接口、泛型还是普通函数

需求 推荐方式
多种实现提供同一行为 接口
同一算法适用于多种类型 泛型
只在一个明确类型上工作 普通函数或方法
运行时需要替换实现 接口
编译期保留输入输出类型关系 泛型
逻辑只有一个调用点且很简单 直接代码

不要为了展示技术而同时叠加接口、泛型和反射。优先选择最简单且能保持类型安全的方案。

十六、Go 包设计基础

1. 包的职责

一个包应该围绕一组紧密相关的能力组织。包名应简短、清晰,调用时读起来自然:

go 复制代码
contact.New(...)
repository.NewMemory(...)

避免 utilscommonhelpers 逐渐变成无边界的杂物包。

2. 导出规则

标识符首字母大写表示对其他包可见:

go 复制代码
type Service struct{}
func NewService() *Service

只导出调用方需要使用的最小 API。实现细节保持未导出,有利于未来修改而不破坏调用方。

3. 避免循环依赖

如果包 A 导入包 B,包 B 不能再导入包 A。出现循环依赖通常说明职责划分需要调整。

可采用以下方法:

  • 把共同模型提取到更基础的包;
  • 在使用方定义小接口;
  • 合并过度拆分且高度耦合的包;
  • 重新梳理依赖方向。

4. internal 目录

放在 internal 下的包只能被其允许范围内的代码导入:

text 复制代码
go-contacts/
├── cmd/contacts/
├── internal/contact/
├── internal/repository/
└── go.mod

这能防止项目内部实现被外部模块依赖,降低未来调整成本。

十七、通讯录第五阶段架构

text 复制代码
go-contacts/
├── cmd/
│   └── contacts/
│       └── main.go
├── internal/
│   ├── contact/
│   │   ├── model.go
│   │   ├── service.go
│   │   └── service_test.go
│   └── repository/
│       ├── memory.go
│       └── jsonfile.go
└── go.mod

职责如下:

  • contact:领域模型、业务规则、仓储接口;
  • repository:接口的具体存储实现;
  • cmd/contacts:组装依赖、处理命令行输入输出;
  • main 不参与业务判断,只负责启动应用。

依赖方向:

text 复制代码
cmd/contacts ──→ contact.Service ──→ contact.Repository
      │                                  ↑
      └────────→ repository.JSONFile ────┘

业务包不知道具体仓储实现,程序入口负责选择和注入实现。

十八、领域模型与仓储接口

internal/contact/model.go

go 复制代码
package contact

import (
	"errors"
	"strings"
)

var (
	ErrNotFound    = errors.New("联系人不存在")
	ErrPhoneExists = errors.New("手机号已存在")
)

type Contact struct {
	Name  string `json:"name"`
	Phone string `json:"phone"`
	City  string `json:"city"`
}

func New(name, phone, city string) (Contact, error) {
	item := Contact{
		Name:  strings.TrimSpace(name),
		Phone: strings.TrimSpace(phone),
		City:  strings.TrimSpace(city),
	}
	if err := item.Validate(); err != nil {
		return Contact{}, err
	}
	return item, nil
}

func (item Contact) Validate() error {
	if item.Name == "" {
		return errors.New("姓名不能为空")
	}
	if len(item.Phone) != 11 {
		return errors.New("手机号必须为11位")
	}
	for _, char := range item.Phone {
		if char < '0' || char > '9' {
			return errors.New("手机号只能包含数字")
		}
	}
	return nil
}

type Repository interface {
	Create(item Contact) error
	FindByPhone(phone string) (Contact, error)
	Update(item Contact) error
	Delete(phone string) error
	List() ([]Contact, error)
}

JSON 标签规定结构体字段序列化名称。领域模型只描述联系人,不包含文件路径、锁或 JSON 编解码逻辑。

十九、业务服务

internal/contact/service.go

go 复制代码
package contact

import (
	"fmt"
	"sort"
	"strings"
)

type Service struct {
	repository Repository
}

func NewService(repository Repository) (*Service, error) {
	if repository == nil {
		return nil, fmt.Errorf("仓储不能为空")
	}
	return &Service{repository: repository}, nil
}

func (service *Service) Add(name, phone, city string) error {
	item, err := New(name, phone, city)
	if err != nil {
		return fmt.Errorf("创建联系人: %w", err)
	}
	if err := service.repository.Create(item); err != nil {
		return fmt.Errorf("保存联系人: %w", err)
	}
	return nil
}

func (service *Service) Find(phone string) (Contact, error) {
	item, err := service.repository.FindByPhone(strings.TrimSpace(phone))
	if err != nil {
		return Contact{}, fmt.Errorf("查询联系人: %w", err)
	}
	return item, nil
}

func (service *Service) Rename(phone, newName string) error {
	item, err := service.repository.FindByPhone(strings.TrimSpace(phone))
	if err != nil {
		return fmt.Errorf("查询待修改联系人: %w", err)
	}

	item.Name = strings.TrimSpace(newName)
	if err := item.Validate(); err != nil {
		return fmt.Errorf("修改联系人: %w", err)
	}
	if err := service.repository.Update(item); err != nil {
		return fmt.Errorf("保存修改: %w", err)
	}
	return nil
}

func (service *Service) Delete(phone string) error {
	if err := service.repository.Delete(strings.TrimSpace(phone)); err != nil {
		return fmt.Errorf("删除联系人: %w", err)
	}
	return nil
}

func (service *Service) List() ([]Contact, error) {
	items, err := service.repository.List()
	if err != nil {
		return nil, fmt.Errorf("读取联系人列表: %w", err)
	}
	sort.Slice(items, func(i, j int) bool {
		if items[i].Name == items[j].Name {
			return items[i].Phone < items[j].Phone
		}
		return items[i].Name < items[j].Name
	})
	return items, nil
}

排序属于当前应用的展示规则,因此由服务层统一处理。仓储只负责数据持久化,不保证列表顺序。

二十、内存仓储实现

internal/repository/memory.go

go 复制代码
package repository

import (
	"contacts/internal/contact"
	"sync"
)

type Memory struct {
	mu    sync.RWMutex
	items map[string]contact.Contact
}

func NewMemory() *Memory {
	return &Memory{items: make(map[string]contact.Contact)}
}

var _ contact.Repository = (*Memory)(nil)

func (memory *Memory) Create(item contact.Contact) error {
	memory.mu.Lock()
	defer memory.mu.Unlock()

	if _, exists := memory.items[item.Phone]; exists {
		return contact.ErrPhoneExists
	}
	memory.items[item.Phone] = item
	return nil
}

func (memory *Memory) FindByPhone(phone string) (contact.Contact, error) {
	memory.mu.RLock()
	defer memory.mu.RUnlock()

	item, exists := memory.items[phone]
	if !exists {
		return contact.Contact{}, contact.ErrNotFound
	}
	return item, nil
}

func (memory *Memory) Update(item contact.Contact) error {
	memory.mu.Lock()
	defer memory.mu.Unlock()

	if _, exists := memory.items[item.Phone]; !exists {
		return contact.ErrNotFound
	}
	memory.items[item.Phone] = item
	return nil
}

func (memory *Memory) Delete(phone string) error {
	memory.mu.Lock()
	defer memory.mu.Unlock()

	if _, exists := memory.items[phone]; !exists {
		return contact.ErrNotFound
	}
	delete(memory.items, phone)
	return nil
}

func (memory *Memory) List() ([]contact.Contact, error) {
	memory.mu.RLock()
	defer memory.mu.RUnlock()

	items := make([]contact.Contact, 0, len(memory.items))
	for _, item := range memory.items {
		items = append(items, item)
	}
	return items, nil
}

RWMutex 允许多个只读操作并行,但写操作独占。锁保护的是 items 的完整访问规则,不能只给写操作加锁而让读操作无保护。

二十一、JSON 文件仓储

文件仓储采用简单可靠的流程:

  1. 加锁;
  2. 读取文件并反序列化;
  3. 在内存中修改;
  4. 写入同目录临时文件;
  5. 关闭文件后用临时文件替换目标文件。

相比直接覆盖目标文件,临时文件方案降低程序中途退出导致原文件损坏的风险。

internal/repository/jsonfile.go 核心实现:

go 复制代码
package repository

import (
	"contacts/internal/contact"
	"encoding/json"
	"errors"
	"fmt"
	"os"
	"path/filepath"
	"sync"
)

type JSONFile struct {
	mu   sync.Mutex
	path string
}

func NewJSONFile(path string) (*JSONFile, error) {
	if path == "" {
		return nil, errors.New("数据文件路径不能为空")
	}
	return &JSONFile{path: path}, nil
}

var _ contact.Repository = (*JSONFile)(nil)

func (repository *JSONFile) load() (map[string]contact.Contact, error) {
	data, err := os.ReadFile(repository.path)
	if errors.Is(err, os.ErrNotExist) {
		return make(map[string]contact.Contact), nil
	}
	if err != nil {
		return nil, fmt.Errorf("读取文件: %w", err)
	}
	if len(data) == 0 {
		return make(map[string]contact.Contact), nil
	}

	items := make(map[string]contact.Contact)
	if err := json.Unmarshal(data, &items); err != nil {
		return nil, fmt.Errorf("解析JSON: %w", err)
	}
	return items, nil
}

func (repository *JSONFile) save(items map[string]contact.Contact) error {
	data, err := json.MarshalIndent(items, "", "  ")
	if err != nil {
		return fmt.Errorf("编码JSON: %w", err)
	}

	directory := filepath.Dir(repository.path)
	if err := os.MkdirAll(directory, 0o755); err != nil {
		return fmt.Errorf("创建数据目录: %w", err)
	}

	temporary, err := os.CreateTemp(directory, ".contacts-*.tmp")
	if err != nil {
		return fmt.Errorf("创建临时文件: %w", err)
	}
	temporaryPath := temporary.Name()
	defer os.Remove(temporaryPath)

	if _, err := temporary.Write(data); err != nil {
		temporary.Close()
		return fmt.Errorf("写入临时文件: %w", err)
	}
	if err := temporary.Sync(); err != nil {
		temporary.Close()
		return fmt.Errorf("同步临时文件: %w", err)
	}
	if err := temporary.Close(); err != nil {
		return fmt.Errorf("关闭临时文件: %w", err)
	}
	if err := os.Rename(temporaryPath, repository.path); err != nil {
		return fmt.Errorf("替换数据文件: %w", err)
	}
	return nil
}

func (repository *JSONFile) Create(item contact.Contact) error {
	repository.mu.Lock()
	defer repository.mu.Unlock()

	items, err := repository.load()
	if err != nil {
		return err
	}
	if _, exists := items[item.Phone]; exists {
		return contact.ErrPhoneExists
	}
	items[item.Phone] = item
	return repository.save(items)
}

func (repository *JSONFile) FindByPhone(phone string) (contact.Contact, error) {
	repository.mu.Lock()
	defer repository.mu.Unlock()

	items, err := repository.load()
	if err != nil {
		return contact.Contact{}, err
	}
	item, exists := items[phone]
	if !exists {
		return contact.Contact{}, contact.ErrNotFound
	}
	return item, nil
}

UpdateDeleteList 与内存实现遵守相同接口契约,按照"加载---校验---修改---保存"实现即可。

文件替换的跨平台注意点

同目录临时文件可以降低跨文件系统移动失败的概率。但不同操作系统对"目标文件已存在时重命名覆盖"的行为可能不同。生产级实现还需要根据平台处理覆盖、备份、目录同步、文件权限、多进程锁和崩溃恢复。

本阶段示例适合学习和单进程工具,不应直接当作高并发数据库替代品。

二十二、依赖注入与程序组装

cmd/contacts/main.go

go 复制代码
package main

import (
	"contacts/internal/contact"
	"contacts/internal/repository"
	"log"
)

func main() {
	storage, err := repository.NewJSONFile("data/contacts.json")
	if err != nil {
		log.Fatal(err)
	}

	service, err := contact.NewService(storage)
	if err != nil {
		log.Fatal(err)
	}

	if err := run(service); err != nil {
		log.Fatal(err)
	}
}

repository.NewJSONFile(...) 改为 repository.NewMemory(),业务服务无需改动。这就是依赖接口带来的可替换性。

依赖注入不是某个框架专属概念。像上面一样通过构造函数传入依赖,就是清晰、显式的依赖注入。

二十三、使用接口替身测试服务

测试服务层时无需真正读写文件,可以实现一个只满足测试需要的仓储:

go 复制代码
package contact

import (
	"errors"
	"testing"
)

type repositoryStub struct {
	createFn func(Contact) error
	findFn   func(string) (Contact, error)
}

func (stub repositoryStub) Create(item Contact) error {
	return stub.createFn(item)
}

func (stub repositoryStub) FindByPhone(phone string) (Contact, error) {
	return stub.findFn(phone)
}

func (repositoryStub) Update(Contact) error { return nil }
func (repositoryStub) Delete(string) error  { return nil }
func (repositoryStub) List() ([]Contact, error) {
	return nil, nil
}

func TestServiceAddWrapsRepositoryError(t *testing.T) {
	storageErr := errors.New("存储不可用")
	storage := repositoryStub{
		createFn: func(Contact) error {
			return storageErr
		},
		findFn: func(string) (Contact, error) {
			return Contact{}, ErrNotFound
		},
	}

	service, err := NewService(storage)
	if err != nil {
		t.Fatal(err)
	}

	err = service.Add("张三", "13800000000", "北京")
	if !errors.Is(err, storageErr) {
		t.Fatalf("错误=%v,期望保留底层错误链", err)
	}
}

这个替身允许测试精确控制依赖返回什么结果,并验证服务是否正确传播错误。

不要为了测试而复制整个生产仓储。替身只需要满足测试关注的行为。

二十四、JSON 仓储测试

文件测试必须使用测试框架提供的临时目录:

go 复制代码
func TestJSONFilePersistsContact(t *testing.T) {
	path := filepath.Join(t.TempDir(), "contacts.json")
	repository, err := NewJSONFile(path)
	if err != nil {
		t.Fatal(err)
	}

	want := contact.Contact{
		Name:  "张三",
		Phone: "13800000000",
		City:  "北京",
	}
	if err := repository.Create(want); err != nil {
		t.Fatal(err)
	}

	got, err := repository.FindByPhone(want.Phone)
	if err != nil {
		t.Fatal(err)
	}
	if got != want {
		t.Fatalf("got=%+v,期望=%+v", got, want)
	}
}

t.TempDir() 会为测试创建独立目录,并在测试结束后清理,避免污染项目或依赖开发者机器上的固定路径。

还应覆盖:

  • 数据文件不存在;
  • 空文件;
  • 非法 JSON;
  • 重复手机号;
  • 更新不存在联系人;
  • 删除不存在联系人;
  • 写入后重新创建仓储实例仍能读到数据。

二十五、常见设计错误

1. 提前定义巨大接口

实现者被迫提供不需要的方法,调用方也难以创建测试替身。接口应从真实使用点生长出来。

2. 返回具体实现而不是必要抽象

构造函数可以返回具体类型,调用方在需要时再赋给接口。不要仅因为存在接口就隐藏所有具体能力。

3. 把 any 当作泛型

any 会丢失类型关系,调用方需要断言;泛型在编译期保持类型安全。二者用途不同。

4. 泛型约束过宽

约束越宽,函数内部可用操作越少。约束应准确描述算法需要的能力。

5. 为单一类型强行使用泛型

没有跨类型复用需求时,普通函数更容易阅读和维护。

6. 接口持有 nil 指针

必须理解接口的动态类型与动态值,避免返回带类型的 nil 指针作为 error

7. 使用类型选择模拟多态

分支持续增加时,应考虑把行为放入接口方法,让具体类型自行实现。

8. 包拆得过细

每个文件一个包会制造大量导出符号和循环依赖。包边界应围绕稳定职责,而不是代码行数。

9. 仓储泄漏内部切片

返回内部切片可能允许调用方修改仓储状态。必要时返回副本,并在接口契约中说明所有权。

10. 文件保存直接覆盖原文件

写入中断可能破坏已有数据。使用同目录临时文件、同步、关闭和替换流程。

二十六、练习任务

  1. 完成 JSON 仓储的 UpdateDeleteList
  2. 为 JSON 仓储补齐所有异常测试;
  3. 定义只读接口 ContactReader,让查询功能依赖更小接口;
  4. 使用泛型实现 ContainsUniqueMapGroupBy
  5. 使用 GroupBy 统计各城市联系人;
  6. Memory 增加深复制策略并测试所有权边界;
  7. 添加 CSV 存储实现,业务层不得修改;
  8. 为仓储定义契约测试,让不同实现运行同一组测试;
  9. 模拟仓储故障,验证服务保留底层错误链;
  10. 尝试将接口移动到使用方,并比较依赖关系变化。

二十七、本阶段验证

powershell 复制代码
go fmt ./...
go vet ./...
go test ./...
go test -race ./...
go test -cover ./...
go build ./...

验证重点:

  • 内存仓储和 JSON 仓储都通过同一接口的行为测试;
  • 服务层测试不读取真实文件;
  • JSON 测试只使用 t.TempDir()
  • 错误使用 %w 包装后仍可被 errors.Is 识别;
  • 并发访问内存仓储时 -race 不报告数据竞争;
  • 切换存储实现时业务服务代码无需改动;
  • 包依赖无环,导出 API 保持最小。

二十八、阶段验收问题

完成本阶段后,应能够独立回答:

  1. Go 类型如何实现接口?
  2. 为什么接口通常由使用方定义?
  3. 值接收者和指针接收者如何影响方法集?
  4. 接口值为什么可能不等于 nil,但内部指针是 nil
  5. any 的合理使用边界是什么?
  6. 类型断言的 comma ok 解决了什么问题?
  7. 类型选择与接口多态分别适合什么场景?
  8. comparable 约束允许执行哪些操作?
  9. 自定义约束中的 ~ 有什么作用?
  10. 接口和泛型分别解决什么问题?
  11. 为什么不应为单一类型强行引入泛型?
  12. 如何避免包循环依赖?
  13. internal 目录提供什么约束?
  14. 为什么业务服务不应依赖 JSON 文件实现?
  15. 为什么程序入口适合负责依赖组装?
  16. 测试替身如何帮助隔离业务测试?
  17. 为什么文件写入应采用临时文件替换?
  18. 如何验证两个仓储实现遵守同一契约?

下一阶段预告

下一篇将学习文件与 I/O、JSON 编解码、时间处理、日志和配置管理。我们会完善 JSON 存储的可靠性,加入备份恢复、配置加载和结构化日志,并把通讯录升级为可长期运行的命令行应用。

上一阶段把联系人从松散的 Map 重构成结构体,并使用方法、指针和错误值组织业务逻辑。但通讯录数据仍直接保存在 Book 内部的 Map 中,程序退出后数据就会丢失,业务逻辑也与具体存储方式绑定。

本阶段将使用接口把"业务规则"和"数据存储"分离,并分别实现内存存储与 JSON 文件存储。同时学习类型断言、类型选择、泛型和包设计,建立 Go 项目中非常重要的抽象能力。

一、本阶段学习目标

完成本阶段后,你应该能够:

  • 定义并使用小而清晰的接口;
  • 理解 Go 接口的隐式实现机制;
  • 解释接口值由动态类型和动态值组成;
  • 识别"接口不为 nil,但内部指针为 nil"的陷阱;
  • 使用类型断言和类型选择安全识别动态类型;
  • 正确理解空接口 any 的适用边界;
  • 根据使用方需求设计接口,而不是机械抽象;
  • 使用泛型类型参数、约束和类型推断;
  • 判断何时使用泛型、接口或普通函数;
  • 设计清晰的包职责与依赖方向;
  • 使用 internal 包限制实现细节;
  • 为通讯录实现内存仓储和 JSON 文件仓储;
  • 使用接口替身编写不依赖文件系统的业务测试。

二、接口解决什么问题

接口描述一组行为,而不是一组字段:

go 复制代码
type Reader interface {
	Read(p []byte) (n int, err error)
}

任何类型只要具有完全匹配的 Read 方法,就实现了 Reader。类型无需显式声明 implements Reader

接口的主要价值是让调用方只依赖自己需要的能力:

text 复制代码
通讯录业务 → ContactRepository 接口 ← 内存存储
                                  ← JSON 文件存储
                                  ← 未来的数据库存储

业务层只关心新增、查询、更新、删除和列表,不需要知道数据最终存放在 Map、文件还是数据库中。

三、定义与实现接口

1. 定义接口

go 复制代码
type ContactRepository interface {
	Save(contact Contact) error
	FindByPhone(phone string) (Contact, error)
	Delete(phone string) error
	List() ([]Contact, error)
}

2. 隐式实现

go 复制代码
type MemoryRepository struct {
	items map[string]Contact
}

func (repository *MemoryRepository) Save(contact Contact) error {
	repository.items[contact.Phone] = contact
	return nil
}

只要方法集完整匹配,*MemoryRepository 就实现了 ContactRepository

编译期断言可以提前暴露实现缺失:

go 复制代码
var _ ContactRepository = (*MemoryRepository)(nil)

这行代码不会创建对象,也没有运行时成本。它只是要求编译器验证接口关系。

四、接口应该由使用方定义

假设业务服务只需要查询联系人:

go 复制代码
type ContactFinder interface {
	FindByPhone(phone string) (Contact, error)
}

这个接口更适合放在使用它的业务包,而不是由底层存储包提前定义一个包含十几个方法的"大接口"。

推荐原则:

  • 接口表达调用方真正需要的最小能力;
  • 接口通常在使用方一侧定义;
  • 一个方法的接口在 Go 中很常见;
  • 不要为了"面向接口"给每个结构体机械创建同名接口;
  • 只有存在替换、隔离、组合或测试需求时再抽象。

五、方法集与接口实现

值接收者方法同时属于 T*T 的方法集;指针接收者方法只属于 *T 的方法集。

go 复制代码
type Counter struct {
	value int
}

func (counter Counter) Value() int {
	return counter.value
}

func (counter *Counter) Increment() {
	counter.value++
}

如果接口要求 Increment,只有 *Counter 实现接口:

go 复制代码
type Incrementer interface {
	Increment()
}

var _ Incrementer = (*Counter)(nil)
// var _ Incrementer = Counter{} // 编译错误

平时调用变量的方法时,编译器可能自动完成取地址或解引用;接口赋值则必须满足真实方法集,不能依赖这种语法便利。

六、接口值的内部语义

一个接口值可以理解为包含两部分:

  1. 动态类型:当前保存的具体类型;
  2. 动态值:该具体类型对应的值。
go 复制代码
var value any = 100
fmt.Printf("动态类型:%T,动态值:%v\n", value, value)

接口只有在动态类型和动态值都不存在时才等于 nil

典型陷阱:带 nil 指针的非 nil 接口

go 复制代码
type AppError struct{}

func (*AppError) Error() string {
	return "应用错误"
}

func execute() error {
	var err *AppError = nil
	return err
}

func main() {
	err := execute()
	fmt.Println(err == nil) // false
}

返回的接口包含动态类型 *AppError,虽然动态值是 nil,整个接口仍不等于 nil

正确做法是在没有错误时直接返回 nil

go 复制代码
func execute() error {
	return nil
}

七、接口组合

小接口可以组合成更大的接口:

go 复制代码
type Reader interface {
	Read([]byte) (int, error)
}

type Writer interface {
	Write([]byte) (int, error)
}

type ReadWriter interface {
	Reader
	Writer
}

标准库大量使用这种方式,例如 io.ReadWriterio.ReadCloser

组合应该表达真实能力关系,不要只是为了减少复制而无意义嵌入接口。

八、空接口 any

anyinterface{} 的别名,可以保存任意类型:

go 复制代码
var value any
value = 100
value = "Go"
value = Contact{Name: "张三"}

常见合理场景:

  • JSON 等动态格式的未知结构;
  • 日志字段值;
  • 框架边界或通用容器;
  • 需要接纳任意类型的格式化 API。

业务模型字段如果类型明确,不应为了省事全部使用 any。过度使用会丢失编译期检查,并把错误推迟到运行时。

九、类型断言

类型断言从接口值中获取具体值:

go 复制代码
value := any("Go语言")
text, ok := value.(string)
if !ok {
	fmt.Println("不是字符串")
	return
}
fmt.Println(text)

不接收 ok 时,断言失败会触发 panic:

go 复制代码
text := value.(string)

只有程序逻辑能够严格保证动态类型时才考虑单返回值形式。面对外部输入或开放接口,优先使用 comma ok。

断言接口能力

类型断言不仅可以断言具体类型,也可以断言另一个接口:

go 复制代码
type Flusher interface {
	Flush() error
}

if flusher, ok := writer.(Flusher); ok {
	if err := flusher.Flush(); err != nil {
		return err
	}
}

这种方式可以检测可选能力,但若某能力是正确运行的必要条件,应直接写进函数参数接口中。

十、类型选择 type switch

需要处理多种动态类型时使用类型选择:

go 复制代码
func describe(value any) string {
	switch typed := value.(type) {
	case nil:
		return "空值"
	case int:
		return fmt.Sprintf("整数:%d", typed)
	case string:
		return "字符串:" + typed
	case Contact:
		return "联系人:" + typed.Name
	default:
		return fmt.Sprintf("未知类型:%T", typed)
	}
}

typed 在每个分支中具有对应的静态类型。

如果类型选择分支不断增加,通常意味着设计过度依赖具体类型。可以考虑把变化行为提取成接口方法。

十一、泛型解决什么问题

接口擅长抽象行为,泛型擅长为一组类型复用同一种算法,同时保留具体类型信息。

没有泛型时,为 []int[]string 分别实现查找会产生重复:

go 复制代码
func IndexOf[T comparable](items []T, target T) int {
	for index, item := range items {
		if item == target {
			return index
		}
	}
	return -1
}

调用时通常可以自动推断类型:

go 复制代码
fmt.Println(IndexOf([]int{10, 20, 30}, 20))
fmt.Println(IndexOf([]string{"Go", "Java"}, "Go"))

十二、类型参数与约束

go 复制代码
func Max[T cmp.Ordered](a, b T) T {
	if a > b {
		return a
	}
	return b
}

T 是类型参数,cmp.Ordered 是约束。约束决定泛型函数内部允许执行哪些操作。

常见预定义约束:

  • any:允许任意类型;
  • comparable:允许使用 ==!=
  • cmp.Ordered:标准库中支持 <<=>>= 的有序类型约束。

自定义约束

go 复制代码
type Number interface {
	~int | ~int64 | ~float32 | ~float64
}

func Sum[T Number](values []T) T {
	var total T
	for _, value := range values {
		total += value
	}
	return total
}

~int 表示底层类型为 int 的所有类型,因此自定义类型也能满足约束:

go 复制代码
type Score int

如果只写 intScore 不会被包含。

十三、泛型切片工具

1. Map 转换

go 复制代码
func Map[T, R any](items []T, transform func(T) R) []R {
	result := make([]R, len(items))
	for index, item := range items {
		result[index] = transform(item)
	}
	return result
}

使用:

go 复制代码
names := Map(contacts, func(contact Contact) string {
	return contact.Name
})

2. Filter 筛选

go 复制代码
func Filter[T any](items []T, keep func(T) bool) []T {
	result := make([]T, 0, len(items))
	for _, item := range items {
		if keep(item) {
			result = append(result, item)
		}
	}
	return result
}

3. GroupBy 分组

go 复制代码
func GroupBy[T any, K comparable](items []T, key func(T) K) map[K][]T {
	result := make(map[K][]T)
	for _, item := range items {
		groupKey := key(item)
		result[groupKey] = append(result[groupKey], item)
	}
	return result
}

泛型能减少类型转换和重复实现,但通用工具应有真实复用需求。只有一个调用点时,普通循环通常更直接。

十四、泛型类型

类型也可以带类型参数:

go 复制代码
type Result[T any] struct {
	Value T
	Err   error
}

简单栈:

go 复制代码
type Stack[T any] struct {
	items []T
}

func (stack *Stack[T]) Push(value T) {
	stack.items = append(stack.items, value)
}

func (stack *Stack[T]) Pop() (T, bool) {
	if len(stack.items) == 0 {
		var zero T
		return zero, false
	}

	last := len(stack.items) - 1
	value := stack.items[last]
	var zero T
	stack.items[last] = zero
	stack.items = stack.items[:last]
	return value, true
}

var zero T 用来得到任意类型参数的零值。

十五、接口、泛型还是普通函数

需求 推荐方式
多种实现提供同一行为 接口
同一算法适用于多种类型 泛型
只在一个明确类型上工作 普通函数或方法
运行时需要替换实现 接口
编译期保留输入输出类型关系 泛型
逻辑只有一个调用点且很简单 直接代码

不要为了展示技术而同时叠加接口、泛型和反射。优先选择最简单且能保持类型安全的方案。

十六、Go 包设计基础

1. 包的职责

一个包应该围绕一组紧密相关的能力组织。包名应简短、清晰,调用时读起来自然:

go 复制代码
contact.New(...)
repository.NewMemory(...)

避免 utilscommonhelpers 逐渐变成无边界的杂物包。

2. 导出规则

标识符首字母大写表示对其他包可见:

go 复制代码
type Service struct{}
func NewService() *Service

只导出调用方需要使用的最小 API。实现细节保持未导出,有利于未来修改而不破坏调用方。

3. 避免循环依赖

如果包 A 导入包 B,包 B 不能再导入包 A。出现循环依赖通常说明职责划分需要调整。

可采用以下方法:

  • 把共同模型提取到更基础的包;
  • 在使用方定义小接口;
  • 合并过度拆分且高度耦合的包;
  • 重新梳理依赖方向。

4. internal 目录

放在 internal 下的包只能被其允许范围内的代码导入:

text 复制代码
go-contacts/
├── cmd/contacts/
├── internal/contact/
├── internal/repository/
└── go.mod

这能防止项目内部实现被外部模块依赖,降低未来调整成本。

十七、通讯录第五阶段架构

text 复制代码
go-contacts/
├── cmd/
│   └── contacts/
│       └── main.go
├── internal/
│   ├── contact/
│   │   ├── model.go
│   │   ├── service.go
│   │   └── service_test.go
│   └── repository/
│       ├── memory.go
│       └── jsonfile.go
└── go.mod

职责如下:

  • contact:领域模型、业务规则、仓储接口;
  • repository:接口的具体存储实现;
  • cmd/contacts:组装依赖、处理命令行输入输出;
  • main 不参与业务判断,只负责启动应用。

依赖方向:

text 复制代码
cmd/contacts ──→ contact.Service ──→ contact.Repository
      │                                  ↑
      └────────→ repository.JSONFile ────┘

业务包不知道具体仓储实现,程序入口负责选择和注入实现。

十八、领域模型与仓储接口

internal/contact/model.go

go 复制代码
package contact

import (
	"errors"
	"strings"
)

var (
	ErrNotFound    = errors.New("联系人不存在")
	ErrPhoneExists = errors.New("手机号已存在")
)

type Contact struct {
	Name  string `json:"name"`
	Phone string `json:"phone"`
	City  string `json:"city"`
}

func New(name, phone, city string) (Contact, error) {
	item := Contact{
		Name:  strings.TrimSpace(name),
		Phone: strings.TrimSpace(phone),
		City:  strings.TrimSpace(city),
	}
	if err := item.Validate(); err != nil {
		return Contact{}, err
	}
	return item, nil
}

func (item Contact) Validate() error {
	if item.Name == "" {
		return errors.New("姓名不能为空")
	}
	if len(item.Phone) != 11 {
		return errors.New("手机号必须为11位")
	}
	for _, char := range item.Phone {
		if char < '0' || char > '9' {
			return errors.New("手机号只能包含数字")
		}
	}
	return nil
}

type Repository interface {
	Create(item Contact) error
	FindByPhone(phone string) (Contact, error)
	Update(item Contact) error
	Delete(phone string) error
	List() ([]Contact, error)
}

JSON 标签规定结构体字段序列化名称。领域模型只描述联系人,不包含文件路径、锁或 JSON 编解码逻辑。

十九、业务服务

internal/contact/service.go

go 复制代码
package contact

import (
	"fmt"
	"sort"
	"strings"
)

type Service struct {
	repository Repository
}

func NewService(repository Repository) (*Service, error) {
	if repository == nil {
		return nil, fmt.Errorf("仓储不能为空")
	}
	return &Service{repository: repository}, nil
}

func (service *Service) Add(name, phone, city string) error {
	item, err := New(name, phone, city)
	if err != nil {
		return fmt.Errorf("创建联系人: %w", err)
	}
	if err := service.repository.Create(item); err != nil {
		return fmt.Errorf("保存联系人: %w", err)
	}
	return nil
}

func (service *Service) Find(phone string) (Contact, error) {
	item, err := service.repository.FindByPhone(strings.TrimSpace(phone))
	if err != nil {
		return Contact{}, fmt.Errorf("查询联系人: %w", err)
	}
	return item, nil
}

func (service *Service) Rename(phone, newName string) error {
	item, err := service.repository.FindByPhone(strings.TrimSpace(phone))
	if err != nil {
		return fmt.Errorf("查询待修改联系人: %w", err)
	}

	item.Name = strings.TrimSpace(newName)
	if err := item.Validate(); err != nil {
		return fmt.Errorf("修改联系人: %w", err)
	}
	if err := service.repository.Update(item); err != nil {
		return fmt.Errorf("保存修改: %w", err)
	}
	return nil
}

func (service *Service) Delete(phone string) error {
	if err := service.repository.Delete(strings.TrimSpace(phone)); err != nil {
		return fmt.Errorf("删除联系人: %w", err)
	}
	return nil
}

func (service *Service) List() ([]Contact, error) {
	items, err := service.repository.List()
	if err != nil {
		return nil, fmt.Errorf("读取联系人列表: %w", err)
	}
	sort.Slice(items, func(i, j int) bool {
		if items[i].Name == items[j].Name {
			return items[i].Phone < items[j].Phone
		}
		return items[i].Name < items[j].Name
	})
	return items, nil
}

排序属于当前应用的展示规则,因此由服务层统一处理。仓储只负责数据持久化,不保证列表顺序。

二十、内存仓储实现

internal/repository/memory.go

go 复制代码
package repository

import (
	"contacts/internal/contact"
	"sync"
)

type Memory struct {
	mu    sync.RWMutex
	items map[string]contact.Contact
}

func NewMemory() *Memory {
	return &Memory{items: make(map[string]contact.Contact)}
}

var _ contact.Repository = (*Memory)(nil)

func (memory *Memory) Create(item contact.Contact) error {
	memory.mu.Lock()
	defer memory.mu.Unlock()

	if _, exists := memory.items[item.Phone]; exists {
		return contact.ErrPhoneExists
	}
	memory.items[item.Phone] = item
	return nil
}

func (memory *Memory) FindByPhone(phone string) (contact.Contact, error) {
	memory.mu.RLock()
	defer memory.mu.RUnlock()

	item, exists := memory.items[phone]
	if !exists {
		return contact.Contact{}, contact.ErrNotFound
	}
	return item, nil
}

func (memory *Memory) Update(item contact.Contact) error {
	memory.mu.Lock()
	defer memory.mu.Unlock()

	if _, exists := memory.items[item.Phone]; !exists {
		return contact.ErrNotFound
	}
	memory.items[item.Phone] = item
	return nil
}

func (memory *Memory) Delete(phone string) error {
	memory.mu.Lock()
	defer memory.mu.Unlock()

	if _, exists := memory.items[phone]; !exists {
		return contact.ErrNotFound
	}
	delete(memory.items, phone)
	return nil
}

func (memory *Memory) List() ([]contact.Contact, error) {
	memory.mu.RLock()
	defer memory.mu.RUnlock()

	items := make([]contact.Contact, 0, len(memory.items))
	for _, item := range memory.items {
		items = append(items, item)
	}
	return items, nil
}

RWMutex 允许多个只读操作并行,但写操作独占。锁保护的是 items 的完整访问规则,不能只给写操作加锁而让读操作无保护。

二十一、JSON 文件仓储

文件仓储采用简单可靠的流程:

  1. 加锁;
  2. 读取文件并反序列化;
  3. 在内存中修改;
  4. 写入同目录临时文件;
  5. 关闭文件后用临时文件替换目标文件。

相比直接覆盖目标文件,临时文件方案降低程序中途退出导致原文件损坏的风险。

internal/repository/jsonfile.go 核心实现:

go 复制代码
package repository

import (
	"contacts/internal/contact"
	"encoding/json"
	"errors"
	"fmt"
	"os"
	"path/filepath"
	"sync"
)

type JSONFile struct {
	mu   sync.Mutex
	path string
}

func NewJSONFile(path string) (*JSONFile, error) {
	if path == "" {
		return nil, errors.New("数据文件路径不能为空")
	}
	return &JSONFile{path: path}, nil
}

var _ contact.Repository = (*JSONFile)(nil)

func (repository *JSONFile) load() (map[string]contact.Contact, error) {
	data, err := os.ReadFile(repository.path)
	if errors.Is(err, os.ErrNotExist) {
		return make(map[string]contact.Contact), nil
	}
	if err != nil {
		return nil, fmt.Errorf("读取文件: %w", err)
	}
	if len(data) == 0 {
		return make(map[string]contact.Contact), nil
	}

	items := make(map[string]contact.Contact)
	if err := json.Unmarshal(data, &items); err != nil {
		return nil, fmt.Errorf("解析JSON: %w", err)
	}
	return items, nil
}

func (repository *JSONFile) save(items map[string]contact.Contact) error {
	data, err := json.MarshalIndent(items, "", "  ")
	if err != nil {
		return fmt.Errorf("编码JSON: %w", err)
	}

	directory := filepath.Dir(repository.path)
	if err := os.MkdirAll(directory, 0o755); err != nil {
		return fmt.Errorf("创建数据目录: %w", err)
	}

	temporary, err := os.CreateTemp(directory, ".contacts-*.tmp")
	if err != nil {
		return fmt.Errorf("创建临时文件: %w", err)
	}
	temporaryPath := temporary.Name()
	defer os.Remove(temporaryPath)

	if _, err := temporary.Write(data); err != nil {
		temporary.Close()
		return fmt.Errorf("写入临时文件: %w", err)
	}
	if err := temporary.Sync(); err != nil {
		temporary.Close()
		return fmt.Errorf("同步临时文件: %w", err)
	}
	if err := temporary.Close(); err != nil {
		return fmt.Errorf("关闭临时文件: %w", err)
	}
	if err := os.Rename(temporaryPath, repository.path); err != nil {
		return fmt.Errorf("替换数据文件: %w", err)
	}
	return nil
}

func (repository *JSONFile) Create(item contact.Contact) error {
	repository.mu.Lock()
	defer repository.mu.Unlock()

	items, err := repository.load()
	if err != nil {
		return err
	}
	if _, exists := items[item.Phone]; exists {
		return contact.ErrPhoneExists
	}
	items[item.Phone] = item
	return repository.save(items)
}

func (repository *JSONFile) FindByPhone(phone string) (contact.Contact, error) {
	repository.mu.Lock()
	defer repository.mu.Unlock()

	items, err := repository.load()
	if err != nil {
		return contact.Contact{}, err
	}
	item, exists := items[phone]
	if !exists {
		return contact.Contact{}, contact.ErrNotFound
	}
	return item, nil
}

UpdateDeleteList 与内存实现遵守相同接口契约,按照"加载---校验---修改---保存"实现即可。

文件替换的跨平台注意点

同目录临时文件可以降低跨文件系统移动失败的概率。但不同操作系统对"目标文件已存在时重命名覆盖"的行为可能不同。生产级实现还需要根据平台处理覆盖、备份、目录同步、文件权限、多进程锁和崩溃恢复。

本阶段示例适合学习和单进程工具,不应直接当作高并发数据库替代品。

二十二、依赖注入与程序组装

cmd/contacts/main.go

go 复制代码
package main

import (
	"contacts/internal/contact"
	"contacts/internal/repository"
	"log"
)

func main() {
	storage, err := repository.NewJSONFile("data/contacts.json")
	if err != nil {
		log.Fatal(err)
	}

	service, err := contact.NewService(storage)
	if err != nil {
		log.Fatal(err)
	}

	if err := run(service); err != nil {
		log.Fatal(err)
	}
}

repository.NewJSONFile(...) 改为 repository.NewMemory(),业务服务无需改动。这就是依赖接口带来的可替换性。

依赖注入不是某个框架专属概念。像上面一样通过构造函数传入依赖,就是清晰、显式的依赖注入。

二十三、使用接口替身测试服务

测试服务层时无需真正读写文件,可以实现一个只满足测试需要的仓储:

go 复制代码
package contact

import (
	"errors"
	"testing"
)

type repositoryStub struct {
	createFn func(Contact) error
	findFn   func(string) (Contact, error)
}

func (stub repositoryStub) Create(item Contact) error {
	return stub.createFn(item)
}

func (stub repositoryStub) FindByPhone(phone string) (Contact, error) {
	return stub.findFn(phone)
}

func (repositoryStub) Update(Contact) error { return nil }
func (repositoryStub) Delete(string) error  { return nil }
func (repositoryStub) List() ([]Contact, error) {
	return nil, nil
}

func TestServiceAddWrapsRepositoryError(t *testing.T) {
	storageErr := errors.New("存储不可用")
	storage := repositoryStub{
		createFn: func(Contact) error {
			return storageErr
		},
		findFn: func(string) (Contact, error) {
			return Contact{}, ErrNotFound
		},
	}

	service, err := NewService(storage)
	if err != nil {
		t.Fatal(err)
	}

	err = service.Add("张三", "13800000000", "北京")
	if !errors.Is(err, storageErr) {
		t.Fatalf("错误=%v,期望保留底层错误链", err)
	}
}

这个替身允许测试精确控制依赖返回什么结果,并验证服务是否正确传播错误。

不要为了测试而复制整个生产仓储。替身只需要满足测试关注的行为。

二十四、JSON 仓储测试

文件测试必须使用测试框架提供的临时目录:

go 复制代码
func TestJSONFilePersistsContact(t *testing.T) {
	path := filepath.Join(t.TempDir(), "contacts.json")
	repository, err := NewJSONFile(path)
	if err != nil {
		t.Fatal(err)
	}

	want := contact.Contact{
		Name:  "张三",
		Phone: "13800000000",
		City:  "北京",
	}
	if err := repository.Create(want); err != nil {
		t.Fatal(err)
	}

	got, err := repository.FindByPhone(want.Phone)
	if err != nil {
		t.Fatal(err)
	}
	if got != want {
		t.Fatalf("got=%+v,期望=%+v", got, want)
	}
}

t.TempDir() 会为测试创建独立目录,并在测试结束后清理,避免污染项目或依赖开发者机器上的固定路径。

还应覆盖:

  • 数据文件不存在;
  • 空文件;
  • 非法 JSON;
  • 重复手机号;
  • 更新不存在联系人;
  • 删除不存在联系人;
  • 写入后重新创建仓储实例仍能读到数据。

二十五、常见设计错误

1. 提前定义巨大接口

实现者被迫提供不需要的方法,调用方也难以创建测试替身。接口应从真实使用点生长出来。

2. 返回具体实现而不是必要抽象

构造函数可以返回具体类型,调用方在需要时再赋给接口。不要仅因为存在接口就隐藏所有具体能力。

3. 把 any 当作泛型

any 会丢失类型关系,调用方需要断言;泛型在编译期保持类型安全。二者用途不同。

4. 泛型约束过宽

约束越宽,函数内部可用操作越少。约束应准确描述算法需要的能力。

5. 为单一类型强行使用泛型

没有跨类型复用需求时,普通函数更容易阅读和维护。

6. 接口持有 nil 指针

必须理解接口的动态类型与动态值,避免返回带类型的 nil 指针作为 error

7. 使用类型选择模拟多态

分支持续增加时,应考虑把行为放入接口方法,让具体类型自行实现。

8. 包拆得过细

每个文件一个包会制造大量导出符号和循环依赖。包边界应围绕稳定职责,而不是代码行数。

9. 仓储泄漏内部切片

返回内部切片可能允许调用方修改仓储状态。必要时返回副本,并在接口契约中说明所有权。

10. 文件保存直接覆盖原文件

写入中断可能破坏已有数据。使用同目录临时文件、同步、关闭和替换流程。

二十六、练习任务

  1. 完成 JSON 仓储的 UpdateDeleteList
  2. 为 JSON 仓储补齐所有异常测试;
  3. 定义只读接口 ContactReader,让查询功能依赖更小接口;
  4. 使用泛型实现 ContainsUniqueMapGroupBy
  5. 使用 GroupBy 统计各城市联系人;
  6. Memory 增加深复制策略并测试所有权边界;
  7. 添加 CSV 存储实现,业务层不得修改;
  8. 为仓储定义契约测试,让不同实现运行同一组测试;
  9. 模拟仓储故障,验证服务保留底层错误链;
  10. 尝试将接口移动到使用方,并比较依赖关系变化。

二十七、本阶段验证

powershell 复制代码
go fmt ./...
go vet ./...
go test ./...
go test -race ./...
go test -cover ./...
go build ./...

验证重点:

  • 内存仓储和 JSON 仓储都通过同一接口的行为测试;
  • 服务层测试不读取真实文件;
  • JSON 测试只使用 t.TempDir()
  • 错误使用 %w 包装后仍可被 errors.Is 识别;
  • 并发访问内存仓储时 -race 不报告数据竞争;
  • 切换存储实现时业务服务代码无需改动;
  • 包依赖无环,导出 API 保持最小。

二十八、阶段验收问题

完成本阶段后,应能够独立回答:

  1. Go 类型如何实现接口?
  2. 为什么接口通常由使用方定义?
  3. 值接收者和指针接收者如何影响方法集?
  4. 接口值为什么可能不等于 nil,但内部指针是 nil
  5. any 的合理使用边界是什么?
  6. 类型断言的 comma ok 解决了什么问题?
  7. 类型选择与接口多态分别适合什么场景?
  8. comparable 约束允许执行哪些操作?
  9. 自定义约束中的 ~ 有什么作用?
  10. 接口和泛型分别解决什么问题?
  11. 为什么不应为单一类型强行引入泛型?
  12. 如何避免包循环依赖?
  13. internal 目录提供什么约束?
  14. 为什么业务服务不应依赖 JSON 文件实现?
  15. 为什么程序入口适合负责依赖组装?
  16. 测试替身如何帮助隔离业务测试?
  17. 为什么文件写入应采用临时文件替换?
  18. 如何验证两个仓储实现遵守同一契约?

下一阶段预告

下一篇将学习文件与 I/O、JSON 编解码、时间处理、日志和配置管理。我们会完善 JSON 存储的可靠性,加入备份恢复、配置加载和结构化日志,并把通讯录升级为可长期运行的命令行应用。

相关推荐
dadaobusi1 小时前
RV设备透传:主线尚未支持
学习
xqqxqxxq1 小时前
AI Agent学习:主动工具发现(李博杰《深入理解 AI Agent》4.8观后总结)
人工智能·学习
爱吃苹果的日记本3 小时前
数据结构第一课
c语言·数据结构·数据库·学习·c#
孫治AllenSun3 小时前
【LangChain4J-09】开发学习知识点
spring boot·后端·学习
边境悍匪4 小时前
蜗牛学苑 Java 智能体学习 Day38|Spring AI Alibaba2 思维导图复盘
java·学习·spring
摘星星的屋顶4 小时前
2026年8月31日~2026年9月13日周报
人工智能·学习
ShuiShenHuoLe5 小时前
golang-jwt v5 入门
开发语言·后端·golang
泡泡鱼(敲代码中)6 小时前
Python语法技术学习笔记
开发语言·笔记·python·学习·pycharm
Yanjun2i6 小时前
Agent学习记录三:完成 Agent Loop
python·学习·agent