软件设计杂谈

  • 本文所讲的设计,非UI/UE的设计,单单指软件代码/功能本身在技术上的设计。UI/UE的主题请出门右转找特赞(Tezign)。
  • 在如今这个Lean/Agile横扫一切的年代,设计似乎有了被边缘化的倾向,做事的周期如此之快,似乎已容不下人们更多的思考。MVP(Minimal Viable Produce)在很多团队里演化成一个形而上的图腾,于是工程师们找到了一个完美的借口:我先做个MVP,设计的事,以后再说。
  • 如果纯属个人玩票,有个点子,hack out还说得过去;但要严肃做一个项目,还是要下工夫设计一番,否则,没完没了的返工会让你无语泪千行。
  • 设计首先得搞懂要解决的问题
  • 工程师大多都是很聪明的人,聪明人有个最大的问题就是自负。很多人拿到一个需求,还没太搞明白其外延和内涵,代码就已经在脑袋里流转。这样做出来的系统,纵使再精妙,也免不了承受因需求理解不明确而导致的返工之苦。
  • 搞懂需求这事,说起来简单,做起来难。需求有正确的但表达错误的需求,有正确的但没表达出来的需求,还有过度表达的需求。所以,拿到需求后,先不忙寻找解决方案,多问问自己,工作伙伴,客户follow up questions来澄清需求模糊不清之处。
  • 搞懂需求,还需要了解需求对应的产品,公司,以及(潜在)竞争对手的现状,需求的上下文,以及需求的约束条件。人有二知二不知:
    1.I know that I know
    2.I know that I don’t know
    3.I don’t know that I know
    4.I don’t know that I don’t know
  • 澄清需求的过程,就是不断驱逐无知,掌握现状,上下文和约束条件的过程。
    这个主题讲起来很大,且非常重要,但毕竟不是本文的重点,所以就此带过。
  • 寻找(多个)解决方案
  • 如果对问题已经有不错的把握,接下来就是解决方案的发现之旅。这是个考察big picture的活计。同样是满足孩子想要个汽车的愿望,你可以:
    1.去玩具店里买一个现成的
    2.买乐高积木,然后组装
    3.用纸糊一个,或者找块木头,刻一个
  • 这对应软件工程问题的几种解决之道:
    1.购买现成软件(acuquire or licensing),二次开发之(如果需要)
    2.寻找building blocks,组装之(glue)
    3.自己开发(build from scratch, or DIY)
  • 大部分时候,如果a或b的TCO [1] 合理,那就不要选择c。做一个产品的目的是为客户提供某种服务,而不是证明自己能一行行码出出来这个产品。
  • a是个很重要的点,可惜大部分工程师脑袋里没有钱的概念,或者出于job security的私心,而忽略了。工程师现在越来越贵,能用合理的价格搞定的功能,就不该雇人去打理(自己打脸)。一个产品,最核心的部分不超过整个系统的20%,把人力资源铺在核心的部分,才是软件设计之道。
  • b我们稍后再讲。
  • 对工程师而言,DIY出一个功能是个极大的诱惑。一种DIY是源自工程师的不满。任何开源软件,在处理某种特定业务逻辑的时候总会有一些不足,眼里如果把这些不足放在,却忽略了人家的好处,是大大的不妥。前两天我听到有人说 “consul sucks, …, I’ll build our own service discovery framework…”,我就苦笑。我相信他能做出来一个简单的service discovery tool,这不是件特别困难的事情。问题是值不值得去做。如果连处于consul这个层次的基础组件都要自己去做,那要么是心太大,要么是没有定义好自己的软件系统的核心价值(除非系统的核心价值就在于此)。代码一旦写出来,无论是5000行还是50行,都是需要有人去维护的,在系统的生命周期里,每一行自己写的代码都是一笔债务,需要定期不定期地偿还利息。
  • 另外一种DIY是出于工程师的无知。「无知者无畏」在某些场合的效果是正向的,有利于打破陈规。但在软件开发上,还是知识和眼界越丰富越开阔越好。一个无知的工程师在面对某个问题时(比如说service discovery),如果不知道这问题也许有现成的解决方案(consul),自己铆足了劲写一个,大半会有失偏颇(比如说没做上游服务的health check,或者自己本身的high availability),结果bug不断,辛辛苦苦一个个都啃下来,才发现,自己走了很多弯路,费了大半天劲,做了某个开源软件的功能的子集。当然,对工程师而言,这个练手的价值还是很大的,但对公司来说,这是一笔沉重的无意义的支出。
  • 眼界定义了一个人的高度,如果你每天见同类的人,看同质的书籍/视频,(读)写隶属同一domain的代码,那多半眼界不够开阔。互联网的发展一日千里,变化太快,如果把自己禁锢在一方小天地里,很容易成为陶渊明笔下的桃花源中人:乃不知有汉,无论魏晋。
  • 构建灵活且有韧性的系统
  • 如果说之前说的都是废话,那么接下来的和真正的软件设计能扯上些关系。
  • 分解和组合
  • 软件设计是一个把大的问题不断分解,直至原子级的小问题,然后再不断组合的过程。这一点可以类比生物学:原子(keyword/macro)组合成分子(function),分子组合成细胞(module/class),细胞组合成组织(micro service),组织组合成器官(service),进而组合成生物(system)。
  • 一个如此组合而成系统,是满足关注点分离(Separation of Concerns)的。大到一个器官,小到一个细胞,都各司其职,把自己要做的事情做到极致。心脏不必关心肾脏会干什么,它只需要做好自己的事情:把新鲜血液通过动脉排出,再把各个器官用过的血液从静脉回收。
  • 分解和组合在软件设计中的作用如此重要,以至于一个系统如果合理分解,那么日后维护的代价就要小得多。同样讲关注点分离,不同的工程师,分离的方式可能完全不同。但究其根本,还有有一些规律可循。
  • 总线(System Bus)
  • 首先我们要把系统的总线定义出来。人体的总线,大的有几条:血管(动脉,静脉),神经网络,气管,输尿管。它们有的完全负责与外界的交互(气管,输尿管),有的完全是内部的信息中枢(血管),有的内外兼修(神经网络)。
  • 总线把生产者和消费者分离,让彼此互不依赖。心脏往外供血时,把血压入动脉血管就是了。它并不需要知道谁是接收者。
  • 同样的,回到我们熟悉的计算机系统,CPU访问内存也是如此:它发送一条消息给总线,总线通知RAM读取数据,然后RAM把数据返回给总线,CPU再获取之。整个过程中CPU只知道一个内存地址,毋须知道访问的具体是哪个内存槽的哪块内存 —— 总线将二者屏蔽开。
  • 学过计算机系统的同学应该都知道,经典的PC结构有几种总线:数据总线,地址总线,控制总线,扩展总线等;做过网络设备的同学也都知道,一个经典的网络设备,其软件系统的总线分为:control plane和data plane。
  • 路由(routing)
  • 有了总线的概念,接下来必然要有路由。我们看人体的血管:
  •  
  • 每一处分叉,就涉及到一次路由。
  • 路由分为外部路由和内部路由。外部路由处理输入,把不同的输入dispatch到系统里不同的组件。做web app的,可能没有意识到,但其实每个web framework,最关键的组件之一就是url dispatch。HTTP的伟大之处就是每个request,都能通过url被dispatch到不同的handler处理。而url是目录式的,可以层层演进 —— 就像分形几何,一个大的系统,通过不断重复的模式,组合起来 —— 非常利于系统的扩展。遗憾的是,我们自己做系统,对于输入既没有总线的考量,又无路由的概念,if-else下去,久而久之,代码便绕成了意大利面条。
  • 再举一例:DOM中的event bubble,在javascript处理起来已然隐含着路由的概念。你只需定义当某个事件(如onclick)发生时的callback函数就好,至于这事件怎么通过eventloop抵达回调函数,无需关心。好的路由系统剥茧抽丝,把繁杂的信息流正确送到处理者手中。
  • 外部路由总还有「底层」为我们完成,内部路由则需工程师考虑。service级别的路由(数据流由哪个service处理)可以用consul等service discovery组件,service内部的路由(数据流到达后怎么处理)则需要自己完成。路由的具体方式有很多种,pattern matching最为常见。
  • 无论用何种方式路由,数据抵达总线前为其定义Identity(ID)非常重要,你可以管这个过程叫data normalization,data encapsulation等,总之,一个消息能被路由,需要有个用于路由的ID。这ID可以是url,可以是一个message header,也可以是一个label(想象MPLS的情况)。当我们为数据赋予一个个合理的ID后,如何路由便清晰可见。
  • 队列(Queue)
  • 对于那些并非需要立即处理的数据,可以使用队列。队列也有把生产者和消费者分离的功效。队列有:
    •single producer single consumer(SPSC)
    •single producer multiple consumers(SPMC)
    •multiple producers single consumer(MPSC)
    •multiple producers multiple consumers(MPMC)
  • 仔细想想,队列其实就是总线+路由(可选)+存储的一个特殊版本。一般而言,system bus之上是系统的各个service,每个service再用service bus(或者queue)把micro service chain起来,然后每个micro service内部的组件间,再用queue连接起来。
  • 有了队列,有利于提高流水线的效率。一般而言,流水线的处理速度取决于最慢的组件。队列的存在,让慢速组件有机会运行多份,来弥补生产者和消费者速度上的差距。
  • Pub/Sub
  • 存储在队列中的数据,除路由外,还有一种处理方式:pub/sub。和路由相似,pub/sub将生产者和消费者分离;但二者不同之处在于,路由的目的地由路由表中的表项控制,而pub/sub一般由publisher控制 [2]:任何subscribe某个数据的consumer,都会到publisher处注册,publisher由此可以定向发送消息。
  • 协议(protocol)
  • 一旦我们把系统分解成一个个service,service再分解成micro service,彼此之间互不依赖,仅仅通过总线或者队列来通讯,那么,我们就需要协议来定义彼此的行为。协议听起来很高大上,其实不然。我们写下的每个function(或者每个class),其实就是在定义一个不成文的协议:function的arity是什么,接受什么参数,返回什么结果。调用者需严格按照协议调用方能得到正确的结果。
  • service级别的协议是一份SLA:服务的endpoint是什么,版本是什么,接收什么格式的消息,返回什么格式的消息,消息在何种网络协议上承载,需要什么样的authorization,可以正常服务的最大吞吐量(throughput)是什么,在什么情况下会触发throttling等等。
  • 头脑中有了总线,路由,队列,协议等这些在computer science 101中介绍的基础概念,系统的分解便有迹可寻:面对一个系统的设计,你要做的不再是一道作文题,而是一道填空题:在若干条system bus里填上其名称和流进流出的数据,在system bus之上的一个个方框里填上服务的名称和服务的功能。然后,每个服务再以此类推,直到感觉毋须再细化为止。
  • 组成系统的必要服务
  • 有些管理性质的服务,尽管和业务逻辑直接关系不大,但无论是任何系统,都需要考虑构建,这里罗列一二。
  • 代谢(sweeping)
  • 一个活着的生物时时刻刻都进行着新陈代谢:每时每刻新的细胞取代老的细胞,同时身体中的「垃圾」通过排泄系统排出体外。一个运转有序的城市也有新陈代谢:下水道,垃圾场,污水处理等维持城市的正常功能。没有了代谢功能,生物会凋零,城市会荒芜。
  • 软件系统也是如此。日志会把硬盘写满,软件会失常,硬件会失效,网络会拥塞等等。一个好的软件系统需要一个好的代谢系统:出现异常的服务会被关闭,同样的服务会被重新启动,恢复运行。
  • 代谢系统可以参考erlang的supervisor/child process结构,以及supervision tree。很多软件,都运行在简单的supervision tree模式下,如nginx。
  • 高可用性(HA)
  • 每个人都有两个肾。为了apple watch卖掉一个肾,另一个还能保证人体的正常工作。当然,人的两个肾是Active-Active工作模式,内部的肾元(micro service)是 N(active)+M(backup) clustering 工作的(看看人家这service的做的),少了一个,performance会一点点有折扣,但可以忽略不计。
  • 大部分软件系统里的各种服务也需要高可用性:除非完全无状态的服务,且服务重启时间在ms级。服务的高可用性和路由是息息相关的:高可用性往往意味着同一服务的冗余,同时也意味着负载分担。好的路由系统(如consul)能够对路由至同一服务的数据在多个冗余服务间进行负载分担,同时在检测出某个失效服务后,将数据路只由至正常运作的服务。
  • 高可用性还意味着非关键服务,即便不可恢复,也只会导致系统降级,而不会让整个系统无法访问。就像壁虎的尾巴断了不妨碍壁虎逃命,人摔伤了手臂还能吃饭一样,一个软件系统里统计模块的异常不该让用户无法访问他的个人页面。
  • 安保(security)
  • 安保服务分为主动安全和被动安全。authentication/authorization + TLS + 敏感信息加密 + 最小化输入输出接口可以算是主动安全,防火墙等安防系统则是被动安全。
  • 继续拿你的肾来比拟 —— 肾脏起码有两大安全系统:
    输入安全。肾器的厚厚的器官膜,保护器官的输入输出安全 —— 主要的输入输出只能是肾动脉,肾静脉和输尿管。
    环境安全。肾器里有大量脂肪填充,避免在撞击时对核心功能的损伤。
  • 除此之外,人体还提供了包括免疫系统,皮肤,骨骼,空腔等一系列安全系统,从各个维度最大程度保护一个器官的正常运作。如果我们仔细研究生物,就会发现,安保是个一揽子解决方案:小到细胞,大到整个人体,都有各自的安全措施。一个软件系统也需如此考虑系统中各个层次的安全。
  • 透支保护(overdraft protection)
  • 任何系统,任何服务都是有服务能力的 —— 当这能力被透支时,需要一定的应急计划。如果使用拥有auto scaling的云服务(如AWS),动态扩容是最好的解决之道,但受限于所用的解决方案,它并非万灵药,AWS的auto scaling依赖于load balancer,如Amazon自有的ELB,或者第三方的HAProxy,但ELB对某些业务,如websocket,支持不佳;而第三方的load balancer,则需要考虑部署,与Amazon的auto scaling结合(需要写点代码),避免单点故障,保证自身的capacity等一堆头疼事。
  • 在无法auto scaling的场景最通用的做法是back pressure,把压力反馈到源头。就好像你不断熬夜,最后大脑受不了,逼着你睡觉一样。还有一种做法是服务降级,停掉非核心的service/micro-service,如analytical service,ad service,保证核心功能正常。
  • 把设计的成果讲给别人听
  • 完成了分解和组合,也严肃对待了诸多与业务没有直接关系,但又不得不做的必要功能后,接下来就是要把设计在白板上画下来,讲给任何一个利益相关者听。听他们的反馈。设计不是一个闭门造车的过程,全程都需要和各种利益相关者交流。然而,很多人都忽视了设计定型后,继续和外界交流的必要性。很多人会认为:我的软件架构,设计结果和工程有关,为何要讲给工程师以外的人听?他们懂么?
  • 其实pitch本身就是自我学习和自我修正的一部分。当着一个人或者几个人的面,在白板上画下脑海中的设计的那一刻,你就会有直觉哪个地方似乎有问题,这是很奇特的一种体验:你自己画给自己看并不会产生这种直觉。这大概是面对公众的焦灼产生的肾上腺素的效果。:)
  • 此外,从听者的表情,或者他们提的听起来很傻很天真的问题,你会进一步知道哪些地方你以为你搞通了,其实自己是一知半解。太简单,太基础的问题,我们take it for granted,不屑去问自己,非要有人点出,自己才发现:啊,原来这里我也不懂哈。这就是破解 “you don’t know what you don’t know” 之法。
  • 记得看过一个video,主讲人大谈企业文化,有个哥们傻乎乎发问:so what it culture literally? 主讲人愣了一下,拖拖拉拉讲了一堆自己都不能让自己信服的废话。估计回头他就去查韦氏词典了。
  • 最后,总有人在某些领域的知识更丰富一些,他们会告诉你你一些你知道自己不懂的事情。填补了 “you know that you don’t know” 的空缺。
  • 设计时的tradeoff
  • Rich hickey(clojure作者)在某个演讲中说:
    everyone says design is about tradeoffs, but you need to enumerate at least two or more possible solutions, and the attributes and deficits of each, in order to make tradeoff.
  • 所以,下回再腆着脸说:偶做了些tradeoff,先确保自己做足了功课再说。