0x000
真正把写代码当成自己的职业, 已有六年有余, 这些年里, 亲身经历从一个只管实现功能的入门者, 到关注层次架构, 很难准确说出自己是从哪什么时候发生变化,不敢说已经变得牛逼非常, 但变化与体会肯定是有的, 在这过程中也总结出一些心得. 以往自己写下的文字大多都是跟技术实现相关, 今天稍稍来点不同, 来聊聊代码职业过程中的一些感想.
在日常工作中, 我们面对更多的应该还是写代码, 但如果代码写得足够多了, 就会发现其实我们更多的时间还并不是写代码, 或者说没有想象中花费的那么多时间在真正写代码上, 因为现在对于程序员的要求我认为不仅仅停留在”会使用特定语言实现特定功能”这种层面上, 而更应该关注整个研发活动过程.
0x100 工作量评估
在我们拿到一个需求, 或者启动一个项目时, 我们可能需要先对整体研发时间作一个大概的评估, 这个评估不应该把它当成是一个军令状, 立了3天就一定要3天, 这个过程中很可能会插入很多开始时没有考虑到的细节, 或者存在技术难点一时无法攻克, 那么既然评估本来就是不准的, 那我们为什么还需要评估呢?
在我看来, 评估及计划的作用有如下两点
心理预期
如果这个项目是政治任务, 或者有deadline要求的, 如果我们评估出来的时间无法与要求时间配合上, 可能就需要采取一些别的办法来减少工作量, 如销减需求等, 甚至说是算上加班时间能不能赶上(当然如果这样子算的话其实就已经埋下了风险, 详见后面), 又或者临时调配人手(当然我也是不建议这种办法, 详见后), 以求评估时间满足预期时间;
关联方配合协调节奏安排
有时一个需求或任务不是单靠一方就能完成的, 一般需要涉及到多方配合, 与客户端到服务端, 前端到客户端等, 哪一方先开始, 什么节点另一方能够配合, 都可能作为参考, 目的也是为了人力资源能得到充分运用
前面也说了, 评估很可能是不准的, 计划也不是固定不变的, 那么就要求在项目进行的过程中随时发现问题, 及时修正, 及时迭代(是的,计划也是需要迭代的), 一个词来概括就是”细微渐进“, 今天不知明天事, 谁也不能保证说我估这么多天就肯定是这么多天, 但随着做的事越来越多, 应该积累更多的经验来让评估趋向准确.
比方说一开始没有考虑到可能会有自己不熟悉的技术问题, 结果在开发过程中卡住了, 一卡卡几天, 那整个过程进度就会出问题; 在吸取教训后, 下一次可以在整体评估时考虑加上预研的时间, 以起到缓冲的作用.
另外, 有时会看到在评估时把一些项列得很细. 比方说一个录音+播放功能, 录音那里写了将近十项的内容
需要写得很多, 但完全没有体现到这个工作项的重点难点, 而工作量评估也不是事项细化, 我们要的应该是一个模块或子模块从开始投入到有产出的工作量评估, 而不是一个模块项里各种细化点的集合. 像上图列的, 首先这样子是比较难评估时间的, 像一个”背景图”, “切换” 这样子的描述, 先不说看的人不知道这是什么内容, 就是算当事人, 要真的针对这样子的一个很基础的项进行时间评估也是很困难的, 因为点细了, 有些是顺手就能完成的, 但有时却是需要半天一天以上的, 这样子就会变得很琐碎, 而且我们要做的事情往往不止上面列的这些, 还会有很多中间需要做的, 还需要缓冲.
事实上, 我们根本不需要这种有点”流水账”的评估, 上面也说, 初期评估只是一个大概的预期, 我们大可以以一个事项为单位去评
虽然只对一个模块或子模块从开始投入到有产出的工作量进行评估, 但这不是说不需要了解自己要做什么事情,这些都需要清晰明白后才能得出一个靠谱的时间评估, 但至于每一个细点分配多少时间并不重要, 轻重任务项是交错的, 也能给我们一点缓冲的时间.
0x200 怎么写代码
看到这里估计很多人都嗤之以鼻, “劳资写代码顺手拈来, 如有神助, 一口气写几百行不带停”.
事实上一般情况底下这样子做其实也不会有什么大问题, 特别是面对一些”似曾相识”的需求时(关于这点, 后面后再讲讲我对这类需求的看法), 靠惯性可能都能写出没什么大问题的代码, 但我这里更多的是想探讨一般情况下怎么开始开发的过程.
0x210 设计
估计很多人又会喷了, 不是说怎么写代码吗怎么说设计的问题. 可在我看来, 一个好的设计能让代码写得贼溜.
不知道大家有没有过类似的感觉
面对一个比较复杂的需求时(可能需要一周或者更多的时间), 做到一半突然不知道为什么之前自己会这么想, 或者已有的代码跟你当下的想法是冲突的, 如果没有留下比较详细的溈, 这时候很可能就会开始把以前的代码修改一番, 以满足你当下的一些设计想法; 结果又过了两天, 又遇到一个问题, 而看到这个问题时才想起两天前修改的那处代码其实是可以cover到现在出现的问题, 修改后反而出了新的问题, 那此时就会陷入两难, 我究竟是恢复代码好呢, 还是再继续打补丁呢?
这个其实跟产品提需求是一样一样的, 因为人的想法是会变的, 如果写代码也是见步行步, 见招拆招, 随着代码越来越多, 要考虑的事情也会越来越多, 到最后就会变成无尽的修bug过程, 补丁一个接一个, 名符其实地在写bug.
而我所倡导的, 是在一开始想好整个设计应该走什么风格, 这个风格会影响到接下来整个代码结构, 组织结构是长什么样的, 包括以后的功能扩展, 问题修复等, 所以基调很重要.
下面我以最近做的一个IM模块为例, 简单描述一我的思路与做法.
需求描述:
基于长链接构建支持1vs1聊天, 支持消息的收发的IM模块
需求其实很简单, 但就好像是阅读理解一样, 除了字面意思外, 还需要理解构思构建这个IM模块的参与角色.切忌看到啥就是啥, 因为我们要的是能聊天的IM模块, 而不是把需求里提到的东西堆起来 就行.
“为什么窗帘是蓝色的?”
“因为它就是蓝色的”
这里我们的确需要发扬一下脑补力.
0x211 分层
既然是IM, 那网络肯定是要依赖的了吧(需求里提到长链接), 也不可能每一次进来都需要去服务端请求聊天数据和聊天列表吧, 那本地缓存机制也要, IM也是存在用户交互的, 那接受用户操作与刷新UI的功能也是需要有的吧, 基于这样子粗略一想, 一个大概的层次结构就出来了.
分层的好处是可以清晰地离清不同的角色以及它们之间的关系, 如上图, 越往下越基础, 比如说网络层的动作不可能需要UI层的参(如果需要那就说明这是不合理的, 或者那已经不是一个单纯的网络层了, 需要再细分), 但反过来UI层有可能会依赖到网络层(直接或者间接),那么它们的关系就出来了, 明确了层次关系后, 在以后写代码时底层的就不再需要考虑属于上层的问题, 也不需要管上层的实现是怎么样的, 只需要定义好这一层的功能, 接口, 职责就可以了.
这样子的好处是可以 减少思考的负担, 专注于解决这一层次可能存在的问题.
然后就可以针对模型再细化. 针对每一层模块再深入考虑
- 是否存在平行的模块(不是上下依赖关系)
- 是否需要添加必要的中间层
- 能否解决上下依赖关系(毕竟越独立的模块越能用,越容易迁移)
考虑点包括但不限于以上几点, 这样考虑下来可以对分层模型图再进行扩展. 因为本地缓存是不需要网络支持的, 它的输入输出都是基于本地的, 所以本地缓存与网络层应该是平行关系; 而消息处理是需要同时依赖于本地缓存与网络的, 所以它是属于两者的上层.
重新整理扩展后分层图如下:
0x212 角色职责
分层模型定了整个设计的基调, 但并不能直接就基于分层模型来写代码, 这里我引入”角色”一词, 用于描述在编码过程中的关键对象(类似于游戏开发中的spirit),它们可以说是编码的基础, 这一步需要明确细分的是需要定义哪些”角色”, 以及”角色”之间的关系是什么.
基于分层图, 大概分了以下几种角色
- View: 这个与分层是一样的, 因为view的数据是不定的, 所以表示有这角色就行了
- Messager: 消息处理者, 用于发送/接收消息
- Network: 网络通信对象, 向上屏蔽长/短链接的差异,并兼具解析消息协议
- Storage: 本地缓存, 具有存取, 读取功能
因为我们要做的是消息模块,整个模块都是基于消息推动的, 与普通的http一拉一推不一样, 在任何时候都可能存在消息收发, 所以我在中间再加多一个中间角色
Dispatcher: 连立View, Messager与Network, 作消息派发
刚开始的时候其实曾经考虑过这样子的结构
因为当网络层收到消息时应该马上进行本地缓存, 则防止异常丢失, 但结合上面的分层图来看, 如果网络需要与存储有关联, 那网络层就不再再是单独的一个模块了, 因此这与分层是相违背的.
结合分层图, 最终它们的关系描述如下
这个关系图基本上与分层图是保持一致的, 那么所谓它们的”职责”又是什么意思呢?
职责, 通俗来说也就是这个角色所负责的事情, 这是大家都能够理解的, 然而我更加需要强调的是, 不要把不属于这个角色的功能加到这个角色里.
以Dispatcher为例, Dispatcher的职责我们一开始就定义成是一个消息转发的中间层, 但由于它各方都依赖到, 所以很多时候一不注意就会把很多的业务流程都写到这里来, 比如说保存一下消息啊, 解析一下协议等. 但如果是这样子的话就会超出了这个角色一开始定下来的职责(越权的事详细在下面再谈),这样子很容易产生一些bad smell, 比如
Dispatcher变成一个公共区,意义不明
因为关联方比较齐全, 代码都可以写在这里, 这就跟命名一个XXXCommon的类一个样, 它的意思就是没意思, 写起代码来就乱套了, 更不要说存在多人合作的情况下, 到最后往往想找一个东西都不知道去哪里找.
看代码的人理不清类的作用, 无法清晰界定
比如判断到是Dispatcher的问题, 我马上就可以针对Dispatcher去修, 而不再需要先看Messager等再分析到Dispatcher,这是一个自然的过程, 提高效率
其他角色的职责受到了干扰
一段代码总归有它自己的归宿, 你把它写到A, 就不能写到B(当然你说复制一段也是可以的, 但这就会产生更糟糕的结果, 参看详细下述#DRY), 这样就有可能会撑死一个角色(堆太多代码), 而饿死了其他的角色(内容空洞, 可有可无)
但当写代码写到一半的时候, 很可能会发现一些角色的定位是很奇怪的, 有些是无论怎么考虑, 好像逻辑都比较归纳到它里面去, 这种就是说明这个角色定位有问题, 很可能是无意义的, 需要把它跟其他的角色进行合并, 甚至直接删除; 而有一些角色是好像啥它都可以管(如上面的Dispatcher), 对于这种角色有两个思路
拆
将这个角色拆分成另外几个小角色, 细分定义
细分职责
不修改角色本身(类名等), 而细分明确其职责范围. 如Dispatcher本身可以将其定义成Controller的角色, 统一管理所有的边界, 但这样子的话势必会造成角色本身功能过多过重, 不利于管理; 而将其职责定义成”只用于信息传递的中转枢纽”,那就简单很多了, 首先它就肯定不会自己产生消息, 也不会有很复杂的逻辑功能(因为具体功能的实现逻辑都在其他对应的角色中), 它要做的只是转发而已, 这就清晰了很多了.
上面两个思路其实思路是一样的, 就是细分角色的职责, 找准角色的定位, 从设计到最终实现肯定是需要经过不断的完善修改的, 这也是一个细微渐进的过程, 随着经验不断丰富找这种定位会越来越准.
那么,说了这么久的角色与职责定位, 这样子做的好处是什么呢? 我把这个话题放到下一节中讲.
0x213 设计图
会画图也是一种本事, 这其实是把脑中的概念以一种符号的形式形象化的过程. 这是一项重要但基础的技能, 可惜在我身边居然发现有N年工作经验的人看到UML图居然表示这是啥, 我不禁感到一丝悲凉.
对于设计图, 我理解上它必然是和谐的, 和谐才是好的, 包括UI设计图与技术的UML图, 如类图. 试想, 如果它看上去复杂无比, 错宗复杂的话, 不论它的功能多强大, 都不会是一个好设计, 因为这与人本身的理解有关, 如果是从理解上出发, 人本身对容易的, 有条理的, 有逻辑的东西 相对容易理解, 而我们的UI设计图与UML本质上的目的也是为了让人理解, 从这点出发, 不能让人清晰理解的东西必然是需要再修改的. 但并不是要把图画得多么的”好看”(甚至有人企图想用优化的画图工具来弥补设计上的缺陷(包括我以前也是), 都必然不会成功), 因为重点并不在于图本身有多好看, 颜色搭配多么好(当然有的话这也是锦上添花), 首要的是让别人容易理解, 毕竟这不是绘画.
这就好比以往用记事本写代码, 没高亮, 没提示, 但如果结构清晰良好, 使用ctrl+f都能看个大概(不太复杂的情况下), IDEA比ECLIPSE也要好看, 效率也能提高 ,但这与设计图不一样
0x214 选型
技术方案上的争持不下, 可能关键点并不在于技术方案本身, A说不需要, 可以满足, B说功能全面, 实用性强, 列出各自优劣是一部分, 如果一方是属于比较偏执于某一点时可能还是不能讨论出结果, 如”这功能是不需要的”, “这个问题我很容易就能修复”, 那问题就已经不再是技术本身的问题了, 如果要有结果为目标, 首先可能要有一个共同的价值观, 如我们目前的项目最迫切的是什么, 是要上线, 还是功能完善强大, 哪一些资源是最重要的, 是时间 , 还是人力, 还是服务器, 单从技术上面不一定能出结果, 否则大家都不用选型了, 直接选最牛逼的就好了, 我要搭一个服务器, 全部都上apache, 但事实上我只是想写个demo, python几行代码就能搞定. 所以选 型 ,选 的不只只是技术, 还有团队环境 , 项目所处的进程 , 也就是当前面临最迫切的核心是什么.
0x220 克制
我每次在写代码的时候都会在想一个问题, “我如何能把代码写上高一个台阶”, 然后在一次次对一行代码反反复复地修改后, 我总结出了这两个字,“克制”. 写代码时想到哪里写到哪里, 飘逸自在多好, 我其实也会这么想, 但在项目团队合作中如果这么飘逸那很可能只是把自己的开心建立在别人帮你擦屁股的基础上而已,因为谁也不想看到逻辑混乱结构不清晰的代码, 严重点这是对别人生命的不尊重(因为要花别人的时间理解本来可以避免的问题). 如果只是头痛治头脚痛治脚一样, 那就是治标不治本, 映射回写代码, 那就是写到哪里算哪里, 自己都没有一个清晰的规划与模型, 写到最后, 就是四不像了, 因为这是堆起来的代码块而已.
我反而觉得写代码就像中医一样, 中医讲究的是身体的整体, 包括免疫, 调节能力等的稳定,以达到预防疾病, 延年益寿的效果; 同样代码也一样, 讲求的是从结构上去组织代码, 从根源处减少潜在的问题, 而不是依赖于无尽的if-else或者try-catch.
拥有良好架构, 合理组织, 逻辑清晰的代码才是健康的代码.
0x221 Don’t Repeat Yourself(DRY)
这个很多人都已经听烂了, 这里再把它强调一次, 主要是因为我在这几个月里, 甚至发现有一些工作好几年的同事朋友还是会犯这样子的错误. 在业务开发中, 有相似性的两个或两个以上的业务模块, 选择在完成一个模块后, 把相关的代码直接复制到另一个地方, 以求代码的复用, 减少开发. 但在我看来, 这根本就不是代码的复用, 这只是代码的复制而已. 结果也是显而易见的, 测试发现了一处出问题, 修了, 重走一次错误路径没问题, 结果往旁边的功能一点, 同样的问题又出来了, 因为修复的过程中只改了测试发现的那个路径, 而其他相关的甚至都没去检查(当然也有可能是已经不记得了), 这就是典型的治标不治本.
我试着问为什么要这样子做, 答曰只需要把另一个地方的也修复一下就可以了, 我反问, 那以后是不是修一个问题都需要全局搜一次再一个个改?回答是”是”, 我说当然不是, 先是不论这样子做的工作量, 而且非常容易漏, 就算代码是一个人写的过个两三天自己都不记得了, 在别人接手时怎么接, 不骂你骂谁?
当然, 重复不是说不允许有任何一行代码是一样的, 这样也是不实际的, 但如果当存在着一段或一大块代码块它们的逻辑是一样的, 或者是极度类似的(只是部分参数不同), 那它们肯定是存在着类似的地方, 再往深就是可以再抽象的; 如果是工具性质的那就只需要抽出即可.
DRY的好处也不用多说, 但我感受最深的是,遇到一个问题时, 我只需要修改对应一个方法/函数, 其他相关的地方就都可以一同修复, 回归的时候也只需要针对对这个方法/函数的引用来回归测试就行, 不容易出现遗漏.
0x222 收放自如
写了代码这么久, 大学开始就有人不断地告诉我们要高内聚低耦合, 但并不是所有人都能理解这六个字, 当然, 我也不敢说我已经完全能够深刻深埋并熟练运用, 仅说一下我自己的一些看法和做法.
所谓的高内聚, 其实跟我上面说到的”角色职责”有点相关, 这个角色承担的功能一个都不能少, 但作为它的关联角色, 需不需要去了解它是怎么运行的呢? 肯定是不要的, 如果这个属性, 这个逻辑是属于这个模块的, 很可能是一个过程里的一部分, 那它就不应该放到外面, 也不应该被外部感知,而应该设置为private.
而所谓的低耦合, 我理解是角色与角色之间应该只产生必要的, 最少的的关联关系, 其他不必要的, 如外部控制状态变量来控制这个模块的执行流程等, 都需要通过别的办法去化解.
举个播放器的例子.
设计一个简单的播放器, 提供音频播放/暂停功能
需求也是很简单的, 直接看一下一个接口设计.
1 | - (void)setBGMFileWith:(NSURL *)url;//选择BGM的路径 |
大概的意思看函数名也能猜得出来, 第一眼看着这些接口名, 我第一感觉有点手足无措, 当真的要用的时候根本不知道应该怎么用, 什么时候用哪个, 因为它开放了太多了接口了, 就一个播放功能就开了四个类似的接口方法, 很容易让人想到这就是头痛治头的方法:
先写了一个播放, 结果后面发现不够用, 又写了一个, 然后又发现满足不了需求, 又写了另一个, 最后只有写的人知道怎么用了
这简直就是灾难. 越基础的代码(不是指越简单的代码, 而是指作为业务基石的代码)就越需要简单.
做任何一个基础模块, 先需要考虑谁会用它, 用它来干嘛. 而对于播放器而言, 最基本的需求, 当然就是播放/暂停了, 那其实整个模块的接口可以做得很简单
1 | - (void)play:(NSString*)url; |
没错就只需要三个接口, 继想其实原来任何的接口都只是这三个接口的一个延伸而已, 你给我一个url就播, 你说暂停就暂停, 说停止就停止, 甚至说如果不需要考虑暂停继续播放, 其实就只需要播放/暂停就可以了(如播放一些简短的音频等). 而对于其他的一些业务化需要的需求(如添加背景音), 因为同一时间, 只会存在一个音源在播放(这是背景前提, 也是常识), 而播放一旦开始, 它就只能暂停或者停止, 没有第三种可能了, 那么我们就可以建立一个configurations配置项,用于描述播放过程中的一些自定义配置, 如刚刚说的背景音等, 但这个配置就只是作为play接口的一个参数, 不用对其他的任何播放产生任何的影响.
而前面的一个接口设置里, 如果我现在正在播放, 但又设置了一个bgm的地址, 那这个动作会触发什么? 不看里面的逻辑大家都不知道, 这种就是不可预期的, 我们应该尽量避免无法预期的代码.
对于像需要在播放时对音频进行缓存降噪等, 这部分是属于播放器内部控制逻辑, 调用方不需要也不应该去关心这里的实现, 就可以直接写在内部就可以了.
我一直信奉的是极简原则, 原因也很简单, 越简单的东西越不容易出错. 我指的简单并不是指功能实现简单, 而是指用的人用起来很简单.
这里包括了两层含意
- 调用简单, 接口定义清晰, 参数意义明显
- 功能描述清晰, 运行结果可以预期
把握好内收外放的度, 无论是写的人还是用的人都能减少出错的概率. 模块出问题时, 只需要在公开接口的范围内对模块内部进行自查即可, 如播放器只开了play/pause/stop三个方法, 现在bgm的播放有问题了, 那我只需要去play接口的实现里查bgm查卷的逻辑即可; 而对于前者的接口设置, 因为BGM的控制与播放是独立的, 查起来涉及的面就比后者广了, 查起来就不好查了; 而对于用的人而言, 接口定义简单了, 能调的就这些了, 即使是在没有文档和原作者参与的情况下也不太可能在调用上出很磊偏差, 因为play就是play, 你调了就播, 不调就不播,没有其他的可能了; 对于前者而言,可能需要根据一定的规则去调用, 如如果需要添加BGM播放, 就需要先调用 setBGMFileWith 接口, 再调用 , 甚至还需要先调用 setPlayingState 停止播放等, 因为谁去调用, 怎么调用在写代码的时候是无法全面预测的, 我在实际中也遇到过类似的情景, 我就按着我对业务和接口注释来调用, 结果发现一跑就崩或者没按预期来, 一问原作者, 查了一番后告诉你应该先调A,再调B, 再调C, 我只能问:你不累吗?
有时规范比注释, 文档都要有用, 而怎么去设计这个规范就看怎么去控制好”内”与”外”的关系了.
0x223 再谈职责
在很多时候, 找到一个合适的代码插入点是需要过一下脑子的, 这些时候, 潜意识里的懒虫就会不断地跳出来在你耳边说”写这里写这里,这里能很方便地拿到XXYY对象, 不行再加个公开方法就可以了”, 贪快贪方便那直接就从了, 但却因此埋下了祸根.
我相信, 贪一时之快往后会以更高的成本来补偿的, 只是付出这个成本和承担这个后果的不一定是写这个代码的人而已(这里涉及到职业素养的问题, 下述), 除非做的是创业项目, 今天写完的代码也不知道能不能活过两天的, 但对于个人而言,无论是代码存活时间多久, 能不能上线, 都不应该放松自己的要求, 习惯, 往往是在不经意间养成的.
三思而后行.
0x230 自底向上地编程
我们在设计分层时其实就已经是这样子做的了, 先定好底部需要什么模块功能支撑, 再考虑上层业务怎么去做, 到我们真正去写代码的时候其实也是一样, 先完成基础支撑, 再向上层扩展迭代.
这就跟建房子是一样的, 地基打不好, 那房子建得再高再漂亮也没用, 哪一天一点小地震甚至一些小问题, 就能把整个楼给搞跨; 同理, 对于程序来说, 基础模块做得不好, 那上面依赖于它的模块也不可能会好, 因为基础层是被依赖的一方, 是多个功能点链条里的其中一环, 这一环断了那整个功能都可以说完蛋了,这种情况UI写出花来用户也会跑.
这其实就跟程序的运行过程是一样的.
源码需要经过编译器, 翻译成当前系统能识别的二进制码, 然后操作系统要把程序运行起来, 还需要把这个程序载入主存, 然后再告诉CPU需要执行什么指令(这里简化成了CPU). 从上往下看, 我们的业务(Program)是针对一个个场景来实现的一个个功能的, 这些功能编译器, 甚至系统都无法提供; 而编译器则是针对Program所用的语言进行语义主流法分析后, 针对当前的系统平台翻译出不一样的字节码, 只要是符合这种语言协议的, 编译器都能编译出来; 而OS则不仅仅需要能运行编译器, 还能运行编译器编译出来的程序, 而且还要向底层硬件申请资源; 而最底层硬件, 它只需要按顺序执行既定的指令即可. 从层次的通用性来看, 越往下, 其通用性是越来越好的, 通用性好背后有两种含义, 一是逻辑简单,比如说加法器, 能用在计算机上, 也能用在计算器上; 二是逻辑极其复杂, 比如说OS, 它通过编写大量的代码以求在不同的硬件平台上都能运行同一套系统. 这里我所说的是前者, 而并非指代码复杂度或者代码量的差别.
这其实跟我们分层是一个意思, 底层它能运用的场合是可以是很多的, 但最高层(如UI层), 它就真的只能起到展示作用而已.
所以我倡导从底往上写代码. 上面也说过了, 越往底层, 它的功能应该是越通用越稳定的, 而我们在考虑基础模块的实现的时候, 因为不需要考虑上层的东西(因为是上层依赖于它, 而不是它依赖于上层), 所以它的目标是”好好完成自己的事”即可, 它甚至不需要考虑这可能是哪个业务在用, 哪个模块会调, 它们的场景是怎么样的, 它只需要按照接口定义, 把自己的功能实现完整就可以了. 从谤个角度来看, 底层的代码应该是最好写, 而且是最好测的才对.
底层完成后, 剩下的其实就是对基础模块功能的一些逻辑调用而已, 说白了就是if-self与循环的组合.底层模块保障好, 就可以安心往上继续写代码了.
当然, 如果涉及到底层的修改,好处是只需要修改一处, 所有的地方的问题都可以得到同步的解决, 但同样也会带会影响过大的问题. 针对这种矛盾, 我的处理办法是需要保持修改上的”克制”与边界的修改不变, 简单来说就是需要保持接口的定义不变, 包括它的作用, 以及返回内容的意义, 才能保证调用方处理没问题. 比如说我原接口返回的数据类型是A, 现在改成了A+B, 此前调用方并不知道B的存在, 在处理过程不能保证都能完全兼容(当然也可以一个个排查, 但这修改面就会更大), 尽量把修改在角色内部消化, 而不影响外部.
0x300 怎么修问题
除了日常的正常开发外, 修bug也是我们每天需要面对的事情, 简单的问题自不必说, 有清晰错误堆栈自然是好, 有固定重现路径的也自然不必说, 但头痛的往拄是那些不稳定重现的, 外部因素相关的(如手机, 系统, 甚至网速,地域等)问题. 对付这种问题, 其实我也没啥万试万灵的好方法, 事实上我也不想象存在这种办法能一招行天下, 所以我在这里想探讨的是一种思维方法.
0x310 理解问题
到手一个问题, 那必然需要先知道它是什么问题, 这并非一件小事, 这其实直接决定了我们接下来应该分析什么以及如何分析, 把握不好, 浪费时间是一方面, 另一方面是可能会走进死胡同里出不来, 就更谈不上解决问题了.
问题拿到手就肯定需要先复现的, 万一运气好一下子就能复现出来那ありがたい了, 即使是不能稳定重现的, 重走一次问题路径, 也可以知道问题大概是出在什么场景里, 进而想到哪些模块是相关的, 哪些模块是非常可疑的, 这需要先对相关的代码比较熟悉 , 如果不熟悉, 那就得先顺着代码一行行断点断下去看, 以求先理解代码是怎么运行优先.
但更多时候我们都不能稳定地复现出现的问题, 那应该怎么办呢?
0x320 排查
针对不稳定出现的问题, 这里先插一点题外话
对于一个函数y=f(x), x一定的情况下, y就是一定的
上面是函数的基本定义. 那么映射到程序上面来, 对于一个方法A, 对于固定参数a,b,c, 它处理后的结果应该是一样的. 如果输入参数不一样, 那就只可能有一种情况引起问题
方法内部引用了除了参数外的其他属性
打个比方, 我们在方法A里对一个全局的状态属性mStatus进行了判断
1 | if(mStatus==YES){ |
两次的结果不一致, 那就只可能是因为执行过程受到这个mStatus属性的影响导致的不一致(这也是我强烈不建议使用状态属性来标记状态的原因,函数方法幂等是保证简单的基本方法).
既然是不稳定出现, 从代码上来看就有两种情况影响
- 输入参数变了
- 执行赛程中方法依赖的全局状态/数据变了
第一种情况首当其冲要考虑的是多线程问题. 多线程问题其实就是因为CPU线程调度不确定性(受配置, 内存和当前CPU负载等影响), 导致执行的过程不同线程间的先后顺序是不定的, 但这是从宏观上来看的, 从微观来看, 还是跟我们上面分析的两个情况是一致的, 比方说一个很简单的例子
1 | int a=0; |
有十个线程一齐去执行打印
1 | printf("%d\n",a++); |
那出来的结果很可能是每次都是不一样的. 从不同线程的执行情况来说, 因为CPU只能一条条指令去执行(针对单核来说), 线程A执行完了, 对a加了1, 但因为下一个线程B里同样也引用到了a, 而此时的a与这个线程刚启动时的值是不一样的了, 对于线程B而言, 也即输入参数变了.
明白了上面的道理后, 就可以直接针对怀疑部分的这些入参, 状态等进行逐一排查了, 常规的控制变量法, 断点, 断言, 再结合日志的输出, 查出与预期值不一样的属性, 既而找出问题所有.当然, 我们在排查的时候可以使用稳定的数据源, 以防止在数据源头引入更多的变量, 产生干扰.
0x330 解决问题
这里我想扩展一下, 并不只想说如何修复一个问题, 还包括我对难点问题攻克的一些看法.
我看到过一些比较魔幻的修bug方式, 也是一个不稳定重现的问题, 然后就不断不断反复反复地重试, 然后从众多的案例中企图发现一些规律, 到最后, 好像还真的发现一些苗头了(事实上可能只是碰巧而已), 然后错误地认为可能是在一些边界位置里, 有一两个值不满足预期, 那怎么办了? 既然是边界问题, 那就加一个if-else就可以了. 然后就可以对外宣称解决了, 到测试的时候, 测试一测, 问题又来了, 然后就在不断地循环中加了很多判断, 企图以这种方式来避开问题(我不认为这叫修复), 在不断修改的过程中, 通过不断反复地尝试,在整体测试样本还不超过20的情况下就说出现的概率从多少多少下降于多少多少, 然后就表示问题已经修复了.
这简直又是一个灾难. 我把这种修bug方式称为玄学解bug. 如果不是发生在我身边我简直不敢相信.
解bug其实跟写代码是一样的, 讲求的也是一个结构技巧, 如果为了解决一个问题, 而破坏了原来代码的结构,理念,甚至伤筋动骨, 这种修改一时可能没问题, 但往后肯定还是需要付出代价的. 我认为一个良好的修bug姿势, 是发现这个bug的原因后, 通过原来代码的结构和运行机制, 在保证原来代码正常运行的情况下, 从源头把问题消除.比方说问题的根源是来自于一个状态变量维护不当, 而这个状态变量目前已经影响到好多个方法了, 此时如果再对它添加其他的特性判断, 那维护的成本就会继续上升. 即倒不如想一个更加合理的机制, 如状态机等比较易懂易跟踪的模式来替代复杂多变的状态变量, 从根本上把问题以及以后可能存在的隐患消除. 当然, 新引入的修改也要足够简单健康, 如果发现修改后的问题或者隐藏成本比维护原来的代码更大, 这个修改也是不及格的.
说起解bug又想到另一个发生在身边的事, 有一同事遇到了一个问题
无法改变一个已存在音频文件的音调, 并存放到另一个文件中, 但可以动态对音频文件进行变调播放
然后另一个同事自告奋勇帮忙一齐研究, 一天下来后, 发现并没有一个好的解决办法, 突然间,想起程序是可以录音的, 然后一拍大腿说想到了解决办法, 既然我们可以变调播放, 我们也可以录音, 那我们就可以一边播一边录啊, 这样子不就可以获取到一个变调后的音频文件了吗!
毫无疑问, 这简直是一个天才的创意. 我在旁边听得一脸懵逼, 无言以对. 而当事同事听完后感觉有什么不对, 但他一下子又说不出来, 另一位同事又马上解释说”这样子是不是可以解决问题”, 当事同事就说了一个”是…..”然后后面都没来得及出口, 另一同事马上说”那就行啦, 我马上做一个demo”. 然后当事同事脸就好像吃了屎一样, 估计我也差不多.
我马上说对当事同事说, 这样子是于事无补的, 你想想使用的场景, 如果我要保存变调音频那就必须要播一次, 那如果我不播呢, 或者播得不完整呢, 或者环境很嘈杂呢, 或者音频很长呢……很多问题我一下子就能说出来, 这完全不是解决方案, 因为它不能解决任何实际的问题, 我们不是在预研或者做demo, 我们是要做产品的, 如果是你, 你用着这样子的一个应用, 你不马上卸了算我输. 我说完后也感觉挺尴尬的, 因为另一同事的demo已经发出来了……可惜是没用的.
总结来说, 无论是修bug还是解决具体的难点问题或是技术攻坚, 都需要根据当前的项目条件, 产品特性来决定, 而不是拍脑袋说”这样子做问题就没了”,”加上这一行代码问题就没了”, 天知道问题是真的修复了还是只是出现的概率变低了, 而不考虑实际情况的解决方案, 也只能存活在demo中, 不可能上线的.
0x340 何谓经验
其实说了这么多, 我其实并没有讲到有什么好招去招呼bug, 只是在修bug的过程里产生了这些个感悟. 然而说实在的,什么方法都不如下场一试, bug调多了自然会有自己的体会与技巧.
但这不是绝对的, 并不是说遇到的问题多了自己解决问题的能力就会提升, 而是看能否从历史的经验中得到教训成长, 即使这个问题不是自己解的, 但依然可以从别人的解决过程中吸取到自己所缺乏的, 是对工具用得不熟练, 还是问题分析得不够透彻, 这些都是成长的地方.
就好比项目里遇到一个问题, 它引入了ABCD几个第三方库, 然后程序编译都编不过, 然后高手过来一指点, 编译参数加了一个宏MACRO_A, 问题迎刃而解, 那下一次遇到类似的问题的时候都可以参考, 是不是库存在冲突了.
有些人跟他聊, 他会告诉你说有多少多少年经验, 参加过多少项目, 然而解决问题的方法就是百度, 找到一个别人遇到的类似问题, 然后把对方的代码一贴, 咦!突然间就好了, 然后就对外宣称自己已经解决了什么问题, 得到掌声,深藏功与名.
然而这真的就是自己的经验吗?
我身边也出现过类似的情况, 问题的确是消失了, 可我习惯多问两句, 比如说他是怎么实现的啊, 为什么原来会有问题啊之类的, 但往往对方可能只会说”没看具体怎么实现””我也不知道””是系统问题”云云, 我不禁诧异: 这真的已经解决问题了吗?甚至更离谱的是, 自己再去深入排查这个问题, 却得出一个更优的, 甚至截然不同的解决办法, 这又让我反思, 之前究竟是解决了问题, 还是掩盖了问题?
再甚者, 那个以为已经解决问题的人, 下一次又遇到类似的问题了, 然后就判断这问题遇到过, 怎么怎么样就能解决很简单的, 结果发现这一次贴代码却不灵了, 然后情况陷入僵局. 就像上面那个例子一样, 加了宏后, 原来不能编译的现在可以编译了. 下一次遇到不能编译, 又想到这个例子, 马上照葫芦画瓢, 然而项目不同了, 产生同样问题的原因也是多种多样的, 不存在一种银弹可以解决所有类似的问题.
解决问题, 还需要因地制宜. 而要达到因地制宜, 就需要知道, 为什么会发生问题, 为什么这样子可以解决问题, 关键是两个方向的能力
- 理解问题, 独立分析问题的能力
- 捉住问题主次的能力
这些都需要在反复思考中锻炼. 第一点好理解, 为什么第二点很重要呢? 举一个我遇到的问题(问题真多=.=||)
有一个播放器的需求, 涉及到播放的基本操作, 以及对应UI元素的更新, 还需要互斥播放. 同事A找到我帮忙分析, 我分析完, 整理出一个简单易操作的设计给他, 问题的核心在于控制每一个播放请求的独立互斥, 以及ui的更新机制上, A很快把代码写完了, 但出来的结果与设计重心是不同的, A把重心放在了如何在代码层面上解决不同页面独立播放上了. 我一看完就有点懵, 感觉就是把自己辛辛苦苦把菜炒出来, 别人吃在嘴里嚼了两口, 就咽了一点汁, 然后就把菜吐出来, 然后吐出来的东西我自己都不认识了.
我们要解决的是一个面的问题, 不能捉住一两个点来解决就完事, 凡事多往深处想想, 我做了这个事情能解决什么问题, 解决完这个问题对于整体来说是不是至关重要, 它是不是核心的问题, 如果不是, 那就说明它只是一个点而已, 解决完本质问题, 很可能这个点自然就不存在了.
0x350 再谈理解问题
说完了一通, 再次回归到问题的理解上, 我们对这个问题的理解认识是什么, 这不是哲学上面的事情,这只是简单的一个基础知识的问题. 这么多年来我遇到过很多很棘手的问题, 我喜欢遇到问题刨根问底, 直到我刨不动为止,刨了这么多年, 我发现了一个规律, 一些表面上看起来很匪夷所思的问题, 刨到最后实现原理的一部分, 它都会像裸女一样毫无保留地暴露到你面前.
举一个刚毕业时遇到的问题, 那时还在做Java/Android,当时无知的我给项目加了动态加载的特性(其实就是利用JavaLoader加载jar包), 出现了一个奇怪的现象
在多次初始化后, 原来已经初始化好的对象,甚至静态变量都发生了改变, 而它们本来是不应该改变的
刚开始拿到这个问题可谓束手无策, 在业务代码里摸了一天依然摸不出来, 后来没办法, 借了一本Java虚拟机的书, 生生把加载部分的章节全啃下来了, 在看的过程中不断地寻找与目前遇到问题相重合的地方, 但很明显, 书上是不可能有明确的答案的. 可就在我完全理解了Java虚拟机的加载原理后, 把原理和问题一结合, 答案居然自然就冒出来了, 原因说来也简单, 因为我每次初始化都会重新加载一次jar, 导致存在两份同样的实例在不同的ClassLoader之中, 而它们对于虚拟机来说虽然都来自于同一份jar, 但他们在内存中却早就变成了孪生兄弟, 兄弟显然不是同一个人.
可能如果我不去深究里面是怎么实现的话, 我可能只会普通地加一个限制, 不让应用多次重复初始化就完了, 根本不会去想为什么多次初始化后会产生这样子的问题. 可因为这次后, 以后遇到对于Java虚拟机加载部分的问题, 都能很快解决, 甚至像遇到一些与常量池, 类初始化之类的相关问题, 我都有足够的知识储备以更系统的, 更底层的思路去思考问题.
包括后来做Objective-C/iOS, 我也是类似的习惯, 语言都是类似的, 而它们的差异在哪里, 为什么存在这样子的差异, KVO是什么,KVC是什么, oc的垃圾回收与java有什么异同等等, 这些目前在我看来都只算是基础知识, 但是有了这些基础知识, 并且深刻理解, 比做很多个千篇一律的项目强得多得多.
如果自己都说不出来为什么会出现这种原因, 这样做后为什么问题就能解决, 那只是非常基础的层面去思考这些问题, 离解决还很远很远. 深入原理后所获取的知识成了我所认为的基础知识, 而这些基础知识又让我想问题更有针对性, 更系统, 面对问题, 也更能一针见血.
这就是我所认为的”理解”.
剑一人敌,不足学, 学万人敌
0x400 职业人
所谓专业的人做专业的事,作为一届码农, 写好代码最基本的本份, 但这离专业还远远不够, 为什么有一些人经历是工作了好几年, 但实际工作起来给人感觉就是一年经验(甚至更短)用了好几年, 小作坊的处事手法,实习生的见识,加之待人接物的低情商,走到现在已数流氓,肯定是不能走得更远的. 究其原因, 是因为我们在工作中不仅仅需要关注做了什么, 还需要关注怎么做, 往大处说就是怎么思考问题的问题, 想问题太浅显, 太直线, 在别人看来就是不够稳, 不应该是工作了这么久后的状态.
自省
人应该不断地向上看, 向前看, 我还听到一种说辞
我会这些(底层原理)我就不用在这里做业务了
首先, 做业务不是什么低级的事, 做业务同样也能做得很出彩; 另外, 底层知识(这里指系统级别, 工程范畴, 非算法层面)并非某一些人的专属, 它们是属于基础知识的一部分, 如果面对问题都开始自我否定, 不肯下功夫下决心去研究理解, 那就以后都只能在自己的一亩三分地里耕耘, 然后慢慢被淘汰.
团队合作
曾经有同事问我, 什么是团队合作. 我说很简单, 同一起点的人, 有能力的自然会冒出来, 在前面带着其他人走, 除了完成自己的本份, 还担起更多的责任, 能力越大责任越大, 这就是他们的团队合作; 而能力一般的人, 按照计划安排完成自己的本身, 能配合上其他人的节奏, 这就是他们的团队合作; 他们两者之间本没有什么高低之分, 只是承担不一样而已, 而一个团队里两类人都是需要的, 出成果也不是单单跑前面的人就能完成的, 对于整体而言, 他们的合作构成了整个团队的团队合作.
在创业团队里, 我还遇到过这样子的情况, 有人完成了自己负责的主流程上的一个模块任务后就嚷嚷着要提测, 但测试一跑, 发现连主流程还走不通, 所有测试都只能模拟数据, 甚至要单独跑代码去测试. 我觉得这样子的测试毫无意义, 倒不如自测多一点, 协助一下集成测试岂不更好, 不必执着于什么时候提测.
这里折射出的一个问题, 产品需要的是团队的协作才能出来的, 完成一个两个模块不叫完成, 我们追求的不应该是完成手上的事情就可以的, 而是为最后的产品, 以及质量负责(我把之称为程序员的尊严). 试想, 对于一个应用而言, 你说提测了, 大家都以为功能已经好了, 结果一跑主流程各种不通畅, 人家只会说, xxx团队的人水平不行, 很多问题, 一骂把整组人都骂了, 把那些在老老实实在集成测试, 目标1Y的人都一并骂了, 这是不公平的. 各人自扫门前雪, 只完成分派给自己的任务这不叫团队合作, 团队内应该是利益共同体, 应同仇敌忾, 为最终产出的内容负责.
人与人之间的信任可以很简单, 也可以很脆弱, 每个人在别人眼里的第一印象都有一次塑造机会, 但也仅有一次.
加班
我对加班的看法一直就是…..没看法, 不是我赞同或者反对这种模式, 而是要首先明白加班, 或者996的目标或者原因是什么.
有一些公司或者团队, 光明正大地宣扬自己实行996, 还恬不知耻认为这是跟大家在一同创业奋斗, 首先说白了不是所有人都是一样这么想的, 有些人只是想糊口, 其次, 就算成功了, 跟着老板在下面透支身体的人能得到每个人期待中的奖励吗, 也不尽然. 公开996, 其实就是错误地把劳动投入与产出联系成正相关关系的结果. 投入得越多得到的不一定会越多, 特别是在互联网行业, 发展到今天如果带是像以前一样, 用人多力量大或大力出奇迹的思路来做事, 那就是把一项智力密集型的产业生生退化成劳动密集型产业了. 事实上, 互联网发展到今天, 绝不是靠着堆人堆时间就有这样子的成果的, 我认为互联网行业里最大的优势是在于, 从业者懂得如何利用机器去提高生产力和生产效率, 而不是一味地靠人力堆时间. 公司不想着从流程, 从技术层面去优化提高效率提高产出, 所以强制要求996实在是愚蠢, 我还见过有些团队也跟风说要996, 结果发现业务根本没那么多, 加班没事做, 很快就取消了, 这不搞笑吗? 最后唯一能达到的成就是, 心散了, 人跑了, 业务黄了.
而另外也有一些个人的想法, 认为既然公司或者团队说要加班, 那就加吧, 反正到几点几点就下班; 或者说今天我不加班, 明天补回来(一般996公司工作日里都有一天family day啥的, 这天不强制加班). 其实说这些想法大问题是没有的, 可一琢磨, 这就只是盲目随大流, 接单办事的态度而已. 无论是什么项目, 人的一天都应该有一个目标, 比如说要完成一个什么模块, 要跟别人对测试用例, 产品需求等, 完成这些事就是今天的目标, 如果提早完成了, 那我觉得什么时候下班都可以(当然前提是目标安排得合理), 而不是非得要等到要求的时间; 同样, 如果今天的任务没完成了, 如果还是按照要求的时间下班, 会不会对其他人有什么影响, 其他人的节奏会不会打乱, 如果是有的, 那就得考虑是不是需要重新评估时间, 或者再延长一下时间去做一个完成度不到100%但起码是可用的东西出来再走. 只要把握这条原则, 其实要不要求加班, 我觉得对于结果来说都是不重要的.
つづく