最近帮一个朋友做代码评审几十个接口翻下来不到一小时我就大概明白这个系统为什么越改越乱了同一套权限校验逻辑在三个模块里各写了一份订单状态判断散落在六个地方而查询接口里拼接 SQL 的参数就那么裸着躺在代码里。这种场景你大概率见过——项目不是一下子烂掉的是每一次先这样后面再改攒出来的。这篇东西想聊的就是 SOLID、DRY、KISS、YAGNI 这几个看起来像教科书概念的设计原则加上 OWASP 安全最佳实践它们在一个真实项目里到底怎么用、边界在哪、在哪一步最容易翻车。适合正准备重构旧系统的人、天天被 bug 追着跑的人以及需要带小团队的开发负责人。我尽量用能直接抄作业的方式讲。1. 设计原则和安全实践为什么要放在一起看1.1 一个管长期维护一个管运行时风险先说个经常被忽略的事实设计原则和安全规范从来不是两套独立的东西。SOLID、DRY、KISS、YAGNI 最终都指向同一个目标——降低代码的长期变更成本。而 OWASP 安全实践指向的是另一件事一旦出错损失有多大。表面上两者分属软件工程和应用安全两个领域但它们其实共享同一个前提代码是给人维护的也是给攻击者分析的。一个系统如果混乱、重复、过度设计它的审计面就特别大。每处重复的业务逻辑都是一个潜在的绕过点每个过度封装的抽象层都会让安全修复变得迟缓。我见过很多安全漏洞根因根本不是攻击者多聪明而是代码里同一个判断逻辑有六份副本开发同学只修了其中一处的校验攻击者顺着另一条旧分支就溜进去了。这就是 DRY 没做好顺手引发了一个安全问题。所以我的观点是设计原则解决的这次改动会不会波及不该动的地方安全实践解决的如果被波及的地方恰好暴露在外怎么办。两个问题必须一起回答缺一个都会出事。1.2 一次让我印象很深的事故复盘讲一个我亲身参与排查的案例细节做了脱敏逻辑是真的。某创业公司有个电商系统大促前一周忽然出现一批异常订单前端把订单金额直接传给后端后端没有重新计算攻击者通过篡改请求里的价格字段用一分钱下了几千块的订单。更麻烦的是商品价格字段在六个地方被读取其中一个内部接口使用了动态拼接 SQL 查询因为历史原因这个接口没接鉴权。排查过程不复杂但后果很重大促暂停、紧急修复、大量订单回滚。事后总结时发现问题根源就两条其一计价规则没有收敛成一个统一服务前后端各算各的这就是典型的 DRY 和单一职责都没守住其二所有数据库访问没有统一走参数化接口这就是 OWASP 注入类攻击的标准反面教材。这个案例给我一个很深的教训原则和安全不是评审时打勾用的复选框它们直接决定了线上事故的规模和修复成本。所以这篇文章前半部分我会把几个设计原则挨个拆开后半部分落到 OWASP 最佳实践的具体操作上最后给你一份可以贴在评审清单里的实战表。2. SOLID 原则从定义到落地的五个姿势2.1 单一职责以变更来源为准而不是功能数量很多同学理解单一职责时容易走极端一个类只允许干一件事于是把几十个方法拆成几十个类每个类里一个方法结果代码跳来跳去反而更难看。这个原则真正的判据不是这个类的功能是不是足够单一而是这个类有没有多个互相独立的变化理由。我拿一个典型例子说明。假设有个 OrderService里面揉进了订单校验、库存扣减、支付调用、消息通知、日志打点。表面上它干了五件事很多人第一反应是要拆。但你可以先问一个问题这五件事会因为什么原因发生变化如果它们都随着订单业务规则一起变那放在一起不仅没问题反而更内聚。真正该拆的信号是一部分逻辑会因为外部物流渠道方调整接口而变另一部分会因为内部风控规则而变这两个变化来源毫不相干却耦合在一个类里。实际操作建议是三步第一写类之前先列出它未来可能收到的变更请求第二把来源相同的变更放一起把来源不同的隔离开第三不要以行数少为目标一个一百行的类只要变更理由只有一个它就是合格的。我给过很多人一个笨办法——为这个类写一条变更理由句子如果一句话里出现和字超过两次大概率需要重新考虑边界。2.2 开闭原则把变化点关进固定的扩展入口开闭原则常说对扩展开放、对修改关闭很多人以为是不让改代码纯加新类。实际上没人能做到完全不改代码它的真实含义是当新需求来临时你能把改动收敛到一个固定入口而不是在核心业务代码里到处铺 if/else。拿支付渠道扩展举例。最差的写法是在交易服务里面天上一个 channelType 字段然后写 switch 分支。每加一个渠道你就要改交易服务这个核心类改多了就变成几千行的意大利面。稍微好一点的做法是定义统一的支付策略接口每种渠道实现接口再通过工厂或注册表映射。新渠道进来时只需要新增一个实现类并在注册表里加一行交易服务的核心逻辑不碰。这就是把变化点关进了策略扩展这个入口。但这里要提醒一句开闭原则和后面要讲的 YAGNI 之间有张力。如果你当前只有一种支付渠道硬要做策略模式加插件框架那不是在应用原则是在表演原则。正确的节奏是出现第二种同族变化时再引入抽象第一种先老老实实写清楚。2.3 里氏替换子类别打破父类的承诺里氏替换原则最直观的意思是任何使用父类的地方换成子类也必须同样正常工作。这是继承的最基本契约但我在评审里看到的违反案例多得吓人。最经典的例子是正方形继承矩形。矩形有宽和高两个字段正方形重写 setWidth 时顺手把 height 也设了结果代码以为自己在操作一个矩形设置宽度后去读高度拿到的是意外值。真实项目里更常见的是子类覆写父类方法时直接抛 UnsupportedOperationException或者子类对入参加了父类没有的前置校验导致原来合法的调用链在子类场景下直接炸掉。我现在的习惯是默认推荐组合而不是继承。如果确实需要继承一定要为父类和子类写契约测试把前置条件、后置条件、不变式都焊死在测试里。父类没承诺的事情子类当然可以不做但一旦父类承诺了子类不能偷偷反悔。这个原则本质上是面向接口编程的保障你之所以敢把依赖指向父类是因为你信任它的行为契约。2.4 接口隔离按调用方视角切接口而不是越碎越好接口隔离说的是客户端不应该被迫依赖它们不用的方法。一个典型的坏味道是一个接口上堆了十几个方法实现类没法全部实现只能把不相关的留空或者抛异常。这就是胖接口。拆分的正确方式不是把方法随机分到更多接口里而是按调用方角色来观察。比如一个订单服务管理后台只需要批量查询订单而内部结算服务需要修改订单状态。那么完全可以拆成 OrderReadService 和 OrderWriteService 两个接口查询方只看到查询方法修改方只看到修改方法。这样好处很明显调用方不会被一堆自己用不到的方法干扰未来接口演进也不会破坏不相关依赖。同样要小心矫枉过正。如果拆出来的每个接口只有一个方法调用方就需要拼装多个细粒度接口接口的语义反而丢了。我通常的建议是当一个接口开始出现部分实现类无法实现的方法时就动手拆如果所有实现类都能完整实现所有方法先不拆。2.5 依赖倒置面向接口编程给你换实现的自由依赖倒置原则要求上层模块不依赖底层模块大家都依赖抽象抽象不依赖细节细节依赖抽象。这个原则带来的直接收益是测试性和可替换性。举一个真实的坑。有个项目里所有代码都在类里直接 new 一个 Redis 客户端散落几百处。后来业务需要从 Redis 迁移到另一个缓存中间件结果每个调用点都要改一遍测试也基本瘫痪。如果最开始定义了一个 CacheClient 接口业务代码只依赖这个接口具体实现放在适配层迁移时就只需要写一个新的适配实现再在装配处换一下其他代码完全不动。依赖注入容器比如 Spring 这类框架的 IoC只是实现依赖倒置的一种工具不是唯一方法。就算不引框架构造器注入也是一个很好的起步对象拿到的是接口而不是实现类单元测试时就能传一个内存假实现进去不用真的连 Redis。这一点和 OWASP 也有关系鉴权、审计这类横切逻辑如果被抽成统一接口和中间件就不会出现某个内部接口漏掉鉴权这种低水平事故。3. DRY、KISS、YAGNI三个缩写背后的平衡艺术3.1 DRY 的准确定义重复的是知识不是代码DRY 全称 Dont Repeat Yourself但很多人只记住了字面意思代码别重复。于是看到两段长得像的代码就立刻抽函数结果强行合并后接口越设计越别扭业务分支被塞进同一个函数用一堆 if 区分本来就不相同的场景。这就是把 DRY 用错了方向。DRY 真正强调的是每一份知识必须在一个系统里有一个单一、明确的表示。什么叫知识比如订单满 100 包邮是一个业务规则这个规则如果在订单服务、营销服务、报表服务里各算各的那它就被复制了三份哪天包邮门槛改成 200只改其中一处另外两处就成了定时炸弹。这是必须抽出来的重复因为它属于同一个业务概念。那么怎么区分值得抽和不值得抽我用一个简单测试这次业务规则需要修改时你是不是必须同时修改多个文件如果是就抽。如果只是两段代码碰巧看起来像但归属的概念完全不同比如一个在算折扣、一个在算佣金即使长相相似也不要强行合并。有个说法我非常认同错误抽象比重复代码更糟。重复至少是显眼的错误抽象则把差异藏进了共享逻辑里改起来像拆地雷。3.2 KISS把代码写给六个月的自己看KISSKeep It Simple, Stupid经常被误解成不要用设计模式不要写复杂的代码这太消极了。它的真实意思是在所有能解决问题的方案里选择那个最容易理解、最容易修改、最不容易出错的而不是最炫技或看起来最高级的。举几个场景。有些同学遇到需要做分支处理第一反应是上规则引擎、状态机框架问题是业务其实只有三个固定状态用 if/else 一眼就能看明白引入框架后反而所有人都要去学那套 DSL。再比如日期处理有人图省事直接拼字符串结果时区一乱就翻车直接用语言标准库的格式化函数多写几个字符但零歧义。这些都是 KISS 的体现复杂度不是你的勋章是后人的成本。我评审代码时有一个经验法则一段代码如果我看一遍不能在两三分钟内说清它在干什么要么是真复杂要么是设计过度。KISS 并不是让你把事情做得糙而是让你用一种普通工程师都能接手的姿势把事情做好。付出最小理解成本的方案才是好方案。3.3 YAGNI不为可能永远不来的明天买单YAGNI 的全称是 You Arent Gonna Need It说的是只在当前确实需要时才去实现功能别为想象中的未来提前设计。这个原则的适用场景太多了。一个新项目刚启动连第一个版本都还没上线就开始规划消息队列、分布式锁、多级缓存、容器编排忙活一个月结果业务量日均两位数。这些基建不是完全没有用问题是成本回收周期太长而且在那之前需求早就变了。你为未来可能出现的规模做设计代价是当前的复杂度、维护成本和团队认知负担这是笔不划算的买卖。但要注意YAGNI 不等于反对任何前瞻。一个系统的关键边界——模块边界、数据所有权、安全信任模型——这些是需要提前想清楚的因为它们一旦定错后面重构成本极高。而具体的实现方案比如队列选哪个、缓存用什么、事务怎么设计按当前真实需求来就好。判断标准很简单这个决定如果错了改起来费力到什么程度费力到宁可过度设计那是架构决策费力不大那就先别做。很多团队把调研怕以后麻烦当成过度设计的借口我用一个反问打回去你确定它能活到那个以后吗3.4 四个原则的协作判断清单这几个原则单独拎出来大家都知道难的是放在一起怎么权衡。比如 DRY 和 YAGNI 会打架三处重复出现了是马上抽还是等第四处KISS 和 SOLID 也偶尔冲突最简单方案是不拆类但单一职责可能希望拆。我把自己的决策过程整理成了清单遇到拿不准的情况可以按顺序问一遍场景主要适用原则判断问题两段代码看起来很像DRY / YAGNI它们改动的知识是同一个吗如果是抽如果只是长相像别抽要不要引入设计模式、框架、中间件KISS / YAGNI当前有没有真实需求支撑团队是否都看得懂没有就继续用朴素方案一个类要不要拆分单一职责 / KISS它有哪些独立变化理由拆分后是否让调用方更混乱接口要不要拆接口隔离有没有实现类被迫实现不需要的方法如果没有先别拆要不要为了可测试而引入抽象接口依赖倒置 / YAGNI这个模块未来换实现的概率大吗测试的时候真需要隔离吗新功能要不要设计扩展点开闭 / YAGNI现在已经有第二种变化了吗没有的话先写清楚第一种这个清单不能让决策自动完成但它能把我拍脑袋觉得该抽/不该抽变成当前查到的答案是……团队里讨论时也更不容易吵起来。4. OWASP 安全最佳实践从 Top 10 到编码习惯4.1 注入攻击参数化是底线不能再低了注入攻击这么多年了一直排在最危险的漏洞类别里。SQL 注入是最常见的一种凡是把外部输入直接拼进 SQL 的代码都是在给别人留后门。攻击者只要看了一眼接口参数就能试着在单引号后面补一段闭合语句轻则拖库重则直接把表删了。防御手段在我们这个时代几乎是无脑的所有数据库访问都走参数化查询Prepared Statement或者 ORM 的参数绑定接口永远不要手工拼接 SQL 片段。具体到代码层面像这样// 错误示范拼接用户输入 String sql SELECT * FROM user WHERE name userName ; // 正确示范参数化查询 String sql SELECT * FROM user WHERE name ?; PreparedStatement ps conn.prepareStatement(sql); ps.setString(1, userName);需要特别提醒的是注入不只在 SQL 里。范围还包括NoSQL 查询、ORM 的 HQL/动态条件拼接、操作系统命令注入、表达式注入比如 SpEL。有些人觉得我用了 MyBatis 就安全了实际上如果习惯了在 XML 里用${}拼接参数那和 JDBC 直接拼字符串没有任何区别#{}才具有参数化语义。还有一个小坑除了 DB 层把用户输入直接拼进文件路径、URL 重定向目标、JavaScript 代码里也都是各种变种的注入。原则只有一句话不可信数据不能进入任何可解释的上下文除非你已经在那个上下文里做了完备的转义或校验。4.2 认证与会话管理别把安全期望压在前端不可见很多团队对认证的重视程度远远不够总以为用户登录了就是好人。OWASP 在这块有明确的最佳实践我挑几个最关键的展开。密码存储必须用专门设计的慢哈希算法比如 bcrypt、scrypt、Argon2并且每次加随机盐。不要用自己的简单哈希、不要用 MD5/SHA 系列叠几次这在现代算力面前就跟明文区别不大。就算用户表泄露慢哈希能做的是尽量拖延破解时间。会话管理上登录成功后一定要重新生成 session ID防止会话固定攻击。session cookie 要加 HttpOnly 和 Secure 标记并有合理的闲置超时。我见过一些项目把用户身份直接放进 cookie 而不是 session 里客户端一改就当自己是管理员这种设计从源头就是错的。再提一嘴 JWT。JWT 本身不是原罪问题在于很多人把它用成自造令牌把用户角色、状态直接塞进 token还不在服务端验证签名算法。正确做法是用成熟库生成和验证签名、设置较短的过期时间、将关键权限变更主动吊销并且不要把敏感数据放进去。如果团队还没完全理解 JWT 的校验链路老老实实用服务端 session 反而更稳妥。4.3 XSS输入过滤救不了输出编码跨站脚本XSS的本质是攻击者的数据被当成页面代码执行了。很多开发同学第一反应是那我在后端过滤用户输入不就行了这一招不能解决问题原因在于上下文的复杂性同样的输入在 HTML 标签里、在属性里、在 JavaScript 字符串里、在 URL 里需要完全不同的转义规则。你在后端一把梭地替换尖括号很可能把正常业务数据也弄坏了还防不住某些上下文。正确的思路是输出编码在数据输出到页面的位置根据上下文做相应的编码。比如在 HTML 正文中输出的内容把 转义在 JavaScript 字符串中输出要处理反斜杠和引号在 style 或 URL 中输出又是另一套规则。现代前端框架普遍默认做转义输出这个方向是对的但要小心用dangerouslySetInnerHTML、v-html这类接口时等于手动关掉了防御必须确认内容是可信的或者经过了白名单净化。富文本场景一定要用成熟的白名单过滤库比如只允许白名单里指定的标签和属性而不是黑名单过滤。黑名单永远追不上漏洞变种的步伐。纵深防御层面再加上内容安全策略CSP响应头限制脚本来源即使某个 XSS 点没堵住也尽量让攻击者没法执行远程代码。4.4 CSRF状态变更请求必须带不可预测 token跨站请求伪造CSRF攻击的逻辑是用户已经登录了某个网站攻击者诱导用户浏览器发出一个携带认证 cookie 的请求服务器分辨不出这个请求是不是本人主动发的于是照单执行。这个漏洞特别容易出现在所有校验都依赖 cookie 自动带上的系统里。防御手段最标准的是在每个会改变状态的请求中放入一个服务器下发、攻击者无法猜测的 token并且验证它必须与当前会话绑定。比如表单里放一个隐藏字段提交时校验是否与会话中的值一致或者要求请求头里带自定义 X-CSRF-Token。现在主流 Web 框架都内置 CSRF 防护只是很多人为了省事关掉了这是最不划算的优化。浏览器层面还有一个很有用的防御给 session cookie 设置 SameSiteLax这样跨站发起的大多数请求不会自动带上 cookie能挡住一大批 CSRF 场景。但要清楚SameSite 不是万能它挡不住同站子域或某些浏览器策略下的情况token 校验还是得做。凡是GET 请求也会改数据的设计本身也要尽量避免把状态变更都收敛到 POST/PUT/DELETE再统一挂 CSRF 校验。4.5 敏感数据保护数据最少化、传输加密、存储加密保护敏感数据的起点不是加密算法而是你真的需要保存这些数据吗。最少化是最便宜的安全措施拿不到数据的攻击者无从下手。能只在内存里用一下就绝不落盘能存 hash 就绝不存明文能脱敏展示就绝不显示完整号码。传输层面全站启用 HTTPSTLS 1.2 以上内部服务之间也不能裸奔。很多人以为内网就是安全的事实是内网横向渗透往往就是靠明文流量抓凭据。存储层面密码用专有哈希算法加密密钥、支付账号、身份信息的密文要使用已有加密库和密钥管理服务不要把密钥写死在代码或配置里提交仓库。.env 文件不进版本库是底线密钥轮换要能流程化。还有一块容易被忽视的是日志。业务日志、异常堆栈、调试输出里如果打印了完整的身份证号、手机号、token、密码哈希那这些日志文件本身就是巨大的泄露面。我曾经重构过一个系统排了一个月的错没找到原因后来发现是历史日志里打印了第三方接口的完整签名串任何人都能从日志里拼出合法请求。从那以后我把日志脱敏直接写进了团队编码规范凡是敏感字段只打末四位。4.6 依赖与供应链安全漏洞往往藏在传递依赖里近年来的安全趋势是攻击面明显向软件供应链转移。你写了十行业务代码依赖了二十个第三方库每个库又依赖另外几十个包真正的漏洞往往不在你直接引用的库里而是藏在某条传递依赖链的深处。这类漏洞的典型特征是修复不难发现难。应对手段也是标准的给项目建立清晰的依赖清单用开源依赖扫描工具比如 OWASP Dependency-Check、市面上常见的 SCA 工具接入 CI在构建时自动检查已知漏洞库发现高危依赖就阻断发布。不要觉得工具报了漏洞就完事还要看传递依赖把不需要的依赖直接从 pom/package.json 里移除减少攻击面。曾经有一段时间全行业都在紧急排查某个日志库的漏洞事件那段时间我从这家公司的反馈里发现一个规律凡是平时就在流水线里跑 SCA 的团队几分钟就能定位到受影响的版本并升级凡是依赖全靠手工记的团队整整几天都在翻到底谁引进了这个包。依赖安全不是一次性的加固工作它是持续性的卫生习惯。5. 代码评审里真正有用的避坑清单5.1 原则端评审重点我把多年评审时最常发现的问题列成了一份快速检查列表。不用背贴在手边每次挑两三个看就行。同一业务规则是不是在多个文件里各自维护改需求时会被遗漏吗某个类/方法是不是同时因为多个互不相干的原因在变调用方的依赖是指向接口还是直接 new 了具体实现接口里是否存在实现类不得不留空/抛异常的方法有没有为还没出现的需求提前引入的抽象、框架或配置某个函数的理解成本是不是太高能不能拆成更直白的表达这套东西不需要一次全查完。我通常的做法是先通读 diff凡是看到这个改动是不是要顺带改另外几个地方的念头就把对应行标出来看到这里就算之后再重构也行先别做复杂设计的就标个 YAGNI 提醒自己。5.2 安全端评审重点安全侧的检查点要和原则侧分开看因为思维模式不一样。原则侧想的是以后改得动吗安全侧想的是如果我是攻击者我会怎么利用这段代码。检查点典型问题快速处置注入是否存在字符串拼接 SQL / 命令 / 表达式改为参数化或白名单校验认证密码是否明文存储或弱哈希换 bcrypt/Argon2 慢哈希会话登录后是否重置 session ID超时是否合理登录成功后 regenerate越权接口是否校验了资源归属权加资源级权限校验不能只看登录态XSS用户输入是否直接输出到页面/属性/JS 上下文按上下文做输出编码CSRF状态变更请求是否有不可预测 token全局开启 CSRF 防护敏感数据日志/错误信息是否打印完整 token、卡号、身份证脱敏后再打日志密钥管理密钥/AK/SK 是否硬编码进了代码仓库接入密钥管理服务先轮换再删除旧密钥依赖漏洞依赖是否存在已知高危漏洞升级或替换扫描接入 CI安全评审不需要每次都覆盖全部项目但是每次上线之前上面表格里越权和注入两行是必须看的因为这两个问题的利用成本最低、危害最大。5.3 我的三个落地习惯第一安全评审要尽量前置。很多团队把安全意识留在上线前最后一天通宵检查结果发现的问题只能靠临时打补丁解决改动还特别容易引入新问题。正确的做法是大的功能设计阶段就对核心流程做一次轻量威胁建模写伪代码时就想清楚这条链路上谁能碰什么数据。越往后发现问题修复成本越高。第二把设计原则变成团队内部的评审语言。PR 评论里写这块逻辑重复了三份改一个地方会漏两个比写这代码丑要有效得多。大家不需要是理论专家但用 DRY、KISS、YAGNI 这些词作为公共词汇能够快速对齐问题在哪。原则的价值很大程度在于沟通效率。第三自动化工具能做的别让人肉扛。代码静态扫描、依赖漏洞检查、密钥检测这些交给流水线人把精力留给真正需要判断的问题——业务逻辑是否正确、权限边界是否合理、异常分支是否有坑。自动化不能覆盖所有情况但它能把安全底线从靠人记变成靠门禁。最后分享一个我自己的习惯。每当我接手一个很久没碰过的老系统不会先去读全部代码而是先做三连检项目里的业务规则是不是散落多处并且互相矛盾数据库访问有没有不经过参数化的拼接密钥和敏感信息有没有出现在不该出现的地方。这三项做完基本能判断出这个系统接下来的维护体验和安全状态。你如果正在做一个新项目也可以现在就对着自己的代码做一遍这个三连检。不要把这些规则当成悬挂在墙上的锦旗它们就是用来帮你减少半夜被线上告警吵醒概率的。