在 Windows 上可以用 Docker 吗?

Docker,或者准确一点说,容器技术,在近几年里几乎成为了应用分发和集群部署的默认技术了。背景部分,如果感兴趣,请参考闲谈集群管理模式一文。Docker 生态的成熟还有赖于其周边工具和实践模式的兴起。比如,曾经雨后春笋般出现的编排技术,以及基于容器技术的 DevOps 实践大规模地开展。

那么这么好的技术,在 Windows 上能用吗?在各种场合,都有人与我讨论这个的话题。每次听到这样的疑问,我也是很无奈的。毕竟,只要稍微搜索一下,就不难回答:是可以的。不过,深入想一下,人们有这样的疑问也是有道理的:毕竟 Docker 是起源于 Linux 上的技术。

Docker 是基于 Linux 内置的 Namespace 和 CGroup 等系统内隔离机制而抽象出来的一种轻虚拟化技术。与虚拟机相比,它以一种轻量级的方式实现了运行空间的隔离。如果物理机是一幢住宅楼,虚拟机就是大楼中的一个个套间,而容器技术就是套间里的一个个隔断。不难理解,Docker 作为一种隔断,它并不能基于一种内核(Linux)提供另一种内核(Windows)的虚拟化运行环境。所以,基于 Linux 的 Docker 是不支持运行 Windows 应用的。

早在 Docker 之前,Linux 就已经提供了今天的 Docker 所使用的那些基础技术。当年 Docker 仿佛一夜之间突然火爆全球的背后,技术上的积累并不是瞬间完成的。这一切在 Windows 上显得有些滞后。在 Docker 已经众所周知的时候,Windows 系统却根本没有类似的机制,更别提 Windows 独有的工具链和实践方法了。所以,我们看到,早期 Windows 与 Docker 的交集只是为其提供应用开发环境。

boot2docker 与 Docker for Windows

可以在 Windows 开发面向 Docker 部署的应用程序——Windows 的桌面体验比 Linux 好太多,所以很早就出现了在异构操作系统上以虚拟机的形式运行 Docker 的项目出现,也就是 boot2docker。它既支持 Windows,也支持 macOS。

后来,Docker 公司开始推出自己的 Docker for Windows 工具包,它旨在为开发人员在 Windows 上开发面向 Docker 的应用程序提供完整的工具链,其中包括运行环境、客户端,Docker Swarm 编排工具和其他工具。Docker for Windows 中负责运行环境配置的工具是 Docker Machine。与 boot2docker 类似,Docker Machine 也会在 Windows 上创建一个 Linux 虚拟机,用于运行 Docker 引擎。也就是说,这个环境也只支持 Linux 的应用程序格式的,并不支持 Windows 应用程序的运行。

(在 Windows 上运行的 Docker for Windows(图片来自 Docker 文档))

Windows 容器技术

正当 Linux 世界的容器技术借着 Docker 的东风刮遍世界的时候,Windows 系统也发现了容器粒度的重要性。

微软与 Docker 在 2014 年宣布了合作,以期将容器技术带到 Windows Server 操作系统,并为传统的 Windows 应用程序的容器化改造提供更直接的支持。不久之后,微软在 Ignite 2015 上宣布将推出为容器优化的 Windows Nano Server;第一次 Windows 容器真正与与开发者见面是在 Windows 10 的年度更新(2016.8)上,它正式提供 Windows 容器的开发环境。在 2017 年 10 月发布的 Windows Server 1709 版本包含了 Windows 容器,意味着这项技术可以用于生产环境了。Windows 容器是真正能够运行 Windows 应用程序的容器技术,包括依赖 IIS、注册表等大量 Windows 特性的应用程序都可以在 Windows 容器中运行。

虽然 Windows 对容器的支持有些姗姗来迟,但社区对 Windows 容器的关注和运用却是异常活跃。这主要得益于容器技术本身生态的成熟,一来人们对这项技术已经有了充分的认知,同时周边工具和实践都已经日趋完善。另一方面,在与 Docker 公司一同打造这项技术的过程中,也注意了与已有技术的兼容性。人们发现,在电脑上启用 Windows Container 功能之后,接下来的操作步骤仍然是基于 Docker 客户端完成的,命令行参数与 Linux 上的 Docker 也没有区别。

几乎与 Windows 容器技术本身日趋成熟过程的同时,周边工具对 Windows 容器的支持也在同步完善。Docker for Windows 在新的版本中添加了一个贴心的菜单,可以一键切换 Linux 容器和 Windows 容器;Kubernetes 从 1.5 版本开始增加对 Windows 容器的支持;云环境方面,包括 Azure 和 AWS 在内的众多云环境都第一时间提供了 Windows 容器的支持……

Windows 容器架构

Windows 是如何既提供自有容器技术,又提供与 Docker 兼容的操作接口的呢? 下面的上图是 Linux 容器的架构,下图则是 Windows 容器的。可以发现两者结构很类似。与 Linux 类似,Windows 也新新抽象出来了 CGroup 和 Namespace 的概念,并提供出一个新的抽象层次 Compute Service,即宿主机运算服务(Host Compute Service,hcs)。相较于底层可能经常重构的实现细节,hcs 旨在为外部(比如 Docker 引擎)提供较稳定的操作接口。hcs 的操作接口目前有 Go 语言版本,以及 C# 语言版本,前者目前在 Docker 客户端中用来操作 Windows 容器。

(图片来自 Black Belt 在 DockerCon 的演讲:Docker 与 Windows 容器揭秘

容器镜像方面,微软自己提供了 Server Core 和 Nano Server 两种服务器版本。Server Core 可以理解为 Windows Server 去掉了 GUI 的部分,因此功能更完整(比如包括文件服务器、DNS 服务器等功能),同时镜像大小也更大(2GB~5GB);而 Nono Server 则是专为容器优化的迷你型系统,只包含有核心的 Windows 服务器功能,镜像大小为(130MB~400MB)。基于基础镜像来构建自己镜像的方法与 Linux 镜像是一样的,所以 DockerFile 文件的格式和语法并没有不同。

授权方面,只要用户已经取得宿主机的授权,微软并不会单独向用户收取容器镜像的授权费

小结

容器技术本身以及围绕它的一系列工具和实践让应用程序的打包和发布变得标准化,很大程度上可以消除应用程序对特定环境的依赖,进而为高效的集群化部署和运维提供有力保障。作为容器技术的代表,Docker 可以以两种形式运行在 Windows 上:以 Hyper-V 虚拟机的形式运行 Linux 格式的容器,或者运行原生的 Windows 容器。其中前者运行 Linux 格式的应用程序,后者能运行 Windows 应用程序。如果稍微用一点技巧,还可以让这两者同时运行在 Windows 电脑上

Windows 10 和 Windows Server 都提供了对 Windows 容器的支持,各种容器化工具对 Windows 容器的支持也在日趋完善当中。基于 Windows 开发新的应用时一方面可以优先考虑跨平台容器化部署的能力,另一方面也可以与存量应用程序一样考虑借助 Windows 容器技术实现容器化、云原生的特性。


更多精彩洞见,请关注微信公众号:思特沃克

Share

什么是框架?

先摘录并翻译一段 wiki:

In computer programming, a software framework is an abstraction in which software providing generic functionality can be selectively changed by additional user-written code, thus providing application-specific software. A software framework provides a standard way to build and deploy applications. A software framework is a universal, reusable software environment that provides particular functionality as part of a larger software platform to facilitate development of software applications, products and solutions. Software frameworks may include support programs, compilers, code libraries, tool sets, and application programming interfaces (APIs) that bring together all the different components to enable development of a project or system.

在编程领域,软件框架是指一种抽象形式,它提供了一个具有通用功能的软件,这些功能可以由使用者编写代码来有选择的进行更改,从而提供服务于特定应用的软件。软件框架提供了一种标准的方式来构建并部署应用。

软件框架是一种通用的、可复用的软件环境,它提供特定的功能,作为一个更大的软件平台的一部分,用以促进软件应用、产品和解决方案的开发工作。软件框架可能会包含支撑程序、编译器、代码、库、工具集以及 API,它把所有这些部件汇集在一起,以支持项目或系统的开发。

Frameworks have key distinguishing features that separate them from normal libraries:

框架和普通的库在特性上具有一些关键性的区别:

  1. inversion of control_: In a framework, unlike in libraries or in standard user applications, the overall program’s flow of control is not dictated by the caller, but by the framework.[1]控制反转:与库或普通的应用不同,在框架中,应用的宏观控制流程不是由调用者决定的,而是由框架本身。
  2. extensibility_: A user can extend the framework – usually by selective overriding; or programmers can add specialized user code to provide specific functionality.可扩展性:用户可以扩展该框架 —— 通常是有选择的进行改写(Override)或者由程序员添加专门的用户代码来提供特定的功能。
  3. non-modifiable framework code_: The framework code, in general, is not supposed to be modified, while accepting user-implemented extensions. In other words, users can extend the framework, but should not modify its code.不可修改框架代码:通常,框架代码都不打算让你修改,而是接受由用户自己实现的某些扩展。换句话说,用户可以扩展该框架,但是不应该修改它的代码。

形象点比喻(但不够严谨),框架就是条生产线,这条生产线上有很多工人(代码)在工作。生产线的管理者(程序员)负责管理这条生产线,比如说有的工序是空的,那么你就可以安排自己的工人进去,让他去达成你的目标。有些工序上的工人干的工作和你预期的不同,你也可以安排自己的工人把他替换掉。

但是无论如何,你的工人除了执行你的意志之外,还要遵守那个工序的强制性要求,他想磨洋工或粗制滥造是不行的,因为这个流水线上的下一个工序可能有超时检查或质量检查,出了错直接就把这个流水线给你停掉,甚至对于一些强制性检查,你作为管理者都无权忽略它。

可以想见,一条好的生产线的价值有多大。生产线绝不仅仅是一组机器而已,它是很多年的管理经验的结晶,这些才是最值钱的,否则光靠那些机器能值几个钱?有了生产线,对工人(代码)的要求大大降低了,甚至对管理者(程序员)的要求也大大降低了。当然,如果你只想生产个“能穿”的鞋子,那么这条生产线几乎没有附加价值,甚至会提高你的成本。但是如果你想生产一个“高质量”的鞋子,那么这条生产线是别人的鞋子卖10块而你的鞋子能卖到1000块的根本保障。

总体来说,建立生产线的目标就是制定规矩,保障品质,让高品质可以用较低的代价进行复制。框架也是如此。

从代码结构上看,框架在宏观层面使用的都是注册、回调机制。这种机制有一个形象的名称,叫做好莱坞法则,为什么叫好莱坞法则呢?因为好莱坞想要成名的演员太多了,都想去找导演,这样下去导演的工作效率势必会受到严重的影响。于是导演就立下了“规矩”,不要打给我们(Don’t call us),等我们打给你(We’ll call you!)。由于这个 call 和程序调用的 call 是同一个词,于是编程界就把这句话搬过来,变成了回调(callback)的形象代言人。

比如在 Java 的 Spring 框架中,你只要给一个类加上 @Service 注解,它就会自动被 Spring 作为服务管理起来,当 Spring 认为需要的时候,就会创建这个类并且把它的实例传给需求方。在 Angular 中也是一样,你只要给一个类加上 @Component 注解(装饰器),它就会自动被 Angular 当做组件管理起来,当 Angular 认为需要的时候,就会创建这个类,并且把它的实例传给需求方(比如路由器)。

这些注解中还可以带一些额外信息,被称为元数据。所谓“元数据”就是 metadata,指的是关于数据的数据,这不是 Angular 自创的名词,其它编程领域已经使用了几十年了。当 Angular 准备创建一个组件的时候,它就会找到这些元数据,从中找出这个组件的模板(因为组件本身是纯类,没有携带任何模板信息),然后据此对 DOM 进行操纵。

而你写的这些组件类和模板,其实就是“由程序员添加专门的用户代码来提供特定的功能”,因为框架是不会关心你的组件的外观和逻辑的,它唯一关心的就是你必须遵循它的规范来工作,否则它就会给你报错(比如,“连模板都没有还敢说自己是 Angular 组件?”)。

而库则跟框架相反,宏观上说,它是等着你调用的,你要什么功能它就给你什么函数,然后你调用这个函数,把所需的参数传进去就行了,而不是让你遵守它的那么多规矩。所以你很自由,但是你也要自己为整件事负责。你要自己创建组件、创建服务等,自己来驱动整个流程,自己做必要的检查,当然也可以不做,反正对十块钱的鞋子别人可能只希望能穿一个月就行了。

那么,问题来了,框架一定会比库高级吗?显然不是,甚至连框架的功能都不一定比库多。它们只是定位不同、设计理念不同而已。对于 Angular 来说,它会更希望你遵守一些规矩,这样当系统需要长期维护、甚至要经历很多人员更迭的时候,才不至于腐化。它希望每个开发人员都不必了解应用的全貌就能很好地完成工作(因为有当前工序的操作手册和检查点)。当然,它也不会干涉那些它不需要关心的事情,比如组件模板中你放 h1 还是放 div 它是不在乎的。这些目标用库也能达到,不过对人员的架构观和做事的自律性会有相应的要求,毕竟没人管了,那你自己就不能放任自流。

然而,在现实中,很多应用的整个生命周期可能都不会超过一年,甚至还有很多生命周期几天的活动页,那么,这些应用和页面的可维护性其实并不重要,甚至连是否能让不同的人协作都无所谓。那么点功能,能出什么 bug?一次性的需求,管什么可扩展性?这时候,学习成本就会成为一个很重要的参考因素。

学习 Angular 最难的就是遵守并理解规矩。然而大部分人天生是不愿遵守规矩的,特别是有些规矩可能他都无法理解为什么(虽然这可能是前人根据血的教训总结出来的)。可是,一旦遵守并理解了这些规矩,那么一扇新的大门就对你敞开了,你会发现跨上这个台阶之后,无论前端技术还是后端技术还是移动端技术,都遵循着很多相同的理念,即使出现了新的挑战,你也可以套用这些理念去解决。

规矩即自由。孔子把“从心所欲,不逾矩”当做自己的最佳状态,其实很多事都是这样。一旦深刻理解了设计和使用框架的思维模式,你将迎来一个实质性的提升。

在我工作的前五年,编程时很“聪明”,用技巧解决了很多问题,但之后的十五年(恰好在那一年我知道了框架的概念),我爱上了规矩,不但自己给自己(根据血泪教训)立各种规矩,而且从别人那里借鉴了很多规矩,无论是宏观的还是微观的。可以说,规矩就是固化的好习惯,虽然有时候也会成为阻碍,但是如果你想在编程领域工作到退休,那么这些规矩就是你表面上最大的资产,而对这些规矩的来龙去脉的理解和领悟,则是你深层次中最大的资产。


更多精彩洞见,请关注微信公众号:思特沃克

Share

写了这么多年代码,你真的了解设计模式么?

昨天和同事聊到最近他要做的一个培训,大概的课程是这样的:

第一天:

  • 上午:面向对象原则(OO+SOLID )
  • 下午:设计模式(Design Pattern)

第二天:

  • 上午:简单设计(SimpleDesign)
  • 下午:重构到模式(Refactor to DesignPattern)

面向对象原则,设计模式,简单设计,重构到模式……看起来都是常规操作,但你有想过他们的关系么?

忍不住要山寨一段《写了这么多年代码,你真的了解SOLID吗?》中的发言:

上面提到的每一项大家都耳熟能详,但我发现大部分开发者并没有真正理解。要获得最大收益,就必须理解它们之间的关系,并综合应用所有这些原则。只有把它们作为一个整体,才可能构建出坚实(Solid)的软件。遗憾的是,我们看到的书籍和文章都在罗列每个原则,没有把它们作为一个整体来看,甚至提出这几项的知名大叔们也没能讲透彻。因此我尝试介绍一下我的理解。

面向对象原则(OO+SOLID )

偷懒一下,请大家移步,直接参考文章《写了这么多年代码,你真的了解SOLID吗?》,记得回来呦~

简单理解,面向对象原则给我们提供了一系列面向对象上下文下的最佳实践,指导原则和终极目标,符合这些原则可以帮助我们最大化OO的威力。

如果把面向对象类比成软件开发领域的一个武林门派,面向对象原则就是这个门派的最高心法和目标,打个比方,有点像:心与意合,意与气合,气与力合,肩与胯合,肘与膝合,手与足合……这种。

心法这种东西就是神神秘秘的,真正看起来也简单,字面上也不难理解,很容易用它来挑战别人:“你看你看,你没有做到手与足合!”

但是回到自己,要想做到就难了,知易行难!

归其原因它虽然提供了目标和评价标准,使我们很容易拿他来评价别人,但并没有告诉我们自己如何才能达到这样的目标和标准。

设计模式(Design Pattern)

那设计模式是什么?

很多人,包括我在内,都曾迷陷于23种设计模式之中,初识设计模式,赞叹于其精妙,就像个萌新的江湖小生,偶然间掉到个山洞,一下就集齐了威震江湖的“7种武器”。

然后……就开始了用长生剑切菜,用碧玉刀削瓜的“幸福生活”,不但把简单的事情搞得巨复杂,最不能忍的是还暴殄天物!

回到设计模式,无非是在面向对象原则这些虚无缥缈的“心法”指导原则下,那些前辈大神们留下的“招式”或是“套路”而已。但招式和套路并不能致胜,它只是为我们这等小白提供了一个接近大神,理解心法的途径而已,通过长年累月的模仿去反思去领悟去体会“心法”的本质和精妙,此时脑中不禁浮现《少林寺》中李连杰夏练三九冬练三伏的画面……

这就能解释为什么使用同样的招式,大神们总能一击致命,而我们却总是被按倒在地摩擦的原因了。

简单设计(SimpleDesign)

设计模式是套路和招式,那简单设计是什么?

首先要区分一下简单设计和容易设计:

简单的反义词是复杂,容易的反义词是困难。简单不等于容易,追求简单的过程往往很困难,如果只是追求容易的往往导致系统过于复杂。

这是好多年前一位同事提到过的一句话,很在点儿上,我们经常混淆了简单和容易,对于这点,另一位同事之前也写过一篇文章来阐述做到“简单”的“困难”。

做为结果的简单设计是这么一种设计,它能被几乎所有人理解, 但只有极少数人能做出. 或者反过来说也可以. 简单设计是一种只有极少数人能做出的设计,但设计一旦做出后,能被所有人理解.

可见简单设计是一种只有极少数人能做出的设计,那我们怎么才能成为那“极少数人“呢?

对此,Kent Beck给出了清晰的答案:

  1. 通过所有测试(Passes its tests)
  2. 尽可能消除重复 (Minimizes duplication)
  3. 尽可能清晰表达 (Maximizes clarity)
  4. 更少代码元素 (Has fewer elements)
  5. 以上四个原则的重要程度依次降低。

这组定义被称做简单设计原则。

我们不具体探讨这几个原则,回到最初的问题,如果说面向对象原则是”心法“是”目标“,设计模式是前辈们沉淀下来的”套路”和“招式“的话,那简单设计是什么呢?

我觉得就是实战指导原则,他可以让我们不局限于哪些经典的过往的招式,跳出套路,无招胜有招。

如果说设计模式是一种自上而下,通过不断模仿前辈大神套路达到目标的一条道路的话(有招胜无招);那简单设计原则则是另一条自下而上,忘掉招式和套路,遵循简单的基本原则,随机应变,不断演进,不断浮现,逐步逼近目标的另一条道路(无招胜有招)。

但,记住,殊途同归。

好,这位客官问了,那两条道路都可以帮我们掌握武功最高的心法的目标,我走哪条呢?如果我已经会了一条,还需要学习另一条路径么?

请移步我的《筷子定理》

重构到模式(Refactor to DesignPattern)

说到这里,就不难理解“重构到模式”到底在说什么了吧。

它无非是在解释如何通过自下而上应用简单设计原则,运用重构的技术和手法,浮现出设计模式。我管这个过程叫“重走长征路”,重新体验一下那些大神前辈们创建发现设计模式的过程。

这个过程也再次证明了这两条通往同一个目标的不同的道路也是可以相互转换的,它们只不过是面向对象原则下的两种不同形式的表现而已。

如果说“设计模式”是“有招胜无招”,“简单设计”是“无招胜有招”的话,那“重构到模式”就是“无招生有招”的过程。

总结

总的来说,我们可以把这几个概念作为一个整体框架来思考。

“面向对象原则”是OO领域的终极目标,是面向对象这门武功的心法,很容易用来评判别人,但是自己却很难达到。

为了能达到心法的境界,之前的各任掌门大神们总结并留下了一些套路和招式,什么降龙十八掌之类的,让我等晚辈可以通过日复一日的刻意练习,去不断参透心法的真谛,这些套路和招式就是“设计模式”。

但只有这十八招显然是不够的,于是我们又学到了一些实战指导原则,让我们以另一种方式,自下而上,跳出招式和套路的限制,甚至能不断浮现出新的招式,且通向的是同一个目标,这些实战指导原则就是“简单设计原则”。

最后,不断操练“重构到模式”,通过实际运用实战指导原则和手法,推演招式和套路,帮助我们将这两种方法融会贯通,相互结合,最终领悟那隐藏在一切表象背后的真理,达到无招胜有招,草木竹石皆可为剑,心随意走,人随心动的境界。


更多精彩洞见,请关注微信公众号:思特沃克

Share

写了这么多年代码,你真的了解SOLID吗?

尽管大家都认为SOLID是非常重要的设计原则,并且对每一条原则都耳熟能详,但我发现大部分开发者并没有真正理解。要获得最大收益,就必须理解它们之间的关系,并综合应用所有这些原则。只有把SOLID作为一个整体,才可能构建出坚实(Solid)的软件。遗憾的是,我们看到的书籍和文章都在罗列每个原则,没有把它们作为一个整体来看,甚至提出SOLID原则的Bob大叔也没能讲透彻。因此我尝试介绍一下我的理解。

先抛出我的观点: 单一职责是所有设计原则的基础,开闭原则是设计的终极目标。里氏替换原则强调的是子类替换父类后程序运行时的正确性,它用来帮助实现开闭原则。而接口隔离原则用来帮助实现里氏替换原则,同时它也体现了单一职责。依赖倒置原则是过程式编程与OO编程的分水岭,同时它也被用来指导接口隔离原则。关系如下图:

单一职责原则(Single Responsibility Principle)

单一职责是最容易理解的设计原则,但也是被违反得最多的设计原则之一。

要真正理解并正确运用单一职责原则,并没有那么容易。单一职责就跟“盐少许”一样,不好把握。Robert C. Martin(又名“Bob大叔”)把职责定义为变化原因,将单一职责描述为 ”A class should have only one reason to change.” 也就是说,如果有多种变化原因导致一个类要修改,那么这个类就违反了单一职责原则。那么问题来了,什么是“变化原因”呢?

利益相关者角色是一个重要的变化原因,不同的角色会有不同的需求,从而产生不同的变化原因。作为居民,家用的电线是普通的220V电线,而对电网建设者,使用的是高压电线。用一个Wire类同时服务于两类角色,通常意味着坏味道。

变更频率是另一个值得考虑的变化原因。即使对同一类角色,需求变更的频率也会存在差异。最典型的例子是业务处理的需求比较稳定,而业务展示的需求更容易发生变更,毕竟人总是喜新厌旧的。因此这两类需求通常要在不同的类中实现。

单一职责原则某种程度上说是在分离关注点。分离不同角色的关注点,分离不同时间的关注点。

在实践中,怎么运用单一职责原则呢?什么时候要拆分,什么时候要合并?我们看看新厨师在学炒菜时,是如何掌握“盐少许”的。他会不断地品尝,直到味道刚好为止。写代码也一样,你需要识别需求变化的信号,不断“品尝”你的代码,当“味道”不够好时,持续重构,直到“味道”刚刚好。

开闭原则(Open-closed Principle)

开闭原则指软件实体(类、模块等)应当对扩展开放,对修改闭合。这听起来似乎很不合理,不能修改,只能扩展?那我怎么写代码?

我们先看看为什么要有开闭原则。假设你是一名成功的开源类库作者,很多开发者使用你的类库。如果某天你要扩展功能,只能通过修改某些代码完成,结果导致类库的使用者都需要修改代码。更可怕的是,他们被迫修改了代码后,又可能造成别的依赖者也被迫修改代码。这种场景绝对是一场灾难。

如果你的设计是满足开闭原则的,那就完全是另一种场景。你可以通过扩展,而不是修改来改变软件的行为,将对依赖方的影响降到最低。

这不正是设计的终极目标吗?解耦、高内聚、低耦合等等设计原则最终不都是为了这个目标吗?畅想一下,类、模块、服务都不需要修改,而是通过扩展就能够改变其行为。就像计算机一样,组件可以轻松扩展。硬盘太小?直接换个大的,显示器不够大的?来个8K的怎么样?

什么时候应该应用开闭原则,怎么做到呢?没有人能够在一开始就识别出所有扩展点,也不可能在所有地方都预留出扩展点,这么做的成本是不可接受的。因此一定是由需求变化驱动。如果你有领域专家的支持,他可以帮你识别出变化点。否则,你应该在变化发生时来做决策,因为在没有任何依据时做过多预先设计违反了Yagni

实现开闭原则的关键是抽象。在Bertrand Meyer提出开闭原则的年代(上世纪80年代),在类库中增加属性或方法,都不可避免地要修改依赖此类库的代码。这显然导致软件很难维护,因此他强调的是要允许通过继承来扩展类。随着技术发展,我们有了更多的方法来实现开闭原则,包括接口、抽象类、策略模式等。

我们也许永远都无法完全做到开闭原则,但不妨碍它是设计的终极目标。SOLID的其它原则都直接或间接为开闭原则服务,例如接下来要介绍的里氏替换原则。

里氏替换原则 (The Liskov Substitution Principle)

里氏替换原则说的是派生类(子类)对象能够替换其基类(父类)对象被使用。学过OO的同学都知道,子类本来就可以替换父类,为什么还要里氏替换原则呢?这里强调的不是编译错误,而是程序运行时的正确性。

程序运行的正确性通常可以分为两类。一类是不能出现运行时异常,最典型的是UnsupportedOperationException,也就是子类不支持父类的方法。第二类是业务的正确性,这取决于业务上下文。

下例中,由于java.sql.Date不支持父类的toInstance方法,当父类被它替换时,程序无法正常运行,破坏了父类与调用方的契约,因此违反了里氏替换原则。

package java.sql;

public class Date extends java.util.Date {
    @Override
    public Instant toInstant() {
        throw new java.lang.UnsupportedOperationException();
    }
}

接下来我们看破坏业务正确性的例子,最典型的例子就是Bob大叔在《敏捷软件开发:原则、模式与实践》中讲到的正方形继承矩形的例子了。从一般意义来看,正方形是一种矩形,但这种继承关系破坏了业务的正确性。

public class Rectangle {
    double width;
    double height;

    public double area() {
        return width * height;
    }
}

public class Square extends Rectangle {
    public void setWidth(double width) {
        this.width = width;
        this.height = width;
    }

    public void setHeight(double height) {
        this.height = width;
        this.width = width;
    }
}

public void testArea(Rectangle r) {
    r.setWidth(5);
    r.setHeight(4);
    assert(r.area() == 20); //! 如果r是一个正方形,则面积为16
}

代码中testArea方法的参数如果是正方形,则面积是16,而不是期望的20,所以结果显然不正确了。

如果你的设计满足里氏替换原则,那么子类(或接口的实现类)就可以保证正确性的前提下替换父类(或接口),改变系统的行为,从而实现扩展。BranchByAbstraction绞杀者模式 都是基于里氏替换原则,实现系统扩展和演进。这也就是对修改封闭,对扩展开放,因此里氏替换原则是实现开闭原则的一种解决方案

而为了达成里氏替换原则,你需要接口隔离原则。

接口隔离原则 (Interface Segregation Principle)

接口隔离原则说的是客户端不应该被迫依赖于它不使用的方法。简单来说就是更小和更具体的瘦接口比庞大臃肿的胖接口好。

胖接口的职责过多,很容易违反单一职责原则,也会导致实现类不得不抛出UnsupportedOperationException这样的异常,违反里氏替换原则。因此,应该将接口设计得更瘦。

怎么给接口减肥呢?接口之所以存在,是为了解耦。开发者常常有一个错误的认知,以为是实现类需要接口。其实是消费者需要接口,实现类只是提供服务,因此应该由消费者(客户端)来定义接口。理解了这一点,才能正确地站在消费者的角度定义Role interface,而不是从实现类中提取Header Interface

什么是Role interface? 举个例子,砖头(Brick)可以被建筑工人用来盖房子,也可以被用来正当防卫:

public class Brick {
    private int length;
    private int width;
    private int height;
    private int weight;

    public void build() {
        //...包工队盖房
    }

    public void defense() {
        //...正当防卫
    }
}

如果直接提取以下接口,这就是Header Interface:

public interface BrickInterface {
    void buildHouse();
    void defense();
}

普通大众需要的是可以防卫的武器,并不需要用砖盖房子。当普通大众(Person)被迫依赖了自己不需要的接口方法时,就违反接口隔离原则。正确的做法是站在消费者的角度,抽象出Role interface:

public interface BuildHouse {
    void build();
}

public interface StrickCompetence {
    void defense();
}

public class Brick implement BuildHouse, StrickCompetence {
}

有了Role interface,作为消费者的普通大众和建筑工人就可以分别消费自己的接口:

Worker.java
brick.build();

Person.java
brick.strike();

接口隔离原则本质上也是单一职责原则的体现,同时它也服务于里氏替换原则。而接下来介绍的依赖倒置原则可以用来指导接口隔离原则的实现。

依赖倒置原则 (Dependence Inversion Principle)

依赖倒置原则说的是高层模块不应该依赖底层模块,两者都应该依赖其抽象。

这个原则其实是在指导如何实现接口隔离原则,也就是前文提到的,高层的消费者不应该依赖于具体实现,应该由消费者定义并依赖于Role interface,底层的具体实现也依赖于Role interface,因为它要实现此接口。

依赖倒置原则是区分过程式编程和面向对象编程的分水岭。过程式编程的依赖没有倒置,A Simple DIP Example | Agile Principles, Patterns, and Practices in C#这篇文章以开关和灯的例子很好地说明了这一点。

上图的关系中,当Button直接调用灯的开和关时,Button就依赖于灯了。其代码完全是过程式编程:

public class Button {   
    private Lamp lamp;   
    public void Poll()   {
        if (/*some condition*/)
           lamp.TurnOn();   
    } 
}

如果Button还想控制电视机,微波炉怎么办?应对这种变化的办法就是抽象,抽象出Role interface ButtonServer:

不管是电灯,还是电视机,只要实现了ButtonServer,Button都可以控制。这是面向对象的编程方式。

总结

总的来说,单独应用SOLID的某一个原则并不能让收益最大化。应该把它作为一个整体来理解和应用,从而更好地指导你的软件设计。单一职责是所有设计原则的基础,开闭原则是设计的终极目标。里氏替换原则强调的是子类替换父类后程序运行时的正确性,它用来帮助实现开闭原则。而接口隔离原则用来帮助实现里氏替换原则,同时它也体现了单一职责。依赖倒置原则是过程式编程与OO编程的分水岭,同时它也被用来指导接口隔离原则。


更多精彩洞见,请关注微信公众号:思特沃克

Share

无法登录的用户

0

“有用户在手机端认证失败。”

ins项目的微信群里的客户又遇到了新的问题。

“不像是网络问题,感觉是后端服务的问题。”

“用其他手机试试呢?”大鹏眉头皱了一下。

自从ins项目上线以后,团队其他成员都纷纷下了项目,只留下他这个项目经理留在一线解决问题。登录这块总是出现问题,上次就出现过一次,不过上次是机房网络原因,而这次貌似并不是。

“她用我的手机是可以登录的。”客户说。

“看来这个问题跟设备有关。”大鹏想。

这时客户发来了报错的手机截图,可以看到屏幕中间有一个提示框,上面显示“认证失败”4个字。

“志豪,帮忙看看什么情况下会出现这个错误。”大鹏呼唤了开发志豪。志豪是ins项目的前端开发,登录功能就是他实现的。

“这个错误是我们报出来的,应该是没有认证通过。”志豪已经上了新的项目,不过依然抽空支持着。“我们的前端登录组件会拿到办公App给我们的参数data和token,然后发送到认证服务进行认证,认证失败了就会报这个错。”

ins项目的手机端应用是一个Web应用。用户登录办公App后点击ins的图标,办公App就会启动WebView,打开ins手机端的URL,并在URL上带上data和token参数。data包含了用户信息,token用于对data的校验。这个URL对应的就是上文提到的前端登录组件,这个组件会把data和token发送给后端的认证服务做认证,认证服务来解析data获取用户信息并校验token。如果这一步出错了就会返回认证失败响应,而前端就会提示“认证失败”。

┌─────┐  /login?data=xxx&token=yyy   ┌───────┐  /auth?data=xxx&token=yyy   ┌──────┐
│ App │ ───────────────────────────> │ Login │ ──────────────────────────> │ Auth │
└─────┘                              └───────┘                             └──────┘

“认证服务什么情况下会返回错误呢?”大鹏追问道。

“这个要看认证服务的日志了,看看到底哪里出了问题。”志豪回答道。现在掌握的信息太少,还无法作出判断。

“下午要去机房看看了。”大鹏喃喃道。

1

在机房里大鹏看到的认证服务的日志。认证服务的日志显示,AuthService.convertHexToByte方法报错了。token应该是一段类似于34ac的十六进制的字符串,但是认证服务拿到的token却是M5开头的,这明显不是十六进制,所以在验证的时候报错了。

“看起来是有些办公App的token格式不对。”志豪猜测。

“应该和设备有关系,跟人无关。同一个人使用自己的设备就不能登录,而使用别人的手机就可以登录。”大鹏补充道。

“不同设备之间会有什么区别呢?”志豪问道。“是不是版本问题?让他们把办公App都升级到最新版本呢?”

“不能登录的设备确认是最新版本。不是版本的问题。”大鹏回答道。

“我们需要更多输入,需要熟悉办公App认证逻辑的人。”志豪提出需要外部支持。

大鹏把隔壁项目的后端TL大宝拉进了群。“大宝,ins项目移动端应用有的用户用别人的手机就可以登录,但是用自己的手机却无法登录。”隔壁项目也有移动端,也和办公App进行了集成。“你能想到大概是什么原因吗?”大鹏在微信群里贴出了convertHexToByte方法的代码。

“我这边后端确实有这个代码。”大宝看到了代码,“不过我们没有遇到无法登录的问题。”

问了一圈但没有人遇到类似的问题,所以很可能是ins项目自身的问题。大鹏又回到了刚才的推测:不同客户端的token格式不对,既然这样,是不是把token的验证这个步骤去掉,用户就可以正常登录了?

“既然验证token的时候报错了,那我去问问客户,是不是可以把token的校验逻辑去掉。去掉以后,虽然有一定安全问题,但应该可以解决用户不能登录的问题。”大鹏在微信群里说道。

“这样不好吧。”志豪说。“问题的原因并没有找到,为什么认证服务拿到的token不是预期的十六进制字符串的原因还不清楚,所以去掉token的校验并不一定就可以登录了。而且就算能登录,还会带来安全性问题,并不是一个正确的方法。”

经过一番讨论,大鹏觉得志豪说的有道理,打消了去掉校验的想法。不过问题仍然没有解决,所以他们商量了一下,决定问一下ins项目的TL张伟。张伟现在已经上了别的项目,项目刚启动,他比较忙,晚上才有时间,所以他们约在了晚上8点开个视频会议。

这个问题引起了志豪的好奇心,登录功能也是反复测试过的,怎么会一上线就遇到了问题呢?为了搞清楚原因,也为了项目顺利验收,志豪决定晚上留下来研究下这个问题。

大鹏也利用这段时间又研究一下日志。他发现认证服务收到的token貌似由两部分组成,前半部分由M5开头,显然不是十六进制,但后半部分是十六进制字符串,两部分之间由一个+符号连接。

“看来后半部分才是正确的token。”他把这个猜测告诉了志豪,“认证服务收到请求的时候token已经错了。”

“嗯,看来是这样。”志豪说道。“而且这个加号貌似有问题。”

2

晚上大鹏来到办公室,和志豪一起跟张伟开了视频会议。张伟把登录的流程完整的说明了一遍,就匆匆下线了。志豪依据张伟的讲述画出了完整的时序图:

​ 可以看到前端登录组件和认证服务之间还有一个API Gateway。

既然发给认证服务的HTTP请求就是错的,那么问题应该出现在认证服务之前的前端登录组件或者API Gateway。大鹏又查看了前端登录组件的日志,日志显示在办公App调用前端登录组件的URL里,data和token是正确的。data中包含的%2B引起了大鹏的注意,%2B之前的部分就是认证服务收到的data,而%2B后面的部分和正确token一起,被当作token传给了认证服务。

“认证服务收到的错误的token,可以分成三个部分:data的%2B之后的部分,这个加号,还有正确的token。”大鹏把这个发现告诉了志豪。“感觉我们越来越接近真相了。” 志豪点点头。“现在问题已经逐渐明确,就是有个倒霉孩子把data的后半部分混入了token。”

还可以通过搜索引擎和阅读代码获取更多信息。志豪暂时想不到合适的搜索关键字,所以他选择先从代码中收集更多信息。

由于前端登录组件收到的信息是对的,而认证服务收到的信息是错的,志豪结合时序图判断问题应该只会出现在以下3个地方:

  • 前端登录组件获取参数并调用API Gateway时
  • API Gateway解析请求时
  • API Gateway调用认证服务时

因为对于前端登录组件的代码还是很有信心的,所以志豪决定从后往前排查问题。

志豪首先检查了API Gateway调用认证服务的代码:

    @GetMapping("/authentications")
    AuthInfo getAuthInfoByDataAndToken(@RequestParam("data") String data,
                                       @RequestParam("token") String token);

由于使用了Feign,代码逻辑也非常简单,看上去没什么可能会造成data的部分混token里。

接下来检查API Gateway解析请求的代码。前端登录组件拿到data和token后,会把他俩传给API Gateway去做认证。具体的方式是把data和token放到HTTP Header里:

X-User-Login: APP $data $token

API Gateway在接收到请求后,取到HTTP Header里的值,把APP前缀去掉,然后找到第一个空格,空格前的部分保存为data,后面的部分保存为token。代码如下:

    private String[] extractAndDecodeHeader(String loginHeaderValue) {
        final String dataAndToken = loginHeaderValue.substring(APP_PREFIX.length());
        int spaceIndex = dataAndToken.indexOf(" ");
        if (spaceIndex == -1) {
            throw new BadCredentialsException();
        }
        return new String[]{dataAndToken.substring(0, spaceIndex), dataAndToken.substring(spaceIndex + 1)};
    }

这段代码有测试覆盖,CI也是过的。志豪思考了一下,得出结论:当请求正确时,这部分代码也不会出现问题;但是如果请求里的data包含了空格,那么data的后半部分就会混在token里。志豪笑了笑,他感觉抓到了线索。

data是Base64编码过的字符串,而token是十六进制对应的字符串。Base64编码后内容只会包含大小写字母、数字和+/这64个字符,十六进制字符串只会包含数字和字母A-F,所以这两者都不会包含空格。

目标继续缩小到了前端登录组件里。相关的代码如下:

import URLSearchParams from 'url-search-params';

const searchParams = new URLSearchParams(search);
const [data, token] = [ searchParams.get('data'), searchParams.get('token') ];

...

return `APP ${data} ${token}`;

这里用到的url-search-params是一个npm包。这段代码分别取到data和token参数,然后用空格作为分隔符,和APP前缀拼在一起返回。

如果URLSearchParams把%2B经过URL解码成空格,那么${data} ${token}就是$data的前半部分$data的后半部分$token,所以API Gateway就会把$data的前半部分当作data$data的后半部分$token当作token传给认证服务,那么认证服务就会在校验token的时候报错,这正好和问题出现时的现象一致。而且也解释了为什么认证服务拿到的错误的token里会包含加号。

如果一个参数要放到URL的query string里,那么这个参数需要经过URL编码。比如在谷歌搜索hello world,结果页的URL则是https://www.google.com/search?q=hello+world。空格会被编码成+,而+会被编码成%2B。相对的,在获取到URL后,需要经过URL解码才能拿到正确的参数。URLSearchParams就是一个可以用来进行URL解码的工具。在日志里看到一般都是URL,所以参数都是编码过的。

看上去一步步接近真相了,志豪有些兴奋。他写了一段简单的测试代码:new URLSearchParams('q=%2B').get('q')。如果结果为+,则是正确的,不会产生问题;如果结果是空格,就是错误的,就会造成无法登录的问题,就意味着原因找到了。

志豪在Node.js环境测试,结果发现返回的是+。“嗯,是正确的。”志豪自言自语道。“还有其他情况吗?对了,url-search-paramsURLSearchParams API的polyfill,所以如果浏览器原生支持URLSearchParams API,那就会使用原生的URLSearchParams API,而不是npm包。”

polyfill允许Web开发人员使用某HTML API,即使浏览器并不支持它。通常ployfill先检查浏览器是否支持了该API,如果支持了则直接使用,否则使用ployfill的实现。

“是不是在原生支持URLSearchParams API的浏览器里有问题?”志豪又打开了Chrome开发者工具的控制台面板,在里面进行了测试。结果也是+。这个结果说明,Chrome已经原生支持了URLSearchParams API,而且原生的URLSearchParams API也是正确的。

志豪摇了摇头,问题仍未确认。

3

“到底在什么情况下才会出现问题这个呢?”志豪思考着。

“这个问题跟设备有关。”大鹏也突然想到了什么。“我去问问无法登录的设备的型号。”

大鹏赶快给客户打了电话,得到的回复是,两部出问题的手机都是iPhone,而且iOS版本分别是10.3.2和10.3.3。

志豪感到眼前一亮:“莫非是iOS 10.3有问题?如果这个假设成立,那么iOS 10.3应该用的不是polyfill,所以它应该是原生支持URLSearchParams API的。”志豪想着。

志豪搜索了一下,找到了MDN的URLSearchParams文档(历史版本),发现浏览器兼容性部分里显示Safari Mobile并不支持URLSearchParams API。

“难道这个推理是错的?”逐渐清晰的真相又模糊起来。“不过还是用iOS模拟器试一下吧。”志豪打开了Xcode,发现只安装了默认的iOS 11模拟器,于是在设置里找到了iOS 10.3.1模拟器,开始下载。

趁着下载的时间,志豪测试了iOS 11,结果同样是+。“看来MDN上写错了,还想骗我。”志豪嘴角翘了起来。

经过十几分钟等待,iOS 10.3.1模拟器终于下载好了。志豪速度测试了一下。 结果是空格!

“终于把你这个倒霉孩子找出来了!”志豪情不自禁的欢呼起来。“终于找到你了。”

4

志豪不放心的又查了一下兼容性,发现在MDN中文版的URLSearchParamsW3cubeDocs赫然显示Safari Mobile从10.3开始原生支持URLSearchParams API。嗯,果然是MDN出错了。

iOS从10.3开始原生支持URLSearchParams API,但也许因为是第一次支持,这个版本有点问题,随后的iOS 11修复了这个问题。

“我刚用iOS 10.2试了一下,返回的是加号啊。”大鹏在一旁也没有闲着。

“那就对了,10.2并不原生支持URLSearchParams API,用的是polyfill,所以也没有问题。”志豪利用刚找到的真相完美的解释了这个问题。

5

不知不觉已经快11点了,志豪和大鹏准备回家,却发现6部电梯都停止了服务,两个人只好爬楼梯。

“没想到浓眉大眼的iOS也有这种坑。”志豪一边下楼一边感慨道。

“是啊,让我们好找啊。”大鹏一边喘气一边说。“话说这个问题有办法避免吗?”

“之前可能还真没办法预料到。如果URLSearchParams API文档里能说明iOS 10.3的问题就好了,但我刚才搜索了一圈,并没有发现有人在讨论这个问题。”志豪回想着。“应该做点什么,不要让它再祸害其他人了。”

“写篇博客?”大鹏提议。

“不仅如此,还应该把这个问题更新到MDN上。”志豪说。“以后的人也许就可以避开这个坑了。”

(完)


更多精彩洞见,请关注微信公众号:思特沃克

Share