本篇文章需要认真对待,我把它当作一次“代码评审”后的经验复盘来写。标题里的 SOLID、DRY、KISS、YAGNI 和 OWASP 最佳实践,看起来是几组独立的词,但放到真实的项目里,它们其实是同一件事的两面:一个是“代码怎么设计才不容易烂”,一个是“系统怎么兜底才不容易被攻破”。下面我会结合自己实际做过的项目,把这套东西拆开讲透。
1. 整体设计思路:先理解四条原则和一个基线的关系
很多人把 SOLID、DRY、KISS、YAGNI 当口号背,但真正写代码的时候又不知道什么时候该用;提到 OWASP,又觉得那是安全团队的事,跟普通开发没关系。我想换个角度来聊:与其把这些概念当成“规范清单”,不如把它们当成写代码时的决策维度。
1.1 四条原则本质上是在回答四个问题
先给不熟悉的朋友快速过一遍这四个词的含义:
- SOLID是五个面向对象设计原则的缩写,分别是单一职责(SRP)、开闭原则(OCP)、里氏替换(LSP)、接口隔离(ISP)和依赖倒置(DIP)。它解决的是“类与类之间怎么划分边界、怎么协作”的问题。
- DRY(Don't Repeat Yourself)解决的是“同一份逻辑不要散落多处,否则改一处漏一处”的问题。
- KISS(Keep It Simple, Stupid)解决的是“方案复杂度不要超过问题本身的复杂度”的问题。
- YAGNI(You Ain't Gonna Need It)解决的是“未来可能用到的功能现在不要提前写,因为大概率用不上,而且会拖垮现在的设计”的问题。
我的理解是,SOLID 管的是结构,DRY 管的是重复,KISS 管的是复杂度,YAGNI 管的是范围。四个原则互相配合,但侧重点完全不同。
1.2 OWASP 不只是“Top 10 名单”,而是一套完整的参考基线
OWASP(开放式Web应用程序安全项目)常被提起的是 Top 10 漏洞列表,但它真正有价值的是配套的一系列文档:ASVS(应用安全验证标准)、各类 Cheat Sheet(速查表)、测试指南、代码审查指南。这些文档覆盖了从威胁建模、安全需求、安全设计、安全编码到安全测试的全流程。
我在实际项目中把 OWASP 当作一个安全基线字典来用:遇到不确定某个功能是否安全,就去翻对应的 Cheat Sheet;做架构设计时,把 ASVS 的 L1 级别作为最低要求;写安全相关代码时,对照 Top 10 的条目逐项排查。它本身不是一套要生搬硬套的教条,而是一份“预防已知风险”的经验库。
1.3 原则与安全的交叉点:代码质量直接影响攻击面
这是我最想强调的一点:SOLID、DRY、KISS、YAGNI 和安全性不是两件独立的事,而是同一件事的先后关系。
- 一个违反 YAGNI 的系统,往往会多出很多“看起来有用但没人用”的功能入口,每个入口都是一次攻击面。
- 一个违反 KISS 的系统,业务逻辑和框架代码混杂在一起,安全响应无处下手,修复 Bug 时容易把校验逻辑一并改坏。
- 一个违反 DRY 的系统,同样的“用户输入校验”散落在十几个地方,安全团队出一道修复通知,你根本不知道哪里漏改了。
- 一个违反 SOLID 的系统,所有逻辑堆在一个上帝类里,任何一处输入的不可信值都可能直接流过核心业务逻辑,追踪数据流比登天还难。
所以我在做项目时,会把“是否遵守基本设计原则”直接纳入安全评审的检查项——这不是凑篇幅,而是经验教训。
2. 四条原则的实战拆解:从理解到落地
2.1 SOLID:不是“五个规范”,而是五把不同的尺子
逐条说一下我在实际代码里是怎么理解和应用 SOLID 的。
单一职责(SRP)。我见过最多的反例是“工具类”,一个类里放了“字符串处理”“日期格式化”“调用外部接口”“解析配置文件”四个完全无关的功能。表面上看挺整齐,实际上每次改动都要把这四个 Diff 同时评审一遍,而且改动一个可能炸掉另外三个。我自己的判断标准是:给这个类写一句话职责描述,如果描述里包含了“和”字,基本就是超纲了。比如“负责订单数据的读写和邮件发送”——这句话里“和”字两侧的事,就应该拆开。
开闭原则(OCP)。核心是“对扩展开放、对修改关闭”。举个例子:老项目里常见这样的代码——一个 purchasePrice 的计算方法,里面用 if 判断是普通用户、会员、VIP,每种折扣一个分支。下个月运营说加一个“大客户”类型,你就得再往里塞一个 if。正确做法是把“折扣策略”抽象成接口,不同用户类型实现不同策略类,新类型来了直接新增类而不改动旧代码。我实际重构过类似的代码,改动前 20 个 if 散布在 5 个文件里,改动后策略接口加 3 个实现类,主流程逻辑一眼能看懂。
里氏替换(LSP)。这条最容易被忽略。它的意思是:子类必须能无缝替换父类,不能改变父类行为的语义。最经典的反面教材是“正方形继承长方形”的问题——重写了 setWidth 方法导致宽度和高度联动,替换后程序行为就变了。我在项目里常用另一句话来实践它:如果子类需要重写父类的方法并改变其输入输出的约定,或者需要调用方感知“你是子类而不是父类”,那就说明继承关系用错了,应该改用组合。
接口隔离(ISP)。这条和下面要讲的“胖接口”问题相关。如果一个接口有 10 个方法,而某个实现类只需要其中 2 个,其余 8 个只能抛“不支持”异常,那这个接口设计就是失败的。正确的思路是把大接口拆成多个特定角色的小接口,让实现者只需要实现自己真正用到的能力。
依赖倒置(DIP)。高层模块不应该依赖低层模块,两者都应该依赖抽象;抽象不应该依赖细节,细节应该依赖抽象。翻译成人话:业务代码不要直接 new 一个具体的外部服务类(比如某个短信服务商或云存储客户端),而是定义一个自己的接口,让外部实现来满足这个接口。这样未来替换服务商时,业务代码一行不用改,只改装配的配置。
2.2 DRY:把“重复”和“抽象”分开看
DRY 听起来简单——“不要重复自己”,但我在实践中发现,很多人对它的理解有两个常见偏差:
第一个偏差是把 DRY 理解为“代码不要重复”,于是把两段长得像但语义完全无关的代码强行合并成一个函数。比如一个把“订单号生成规则”的函数和一个“优惠券码生成规则”的函数,恰好都用到了“把数字转成 36 进制”的逻辑,于是有人就把它们合成一个通用函数,传入一个 type 参数做分支。结果改订单号的规则时,这个函数里的分支越来越多,变成一坨“万能类”。
我自己的实践是区分两种重复:逻辑重复(semantic duplication)和语法重复(syntactical duplication)。逻辑重复必须消除,比如“判断用户是否有权限下单”这段逻辑在 controller、service、repository 三个层各写了一遍,必须收敛。语法重复则要看情况——如果两段代码仅仅是长得像,但它们的业务语义、变化方向都不同,那么强行合并反而制造耦合。真正的 DRY 是“消灭知识点的重复”,而不是“消灭字符的重复”。
第二个偏差是在项目第一天就追求 DRY。一上来就把所有可能重复的地方都抽象好,结果抽象错方向。我现在的习惯是:写第二遍重复代码时,先忍住;写第三遍时,明确提炼出一个函数/类的过程。这是被 Andrew Hunt 和 David Thomas 在《程序员修炼之道》里的建议影响的——规则是“三次法则”。
2.3 KISS:复杂度是个税,能少交就少交
KISS 原则的执行难点在于:你很难在“写一个通用优雅的方案”和“写一个跑得通但看起来笨的方案”之间,承认后者是对的。大多数程序员下意识会选择前者,因为前者让你觉得自己专业。
我在一个订单导出功能上吃过亏。业务方说“需要导出 Excel”,我一开始设计了一个支持“表头自定义、样式自定义、多 sheet、模板引擎”的导出框架,花了两天时间。结果最后交付时,业务方只用了一个默认模板,那些框架能力 90% 没用到。后来维护的时候,框架自身配置反而成了问题。这就是典型违反 KISS。
KISS 的实践标准我总结为三条:
- 解决问题的最小可行方案是什么?先把这个做出来,再考虑扩展。
- 如果现有方案需要文档解释才能让人看懂,那它还不够简单。
- 每增加一层抽象,必须回答一个问题:它现在就能帮我减少多少具体的复杂度?如果答案是“将来可能会”,那就先不加。
有个同事说得很好:KISS 不是让你写低级的代码,而是让你把高级的解决方案放进合理的最小外壳里。
2.4 YAGNI:不要为“未来可能”买单
YAGNI 和 KISS 经常一起出现,但它们的关注点不同。KISS 关注“当前方案复杂度”,YAGNI 关注“当前代码范围”。
举一个真实案例:某模块需要做用户登录,设计者为了“方便未来做单点登录”,一开始就引入了分布式会话中心,把会话状态放到外部缓存里,结果登录功能本身还没跑通,先被分布式会话的序列化问题拖了两个星期。等到真的需要单点登录时,架构早就换了几轮,当初的“缓存会话设计”全被推翻重写。这就是为“假想中的未来”付出真实成本。
我现在的做法是:当你说出“将来可能”这四个字的时候,请把这个“将来”具体到某个业务版本和某个触发条件。如果这个触发条件说不清楚,那么现在就别做。技术债和过度设计之间有一个很微妙的界限,我的经验是:如果一件事只是“可能有用”,它大概率会让当前的系统更脆弱——因为你为不确定的假设增加了复杂度,而这些假设往往不会发生。
3. OWASP 安全最佳实践:从“了解漏洞”到“写进代码”
3.1 先建立安全编码的上下文意识
前面聊了代码设计原则,现在转到 OWASP。我观察到很多开发者对“安全”的理解停留在“密码要加密”“SQL 要加参数化”这种零散点状认知上。但实际项目中,安全问题大多是架构层面的:用户输入从哪里来、数据流向哪里、哪些接口需要鉴权、异常信息是否会暴露内部细节。
OWASP 最有价值的不是那份 Top 10 名单,而是它背后的思维方式:把所有外部输入都当成不可信数据,然后从数据入口到数据出口全链路做校验。一旦你建立了这个上下文意识,再去看那些设计原则,会发现它们和安全是天然互补的——比如单一职责让每个数据处理阶段的校验责任清晰,依赖倒置让安全过滤组件可以灵活替换和升级。
3.2 常见 Top 10 威胁的日常代码对应
我平时做代码评审时,会在 OWASP Top 10 里挑出几条高频出现的,逐一对照代码:A01 失效的访问控制、A03 注入、A05 安全配置错误、A07 身份验证与会话管理失败、A09 安全日志与监控失效。这几条在业务代码里出现的频率最高,而且我都能用简单的例子说清楚。
以注入为例。很多人知道我不用字符串拼接 SQL,但当面对“排序字段”这种需求时,往往会写:
String orderBy = "create_time"; if ("price".equals(sortParam)) { orderBy = "price"; } else if ("sales".equals(sortParam)) { orderBy = "sales"; } String sql = "SELECT * FROM product ORDER BY " + orderBy;这段代码用了白名单限制了排序字段,其实就避开了注入风险。虽然它看起来“不够优雅”,但在 KISS 和安全的双重标准下,这种“笨但安全”的写法是正确的。真正的坏例子是直接把用户参数拼进 order by 或表名、列名。
再以失效的访问控制为例。很多系统只在“菜单显示”上判断了权限,后端接口却没校验“当前用户是否能访问这个用户的订单”。我见过线上事故:对一个订单详情接口抓包,只改 URL 里的订单 ID,就能查看别人的订单。这是 A01 最典型的形态——水平越权。对应的 OWASP 建议是:后端每个需要资源持有者信息的接口,都必须校验资源的“拥有者属性”,而不能仅仅依赖“登录用户 ID 由前端传过来”。
3.3 实操落地:依赖管理与安全配置
除了编写安全代码,OWASP 最佳实践里还有一项被我个人视为“即时见效”的工作:依赖管理(SCA,Software Composition Analysis)。
我实际带的项目,曾经因为一个深层依赖组件存在已知漏洞而被安全团队通报。当时的排查流程是这样的:
- 用 OWASP Dependency-Check 对项目做全量扫描,生成 HTML 报告。
- 在报告中识别出漏洞组件:某版本号小于 2.6 的 JSON 解析库存在反序列化漏洞。
- 更新依赖到修复版本,重新跑全量测试。
- 把依赖漏洞扫描接入 CI 流水线,定为“中危及以上漏洞阻断发布”。
这里我想提醒的是:很多开发团队只关注“引入的依赖能干什么”,却从不关注“这个依赖版本有什么已知漏洞”。OWASP 的依赖检查并不难接入,难的是持续做下去。现在我每个项目都要求把依赖检查作为 Merged Request 的必检项,和单元测试、代码风格检查并列。
另一个高频问题是安全配置错误(A05)。Spring 生态常见的配置坑:
- 开发环境的调试接口(比如 actuator、Swagger)被发布到生产环境且未做访问控制;
- 错误信息里把堆栈异常直接返回给前端,暴露了包名、类名、数据库驱动版本;
- 默认密码未修改,比如 Redis 默认无密码、管理后台默认 admin/123456;
- 缺少安全响应头,比如 X-Frame-Options、Content-Security-Policy。
这些配置错误在代码里往往只是一行配置的事儿,但一旦出问题,修复成本不高、影响面却不小。它们恰恰最符合“预防胜于补救”这一安全最基本原则。
3.4 安全代码评审清单(可直接抄用)
我把这些年项目里用到的安全评审要点整理成一份清单,放在评审模板里。这里分享给大家:
| 评审项 | 检查重点 |
|---|---|
| 输入校验 | 所有外部输入(HTTP 参数、文件上传、消息队列、第三方回调)是否做了白名单格式校验? |
| 输出编码 | JSON/HTML/XML 等输出是否做了上下文相关编码? |
| 访问控制 | 是否在每个需要资源归属校验的接口都做了水平/垂直越权校验? |
| 认证与会话 | 密码存储是否使用 BCrypt 等自适应加盐算法?会话 ID 是否设置了 HttpOnly、Secure 属性? |
| 依赖安全 | 是否已跑 SCA 扫描并确认无中危以上漏洞? |
| 错误处理 | 错误信息是否不暴露堆栈、SQL、内部路径?是否统一走日志系统而不是吐给前端? |
| 日志 | 是否记录了足够的审计日志(登录、登出、权限变更、关键业务操作)?是否对密码、令牌、身份证号做了日志脱敏? |
| 传输安全 | 敏感接口是否仅允许 HTTPS 访问?HSTS 是否开启? |
| 配置安全 | 调试端点、默认账号、默认密码是否已禁用或修改?安全头是否齐备? |
这份清单不是说每一项都必须全做到才能上线,而是说:如果评审时发现某一项缺失,你必须要能讲清楚“为什么这次可以接受这个风险”。很多安全问题就是在这种“说不上为什么,但先放着没关系”的过程中埋下的。
4. 融合实践:一个“订单系统”的代码改造实录
理论讲多了得落到代码上。我拿一个具体案例做演示——模拟项目X是一个订单系统,它在一次安全扫描和一次代码评审中暴露了不少问题。我把整个改造过程拆解成几步,大家可以直接借鉴思路。
4.1 改造前:一堆违反原则和安全规范的问题
改造前的代码有几个典型毛病:
- Controller 层直接写了业务逻辑,还顺手拼了 SQL;
- 同一个“校验用户是否有权限操作该订单”的逻辑,在 3 个 Service 方法里各写了一遍;
- 为了“未来支持多支付渠道”,引入了一个支付抽象工厂,结果两年只上线了一个渠道,抽象工厂里的代码比实际支付逻辑还多;
- 订单详情接口只校验登录态,没校验订单归属;
- 错误处理直接把异常堆栈返回给前端,日志里却能搜到大量敏感字段的明文。
当时评审时的结论是:系统“能跑”,但改一个功能要懂整个流程,而且安全漏洞集中在上方几个点。于是按“原则优先、安全兜底”的思路分两步改造。
4.2 第一步:用 SOLID 和 KISS 重构结构
改造的第一步不是加安全组件,而是先把代码结构捋顺。因为如果代码是一坨,安全校验该放在哪里根本说不清楚。
我用依赖倒置的思路,定义了一个订单查询接口和一个订单归属校验接口,让外部基础设施去实现它;用单一职责,把订单创建、订单查询、订单支付拆成三个独立 Service;用开闭原则,把“支付渠道支持”从抽象工厂改成了策略列表——未来加渠道时只需注册新策略,旧逻辑完全不动;同时把原来 Controller 里的业务逻辑全部下沉到 Service 层,Controller 只负责参数接收和结果返回。
这个阶段我把之前那套过度设计的支付抽象工厂砍掉了一多半,只保留了当前需要的策略接口,严格遵守 YAGNI 和 KISS。改完之后,整个代码行数少了大概三分之一,但功能没有减少。
4.3 第二步:用 DRY 收敛重复,用 OWASP 补齐安全缺口
结构理顺之后,我做了两个动作:
动作一:收敛重复逻辑。把散落的三处“订单归属校验”抽成一个公共方法,所有入口统一调用。这样安全团队后续若要求加强校验逻辑(比如增加风控规则),只需要改这一个方法,不用再追着十几个调用点逐一打补丁。
动作二:按 OWASP ASVS 的要求补安全控制。我重点做了四件事:
- 所有订单查询接口强制校验当前登录用户是否与该订单关联,核心判断放在 Service 层而不是 Controller 层,防止因为不同入口遗漏校验。
- 输入校验改成白名单模式。对于订单状态这类枚举值,前端传什么我都先映射到后端定义的枚举里,映射不了就直接拒绝。
- 错误处理统一改为“通用错误码 + 日志详情”。生产环境不再向前端返回异常堆栈。
- 日志脱敏。所有涉及手机号、身份证号、支付账户的字段,统一用掩码工具处理后再写日志。
这一步的好处是:结构清晰之后,我能准确说出“哪个边界应该拦截什么”,不需要靠“大概可能在这里拦截”这么模糊的方式做安全。
4.4 改造后的效果与复盘
改造完成后的三个月里,没有再出现一条与越权相关的安全工单;代码评审的效率也提升了不少——以前评审一个改动要通读整个 Controller 和 Service 的上下文,现在只需要看改动涉及的那一个类的职责和边界。
复盘时我最深的体会是:原则先行的重构,让安全加固变得低成本、高确定性。反过来说,如果先加安全组件而不动结构,那些组件的接入位置和调用关系依然是一笔糊涂账,安全会和业务继续互相纠缠。
5. 常见问题与排查技巧实录
我把自己在这类项目里踩过的坑、排查过的问题整理成了一份速查表,也把一些“看起来对但实际是坑”的实践写下来。这些都是常规文档里很少提到的。
5.1 典型问题速查表
| 典型问题 | 症状 | 根因 | 正确姿势 |
|---|---|---|---|
| 过度抽象 | 类数量呈指数增长,每个类只有两行逻辑 | 把 YAGNI 忘了,为了“通用”而抽象 | 用到第三次再抽象,抽象时必须明确服务谁 |
| 单一职责被教条化 | 一个业务操作被拆成 8 个 Service,调用链像迷宫 | 把“职责”拆得过细,违背了 KISS | 职责拆分的颗粒度以“变更频率是否相同”为准 |
| DRY 变成“复制粘贴” | 三个方法里都有一模一样的三行代码 | 写的时候图快,没意识到这是逻辑重复 | 用 code smell 工具或 Code Review 发现后立即收敛 |
| 接口里塞满方法 | 某个实现类有大量“不支持”方法 | 接口隔离没做到位 | 把大接口拆成多个角色接口 |
| 依赖扫描形同虚设 | 安全通报里出现自家项目引用的第三方漏洞 | SCA 没接入 CI,或者扫描了但没人跟进 | 接入 CI 且设阈值,中危及以上阻断发布 |
| 错误信息泄露细节 | 前端能看到 SQL 语句和内部路径 | 没有统一异常处理,堆栈直接返回 | 统一异常拦截器,返回通用错误码,详情只进日志 |
这张表看着简单,但每一项背后都是真实发生过的事故。我特别想强调最后一行:错误信息泄露在 OWASP Top 10 里属于“安全日志与监控失效”的相邻问题,它的危害不是直接利用,而是降低后续攻击的难度,因此一定要从根上处理。
5.2 快速排查技巧:在代码评审里抓安全和设计问题
有些同事问我,代码评审时怎么快速发现问题。我分享几个自己惯用的判断技巧:
其一,看方法签名。如果一个方法有 6 个以上参数,或者一个参数是 Map/JSON 对象,背后大概率存在职责混乱或接口设计不良的问题,安全校验也随之变得不可控。
其二,看类的方法个数和行数。超过 300 行且方法极多,这个类几乎必然违反单一职责。把这种类展开,里面一定绕不开直接拼 SQL、直接写日志、直接调外部服务等问题,安全风险自然水涨船高。
其三,找硬编码的“关键字符串”。比如直接把用户 ID 从会话里取出来,然后从请求参数里拼进 SQL,这类代码的安全风险非常高,看到就应该标出来。
其四,顺着数据流找出口。从一个 HTTP 入口开始,看参数怎么流转,有没有经过校验、有没有经过权限判断、有没有经过转义。如果入口到出口之间没有任何校验层,基本已经可以判定这条链路存在注入或越权风险。
5.3 我踩过的几个坑与心态调整
第一个坑:把“不写安全代码”当成 YAGNI。有时候为了赶进度,想着“这个页面只有内部人员用,不需要严格校验”,于是跳过权限判断。结果后来链路改了,内部页面变成对外页面,漏洞立刻暴露。我现在的心态是:安全底线不能用 YAGNI 去砍——YAGNI 解决的是“功能需求预测”,而安全是“已知风险防控”,两者不是一回事。
第二个坑:为了实现“绝对 DRY”而过度抽象。我有一次为了保证某个校验逻辑全局唯一,把它封装成一个注解加切面的方式,代码确实少了。但问题是,新同事根本不知道这个注解在哪里声明,也不知道如何覆盖它,排查问题时花了大量时间找这个切面的逻辑。这告诉我:DRY 是好东西,但抽象必须保持对团队认知水平的适配。如果团队的认知水平暂时达不到某种抽象的复杂度,即使代码物理上满足 DRY,也是“伪 DRY”。
第三个坑:安全扫描工具出了报告不闭环。前文提到的依赖扫描,很多时候止步于“生成了一堆报告”但没有真正的处理机制。现在我把这三步做成强制流程:扫描报告必须有人确认;中危以上漏洞必须有修复计划或风险验收;下个迭代必须确认修复结果。没有这个闭环,扫描工具只是“做了个样子”。
6. 最后分享一个习惯
除了上面的经验,我还有一个一直坚持的习惯:每次新建模块时,在 README 里写清楚“这个模块为什么只用这些技术方案,不引入更多依赖”。刚开始同事觉得多此一举,后来发现这其实是 KISS 和 YAGNI 的很好落地方式——每个不合理的设计,都要能回到这里找到当时的决策依据。
还有一件事也值得多说一句:代码原则与安全实践不是一次性的“大改造”,而是要在日常开发中持续维护。我目前比较认可的做法是,每个 Merge Request 的评审清单里,固定包含“是否新增了重复逻辑”“是否引入了不必要的依赖”“接口是否校验了越权”“错误信息是否会泄露细节”这几项。这些问题不需要全部回答“是”,但必须有人有意识地回答。长期坚持下来,代码库的质量和安全水平是能明确感知到在提升的。
我自己在这个领域带过一个模拟项目,经历了从“能跑就行”到“可维护、可审计、对抗已知威胁”的转变,前后跨度将近一年。那一年最大的收获不是学会了哪条原则或哪个安全配置,而是明白了一个道理:原则是给未来省钱的,安全是给未来兜底的,两者都不能靠“将来再说”来赌。希望这篇复盘对正在做代码质量改进和安全加固的你有参考价值。