设计哲学的迷思
我们常说的设计哲学,如通过数据模型驱动界面、高内聚松耦合、职责分离、关注点分离、控制反转、单一职责、依赖倒置、接口隔离、领域驱动设计,听起来玄之又玄,但核心目标其实很现实:让代码生命周期更长、易于修改、能承受规模扩展和需求变化。
实现这一目标的关键之一是开闭原则,即在不改动已有代码的基础上,通过扩展来应对新需求。本质是通过标准接口解耦,让组件可以各自演化、互不干扰。就像电源开关厂不关心它控制的设备类型,而灯泡厂也不关心开关的控制方式(手动、声控、遥控)。双方只需约定好接口。这种接口隔离带来的低耦合、高扩展性,就是开闭原则的现实映射。
就像现实中造开关的工厂不需要知道它控制的是灯、风扇还是电饭煲,它只要负责通断电;而灯泡厂也不关心你用的是手动开关、声控、还是遥控器,它只要定义好标准电源接口。这种通过接口实现的解耦关系,让双方都能独立演进,互不影响,这正是开闭原则和接口设计在现实世界中的直观体现。
有些编程设计模式起名字特别直白,字面就能看出意图。比如建造者模式,作为一种创建型设计模式,其核心目的把“创建”和“使用”解耦。这就好像这其实就像你去餐馆只管点菜吃饭,根本不用关心后厨用了什么锅、什么火、多少油温来烹饪。
srv, err := new(ServerBuilder).
New("127.0.0.1", -1).
WithProtocol("xxx").
WithMaxConn(1024).
WithTimeout(30 * time.Second).
Build()
复杂繁琐的步骤交由建造者处理,不仅实现了构建流程的模块化和安全校验,很好地实现了关注点分离,代码还显得尤为简洁优雅:
- 通常使用指针接收器和返回指针值的方式,实现链式(Fluent)调用
- 为了避免大量
if err != nil导致的可读性下降,还可以将错误延到最后统一处理
控制反转:思想与例子
但是,控制反转这个名字就显得没那么通俗直白了。控制怎么还反转了?我代码里 if、for 不都是控制语句吗?实际上,这里的“控制”指的是对象创建、依赖注入、生命周期、调度策略等本应全由开发者显式控制的行为;然而,这些并不是核心业务的关注点,因此可以把这个职责委托给其他类(框架/容器),这就是IoC的核心思想:
| 控制维度 | 技术机制 / 表现形式 | 控制权转移说明 | 示例 |
|---|---|---|---|
| 对象创建控制 | 工厂模式、DI 容器、构造器注入 | 对象由容器创建,避免显式 new | Spring @Component、Guice、Uber/dig、Go 构造注入 |
| 依赖关系控制 | 构造注入、字段注入、配置绑定 | 依赖注入由容器解析并装配 | @Autowired, @Inject, @Value, @ConfigurationProperties |
| 生命周期控制 | 生命周期钩子、回调接口、容器感知接口 | 初始化、销毁逻辑由容器管理 | @PostConstruct, @PreDestroy, InitializingBean |
| 执行流程控制 | AOP、拦截器、Filter、事件机制 | 容器控制横切逻辑插入点,统一流程控制 | @Transactional, Spring AOP, Servlet Filter, Go Gin Middleware |
| 事件驱动控制 | 发布订阅、观察者、事件总线、Reactive | 调用时机/顺序/线程由框架调度 | @EventListener, RxJava, LiveData, EventBus |
| 配置/环境控制 | 外部配置注入、条件装配、配置中心 | 配置来源与加载方式由容器统一管理 | @Profile, Spring Cloud Config, EnvironmentAware |
| 访问权限控制 | 安全框架、权限注解、限流器 | 资源访问规则交由安全组件统一管理 | Spring Security, OAuth2, Shiro |
| 任务/流程控制 | 状态机、编排器、流程引擎 | 业务流转逻辑交由流程组件驱动 | Spring Batch, Flowable, AWS Step Functions |
| 扩展点控制 | 插件机制、SPI、策略模式、回调接口 | 运行时扩展行为由容器发现与调度 | Java SPI, Spring BeanPostProcessor, ApplicationContextAware |
| 并发/调度控制 | 线程池、调度器、异步框架、消息中间件 | 并发策略与执行时机交由框架控制 | @Async, Scheduled, ExecutorService, Kafka, Looper |
从广义来说,笔者认为,甚至 Apollo、etcd 这类配置中心的使用也可以算作一种 Inversion of Control,它算然不是典型的控制反转容器(如 Spring IOC);但是比起硬编码配置,由外部系统控制配置的注入与更新是一种体现职责分离的实践做法。
例一:Spring IoC容器
IoC(控制反转)是一种设计思想,而 DI(依赖注入)是一种具体的实现手段。
// 传统写法:手动 new
Repo repo = new Repo(cfg);
Service svc = new Service(repo, logger);
Controller ctl = new Controller(svc, metrics, cache);
// 使用 Spring IoC
@Service
public class Service {
@Autowired
private Repo repo;
}
@RestController
public class Controller {
@Autowired
private Service service;
}
Spring IoC 容器支持:自动创建对象(通过扫描标注了 @Service、@Component 等注解的类,生成相应的 BeanDefinition 并实例化);自动注入依赖(使用 @Autowired 注解自动将所需的 Service 注入到 Controller 等组件中);管理 Bean 生命周期(通过调用如 @PostConstruct、afterPropertiesSet() 等方法完成初始化与销毁过程);以及通过三级缓存机制解决循环依赖问题。
例二:Android LifecycleObserver/LiveData
Android 虽没有显式的 IoC 容器,但 Jetpack 架构组件中大量采用了 IoC 思想,主要体现在对象创建控制、生命周期感知和事件驱动:
viewModel = new ViewModelProvider(this).get(MyViewModel.class);
例如,在 Jetpack 架构组件中,ViewModel 并不是开发者手动 new 出来的,而是由系统根据 Activity/Fragment 生命周期自动创建/缓存/复用;支持配置变更时保留数据,避免手动管理生命周期。
liveData.observe(lifecycleOwner) { data -> updateUI(data) }
此外,LiveData 与 LifecycleObserver 的配合,也体现出执行控制的“反转”:通过注册观察者,让框架在合适时机回调相应方法,相比起开发者显式地定时检测更新和通知,LiveData中存放的数据发生变化时,观察者可以自动执行操作。又比如,我们希望某一个APP在处于前台可见状态时(onStart),统计时长、播放音乐、调用手机摄像头;当 APP 处于后台不可见状态时(onStop),它便停止计时、停止播放,不再获取摄像头数据。在 2017 年,谷歌I/O 开发者大会首次提出Lifecycle 类、LifecycleOwner 和 LifecycleObserver 接口前,一般都需要开发者在 Activity 的回调函数中控制其他对象生命周期。
| 传统高耦合模式 | 生命周期感知模式 |
|---|---|
显然,这种依赖 Activity 类回调函数中控制其他对象生命周期的传统高耦合模式方式,会增加代码出错风险和维护成本;而生命周期感知型组件(LifecycleObserver)可以观察遵循 Activity 或 Fragment 状态,这与传统“命令式”的思维模式正好相反,让资源管理(如摄像头、计时器等)更加自动化,可维护性也更强。
实战:给集合增加撤回功能
下面通过一个通用集合类,演示如何从直接实现功能逐步演进到通过重构实现职责分离,以及控制逻辑的反转(IoC)。
完整代码:GitHub/DURUII
基础版本:泛型集合
这是最基础的集合实现,数据结构简单,增删查逻辑清晰直接。
type Set[T comparable] struct {
data map[T]struct{}
}
func NewSet[T comparable]() *Set[T] {
return &Set[T]{data: make(map[T]struct{})}
}
func (s *Set[T]) Add(x T) { s.data[x] = struct{}{} }
func (s *Set[T]) Delete(x T) { delete(s.data, x) }
func (s *Set[T]) Contains(x T) bool {
_, ok := s.data[x]
return ok
}
func TestSet(t *testing.T) {
s := NewSet[string]()
s.Add("武汉科技大学")
s.Add("武汉大学")
s.Add("华中科技大学")
assert.True(t, s.Contains("武汉科技大学"))
assert.False(t, s.Contains("武汉理工大学"))
s.Delete("武汉科技大学")
assert.False(t, s.Contains("武汉科技大学"))
}
但当我们加入撤销(Undo)功能,就会开始侵入和污染核心逻辑。
演进版本:耦合 Undo 实现
我们通过维护一个函数栈 []func() 来记录每步操作的撤销函数。
var (
ErrNoUndo = errors.New("no functions to undo")
)
type Set[T comparable] struct {
data map[T]struct{}
undo []func()
}
func NewSet[T comparable]() *Set[T] {
return &Set[T]{
data: make(map[T]struct{}),
undo: make([]func(), 0, 10),
}
}
func (s *Set[T]) Add(x T) {
if s.Contains(x) {
s.undo = append(s.undo, nil)
return
}
s.undo = append(s.undo, func() { s.Delete(x) })
s.add(x)
}
func (s *Set[T]) Delete(x T) {
if !s.Contains(x) {
s.undo = append(s.undo, nil)
return
}
s.undo = append(s.undo, func() { s.Add(x) })
s.delete(x)
}
func (s *Set[T]) add(x T) {
s.data[x] = struct{}{}
}
func (s *Set[T]) delete(x T) {
delete(s.data, x)
}
func (s *Set[T]) Contains(x T) bool {
_, ok := s.data[x]
return ok
}
func (s *Set[T]) Undo() error {
if len(s.undo) == 0 {
return ErrNoUndo
}
index := len(s.undo) - 1
if f := s.undo[index]; f != nil {
f()
s.undo[index] = nil
}
s.undo = s.undo[:index]
return nil
}
func TestSetUndo(t *testing.T) {
s := NewSet[string]()
s.Add("武汉科技大学")
assert.Equal(t, 1, len(s.undo))
s.Undo()
assert.Equal(t, 0, len(s.undo))
s.Undo()
assert.Equal(t, ErrNoUndo, s.Undo())
s.Add("武汉大学")
assert.Equal(t, 1, len(s.undo))
s.Add("华中科技大学")
assert.Equal(t, 2, len(s.undo))
assert.True(t, s.Contains("武汉大学"))
assert.False(t, s.Contains("武汉科技大学"))
s.Undo()
s.Undo()
assert.False(t, s.Contains("华中科技大学"))
s.Add("武汉科技大学")
assert.True(t, s.Contains("武汉科技大学"))
s.Delete("武汉科技大学")
assert.False(t, s.Contains("武汉科技大学"))
s.Undo()
assert.True(t, s.Contains("武汉科技大学"))
}
虽然我们用非导出方法(小写的 delete和add)进行了一定的封装,但还是显得耦合过于严重:集合的核心操作是增删查,怎么回滚撤销是按LIFO还是按FIFO顺序回滚不应是它的关注点,而且假如扩展新行为(如日志)又需要修改Set的核心结构,不符合开闭原则。
再演进版本:命令解耦和控制反转
为解决耦合问题我们采用命令模式(Command Pattern):每个操作生成一个可回滚的命令对象,撤销逻辑集中由一个 UndoManager 管理。这样可以把控制逻辑与业务逻辑分离,集合类不再负责撤销细节,而是将“撤销”的职责反转到外部组件中管理。
我们定义 Command 接口和 UndoManager:
var ErrNoUndo = errors.New("no functions to undo")
type Command interface {
Undo()
}
// 命令管理器
type UndoManager struct {
stack []Command
}
func NewUndoManager(cap int) *UndoManager {
return &UndoManager{
stack: make([]Command, 0, cap),
}
}
func (m *UndoManager) Add(cmd Command) {
m.stack = append(m.stack, cmd)
}
func (m *UndoManager) Push(cmd Command) {
m.stack = append(m.stack, cmd)
}
func (m *UndoManager) Undo() error {
if len(m.stack) == 0 {
return ErrNoUndo
}
idx := len(m.stack) - 1
cmd := m.stack[idx]
if cmd != nil {
cmd.Undo()
}
m.stack = m.stack[:idx]
return nil
}
UndoManager 仅负责按 LIFO 顺序管理和回退操作,不关心命令的具体内容,Command 是通用接口,任何实现该接口的类型都能接入使用。它将控制逻辑与业务逻辑解耦,只负责调度命令的执行和撤销,因此可以作为独立的通用组件复用到任何需要“撤销”功能的场景中,无需依赖具体的数据结构或业务语义。
每个操作实现自己的撤销逻辑:
type AddCommand[T comparable] struct {
set *Set[T]
x T
}
func (c *AddCommand[T]) Undo() {
c.set.Delete(c.x)
}
type DeleteCommand[T comparable] struct {
set *Set[T]
x T
}
func (c *DeleteCommand[T]) Undo() {
c.set.Add(c.x)
}
集合现在只需调用 Push() 注入命令对象,无需关心撤销逻辑细节,控制逻辑已被反转到 UndoManager。
type Set[T comparable] struct {
data map[T]struct{}
mgr *undo.UndoManager
err error
}
func NewSet[T comparable]() *Set[T] {
return &Set[T]{
data: make(map[T]struct{}),
mgr: undo.NewUndoManager(10),
}
}
func (s *Set[T]) Add(x T) {
if s.Contains(x) {
s.mgr.Push(nil)
return
}
s.mgr.Push(&AddCommand[T]{set: s, x: x})
s.add(x)
}
func (s *Set[T]) Delete(x T) {
if !s.Contains(x) {
s.mgr.Push(nil)
return
}
s.mgr.Push(&DeleteCommand[T]{set: s, x: x})
s.delete(x)
}
func (s *Set[T]) add(x T) {
s.data[x] = struct{}{}
}
func (s *Set[T]) delete(x T) {
delete(s.data, x)
}
func (s *Set[T]) Contains(x T) bool {
_, ok := s.data[x]
return ok
}
func (s *Set[T]) Undo() {
if err := s.mgr.Undo(); err != nil {
s.err = err
}
return
}
func (s *Set[T]) Size() int {
return len(s.data)
}
func (s *Set[T]) Err() error {
return s.err
}
至此,集合类只关注增删查核心行为,撤销等流程控制交由外部组件管理。这种设计不仅减少耦合,提高可读性,方便扩展如日志等功能,无需修改已有代码,也便于复用控制逻辑到其他场景。