一些个人碎碎念思考: 从什么是面向对象,发展历程,现代框架,到企业开发中的问题

    41

声明:这篇文章没有特别明确的主旨。不是编程教学,不是技术分享,就是记录一些我平时的思考。可能像说梦话,想到哪说到哪。如果你追求的是"如何快速上手 Spring Boot",请现在直接按下Alt + F4。

任何抛开场景的优劣讨论都是没有意义的,都是耍流氓,本篇探讨的也只是个人实践中,在一些特定场景下遇到的问题,从而引发的思考和吐槽,不是为了反驳什么,并没有完全否认现在任何主流方案。而是当我意识到自己总是下意识默认了一些东西应当是这样的时候,才反应过来自己忘记了思考,


开篇:从别人告诉我的东西开始

学习路上,总是有人在告诉你"是什么":

  • goto 是有害的,不要用
  • OOP 有三大特性:封装、继承、多态
  • 继承是为了代码复用
  • 组合优于继承
  • 注解是优雅的,Spring 很优雅
  • 声明式比命令式高级

然后大部分人会问:是不是?

很少有人会问,或深入思考的问:为什么?


老师说 goto 是有害的,就记住了:goto 是不好的,不要用,看到别人用就要站出来职责对方连基本功都没掌握。

但是如果去阅读 Linux 内核代码, 里充斥着大量的 goto


刚学 Spring 的时候,觉得注解太优雅了。

哇噻哇噻

加一个 @Transactional,事务就有了。
加一个 @Cacheable,缓存就有了。

后来写了企业级开发,看到别人一个方法上加十几个注解:

@DataScope(deptId = "#deptId")
@DataFilter(roles = {"ADMIN", "USER"})
@RateLimit(key = "#id", requests = 10, window = 60)
@OperationLog(module = "user", action = "get")
@Cacheable(value = "user", key = "#id")
@Transactional
@Async
public User getUser(Long id) { ... }

我开始问:如果注解真的这么优雅,为什么我看到这一坨东西浑身难受


学 OOP 的时候,书上说:继承是为了代码复用。

然后看到企业级代码里:

class Order extends BaseEntity {}
class User extends BaseEntity {}

我开始问:Order 是一个 BaseEntity 吗?BaseEntity 是什么东西?

到底在复用什么。。好恶心


这篇文章就是我对这些问题的思考。

不是教科书式的答案,而是基于实践和个人观察的理解。


一、命令式 vs 声明式

1.1 传统定义

你应该见过这样的表格:

命令式声明式
描述"怎么做"描述"是什么"
流程控制结果描述
for 循环SQL
一步步指令配置

然后有人告诉你:声明式好,因为它隐藏了实现细节,程序员可以专注的关心业务逻辑。(就和最初学 Spring 的时候,加一个注解,功能就有了,哇噻哇噻太优雅了)

1.2 框架的声明式本质

框架的设计哲学是什么?

封装所有通用的东西,对外暴露所有不属于"通用职责"的变更点。

大部分情况下,这个"变更点"就是业务逻辑。

所以框架的本质是:让你用声明式的方式写业务

// Spring 的声明式
@RestController
@RequestMapping("/users")
public class UserController {

    @GetMapping("/{id}")
    public UserDTO getUser(@PathVariable Long id) {
        return userService.getUser(id);
    }
}

这段代码在"声明"什么?

  • 声明这是一个 REST 控制器
  • 声明路由是 /users/{id}
  • 声明 GET 请求会调用这个方法

但真正的业务逻辑在哪?在 userService.getUser(id) 里。而 userService 背后可能又是:

@Service
public class UserServiceImpl implements UserService {

    @Autowired
    private UserMapper userMapper;

    @Autowired
    private EventPublisher eventPublisher;

    @Override
    @Transactional
    public UserDTO getUser(Long id) {
        // 真正的业务逻辑
    }
}

然后 @Transactional 背后又有事务切面,@Autowired 背后又有依赖注入容器……

框架的声明式,最后变成了"配置驱动开发"。

总是想着灵活,配置,恨不得写一次代码之后,让产品和运营自己去改配置,最后发现还是要程序员来改。

于是有了各种DSL,甚至是在配置文件里面发明了自己的语法。写错了根本没有任何报错提示。

比如我见过的 (Spigot插件-MythicMobs的技能配置文件,我没否认这个做法是错误的,毕竟是通用插件框架。):

``1773126247495.png

1.3 用命令式来声明

近几年我写代码,越来越倾向于用命令式的方式写声明式代码

什么意思?

或者说:我的代码风格,更像是 Builder 模式的思想

Builder 模式是什么?一步一步构建最终对象。每一步都是显式的,每一步都有迹可循。最终产物是一个完整的、逻辑闭环的对象。

// Builder 模式:显式、分步、可追溯, 每个参数都有名字,意图清晰
User user = User.builder()
    .name("张三")
    .age(18)
    .email("zhangsan@example.com")
    .activated(true)
    .subscribed(false)
    .build();

我用命令式写声明式,也是这个道理。

用 Builder 模式来改写缓存的例子:

// 纯声明式:逻辑链路不直观,依赖 AOP
@Cacheable(value = "user", key = "#id", unless = "#result == null")
public User getUser(Long id) {
    return userMapper.selectById(id);
}

// 用 Builder 模式显式构建缓存逻辑
public User getUser(Long id) {
    return Cache.builder(User.class)
        .key("user:" + id)
        .loader(() -> userMapper.selectById(id))
        .onHit(cache -> log.debug("cache hit"))
        .onMiss(cache -> log.debug("cache miss"))
        .expire(10, TimeUnit.MINUTES)
        .build()
        .get();
}

或者更通用的方式:

// 显式构建缓存配置、参数、回调
public User getUser(Long id) {
    return Caching.get("user:" + id,
        CacheConfig.<User>builder()
            .loader(() -> userMapper.selectById(id))
            .onHit(log::debug)
            .onMiss(log::debug)
            .expire(10, TimeUnit.MINUTES)
            .build());
}

第一段代码有什么问题?

  • 你看不出缓存逻辑在哪
  • 你看不出缓存什么时候失效
  • 你看不出缓存失败了会怎样
  • 出了 bug,堆栈里有 20 个代理类

第二段代码呢?

  • 配置、参数、回调都是显式传递
  • 逻辑链路清晰
  • 缓存策略一目了然
  • 异常情况可以显式处理
  • 显式优于隐式

有人说:但第一段代码更简洁啊!

我的回答是:简洁不等于清晰。简洁是给写代码的人看的,清晰是给读代码的人看的。而代码被读的次数,永远比被写的次数多。

1.4 AOP 的滥用

先声明一下:AOP 本身不是坏东西

日志、权限、事务,这些横切关注点确实适合用 AOP 处理。

缓存也是。 我上面用 Builder 模式对比的例子,其实有点极端。大部分情况下,缓存用 @Cacheable 是合理的,没必要每个项目都搞一套 Builder 封装。

但问题是:有些人用 AOP 是为了"炫技",不是为了"解决问题"

我见过一个方法上面加十几个注解的情况(虽然我找不到原代码了,但大概是这样):

// 这不是真实代码,但差不多是这个感觉
@DataScope(deptId = "#deptId")
@DataFilter(roles = {"ADMIN", "USER"})
@RateLimit(key = "#id", requests = 10, window = 60)
@OperationLog(module = "user", action = "get")
@Cacheable(value = "user", key = "#id")
@Transactional
@Async
@Scheduled(cron = "0 0/5 * * * ?")
@Retry(times = 3)
@CircuitBreaker
@SentinelResource
public User getUser(Long id) { ... }

这段代码的问题:

  • 你看不出逻辑链路在哪
  • 你看不出各个注解的执行顺序
  • 出了 bug 不知道在哪,因为堆栈里有 20 个代理类
  • 你想调试,但断点永远进不去,因为代码是动态生成的

这不是高级感,这在埋雷.. :)

我的观点很明确:能用显式代码表达的,就不要用隐式注解。注解应该是"辅助说明",不应该是"逻辑主体"。


二、OOP 的本质:从历史发展看设计哲学

2.1 历史发展脉络

要理解 OOP,得先理解它从哪来。

汇编 → 结构化编程 → OOP → 多范式融合

汇编语言的问题是什么?

  • 非结构化
  • 本质是地址跳转
  • 维护性极差

你写一段汇编,三个月后你自己都看不懂。为什么?因为 jmp 0x4f3a 不会告诉你它为什么要跳,也不会告诉你跳过去之后会发生什么。

结构化编程解决了这个问题。

它抽象出了三个基本结构:

  • 顺序:一行一行执行
  • 分支:if/else
  • 循环:for/while

这三个结构,足以表达任何算法。但关键是:它们是人类能理解的

但这里有个问题:结构化编程破坏了跳转的自由性

goto 就是受害者。

答案是:goto 本身没有问题,有问题的是没有规范的滥用

Linux 内核里的 goto 有严格的规范:

  • 只用于错误处理
  • 只用于资源清理
  • 只能向前跳转
  • 有统一的标签命名(outerrcleanup

在有明确目的和严格规范的时候,goto 是更优的选择。

这再次证明了我的观点:抛开场景谈好坏,都是耍流氓。

C 语言就是这个理念的最佳实践。

2.2 C 语言的位置

我说一句话,可能很多人不同意:

无论技术如何演进,总会有一个语言占据 C 现在的位置。

C 语言是什么位置?

底层硬件与上层抽象的最小平衡点。

  • 再往下,是汇编,是寄存器,是内存地址
  • 再往上,是对象,是垃圾回收,是运行时

C 语言刚好卡在中间:它给你指针,让你能操作内存;它给你结构体,让你能组织数据;它给你函数,让你能封装逻辑。

但也就到此为止了。C 语言没有"对象",没有"继承",没有"多态"。它相信程序员知道自己的行为,相信你不会随便*(int*)0

这个位置是生态位,不是技术选择。 就像草原上总会有食草动物的位置,也会有食肉动物的位置。

2.3 OOP 的产生原因

那 OOP 为什么会出现?

教科书上说是为了"代码复用",为了"模块化"。

我认为这些答案都太浅了。

OOP 的本质是:工程上为了构造更复杂的系统

注意这里的"复杂"是什么意思。

操作系统内核复不复杂?复杂。但它是一种"纯粹"的复杂——它关注的是进程调度、内存管理、文件系统。这些都是技术问题。

企业级系统复不复杂?也复杂。但它是"多样性"的复杂——它关注的是用户、订单、支付、库存、物流、营销。这些都是业务问题。

OOP 选择"对象"作为基本单元,是因为它更符合人类认知世界的方式。

现实世界里有什么?有"东西"。

  • 一个人是一个东西
  • 一辆车是一个东西
  • 一笔订单是一个东西

这些东西有自己的属性(颜色、名字、状态),也有自己的行为(跑、飞、支付)。

OOP 就是把这种认知方式映射到代码里。

所以 OOP 不是"更好的结构化编程",它是另一个层面的东西。结构化编程解决的是"如何组织指令",OOP 解决的是"如何描述世界"。


三、封装、继承、多态

教科书上永远在讲"OOP 三大特性:封装、继承、多态"。

但这是"是什么",不是"为什么"。

我要从第一性原理出发,解释这三个概念。

3.1 封装:物自体的不可知性

先说封装。

教科书说:封装是把数据和操作数据的方法包装在一起,对外隐藏实现细节。

这个解释没错,但太浅了。

封装的本质是:属性本质上是不可探测的。

想象一个物体放在你面前。没有任何探测手段,没有任何仪器,你怎么知道它内部有什么结构?

你做不到。你唯一能做的,是和它交互:推它一下,看它动不动;敲它一下,听它响不响。

你只能通过行为推测内部结构,而不能直接获取内部状态。

这是哲学里的"物自体"概念。康德说:事物本身(物自体)是不可知的,我们只能认识现象(事物对我们的显现)。

对象就是物自体。

public class User {
    private String name;  // 物自体的内部状态

    public String getDisplayName() {  // 现象:对象对我们的显现
        return this.name.toUpperCase();
    }
}

name 是私有的,不是因为它"需要被保护",而是因为它本来就是不可探测的getDisplayName() 是公有的,因为这是对象对我们的"显现"。

任何能被我们察觉到的,都是对外表现的行为,而不是直接是属性。

我们日常的与万物的交互,都是通过一个对象的行为来推测的,而不是直接能得到其"属性"。

反射的破坏性

这里可以引出反射的问题。

反射是什么?反射是上帝视角工具。

Field field = User.class.getDeclaredField("name");
field.setAccessible(true);
String name = (String) field.get(user);

这段代码做了什么?它绕过了对象的"现象",直接获取了"物自体"的内部状态。

这对 OOP 有破坏性。

注意:我说"破坏性"不是贬义。反射有很多合法用途:序列化、ORM、依赖注入。但它确实破坏了 OOP 的逻辑闭环。

为什么?

因为在 OOP 的哲学里,对象的状态应该是不可直接访问的。反射打破了这个规则,让逻辑在 OOP 层面无法闭环。

有人说:但反射套件本身也是对象啊,FieldMethod 都是对象。

没错,这是语言语法层面的闭环,但不是 OOP 行为本身的闭环。就像一个游戏里,角色不能穿墙,但玩家可以按 F5 重置位置——这在游戏规则之外,但游戏引擎层面是合法的。

3.2 继承:is-a 关系的真相

继承是什么?

见过最多的说法是:继承是为了代码复用。

我认为这个解释误导了无数人。

继承的本质是:泛化的手段,符合世界规则的直觉方式。

强调三遍的原则:

继承必须是 is-a 关系!

继承必须是 is-a 关系!

继承必须是 is-a 关系!

怎么判断是不是 is-a 关系?很简单:把你写的类用 A is a B 读出来,判断这句话对不对。

// 正确的继承
class Dog extends Animal {}  // Dog is a Animal ✓

// 错误的继承(企业级常见病)
class UserServiceImpl implements UserService {}  // UserServiceImpl is a UserService ???
class OrderController extends BaseController {}  // OrderController is a BaseController ???

第二段代码,你读出来看看对不对?

UserServiceImpl 是一个 UserService 吗?不,它是 UserService 的一个实现。

OrderController 是一个 BaseController 吗?不,它只是复用了 BaseController 的代码。

这不是 is-a 关系,这是 has-code 关系。

继承 vs 组合

然后说到"组合优于继承"。

这句话本身没错,但它被误解了。

"组合优于继承"的前提是:当你没有 is-a 关系的时候。

如果你有 is-a 关系,继承是自然的选择。DogAnimal 是 is-a 关系,不用继承用什么?

但如果你只是为了复用代码,而没有 is-a 关系,那就用组合。

// 错误:为了复用而继承
class Stack extends ArrayList {}  // Stack is a ArrayList ???

// 正确:用组合
class Stack {
    private List<Integer> list = new ArrayList<>();
    public void push(int x) { list.add(x); }
    public int pop() { ... }
}

Stack 是一个 ArrayList 吗?不,它内部用了一个 ArrayList

所以"组合优于继承"的真正意思是:不要为了复用而破坏 is-a 关系

我见过的一些让我想吐槽的实践:

// 两个 Bean 做继承,就因为它们都有 id 和 createTime
class Order extends BaseEntity {}
class User extends BaseEntity {}

Order 是一个 BaseEntity 吗?BaseEntity 是什么?是一个哲学概念吗?

我不知道"组合优于继承"这句话是什么情况下得出来的,但它肯定不是在 JavaEE 的 Bean 继承场景下得出来的。

3.3 多态:对象自己决定如何响应

多态是什么?

教科书说:多态是同一接口,多种实现。

这个解释没错,但可以更深入。

多态的本质是:让对象自己决定如何响应,而不是让调用者决定对象是什么。

Animal a = new Dog();
a.speak();  // 对象自己决定如何响应

这段代码里,调用者(a.speak())不需要知道 aDog 还是 Cat。调用者只需要知道 a 是一个 Animal,而 Animalspeak() 方法。

至于具体怎么 speak,那是对象自己的事。

多态是封装的延伸。

既然我们只能通过行为认识对象,那么不同对象对同一消息有不同响应是自然的。

多态也是泛化的执行层面体现。

继承建立了类型层次(DogAnimal 的子类),多态让这个层次在运行时工作(a.speak() 会根据实际类型调用正确的方法)。

基于继承的多态 vs 基于接口的多态

多态有两种形式:

// 基于继承的多态
Animal a = new Dog();
a.speak();

// 基于接口的多态
Flyable f = new Bird();
f.fly();

第一种是类型驱动的多态:DogAnimal 的子类,所以可以统一处理。

第二种是能力驱动的多态:Bird 实现了 Flyable 接口,所以可以统一处理。

第二种更接近多态的本质。

因为现实世界里,我们更多是根据"能做什么"来分类,而不是根据"是什么"来分类。

  • 会飞的东西:鸟、飞机、超人
  • 能吃的东西:苹果、面包、饭
  • 可以取消的东西:订单、订阅、任务

这些分类跨越了类型边界,但共享同一个"能力"。

这也是为什么现代语言(如 Go)用接口 + 结构体也能实现 OOP 的核心思想,而不需要类的继承体系。


四、接口 vs 类:能力与身份的区别

4.1 第一性原理的解释

为什么有了继承还要有接口?为什么接口可以多实现?

答案藏在实践里。

大部分接口的后缀都喜欢用 able

interface Flyable { void fly(); }
interface Cancellable { void cancel(); }
interface Observable { void observe(); }

able 是什么?是"能够"。

接口表达的就是能力!

类(继承)接口(实现)
表达"是什么"(is-a)表达"能做什么"(can-do)
命名是名词:User, Order命名是形容词:Flyable, Cancellable
单继承(一个东西只能有一个本质身份)多实现(一个东西可以有多种能力)

为什么继承是单继承?因为现实世界中一个东西只能有一个本质身份。

为什么接口是多实现?因为一个东西可以有多种能力。

class Bird extends Animal implements Flyable, Singable, NestBuildingable {}

一只鸟是一个动物(本质身份),但它能飞、能唱、能筑巢(多种能力)。

4.2 接口命名的真相

好的命名会告诉你设计的本质。

  • 如果接口名叫 Xxxable,它在描述能力
  • 如果接口名叫 XxxService,它在描述角色
  • 如果类名叫 Xxx,它在描述实体

当命名和设计目的一致时,代码是自然流畅的;当不一致时,代码就开始拧巴了。

比如:

// 拧巴的代码
interface UserService { ... }
class UserServiceImpl implements UserService {}

谁会写第二个 UserService 的实现?UserServiceImplV2AdminUserService

不会的。业务接口天生就是单一的,不需要多态。

所以企业级开发里面,所有的业务代码都定义一个接口,其实很傻逼(抛开框架需要的话,但是很多人学的时候被告知的是方便扩展,这是规范)。

本身业务就是天天变的,没人会去想着再实现一个你那 B 业务接口。

但类似短信平台、数据源等这种,是推荐用接口和多实现的:

// 合理的接口设计
interface SmsProvider {
    void send(String phone, String message);
}

class AliyunSms implements SmsProvider {}
class TencentSms implements SmsProvider {}

这个接口有明确的能力边界,而且确实有多个实现的需求。

4.3 自然的设计演进

理解了这一点,编码就会自然而然:

// 先定义能力接口
interface Flyable { void fly(); }
interface PoisonAttack { void poisonAttack(); }

// 再定义基础类
abstract class Enemy {
    protected int hp;
    protected int damage;
}

// 然后实现具体的敌人
class FlyingPoisonEnemy extends Enemy implements Flyable, PoisonAttack {
    public void fly() { ... }
    public void poisonAttack() { ... }
}

这个设计是从第一性原理推出来的,不是从"设计模式"背出来的。


五、现状:组件系统与传统 OOP 的演进

5.1 传统 OOP 的类爆炸问题

游戏开发里有个经典问题。

假设你做一个游戏,敌人有不同的能力组合:

class FlyingEnemy extends Enemy implements Flyable {}
class PoisonEnemy extends Enemy implements PoisonAttack {}
class FlyingPoisonEnemy extends Enemy implements Flyable, PoisonAttack {}
class FlyingPoisonSummonEnemy extends Enemy implements Flyable, PoisonAttack, Summoner {}
class FlyingExplodingEnemy extends Enemy implements Flyable, Explodable {}
// ... 继续排列组合

每加一个能力组合,就要一个新类。

这不是工程问题,这是数学问题:n 个能力,就有 2^n 种组合。

5.2 组件系统的解决方案

组件系统怎么解决?

// 组件系统:运行时组合
Entity entity = new Entity();
entity.addComponent(new FlyComponent());
entity.addComponent(new PoisonAttackComponent());

// 动态变化
entity.removeComponent(FlyComponent.class);  // 不能飞了
entity.addComponent(new ExplodableComponent());  // 可以爆炸了

能力变成了运行时状态,而不是编译时类型。

维度传统 OOP组件系统
本质定义类定义"是什么"(静态身份)组件集合定义"当前有什么能力"(动态状态)
变化粒度类型级别的变化需要新类实例级别的变化只需添加/移除组件
组合时机编译时组合运行时组合

5.3 为什么组件系统还是 OOP

组件系统(如 ECS)乍一看好像是"反 OOP"的。

我认为恰恰相反:组件系统是更纯粹的 OOP。

为什么?

设计者在更高的层面上,找到了一个更优的抽象逻辑。

因为现实世界中,一个东西的"能力"本身就是动态的。

  • 你穿上泳衣,就"能游泳了"
  • 你脱下泳衣,就"不能游泳了"
  • 你学会开车,就"能开车了"
  • 你喝醉了,就"不能开车了"

传统 OOP 用类定义"是什么",无法表达这种动态变化。你的类定义好了,这个对象的"身份"就固定了。

组件系统用"有什么组件"定义当前状态,更符合现实。

组件系统不是抛弃了 OOP,而是把封装的粒度从"类"变成了"组件",把组合的时机从"编译时"移到了"运行时"。

这是一种更纯粹的 OOP。

5.4 设计模式的应对方案

在传统 OOP 中,怎么应对类爆炸问题?

答案是:设计模式。

模式解决思路与组件系统的关系
策略模式把算法抽象成接口,运行时切换类似组件,但粒度更细
装饰器模式动态添加职责几乎就是组件系统的思想
组合模式用树形结构表示部分 - 整体组件系统的理论基础之一

设计模式是"补丁",组件系统是"原生支持"。

设计模式告诉你:"你可以用这个模式解决类爆炸问题"。

组件系统告诉你:"这个语言原生支持组件,你不需要设计模式"。

但这有个前提:你的语言支持组件系统。Java 不支持吗?支持,但需要你自己写框架。C++ 不支持吗?支持,但需要你自己实现 ECS。

语言的选择,决定了你解决问题的成本。

5.5 解耦不全是好的,耦合不全是坏的

经常听到一句话:"高内聚,低耦合"。

这句话本身没错,但它被误解了。

耦合不是敌人,糟糕的耦合才是。

游戏开发里的类耦合是什么样的?

// 游戏里的对象,耦合是通畅的
class Player {
    private Weapon weapon;
    private Armor armor;

    void attack(Enemy target) {
        int damage = this.weapon.getDamage() + this.getStrengthBonus();
        target.takeDamage(damage);
    }
}

这段代码耦合了吗?耦合了。

但这种耦合是合理的、顺畅的、符合直觉的

  • 玩家有武器,很合理
  • 攻击敌人时计算伤害,很自然
  • 对象之间互相调用,很通畅

企业级开发里的解耦是什么样的?

// 企业级开发,为了耦合而解耦
interface IUserService {
    UserDTO getUserById(Long id);
}

@Service
public class UserServiceImpl implements IUserService {
    @Autowired
    private IUserMapper userMapper;

    @Override
    public UserDTO getUserById(Long id) {
        return userMapper.selectById(id);
    }
}

这段代码解耦了吗?解耦了。

但这种解耦是必要的吗

  • 一个永远不会有第二个实现的接口
  • 一层又一层的抽象
  • 为了"可扩展性"牺牲了可读性

企业级开发追求解耦,游戏开发接受耦合。

为什么?

因为企业级开发的耦合,往往是人为制造的耦合

  • 两个 Bean 都有 id,所以要继承 BaseEntity
  • 为了"方便测试",所以要注入接口
  • 为了"以后可能扩展",所以要加分层

而游戏开发的耦合,是业务本身的耦合

  • 玩家本来就有武器
  • 攻击本来就要计算伤害
  • 对象之间本来就要交互

解耦的目的是降低复杂度,不是为了耦合而解耦。

如果一个耦合是符合直觉的、是业务本质的,那它就不需要被解掉。


六、Java 框架吐槽大会

好了,下面进入吐槽环节。以下内容可能引起部分 Javaer不适,请酌情阅读。

6.1 过度抽象

想知道一个方法最终调用了什么?按住 Ctrl 点进去,再点进去,再点进去……点到第七层的时候,你已经忘了自己最初要找什么。

Java 企业级开发的分层:

Controller → Service → Manager → DAO → Entity
            ↓         ↓        ↓       ↓
           DTO       BO       PO      DO
            ↓         ↓        ↓       ↓
        Converter  Mapper  Assembler ...

一个字段从数据库到前端,旅行了一整个微服务架构。

我不是说分层不好。分层是好的,但过度分层就是折磨人了。

我见过最离谱的项目:

  • UserController 调用 UserService
  • UserService 调用 UserManager
  • UserManager 调用 UserRemoteService
  • UserRemoteService 调用 UserFeignClient
  • UserFeignClient 调用远程服务
  • 远程服务返回 UserRpcDTO
  • 转成 UserDTO
  • 转成 UserVO
  • 转成 UserResponse

一个 selectById 能写出九曲十八弯的感觉。

6.2 配置大于代码

@Transactional(rollbackFor = Exception.class, propagation = Propagation.REQUIRED)
@Cacheable(value = "user", key = "#id", unless = "#result == null", sync = true)
@Async("userExecutor")
@Scheduled(cron = "0 0/5 * * * ?")
@EnableSomethingImportant
public class MyService {
    // 真正的代码?谁在乎呢
}

注解比代码多,配置比逻辑复杂。

出了问题?查文档吧。注解的背后是三千行自动配置,是二十个条件装配,是五十个 Bean 的创建顺序。

"约定优于配置"的 Spring Boot,最后变成了"配置优于代码"。

6.3 框架职责过度

框架:我已经帮你设计好了一切,你只需要填空。

我:那我能自己设计吗?

框架:不能,填就完了。

Java 框架的经典套路:

  1. 继承 AbstractXxxService
  2. 实现 XxxAware
  3. 注入 XxxTemplate
  4. 重写 doSomething() 方法
  5. @PostConstruct 里初始化
  6. @PreDestroy 里清理

框架帮你决定了包名、类名、方法名、甚至变量名。

你不是在写代码,你是在完成框架的服从性测试。。。

6.4 AOP 过度切入:逻辑在哪?

日志、权限、事务、缓存、监控……甚至是莫名其妙的功能都用 AOP 切进去,然后祈祷它能正常工作。

AOP 的问题是什么?

  • 代码里看不到逻辑,因为逻辑在切面里
  • 出了 bug 不知道在哪,因为堆栈里有 20 个代理类
  • 你想调试,但断点永远进不去,因为代码是动态生成的

显式优于隐式?不,隐式才是高级感? 也不是。

6.5 DDD 也是屎:换皮不换骨

DDD:领域驱动设计

实际:领域贫血,服务驱动,数据库驱动

DDD 的理念是好的:把业务逻辑放在领域对象里,而不是放在 Service 里。也就是所谓的"充血模型"。

但实际开发中是什么情况?

问题一:贫血模型

// 说好的领域对象呢?
public class Order {
    private Long id;
    private Long userId;
    private BigDecimal amount;
    // getter and setter
}

// 逻辑全在 Service 里
public class OrderService {
    public void createOrder(Order order) {
        // 校验用户
        // 校验库存
        // 计算价格
        // 创建订单
        // 发送事件
    }
}

说是 DDD,实际上 Entity 只有 getter/setter。逻辑全在 Service 里,Domain 成了数据容器。

问题二:就算你充血了,也还是屎

退一步说,就算你真的把逻辑放进 Entity 里:

public class Order {
    public void calculatePrice() { ... }
    public void validate() { ... }
}

但 CRUD 的本质就是数据流转啊。你给 Order 加了十个方法,最后它还是要变成 DTO、变成 VO、变成 JSON。

DDD 改变不了 CRUD 是屎的事实。

问题三:过度设计

一个简单 CRUD,搞出:

  • Entity(数据库映射)
  • DTO(数据传输)
  • VO(视图对象)
  • BO(业务对象)
  • DO(领域对象)

然后还有一个完整的分层:

  • Controller 层
  • Service 层
  • Manager 层
  • Domain 层
  • Repository 层

累不累啊?

问题四:术语滥用

什么业务都往 DDD 上靠:

  • 用户管理 = 聚合根?
  • 增删改查 = 领域事件?
  • 数据库表 = 实体?

话说回来:

CRUD 就是用 DDD 写也是 CRUD,屎的味道不会变。

你换个包装,屎还是屎。

甚至有时候,贫血模型 + 规范分层拉出来的屎,至少形状统一,看起来还规整一点。

6.6 编程乐趣的丧失

以前的编程:设计算法、优化结构、挑战难题

现在的编程:@Autowiredreturn xxxMapper.selectById(id)

业务开发成了数据搬运工。

  • 数据库 → Entity
  • Entity → DTO
  • DTO → VO
  • VO → JSON

唯一的变化是字段名从 user_name 变成了 userName

这不是编程,这是体力劳动。


七、框架的真相:服务于企业,不是服务于你

7.1 你以为框架是为了什么?

很多开发者有一个误解:

框架是为了帮我写出优雅的代码。

错。

框架的真正目的是:

让任何一个中级工程师,都能在半天内看懂你的代码,然后接替你的工作。

开发者以为的实际的
框架帮我写出优雅的代码框架帮我写出可替换的代码
框架服务于开发者框架服务于企业
命名规范是为了代码好看命名规范是为了任何人能快速看懂
分层设计是为了架构清晰分层设计是为了流水线作业

7.2 框架的真正价值

  1. 降低对人的要求 —— 不需要天才程序员,只需要会填空的工程师
  2. 提高可替换性 —— 你离职了,随便招个人都能接手
  3. 统一输出质量 —— 下限提高了,上限也锁死了
    但这是工程化的必然。

7.3 这不是坏事,是工程化最终必然导致的结果

企业需要的是什么?

企业需要的是稳定交付,不是代码艺术。

框架是工业流水线,不是个人工作室。

CRUD 本来就是屎,框架只是让屎有了统一的形状。

统一的屎,也是屎。但至少它看起来不那么屎了。

7.4 所以

  • 别怪框架让你不爽,框架本来就不是为你爽的
  • 别怪代码没有乐趣,CRUD 本身就没有乐趣
  • 框架让你成为了程序员里的螺丝钉,但企业需要的就是螺丝钉

这是现实。但也不要难过


八、总结

8.1 编程范式的真相

写了这么多,我想说的核心观点是什么?

编程范式没有最好的,只有最适合场景的。

  • OOP 不是信仰,是工具
  • 框架不是敌人,是工程化的产物
  • 组件系统不是反 OOP,是更纯粹的 OOP

理解这一点,你就不会被各种"最佳实践"绑架。

8.2 企业级开发的真相

企业级开发的真相是什么?

  • 框架服务于企业,不是服务于你
  • 规范是为了可替换性,不是为了代码质量
  • CRUD 就是屎,框架只是让屎有统一的形状

理解这一点,你就不会对框架有不切实际的期待。

8.3 那我们能做什么?

既然框架是流水线,既然 CRUD 是屎,那我们还能做什么?

理解规则,然后选择:接受还是突破。

  • 接受:在企业级开发里,按框架的规则写代码,保证可维护性和可替换性
  • 突破:在业余项目里,写自己想写的代码,保持对编程的热爱

在流水线里,偶尔偷偷写点让自己骄傲的代码。

8.4 最后的建议

学习语言的时候,理解其设计的核心思想和第一性原理。

你会发现:

好的设计与实现,永远都是从上到下、从里到外,都能体现它的统一的思想和理念的。

Java 的 OOP 逻辑自举可能没有 Python 完善,但 Java 的编程范式思想是贯穿始终的。哪怕引入的函数式编程,也没有脱离 OOP 的体系。

理解这一点,你学习任何语言都会更快。

因为你不是在学"语法",你是在学"设计思想"。


后记

这篇文章写了很多我的个人观点。

有些观点可能偏激,有些观点可能片面。

但这就是我的真实想法。

编程不是只有优雅和屎,它中间有很多灰色的东西。


如果你觉得这篇文章对你有启发,或者你不同意我的观点,欢迎留言讨论。

文章是不是为了教会你什么,而是如何在思考中,知道自己在做什么,而不是一味地听从别人的说法。

但如果你说"Spring Boot 就是最好的框架",那我也不会反驳你。

因为你开心就好。

消息盒子

# 暂无消息 #

只显示最新10条未读和已读信息