上一阶段把联系人从松散的 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{} // 编译错误
平时调用变量的方法时,编译器可能自动完成取地址或解引用;接口赋值则必须满足真实方法集,不能依赖这种语法便利。
六、接口值的内部语义
一个接口值可以理解为包含两部分:
- 动态类型:当前保存的具体类型;
- 动态值:该具体类型对应的值。
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.ReadWriter、io.ReadCloser。
组合应该表达真实能力关系,不要只是为了减少复制而无意义嵌入接口。
八、空接口 any
any 是 interface{} 的别名,可以保存任意类型:
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
如果只写 int,Score 不会被包含。
十三、泛型切片工具
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(...)
避免 utils、common、helpers 逐渐变成无边界的杂物包。
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 文件仓储
文件仓储采用简单可靠的流程:
- 加锁;
- 读取文件并反序列化;
- 在内存中修改;
- 写入同目录临时文件;
- 关闭文件后用临时文件替换目标文件。
相比直接覆盖目标文件,临时文件方案降低程序中途退出导致原文件损坏的风险。
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
}
Update、Delete 和 List 与内存实现遵守相同接口契约,按照"加载---校验---修改---保存"实现即可。
文件替换的跨平台注意点
同目录临时文件可以降低跨文件系统移动失败的概率。但不同操作系统对"目标文件已存在时重命名覆盖"的行为可能不同。生产级实现还需要根据平台处理覆盖、备份、目录同步、文件权限、多进程锁和崩溃恢复。
本阶段示例适合学习和单进程工具,不应直接当作高并发数据库替代品。
二十二、依赖注入与程序组装
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. 文件保存直接覆盖原文件
写入中断可能破坏已有数据。使用同目录临时文件、同步、关闭和替换流程。
二十六、练习任务
- 完成 JSON 仓储的
Update、Delete和List; - 为 JSON 仓储补齐所有异常测试;
- 定义只读接口
ContactReader,让查询功能依赖更小接口; - 使用泛型实现
Contains、Unique、Map和GroupBy; - 使用
GroupBy统计各城市联系人; - 为
Memory增加深复制策略并测试所有权边界; - 添加 CSV 存储实现,业务层不得修改;
- 为仓储定义契约测试,让不同实现运行同一组测试;
- 模拟仓储故障,验证服务保留底层错误链;
- 尝试将接口移动到使用方,并比较依赖关系变化。
二十七、本阶段验证
powershell
go fmt ./...
go vet ./...
go test ./...
go test -race ./...
go test -cover ./...
go build ./...
验证重点:
- 内存仓储和 JSON 仓储都通过同一接口的行为测试;
- 服务层测试不读取真实文件;
- JSON 测试只使用
t.TempDir(); - 错误使用
%w包装后仍可被errors.Is识别; - 并发访问内存仓储时
-race不报告数据竞争; - 切换存储实现时业务服务代码无需改动;
- 包依赖无环,导出 API 保持最小。
二十八、阶段验收问题
完成本阶段后,应能够独立回答:
- Go 类型如何实现接口?
- 为什么接口通常由使用方定义?
- 值接收者和指针接收者如何影响方法集?
- 接口值为什么可能不等于
nil,但内部指针是nil? any的合理使用边界是什么?- 类型断言的 comma ok 解决了什么问题?
- 类型选择与接口多态分别适合什么场景?
comparable约束允许执行哪些操作?- 自定义约束中的
~有什么作用? - 接口和泛型分别解决什么问题?
- 为什么不应为单一类型强行引入泛型?
- 如何避免包循环依赖?
internal目录提供什么约束?- 为什么业务服务不应依赖 JSON 文件实现?
- 为什么程序入口适合负责依赖组装?
- 测试替身如何帮助隔离业务测试?
- 为什么文件写入应采用临时文件替换?
- 如何验证两个仓储实现遵守同一契约?
下一阶段预告
下一篇将学习文件与 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{} // 编译错误
平时调用变量的方法时,编译器可能自动完成取地址或解引用;接口赋值则必须满足真实方法集,不能依赖这种语法便利。
六、接口值的内部语义
一个接口值可以理解为包含两部分:
- 动态类型:当前保存的具体类型;
- 动态值:该具体类型对应的值。
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.ReadWriter、io.ReadCloser。
组合应该表达真实能力关系,不要只是为了减少复制而无意义嵌入接口。
八、空接口 any
any 是 interface{} 的别名,可以保存任意类型:
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
如果只写 int,Score 不会被包含。
十三、泛型切片工具
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(...)
避免 utils、common、helpers 逐渐变成无边界的杂物包。
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 文件仓储
文件仓储采用简单可靠的流程:
- 加锁;
- 读取文件并反序列化;
- 在内存中修改;
- 写入同目录临时文件;
- 关闭文件后用临时文件替换目标文件。
相比直接覆盖目标文件,临时文件方案降低程序中途退出导致原文件损坏的风险。
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
}
Update、Delete 和 List 与内存实现遵守相同接口契约,按照"加载---校验---修改---保存"实现即可。
文件替换的跨平台注意点
同目录临时文件可以降低跨文件系统移动失败的概率。但不同操作系统对"目标文件已存在时重命名覆盖"的行为可能不同。生产级实现还需要根据平台处理覆盖、备份、目录同步、文件权限、多进程锁和崩溃恢复。
本阶段示例适合学习和单进程工具,不应直接当作高并发数据库替代品。
二十二、依赖注入与程序组装
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. 文件保存直接覆盖原文件
写入中断可能破坏已有数据。使用同目录临时文件、同步、关闭和替换流程。
二十六、练习任务
- 完成 JSON 仓储的
Update、Delete和List; - 为 JSON 仓储补齐所有异常测试;
- 定义只读接口
ContactReader,让查询功能依赖更小接口; - 使用泛型实现
Contains、Unique、Map和GroupBy; - 使用
GroupBy统计各城市联系人; - 为
Memory增加深复制策略并测试所有权边界; - 添加 CSV 存储实现,业务层不得修改;
- 为仓储定义契约测试,让不同实现运行同一组测试;
- 模拟仓储故障,验证服务保留底层错误链;
- 尝试将接口移动到使用方,并比较依赖关系变化。
二十七、本阶段验证
powershell
go fmt ./...
go vet ./...
go test ./...
go test -race ./...
go test -cover ./...
go build ./...
验证重点:
- 内存仓储和 JSON 仓储都通过同一接口的行为测试;
- 服务层测试不读取真实文件;
- JSON 测试只使用
t.TempDir(); - 错误使用
%w包装后仍可被errors.Is识别; - 并发访问内存仓储时
-race不报告数据竞争; - 切换存储实现时业务服务代码无需改动;
- 包依赖无环,导出 API 保持最小。
二十八、阶段验收问题
完成本阶段后,应能够独立回答:
- Go 类型如何实现接口?
- 为什么接口通常由使用方定义?
- 值接收者和指针接收者如何影响方法集?
- 接口值为什么可能不等于
nil,但内部指针是nil? any的合理使用边界是什么?- 类型断言的 comma ok 解决了什么问题?
- 类型选择与接口多态分别适合什么场景?
comparable约束允许执行哪些操作?- 自定义约束中的
~有什么作用? - 接口和泛型分别解决什么问题?
- 为什么不应为单一类型强行引入泛型?
- 如何避免包循环依赖?
internal目录提供什么约束?- 为什么业务服务不应依赖 JSON 文件实现?
- 为什么程序入口适合负责依赖组装?
- 测试替身如何帮助隔离业务测试?
- 为什么文件写入应采用临时文件替换?
- 如何验证两个仓储实现遵守同一契约?
下一阶段预告
下一篇将学习文件与 I/O、JSON 编解码、时间处理、日志和配置管理。我们会完善 JSON 存储的可靠性,加入备份恢复、配置加载和结构化日志,并把通讯录升级为可长期运行的命令行应用。