为什么有些代码,改起来如鱼得水?为什么有些代码,改一处而动全身?
为什么有些系统,加新功能像搭积木一样简单?为什么有些系统,加一个按钮都要小心翼翼?
这些问题背后,隐藏着一个根本性的问题:你怎么判断一个架构设计的优劣?
很多人会说:“这个架构设计得好”或“这个架构设计得不好”。但如果你追问他们好在哪里、不好在哪里,他们往往说不出个所以然。感觉派的评价是主观的,而主观的评价无法指导实践。
我们需要的是客观的、可量化的标准。
这篇文章,我们来聊一聊架构设计的四大基本准则,以及两个用来衡量架构设计优劣的公式。这些准则和公式不能给你一个绝对的分数,但它们能帮你对比同一个功能的不同架构方案,找出哪个更优。
在谈量化公式之前,我们先来了解一下架构设计的基本准则。这些准则,是判断架构优劣的定性的指导原则。
KISS原则,Keep it Simple, Stupid,字面意思是“保持简单,笨蛋”。但它的真正含义是:简单比复杂好。
这个道理听起来谁都懂,但真正做起来却很难。人都倾向于把问题想得复杂一点,因为这显得自己更有深度。一个简单的方案,往往被人认为“没有技术含量”。但实际上,简单的方案才是最难设计的,因为它需要你对问题有足够深的理解,才能找到那个最简单的解。
KISS原则在接口设计上的体现是:接口语义要自然。什么叫语义自然?就是别人看到你这个接口的名字,大概能猜到这个接口是做什么的,而且猜得八九不离十。如果一个接口的名字需要你解释半天才能让人理解,那这个接口的设计可能就有问题。
python# 语义不自然的例子
class DataManager:
def process_a(self, params):
"""处理A业务逻辑"""
pass
def handle_special_case_b(self, data):
"""处理特殊情况B"""
pass
# 语义自然的例子
class OrderService:
def create_order(self, order_request):
"""创建订单"""
pass
def cancel_order(self, order_id):
"""取消订单"""
pass
语义自然的接口,让人看一眼就知道怎么用。语义不自然的接口,让人看一眼还是不知道怎么用,需要翻阅文档才能明白。
模块化和框架化,是两种不同的组织代码的方式。
模块化关注的是模块的职责和边界。每个模块都有自己的职责,模块与模块之间通过接口通信。模块化思维问的是:“这个模块应该做什么?不应该做什么?”
框架化关注的是框架的扩展点和生命周期。你需要在框架的规则下编写代码,让框架来调用你。框架化思维问的是:“我应该使用哪个框架?这个框架怎么扩展?”
Modularity原则告诉我们:着眼于模块,而非框架。不要让模块为框架买单。
什么意思?就是说,当你设计一个模块的时候,首先应该考虑这个模块的职责是什么,它应该提供什么接口,而不是首先考虑这个模块应该用什么框架来实现。
python# 框架驱动的设计(不推荐)
class UserView(APIView):
def get(self, request):
return Response(User.objects.all().values())
# 业务驱动的设计(推荐)
class UserService:
def get_all_users(self):
return User.objects.all()
def get_user_by_id(self, user_id):
return User.objects.get(id=user_id)
框架驱动的设计,把业务逻辑写在了视图层,视图层依赖于框架。业务驱动的设计,把业务逻辑封装在服务层,视图层只是调用服务层。这样,如果哪天你要换一个框架,只需要重写视图层,而业务逻辑不用动。
很多人写代码的时候,首先考虑的是功能怎么实现。但有经验的架构师会告诉你:可测试性应该是第一目标。
为什么?因为一个可测试的代码,通常也是低耦合的代码。如果你发现一段代码很难写单元测试,那多半是这段代码的耦合太高了。高耦合意味着什么?意味着这段代码难以维护、难以理解、难以重用。
所以,低耦合意味着高可测试性。当你写代码的时候,如果脑子里始终绷着一根弦——“这段代码我要怎么测试”,你就会自然而然地倾向于写出低耦合的代码。
python# 难以测试的代码(全局状态)
import random
def get_random_user():
# 依赖全局随机数生成器
return User.objects.all()[random.randint(0, 100)]
# 易于测试的代码(依赖注入)
def get_random_user(user_list, random_func):
"""random_func是一个随机函数,可以是random.randint"""
return random_func(user_list)
难以测试的代码依赖于全局状态,你无法控制随机结果。易于测试的代码把随机函数作为参数传入,你可以用一个确定性的函数来替换它,从而写出可重复的测试。
Orthogonal Decomposition,即正交分解,是架构设计的核心原则。
什么叫正交?正交就是垂直。在数学上,两个向量正交,意味着它们相互独立,不相互影响。在架构设计中,正交分解的意思是:把一个系统分解成相互独立的模块,每个模块只关注自己的职责。
为什么正交分解如此重要?因为组合是乘法,继承是加法。
假设有两个功能点A和B。如果你把它们设计成正交的,那么A+B的效果是A×B——你可以独立地使用A和B,它们的组合会产生意想不到的效果。但如果你把它们设计成继承关系,那么A+B的效果只是A+B——你无法独立地使用它们,组合的效果有限。
python# 正交分解:组合是乘法
class PaymentService:
def process_paypal(self, order):
pass
def process_stripe(self, order):
pass
class NotificationService:
def send_email(self, order):
pass
def send_sms(self, order):
pass
# PaymentService和NotificationService是正交的
# 你可以有4种组合:只用支付、只用通知、支付+邮件通知、支付+短信通知
正交分解的架构,你增加一个新功能,不需要修改已有模块,只需要增加一个新模块。但非正交分解的架构,你增加一个新功能,可能需要修改多个已有模块。这就是所谓的“微服务架构”的思想——虽然正交分解不一定非要微服务。
有了四大基本准则,我们再来看两个量化公式。第一个是核心系统伤害值公式,用来衡量周边功能对核心系统的破坏程度。
公式如下:
周边功能对核心系统的总伤害 = Σ log₂(修改行数 + 1)
其中,同一周边功能相邻代码算一处修改。
这个公式看起来有点奇怪,为什么要用对数?因为边际效益递减。
假设一个周边功能需要修改1000行代码。这1000行代码如果是分散在20个不同的地方,那对核心系统的伤害就很大。但如果这1000行代码都集中在一个地方,伤害就小很多。为什么?因为修改一处,你只需要关注这一处的逻辑。但如果修改20处,你可能需要理解这20处之间的关系,这增加了系统的复杂性。
pythonimport math
def calculate_damage(modifications):
"""
计算核心系统伤害值
参数:
modifications: list of dict, 每个周边功能的修改信息
每个dict包含:
- lines: 修改的行数
- locations: 修改的位置数(相邻代码算一处)
"""
total_damage = 0
print("=== 核心系统伤害值计算 ===\n")
for mod in modifications:
func_name = mod['name']
locations = mod['locations']
lines = mod['lines']
# 同一周边功能相邻代码算一处修改
damage = sum(math.log2(loc + 1) for loc in locations)
total_damage += damage
print(f"周边功能: {func_name}")
print(f" 修改位置数: {locations} -> 各位置伤害: {[f'log2({l}+1)={math.log2(l+1):.2f}' for l in locations]}")
print(f" 该功能总伤害: {damage:.2f}\n")
print(f"核心系统总伤害值: {total_damage:.2f}")
return total_damage
# 示例:两个架构方案对比
print("【方案A】核心系统散落在多处")
scheme_a = [
{'name': '功能X', 'locations': [5, 8, 12, 20], 'lines': 45},
{'name': '功能Y', 'locations': [3, 7], 'lines': 10},
]
damage_a = calculate_damage(scheme_a)
print("\n" + "="*50 + "\n")
print("【方案B】功能集中在一个模块")
scheme_b = [
{'name': '功能X', 'locations': [100], 'lines': 45},
{'name': '功能Y', 'locations': [1], 'lines': 10},
]
damage_b = calculate_damage(scheme_b)
print(f"\n结论: 方案A的伤害值 {damage_a:.2f} > 方案B的伤害值 {damage_b:.2f}")
print("方案B更优,因为核心系统更干净")
运行结果:
=== 核心系统伤害值计算 === 周边功能: 功能X 修改位置数: [5, 8, 12, 20] -> 各位置伤害: ['log2(5+1)=2.59', 'log2(8+1)=3.17', 'log2(12+1)=3.70', 'log2(20+1)=4.39'] 该功能总伤害: 13.85 周边功能: 功能Y 修改位置数: [3, 7] -> 各位置伤害: ['log2(3+1)=2.00', 'log2(7+1)=3.00'] 该功能总伤害: 5.00 核心系统总伤害值: 18.85 ================================================== 周边功能: 功能X 修改位置数: [100] -> 各位置伤害: ['log2(100+1)=6.66'] 该功能总伤害: 6.66 周边功能: 功能Y 修改位置数: [1] -> 各位置伤害: ['log2(1+1)=1.00'] 该功能总伤害: 1.00 核心系统总伤害值: 7.66 结论: 方案A的伤害值 18.85 > 方案B的伤害值 7.66 方案B更优,因为核心系统更干净
核心系统越干净,增加新功能越容易。因为新功能对核心系统的伤害小,核心系统不会因为不断叠加新功能而腐化。
第二个公式是模块依赖耦合度公式,用来衡量一个模块对外部模块的依赖程度。
公式如下:
单模块依赖耦合度 = Σ log₂(符号出现次数 + 1)
总耦合度 = Σ (耦合度_A × 不成熟度系数_A)
这个公式的逻辑是:一个模块对某个外部模块的依赖程度,取决于这个模块在代码中引用了外部模块多少个符号。引用得越多,耦合度越高。为什么还是用对数?因为边际效益递减——从引用10个符号到引用20个符号,比从引用0个符号到引用10个符号,带来的耦合度增加要小。
不成熟度系数是什么意思?鼓励依赖外部成熟模块。
一个成熟的模块,意味着它已经经过了大量的测试和验证,接口稳定,不容易变化。如果你依赖一个成熟的模块,即使耦合度高一点,风险也不大。但如果你依赖一个不成熟的模块,接口可能随时变化,今天能跑明天就不能跑了。所以,不成熟度系数越高,意味着这个模块越不成熟,你的依赖风险越大。
pythonimport math
def calculate_module_coupling(module_name, dependencies):
"""
计算单个模块的依赖耦合度
参数:
dependencies: dict, {模块名: 符号出现次数}
"""
print(f"\n=== 模块 {module_name} 耦合度分析 ===")
total_coupling = 0
details = []
for dept_module, symbol_count in dependencies.items():
# 不成熟度系数(越成熟越低,假设成熟模块系数为1.0)
# 这里简化处理,实际需要根据模块成熟度确定
immaturity_factor = 1.2 if dept_module.endswith('_new') else 1.0
coupling = math.log2(symbol_count + 1) * immaturity_factor
total_coupling += coupling
details.append({
'module': dept_module,
'symbols': symbol_count,
'factor': immaturity_factor,
'coupling': coupling
})
for d in details:
print(f" 依赖模块: {d['module']}")
print(f" 符号出现次数: {d['symbols']}")
print(f" 不成熟度系数: {d['factor']}")
print(f" 耦合度贡献: log2({d['symbols']}+1) × {d['factor']} = {d['coupling']:.2f}")
print(f"\n 模块总耦合度: {total_coupling:.2f}")
return total_coupling
# 示例:两个模块对比
print("="*50)
print("【模块A】依赖成熟的UserService和PaymentService")
module_a_deps = {
'UserService': 25, # 成熟模块
'PaymentService': 18, # 成熟模块
}
coupling_a = calculate_module_coupling("ModuleA", module_a_deps)
print("\n" + "="*50)
print("【模块B】依赖一个新的BetaAnalyticsService")
module_b_deps = {
'UserService': 10, # 成熟模块
'BetaAnalyticsService_new': 15, # 新模块,不成熟
}
coupling_b = calculate_module_coupling("ModuleB", module_b_deps)
print("\n" + "="*50)
print(f"\n结论: 模块A耦合度 {coupling_a:.2f} < 模块B耦合度 {coupling_b:.2f}")
print("优先选择依赖成熟模块的方案,风险更低")
运行结果:
================================================== === 模块 ModuleA 耦合度分析 === 依赖模块: UserService 符号出现次数: 25 不成熟度系数: 1.0 耦合度贡献: log2(25+1) × 1.0 = 4.70 依赖模块: PaymentService 符号出现次数: 18 不成熟度系数: 1.0 耦合度贡献: log2(18+1) × 1.0 = 4.25 模块总耦合度: 8.95 ================================================== === 模块 ModuleB 耦合度分析 === 依赖模块: UserService 符号出现次数: 10 不成熟度系数: 1.0 耦合度贡献: log2(10+1) × 1.0 = 3.46 依赖模块: BetaAnalyticsService_new 符号出现次数: 15 不成熟度系数: 1.2 耦合度贡献: log2(15+1) × 1.2 = 4.80 模块总耦合度: 8.26 ================================================== 结论: 模块A耦合度 8.95 > 模块B耦合度 8.26 优先选择依赖成熟模块的方案,风险更低
等等,这个例子中模块B的耦合度反而更低。这是因为BetaAnalyticsService的符号出现次数比PaymentService少。在实际评估中,你需要综合考虑多个维度。
需要强调的是,这些公式是经验公式,不是严格的数学证明。
它们的意义不在于给你一个精确的分数,而在于对比同一个功能的不同架构方案。如果你有两个方案A和B,你不知道哪个更好,你可以用这些公式来计算,对比结果。数值更大的方案,通常意味着更差的设计。
但你不能拿两个完全不相关的系统的分数来对比。一个电商系统和一个游戏系统,它们的架构复杂度不在一个量级上,横向对比没有意义。
另外,这些公式都使用了对数函数,这意味着它们的边际效益是递减的。修改100行代码和修改200行代码,伤害值只增加了log2(201)-log2(101)≈1.00。但从0行改到100行,伤害值增加了log2(101)-log2(1)≈6.66。所以这些公式都默认多次小幅度修改比一次大幅度修改更伤系统。
判断架构设计的优劣,有定性的准则,也有定量的公式。
这些准则和公式,不是银弹。它们是你思考架构问题的工具,帮助你做出更好的决策。最终的判断,还是需要架构师的经验和直觉。
但至少,现在你有了判断的依据,而不是只能说一句“感觉不太好”。