什么是软件架构
架构的本质
架构这个词源于英文里的"Architecture",在建筑学中指的是建筑物的整体结构设计。类比到软件领域,软件架构就是软件系统的"骨架",它定义了:
- 代码的组织结构:代码如何分层、模块如何划分
- 设计模式:解决常见问题的标准方案
- 开发规范:统一的编码风格和约定
- 组件通信方式:不同模块之间如何交互
就像建房子需要先设计框架结构一样,开发软件也需要先设计架构。一个好的架构能让系统更安全、更稳定、更容易扩展和维护。
为什么需要架构
想象一下,如果没有统一的架构设计,会发生什么:
- 代码混乱:每个开发者按自己的方式写代码,风格不统一
- 难以维护:修改一个功能可能影响其他功能,牵一发而动全身
- 难以扩展:添加新功能需要大量修改现有代码
- 难以测试:业务逻辑与外部依赖耦合,无法独立测试
- 团队协作困难:新人难以理解代码结构,沟通成本高
而有了良好的架构设计,这些问题都能得到解决。架构就像是团队开发的"交通规则",让所有人按照统一的方式工作,降低沟通成本,提升开发效率和代码质量。
优秀架构的特征
一个好的架构应该具备以下特征,这些特征也被称为"架构的独立性原则":
1. 独立于框架
含义:业务逻辑不应该被框架"绑架",框架可以替换。
例子:
- ❌ 不好的做法:业务代码直接使用 Spring 的
@Transactional,如果换成其他框架就需要重写 - ✅ 好的做法:定义自己的事务接口,框架实现可以替换
为什么重要:框架会升级、会变化,甚至可能被淘汰。如果业务逻辑与框架强耦合,框架变化时业务代码也需要大量修改。
2. 独立于 UI
含义:业务逻辑不应该依赖具体的用户界面。
例子:
- ❌ 不好的做法:业务代码中直接使用
HttpServletRequest、HttpSession等 Web 特有对象 - ✅ 好的做法:业务逻辑接收标准的业务对象,由 Controller 层负责转换
为什么重要:用户界面需求变化频繁,今天可能是网页,明天可能是移动 App。如果业务逻辑与 UI 耦合,每次 UI 变化都需要修改业务代码。
3. 独立于底层数据源
含义:业务逻辑不应该依赖具体的数据库或存储方式。
例子:
- ❌ 不好的做法:业务代码中直接写 SQL,使用数据库特有的函数
- ✅ 好的做法:通过 Repository 接口抽象数据访问,可以切换不同的数据库
为什么重要:数据库选择可能会变化,今天用 MySQL,明天可能需要迁移到 PostgreSQL 或 MongoDB。如果业务逻辑与数据库强耦合,更换数据库的成本会非常高。
4. 独立于外部依赖
含义:业务逻辑不应该依赖具体的第三方服务或中间件。
例子:
- ❌ 不好的做法:业务代码直接调用第三方 API,使用第三方 SDK 的特定方法
- ✅ 好的做法:定义自己的服务接口,第三方服务的实现可以替换
为什么重要:第三方服务会变化、会升级,甚至可能下线。如果业务逻辑与外部依赖强耦合,任何外部变化都会影响核心业务。
5. 可测试性
含义:业务逻辑应该能够独立测试,不依赖外部环境。
例子:
- ❌ 不好的做法:测试转账功能需要启动真实的数据库和消息队列
- ✅ 好的做法:通过依赖注入,测试时可以使用内存数据库和 Mock 的消息队列
为什么重要:测试是保证代码质量的重要手段。如果业务逻辑与外部依赖强耦合,测试时需要启动各种外部服务,测试成本高、速度慢。
架构的层次
软件架构可以分为不同的层次:
- 系统架构(宏观):关注整个系统的组织方式,如微服务架构、SOA 架构
- 应用架构(中观):关注单个应用内部的代码组织,如分层架构、六边形架构
- 代码架构(微观):关注具体的代码实现,如设计模式、代码规范
当前的问题
很多团队在做业务研发时,更多地关注宏观的系统架构(如微服务架构),而忽略了应用内部的架构设计。这就像只关注房子的外观,而忽略了内部结构设计,导致:
- 代码逻辑混乱,各种职责混在一起
- 难以维护,修改一个功能影响其他功能
- 容易产生 Bug,边界情况难以覆盖
- 难以测试,业务逻辑与外部依赖耦合
因此,我们不仅要关注宏观的系统架构,更要重视应用内部的架构设计,这是保证代码质量和系统稳定性的基础。
常见的架构模式
- 分层架构:将系统分为多个层次,如表现层、业务逻辑层、数据访问层。
- 微服务架构:将系统拆分为多个独立的服务,每个服务负责特定的业务功能。
- 事件驱动架构:基于事件的松耦合系统设计。
- 领域驱动设计:围绕业务领域模型组织系统结构。
案例
我们先看一个简单的案例需求如下: 用户可以通过银行网页转账给另一个账号,支持跨币种转账。 同时因为监管和对账需求,需要记录本次转账活动。
| 需求编号 | 功能需求 | 技术实现 | 备注 |
|---|---|---|---|
| 1 | 账户查询 | 使用 MyBatis mapper 实现 DAO | 从 MySQL 数据库查询转出和转入账户信息 |
| 2 | 汇率查询 | 调用 Yahoo 汇率服务 API | 通过 HTTP 接口获取实时汇率信息 |
| 3 | 转账金额计算 | 业务逻辑计算 | - 验证账户余额充足 - 检查每日转账限额 |
| 4 | 转账操作 | 数据库事务处理 | - 执行转入转出操作 - 扣除手续费 - 更新账户余额 |
| 5 | 审计记录 | Kafka 消息队列 | 发送审计消息用于监管和对账 |
而一个简单的代码实现如下:
public class TransferController {
private TransferService transferService;
public Result<Boolean> transfer(String targetAccountNumber, BigDecimal amount, HttpSession session) {
Long userId = (Long) session.getAttribute("userId");
return transferService.transfer(userId, targetAccountNumber, amount, "CNY");
}
}
public class TransferServiceImpl implements TransferService {
private static final String TOPIC_AUDIT_LOG = "TOPIC_AUDIT_LOG";
private AccountMapper accountDAO;
private KafkaTemplate<String, String> kafkaTemplate;
private YahooForexService yahooForex;
@Override
public Result<Boolean> transfer(Long sourceUserId, String targetAccountNumber, BigDecimal targetAmount, String targetCurrency) {
// 1. 从数据库读取数据,忽略所有校验逻辑如账号是否存在等
AccountDO sourceAccountDO = accountDAO.selectByUserId(sourceUserId);
AccountDO targetAccountDO = accountDAO.selectByAccountNumber(targetAccountNumber);
// 2. 业务参数校验
if (!targetAccountDO.getCurrency().equals(targetCurrency)) {
throw new InvalidCurrencyException();
}
// 3. 获取外部数据,并且包含一定的业务逻辑
// exchange rate = 1 source currency = X target currency
BigDecimal exchangeRate = BigDecimal.ONE;
if (sourceAccountDO.getCurrency().equals(targetCurrency)) {
exchangeRate = yahooForex.getExchangeRate(sourceAccountDO.getCurrency(), targetCurrency);
}
BigDecimal sourceAmount = targetAmount.divide(exchangeRate, RoundingMode.DOWN);
// 4. 业务参数校验
if (sourceAccountDO.getAvailable().compareTo(sourceAmount) < 0) {
throw new InsufficientFundsException();
}
if (sourceAccountDO.getDailyLimit().compareTo(sourceAmount) < 0) {
throw new DailyLimitExceededException();
}
// 5. 计算新值,并且更新字段
BigDecimal newSource = sourceAccountDO.getAvailable().subtract(sourceAmount);
BigDecimal newTarget = targetAccountDO.getAvailable().add(targetAmount);
sourceAccountDO.setAvailable(newSource);
targetAccountDO.setAvailable(newTarget);
// 6. 更新到数据库
accountDAO.update(sourceAccountDO);
accountDAO.update(targetAccountDO);
// 7. 发送审计消息
String message = sourceUserId + "," + targetAccountNumber + "," + targetAmount + "," + targetCurrency;
kafkaTemplate.send(TOPIC_AUDIT_LOG, message);
return Result.success(true);
}
}
我们可以看到,一段业务代码里经常包含了参数校验、数据读取存储、业务计算、调用外部服务、发送消息等多种逻辑。在这个案例里虽然是写在了同一个方法里,在真实代码中经常会被拆分成多个子方法,但实际效果是一样的,而在我们日常的工作中,绝大部分代码都或多或少的接近于此类结构。在 Martin Fowler 的 P of EAA 书中,这种很常见的代码样式被叫做 Transaction Script(事务脚本)。虽然这种类似于脚本的写法在功能上没有什么问题,但是长久来看,他有以下几个很大的问题:可维护性差、可扩展性差、可测试性差。

问题一-可维护性能差
一个应用最大的成本一般都不是来自于开发阶段,而是应用整个生命周期的总维护成本,所以代码的可维护性代表了最终成本。
参考以上的案例代码,事务脚本类的代码很难维护因为以下几点:
- 数据结构的不稳定性:AccountDO 类是一个纯数据结构,映射了数据库中的一个表。这里的问题是数据库的表结构和设计是应用的外部依赖,长远来看都有可能会改变,比如数据库要做 Sharding,或者换一个表设计,或者改变字段名。
- 依赖库的升级:AccountMapper 依赖 MyBatis 的实现,如果 MyBatis 未来升级版本,可能会造成用法的不同(可以参考 iBatis 升级到基于注解的 MyBatis 的迁移成本)。同样的,如果未来换一个 ORM 体系,迁移成本也是巨大的。
- 第三方服务依赖的不确定性:第三方服务,比如 Yahoo 的汇率服务未来很有可能会有变化:轻则 API 签名变化,重则服务不可用需要寻找其他可替代的服务。在这些情况下改造和迁移成本都是巨大的。同时,外部依赖的兜底、限流、熔断等方案都需要随之改变。
- 第三方服务 API 的接口变化:YahooForexService.getExchangeRate 返回的结果是小数点还是百分比?入参是(source, target)还是(target, source)?谁能保证未来接口不会改变?如果改变了,核心的金额计算逻辑必须跟着改,否则会造成资损。
- 中间件更换:今天我们用 Kafka 发消息,明天如果要上阿里云用 RocketMQ 该怎么办?后天如果消息的序列化方式从 String 改为 Binary 该怎么办?如果需要消息分片该怎么改?
我们发现案例里的代码对于任何外部依赖的改变都会有比较大的影响。如果你的应用里有大量的此类代码,你每一天的时间基本上会被各种库升级、依赖服务升级、中间件升级、jar 包冲突占满,最终这个应用变成了一个不敢升级、不敢部署、不敢写新功能、并且随时会爆发的炸弹,终有一天会给你带来惊喜。
问题 2-可拓展性差
事务脚本式代码的第二大缺陷是:虽然写单个用例的代码非常高效简单,但是当用例多起来时,其扩展性会变得越来越差。 参考以上的代码,如果今天需要增加一个跨行转账的能力,你会发现基本上需要重新开发,基本上没有任何的可复用性:
-数据来源被固定、数据格式不兼容:原有的 AccountDO 是从本地获取的,而跨行转账的数据可能需要从一个第三方服务获取,而服务之间数据格式不太可能是兼容的,导致从数据校验、数据读写、到异常处理、金额计算等逻辑都要重写。
-业务逻辑无法复用:数据格式不兼容的问题会导致核心业务逻辑无法复用。每个用例都是特殊逻辑的后果是最终会造成大量的 if-else 语句,而这种分支多的逻辑会让分析代码非常困难,容易错过边界情况,造成 bug。
-逻辑和数据存储的相互依赖:当业务逻辑增加变得越来越复杂时,新加入的逻辑很有可能需要对数据库 schema 或消息格式做变更。而变更了数据格式后会导致原有的其他逻辑需要一起跟着动。在最极端的场景下,一个新功能的增加会导致所有原有功能的重构,成本巨大。
在事务脚本式的架构下,一般做第一个需求都非常的快,但是做第 N 个需求时需要的时间很有可能是呈指数级上升的,绝大部分时间花费在老功能的重构和兼容上,最终你的创新速度会跌为 0,促使老应用被推翻重构。
架构设计原则
- 单一性原则(Single Responsibility Principle):单一性原则要求一个对象/类应该只有一个变更的原因。但是在这个案例里,代码可能会因为任意一个外部依赖或计算逻辑的改变而改变。
- 依赖反转原则(Dependency Inversion Principle):依赖反转原则要求在代码中依赖抽象,而不是具体的实现。在这个案例里外部依赖都是具体的实现,比如 YahooForexService 虽然是一个接口类,但是它对应的是依赖了 Yahoo 提供的具体服务,所以也算是依赖了实现。同样的 KafkaTemplate、MyBatis 的 DAO 实现都属于具体实现。
- 开放封闭原则(Open Closed Principle):开放封闭原则指开放扩展,但是封闭修改。在这个案例里的金额计算属于可能会被修改的代码,这个时候该逻辑应该需要被包装成为不可修改的计算类,新功能通过计算类的拓展实现。
