背景
我们所有产品在初期的时候都使用的Java语言作为后端开发语言,整个架构在演进了几次之后形成了基于微服务的一个复杂架构状态,包括了Restful Service作为Web层抽象,基于Netty的分布式抓取框架,基于Quartz的定时服务,基于Storm的分布式实时计算框架,还有一些附加的日志处理、消息转发、Websocket消息推送服务等等,多个服务之间通过Http协议、或者TCP/UDP协议,或者基于AMQP的消息队列来进行通信。总体来看整体的解决方案严重依赖Java语言或者说JVM语言,在开发的过程中这些解决方案无疑都是强大的,不管是Netty还是Storm都是当时效率比较高且成熟的基础组件,可以说从IO和计算层面我们尽可能的都选用了JVM生态系统里边当时最好的,比如说考虑到Netty本身的多路复用和多线程模型的优秀,我们尽可能的在处理IO上采用类似的结构,或者直接使用Netty作为入口,包括JAX-RS的容器都是直接采用的Netty作为IO和协议解析的框架,而没有采用其他的虽然现在也支持NIO的Tomcat或者Jetty,分布式抓取之前的通信也是采用Netty,Storm之间的通信也依赖于Netty(官方声明测试结果比之前的ZeroMQ的解决方案要好不少),也是在我们技术栈内部实现了某种层面的统一,所有的IO或者通信都采用的Netty,不过也在这几年的开发中发现了一些问题,导致我们一直在用Java的过程中也想找到一种合适的语言来替代如日中天的Java。
Java语言的问题
1. 生产力问题
开发调试慢,整体解决方案复杂。这只是相对而言,对于熟练的开发这也许不是问题,作为企业级解决方案而言,Java语言确实比较繁重,即使依赖了很多自动编译、动态加载、打包并部署的持续集成工具,调试速度依然很慢,与现在的快节奏的开发要求有明显矛盾,举例来说,如果我们要开发一个小的项目,就是包含一个Web api服务,这时候我们需要新建一个工程,写自己的pom文件,组织好工程的依赖和配制文件,然后呢就是基于某种框架,可以是servlet-api也可以是restful风格,或者是spring等等,然后选择一种嵌入式的容器以便不想要部署到哪里就可以自己启动服务,然后代码可能很简单,写一个接口然后打包,编译,一般情况下生成一个可以自己运行的Jar包以及他所依赖的jar包目录,将这个目录上传,启动并测试,假如说需要修改的话就需要改动然后重启整个服务,等着新的代码提交并触发编译部署重启才能生效,为了避免中断服务也需要做一些HA的策略等等,即使做一个很小的事情也需要想对比较重量级的一套东西来作为支撑,互联网时代的开发节奏要求更加敏捷,Java的状态有点令人着急,相对来说动态语言在这方面确实得天独厚,用来开发产品或者服务的原型速度更快,之后如果有性能问题(一般也确实会有)可以考虑用静态类型语言重构。
2. 表达能力问题
作为一个上世纪的语言,一个面向对象但又不是纯的面向对象语言,本身的抽象能力是不错的,在很多大型的服务系统里边我们能够使用一些模式来进行很好的抽象,使整个架构优美,可读性和扩展性都能够良好的体现出来。但总体来说Java语言的编写过程更倾向于过程式的开发,在上一层面上封装了面向对象的特征和行为,语言的设计是上个世纪九十年代的风格,不是说语言本身不好是其抽象能力不够,即使到了Java8也只是对Lambda表达式进行了支持,因此引入了Functional Interface也即只有一个方法的接口,和接口里边的带具体实现的方法(为了兼容以前的代码不得不作出的让步)。Java语言发展到现在其语言特性庞大,如果要完全了解需要几百页的文档,在其发展过程中又只做加法没又减法,语言慢慢风格混杂,变成了现在这种四不像的状态,函数式的特性硬生生的嫁接在原来的面向对象特性之上。
3. 资源消耗问题
Java语言号称一次编译,处处运行,就在于它基于一个需要首先先安装到他所谓的处处的JDK,通过JVM解析编译完成后的字节码来运行,跟操作系统的接口也是在JVM托管的。这样的好处是JVM可以在实时运行的时候对字节码进行进一步的优化,也就是大名鼎鼎的JIT,问题是所有的机器上都要安装可以兼容你的应用程序的JDK,同时JVM启动消耗的资源不少,起码数百M,且启动速度缓慢,同样的直接编译成目标操作系统二进制可执行程序的服务,启动起来消耗的资源小很多且速度快了很多。因此,我个人更喜欢编译语言而不是某种VM上运行的语言,在当前差异化的芯片结构中,像C、GO、RUST这种能直接运行于操作系统之上不基于某些庞大繁重的VM之上还是很有必要的,比如物联网的控制芯片,通常内存也只有几百K,适用性更强一些,而且现在LLVM架构的编译器能够带来性能的大幅优化,所以编译依然是一个很好的选择,除非JIT能够逆天的达到解释执行的极限,因此假如我们看到某些语言有Java语言的开发能力和内存安全特性,依然是可以考虑的。
最近几年所关注的语言
以上说了一些我们虽然完全在使用Java语言开发但是也不得不承受的一些问题,随着时代的发展有一些新的语言进入大家的视线,首先要说的是,我们都不是研究语言的科学家,不是严格的从语言的语法设计和类型系统的别致程度来评价一门语言,更多的是从我们作为使用者实际应用中来考虑哪个更有利于我们整个团队的进化和生产力的提高,如有疏漏和不严谨的地方,望指出。
曾经在考虑之中的语言包括了Haskell, Go, Scala, Rust四种,主要考虑的因素包括了语言的能力、开发效率、商业化程度、学习曲线、招聘市场人才储备状态。
后边三中都是比较新的语言了,为什么包含了Haskell呢,他虽然很老但是一直是作为学院派函数式语言的代表,其纯函数式的特性和简洁漂亮的语法(糖)让人看了非常舒服,在接触了面向过程和面向对象的开发后,如果要学习一种新的写代码的思路,面向函数式的语言是目前最好的选择了,而Haskell有是函数式语言的先驱和集大成者,很多函数式语言的语法都是从Haskell借鉴来的。下边我分开说一下我自己的体验,通过学习和写一些实际的项目所体会到的一些优点和缺点。
1. Haskell
作为纯函数式语言,Haskell将必然会产生Side-Effect的代码比如IO操作放到了一起,也即monad风格的部分,而其他的函数可以保证完全的函数式特征,对于同样的输入无论运行多少次结果都是一样的,跟数学中函数的定义一样严格,函数式是一种CPU友好的语言,在当前多核计算机发展状况下,函数式可以让程序非常安全的在多个核心上并发而不用担心大量的数据交互和side-effect, 从而在语言编译过程中能够针对并发进行大幅的优化。语言本身的很多写法也跟数学中的定义很接近,比如定义一个集合
ghci> [x*2 | x <- [1..10]] [2,4,6,8,10,12,14,16,18,20]
看起来很像数学定义,语言可谓优雅漂亮,看着很舒服。作为学院派语言,语言自身设计的要求不可谓不严格,完美的阐述了函数式是什么意思,但是语言的复杂度较高,学习曲线很陡峭,很难保证团队成员的接收程度,也很难招到相关的技术人才。从效率上来讲,Haskell可以优化的跟C语言的级别类似,但如果对某些特性不熟悉稍微改动一些就会造成性能的大幅下降,对新手不算友好。同时在函数式不那么擅长的领域Haskell的商业化程度很低,我们不可能都用Haskell来写一些语法解释或者正则解析等,涉及IO的分布式存储和计算都相对很初级,尤其是对于我们比较感兴趣的数据挖掘机器学习领域没有成熟的解决方案,对于Web项目支持的尚可,有优秀的Yesod框架作为代表。总的来说,我们最终将Haskell定义为值的学习但不会在大型的生产环境中使用的语言。
2. Scala
Scala语言的出现目的很明确,感觉就是为了替代Java而存在,在Java语言越来越力不从心的今天,能够有一门语言既继承了它广大的生态系统,又能够在表达能力和开发效率大大改进的情况,可以说是很有希望的。
- Scala从一开始就是一门设计良好的语言,几乎完美的集合了函数式的特性和面向对象的特性,虽然他的函数式不是纯函数式。其面向对象的感觉更像Ruby而不是Java,所有的东西都是对象,包括简单类型例如Int,以及函数本身都是一种对象,这样在这个层面实现了面向对象和函数式的统一。具体的我们会在接下来的文章中介绍。
- 庞大的Java生态系统,Scala运行于JVM之上,能够无缝的使用所有的原来Java语言所开发的各种库,语言上作为Java的超集,迁移过来只会更强大而不会打折。
- Java8的出现虽然增加了针对集合的stream api以及Lambda表达式这种函数式特性的支持,但只会让人们觉得Java与Scala更像了,即使Java在以后的发展过程中拥有了所有的Scala的能力,我也不会考虑,打个比方一块歪歪扭扭的经过各种后期焊接所建造起来的机器和一个一开始就有目的的设计出来的结构精密、风格统一、表达高效的机器比较,后者更像前者的重构,而前者虽然如日中天但已经是暮年的四不像,不停的往身上增加各种各样的功能,也许Java9会有进步,但现在我看到Java8后反而更倾向于Scala。
- 函数式特性
- Pattern Matching,异常强大的模式匹配系统,可以出现在match语言里边的case表达式中,可以出现在构造函数中,也可以出现在变量初始化,通过模式匹配大大减轻赋值的复杂度,简化了代码,专注于处理逻辑。可以说在基本上所有的函数式语言中都有Pattern Matching特性,通过模式匹配来构造的函数体表达能力更强,也更安全。
- Actor Model,Scala的并发除了使用Java原生的线程之外还实现了一个Actor Model的并发模型,跟Erlang语言的并发模型相同,主流的并发通信模型还有CSP,比如GO语言就采用的这种模型来组织goroutin以及他们的通信channel。Scala的Actor Model不是语言原生带的,而是一个叫Akka的扩展包所实现,这也是Scala语言的一个特点,除了自身核心的语言特性外,其他的扩展都是在其语言的基础上实现,保持语言自身language specification足够的精简,不像Java一样不停的集成新的特性到语言自身。Actor Model自身的最小执行单元也就是一个个的Actor本身比线程还要轻量级,只是一种逻辑上的抽象而不是物理上的更小的线程,对于操作系统而言能够分配时间片的最小的执行单位依然是线程,当然不同的操作系统的线程概念会有一些差异,这里只是笼统的说,Actor本身在JVM里边依然是基于一个线程池来作为自己的执行容器,默认情况下选择的是最适合多核并发的ForkJoinPool,允许执行快速的队列从另外的队列尾部偷任务。使用Actor而不是直接使用线程的好处也比较明显。
- Actor更加轻量级,在一个进程中启动上千个Actor完全没有问题,而线程要重量级的多,如果一个进程中启动的线程过多会增加上下文切换的频率以及操作系统CPU调度的浪费,我们曾经写过很多的多线程的服务,有些同学因为只专注于自己的业务逻辑,有时候会在不知道全局线程状态的情况下再启动记得线程池,这样在一些情况会发现CPU的负载迅速爬升,经常出现一个进程占用十个核心的状况。而由于在一个ActorSystem中多个Actor都共用一个线程池,Actor之间的执行分配通过自身的dispatcher来调度,这样减少了底层的线程数量,变相提高了CPU利用率。实际上这也是现在语言的一个趋势,不直接操作线程而选择更小的执行单元,比如Go语言中的goroutine,鼎鼎大名的Java语言所写的Netty框架也是通过高效的利用线程池而隐藏了具体的多路复用细节,使开发专注于具体的处理逻辑,这也是Netty可以处理大规模并发连接的秘诀,如果为每个连接都分配一个线程,那负载会高出很多,效率可想而知。当然直接使用Actor也是有弊端的,对于延迟要求很高的场景,需要马上为当前运行逻辑分配线程执行,而不是再等待ActorSystem的dipatcher去分配,这种情况还是要直接使用自己的线程池,对于大规模高并发吞吐量要求高但是对单个请求响应不需要太迅速的情况下可以使用,如果仔细规划Actor的数量,其实也是可以满足直接使用线程的情况,只是没有操作线程那么的直接。
- Stateful,在实现有限状态机的时候很方便
- 事件驱动,类似于Netty NIO的框架,所有的操作都是通过异步的事件驱动,最大限度的利用线程资源,对于Idol的Actor是不会占用CPU资源的,同时使用上也要注意尽量不要阻塞Actor里边的Receive方法,这样会导致该Actor所占用的线程池里边的某个线程被阻塞,非常影响整个系统的执行效率,因此一旦考虑使用Actor Model,从思路上就要转换为全异步的执行方式,比如提交一个任务给Spark执行,这时候这个消息就处理完了,而不是阻塞着等待Spark执行结果返回,当Spark执行完成后通过异步的消息通知给当前系统的某个Actor,将消息发送给能够处理这个事件的Actor,简单说就是保证每个消息的处理过程都是非阻塞的,尽量减少处理时间。
- DSL Scala核心的语法特点可以让他支持对自身的扩展,极少的保留字符可以让开发人员按照自己的喜好定义一种新的编写方式和习惯,比如Akka的Actor包就定义了自己的Actor DSL,用!来替代tell ?替代ask,可以更有效的来操作Actor,再比如Scala-Graph API也包含了大量的DSL,通过 1 ~> 2来表示连接两个node,~>本身是这个扩展包自己定义的操作,而不是scala原生的保留操作符。可以说我们通过scala可以扩展出另外一种跟scala完全不同的语言,这是java语言很难做到的。Scala的元编程能力可以让他修改自己的语言定义,不只是实现某些业务逻辑,这样从符号层面上,scala可以做到自洽,除了核心的一些规则,其他的都可以被自己根据状态调整所修改,这种能力可以极大的扩展语言自身的能力,当然也带来了一些负面效果,每学习一种新的包不只是了解他的API,而是学习了一种新的语言,风格可能跟scala大不相同。
- 强有力的证明,大数据生态系统代表-Spark&Kafka,一个是分布式计算一个是分布式大规模数据吞吐,都证明了Scala的开发能力和效率。
- Scala的问题其实也有跟Java类似的地方,首先这个语言虽然是重新设计的,但是使用起来复杂度依然很高,对于范型的继承,+-等范型标注不好理解,
3. Go
Go语言目前呈现了很火爆的趋势,由于其简单,整个语言的specification也不过十几页,最多半天就能够完全了解并上手写一些小工具。GO语言最初是希望替代C和C++成为新的系统语言,自带GC垃圾回收,不过最终更多的是替代了python来开发一些服务或者工具,并没有成为系统级别的语言。
Go语言有很多的优点,编译速度快,有协程和Channel做并发支持和通信,有很多官方的网络协议的库,非常适合于写一些网络服务,启动一个http的接口服务只需要几行代码。目前github上也有大量的第三方项目使用go语言来开发应用或者扩展go的功能,在使用的时候直接import即可。Go的多返回机制也还不错,节省了大量的无意义数据结构和不可读的Map的使用,总的来说Go在其擅长的领域生产力很高,写起来比较流畅,静态类型也足够的安全。目前Docker生态系统里边的各种工具都是Go来写的。最新发布的1.5版本使得交叉编译更加容易,静态链接库的方式使生成的可执行文件在相同CPU架构的操作系统都能运行,减少了额外查找依赖的问题,对我们现在基本同构的Linux服务器而言,也打到了一次编译处处运行的目的。同时Go语言在运行时消耗的资源也比Java要小,启动速度更快,确实是轻量级服务的优选。
在公司内部我们也使用Go语言做了一些项目,包括了日志分析、日志收集、小规模的产品项目实现,开发的过程中也找到了一些优秀的框架,包括revel, goin等,由于使用简单上手容易,需求的实现速度很快,同时部署也比较容易,就一个二进制文件和对应的配制文件即可。既保持了生产力,也没有牺牲运行效率,处理速度很快,goroutine很好用。但是同时我们也发现了一些问题,在往大规模的系统架构上去演进的时候,go语言的抽象能力有限,除了interface我们没有太多的手段去让整个工程的层次更清晰,只能依靠interface之间的继承、包含关系,让实现有一定程度的分离,可是对于结构复杂的程序抽想出来的效果不够好,也许是我们对Go语言的一些开发模式还不够熟悉,经常出现一个复杂的功能都在一个文件里边且代码上千行。对于小规模的开发,Go语言速度尚可,也相对比较成熟了,Go语言语法简单的好处还在于实现一个东西往往只有一种方法,不同习惯的人看不同的代码也容易看懂,简单直接。
如果说Go语言最让人烦的地方,目前我感觉就是对于error的处理了,基本上所有的多返回的api都会习惯性的返回一个error,在一些开发调用的方法内部可能是一层一层的往上返回,在应用程序级别的时候就需要对这些error做一些判断处理了,经常发现在一个处理过程中出现了好多个if err != nil 的判断,如果全部都panic出去可能会导致进程不稳定,导致当前运行的goroutine挂掉,进而使整个进程崩溃。
4. Rust
Rust由于1.0版本release的时间太晚,目前没有在实际的项目中使用过,通过目前的了解,Rust应该是可以替代C语言在系统级开发的地位,同时由于一些新的语言特性使得Rust在进行应用开发的时候也依然高效,目前看起来整个语言的适用范围非常的广,只是现在成熟程度太低,没有大规模的应用开发实例。另外直观的感觉,Rust的代码看起来很丑,远不如Haskell的优美,可能对于面向生产环境的语言跟学院派风格差异确实比较大,但也太丑了一点。
最终的选择和原因
通过上边的列举,从篇幅上也能看出我们最终的选择是Scala, 真正在生产中经过开发测试的就是Go和Scala,初期我们优先考虑了Go,毕竟编译成原生的可执行程序理论上能够达到比JVM语言更快的运行速度并且节省资源消耗,如果一门语言有C的效率和Java的生产力,那最好不过了,不过后期使用中也发现了虽然开发足够糙快猛,但是纯粹的面向过程开发,代码平铺直叙的有点冗长,不容易抽象,不是说不能做,而是做起来不那么方便,没有范型,更是缺乏现在的一些高级语言的特点。在以后的技术演进中,整个技术栈可能是异构的,那Go会占其中一部分,但主体可能不是。
汇总下这两个语言在目前我们公司的技术栈下进行切换的优缺点:
- Go的优点
- 轻量级
- 编译速度快
- 部署方便,生成的可执行文件相对较小
- 多返回值
- 简单的import
- 原生的goroutine&channel
- Github大量的第三方组件支持,有大规模应用开发实例,比如Docker
- 目前的版本执行效率很快
- Go的缺点
- 面向过程,抽象能力有限,语法太简单,不支持Pattern matching,也无法实现DSL
- 在分布式计算领域不够成熟,写一些简单的服务可以,复杂的代码结构会不清晰
- 与当前我们的技术栈差异较大,只能通过远程服务调用的形式使用
- Scala的优点
- 与当前Java技术栈无缝结合
- 表达能力强,可以实现一些自定义的DSL,在我们最新的一个项目需要这个能力
- 具有函数式特性,适合多核情况下并发优化,Actor Model抽象高效且使用方便。
- 我们以前的项目就较多的利用了Akka和Spark,使用Scala来写部分工程能够让大家更好的了解Spark,同构的情况下结合的更好一些,能够利用更多的特性。
- 在大数据领域可谓i久经考验
- Scala的缺点
- 语法复杂,不利于新手,其实本身的风格与Java相差甚远,特别要注意immutable和mutable的同样名字的集合类的使用,每个库都有自己的DSL,需要学一种新的语言,这也是表达力强的一个副作用,写一个东西通常有多种方式,达到相同的效果。
- 依赖于JVM,依然有资源消耗的问题,启动慢
- implicit是个坑,经常需要自己诊断使用的到底是哪个实例
- 目前人才缺乏,需要自己培养
可见,虽然我们从商业化的角度选择了Scala作为下一代的替换语言,这样以前的技术栈依然有效,目前的团队也能比较容易的过渡,但是也不会马上的把所有的项目都由Scala来实现,而是一些偏分布式计算的核心框架,大部分依然使用java语言,经过较长的一段过渡期(与团队能力相关),再决定是否完全转换为Scala语言。
本文所有的观点都是在当前时间的一些了解写的,随着时间推进,很多东西会发生变化,不再有效,请注意甄别。