创业一年来经历的技术风雨-阿里云开发者社区

开发者社区> 中生代技术> 正文

创业一年来经历的技术风雨

简介: 本文来自中生代技术群的分享,BigEye科技联合创始人与技术负责人张逸将为大家分享来自于自身创业一年来经历的技术风雨。创业的路上,且行且珍惜。
虽然说是技术风雨,其实不过是一种文艺范儿的描述而已,没有这么夸张,只是在创业的这一年中,整个产品研发过程给了我许多前所未有的体验和启示,所以想借助这个机会大家一起交流,分享自己的一些收获。

这次分享打算从三个方面开始讲解,分别是:
  • 1产品的架构以及技术选型
  • 2技术团队的管理
  • 3研发团队总结的技术实践

1产品的架构以及技术选型

产品的架构

首先来说说产品的架构。首先声明这不是广告,我们公司产品的商业版本也还未完全成型,所以本次交流仅仅是技术上的。

整体架构

产品代号为Mort,是基于大数据平台的商业智能(BI)产品。我们的产品架构如下所示:
6cab5e3e5ef6ce60c61e9982fba8ac73da023067
我们选择了Spark作为我们的大数据分析平台。基于目前的应用场景,主要使用了Spark SQL,目前使用的版本为Spark 1.5.0。会在7月推出商业版本之前同步升级到Spark的最新版本。

在研发期间,我们从Spark 1.4升级到1.5,经过性能测评的Benchmark,确实有显著的提高。1.6版本在内存管理方面有明显的改善,Execution Memory与Store Memory的比例可以动态分配,但在测试我们自己的产品时发现内存并非主要的性能瓶颈,而是CPU,故而暂时没有考虑升级1.6。

从第一次升级Spark的性能测评,以及观察这一年来Spark版本的演进,我们对Spark的未来充满信心,尤其是Tungsten项目计划,会在内存管理、代码生成以及缓存管理等多方面都会有所提高,对于我们产品而言,算是“坐享其成”了。

由于我们要分析的维度和指标是由客户指定的,这就需要数据分析的聚合操作是灵活可定制的。因此在我们产品中写了一个简单的语法Parser,用以组装Spark SQL的SQL语句,执行分析,最后将DataFrame转换为我们期待的数据结构返回给前端。

但是,这种设计方案其实牵涉到两层解析的性能损耗,一个是我们自己的语法Parser,另一个是Spark SQL提供的Parser(通过它将其解析为DataFrame的API调用)。所以在将来我们会调整方案,直接将客户定制的聚合操作解析为对DataFrame的API调用(可能会使用新版本Spark的DataSet)。

微服务架构

我们的产品需要支持多种数据源,对数据源的访问是由另外一个standalone的服务CData来完成的,通过它可以隔离这种数据源的多样性。这相当于一个简单的微服务架构,目前仅提供两个服务,一个服务用于数据分析,一个服务用于对客户数据源的处理:

286df07ac7fa87096c40c9949db0f5ae7833d796

在未来,产品不止限于现有的两个服务,例如我正在考虑将定期的邮件导出服务独立出来,保证该服务的独立性,避免受到其他功能执行的影响,因为这个功能一旦失败,可能会对客户的业务产生重要影响。

然而,我们还是在理智地控制服务的粒度。我们不希望因为盲目地追求微服务架构,而带来运维上的成本。

元数据架构

我们的产品需要存储元数据(Metadata),用以支持Report、Dashboard以及数据分析,主要的数据模型结果如图所示:

5d5f9f3217f3640e8dcfda9dff8c799f2cedbeec

针对元数据的处理逻辑,我们将之分为职责清晰的三层架构。自上而下分别为REST路由层、应用服务层和元数据资源库层。
  • REST路由层:将元数据视为资源,响应客户端的HTTP请求,并利用Spray Route将请求路由到对应的动词上。路由层为核心资源提供Router的trait。这些Router只负责处理客户端请求,以及服务端的响应,不应包含具体的业务逻辑。传递的消息格式为Json格式,由Spray实现消息到Json数据的序列化与反序列化。
  • 应用服务层:每个应用服务对应元数据资源的操作用例。由于Mort对元数据的操作并没有非常复杂的业务逻辑,因此这些服务实际上成为了Router到Repository的中转站,目的是为了隔离REST路由层对元数据资源库的依赖。每个服务都被细分为Creator、Editor、Fetcher与Destroyer这样四个细粒度的trait,并放在对应服务的同一个scala文件中。同时,应用服务要负责保障元数据操作的数据完整性和一致性,因而引入了横切关注点(Cross Concern Points)中的事务管理。同时,对操作的验证以及权限和授权操作也会放到应用服务中。
  • 元数据资源库层:每个资源库对象都是一个Scala Object,并对应着数据库中的元数据表。这些对象中的CRUD操作都是原子操作。事实上我们可以认为每个资源库对象就是元数据的访问入口。在其实现中,实际上封装了scalikejdbc的访问逻辑。

51650789bff3bc15dbdeda71f6fae401ba594b6a

REST路由层和应用服务层需要接收和返回的消息非常相似,甚至在某些场景中,消息结构完全相同,但我们仍然定义了两套消息体系(皆被定义为Case Class)。逻辑层与消息之间的关系如下图所示:
0e354a7d8dbe3df06a04bc8637f6f5f4deda7872
在REST服务框架方面,我们选择使用了Spray。选择这个框架的原因:一方面它足够轻量级,是一个纯粹的Web服务端框架;另一方面是它基于AKKA的消息处理架构在并发处理方面能够满足我们的要求。

技术选型

语言的选型

相信大家已经能够猜测到我们选择的语言,那就是Scala。巧合的是,在上周三,我的前同事吴雪峰(我们都称他为“吴大师”)正好“布道”了一把Scala。我们选择Scala的一个主因确实就是因为Spark。另一个原因呢?就是因为我确实不想再写Java了。

其实有时候我觉得语言的选型是没有什么道理的。除了特殊的应用场景,几乎所有的程序设计语言都能满足如今的软件开发需求。所以语言的纷争,有时候都成了宗教的纷争一般。

在我的团队,有熟悉Java的、有熟悉JavaScript包括NodeJS的,有熟悉Clojure的,当然也有熟悉Scala的。除了NodeJS,后端开发几乎都在JVM平台下。

我对语言选型的判断标准是:实用、高效、简洁、可维护。我对Java其实没有意见,但我始终认为即使是引入了Lambda以及Method Reference的Java 8在语法方面还是太冗长了。

Scala似乎从诞生开始,一直争议很大。早在2014年1月ThoughtWorks的Tech Radar中,就讲Scala列入了Adopt圈中,但却在其中特别标注了“the good parts”:

936011030ff25f72743b6cb36e930190993177a5

在2016年Stack Overflow发布的开发人员调查结果中,我们也收获了一些信心。在最爱语言的调查中,Scala排在了第四名:
e3fc7f842f5e7c66c87b69c2eebb5946cea17d8f
在引领技术趋势的调查中,我们选用的React与Spark分列冠亚军:
b2ff2d32172c6d9f60a02f44e2a9a05781453f82
在Top Paying Tech调查中,在美国学习Spark和Scala所值不菲,居然并列冠军:
55a0707036c68861394d61195b54366d94e38ba0
其实有了微服务,在不影响代码维护性的情况下,使用多语言进行开发也成为了可能。或许在将来,我们产品的可能会用clojure或者Ruby来写DSL,用NodeJS负责元数据(以避免Spray + JSON4S不太好的Json对象序列化)。坦白说,我没有强烈的语言倾向性。

数据集的选型

我们还有一个最初的技术选型后来被认为是失败的选择。

CData服务需要将客户的数据源经过简单的ETL导入到系统中,我们称之为数据集(DataSet)。最初在进行技术选型时,我先后考虑过MySQL、Cassandra、HBase。后面两种都可以认为是列式存储的NoSQL数据库。团队中没有一个人有Cassandra的经验,至于HBase,在查询方面非常高效,但对聚合运算的支持明显不足,不适合我们的场景。再加上团队中有一位成员比较熟悉MySQL,最终决定使用MySQL。

然而,我们的产品需要支持大数据,当数据量上升到一定级别时,就需要系统很好地支持水平扩展,通过增加更多机器来满足性能上的需求。评估我们的架构,后端平台可以简单划分为三个层次:Web应用服务层(Spray + Nginix)、数据分析层(MESOS + Spark)以及存储层(主要用于存储分析数据DataSet,MySQL)。就会发现MySQL正是水平伸缩的最大障碍。

还好我们醒悟得早,在项目初期就否定了这个方案,而改为采用HDFS+Parquet。Parquet文件是一种列式数据存储结构,对于主要为分析型查询方式的BI数据操作,能够提供更好的查询性能。同时,Parquet文件存储的内容以二进制形式存放,相较于文本形式容量更小,可以节省更多的存储空间。

Spark SQL提供了对访问Parquet文件很好的集成。将Parquet文件存放到HDFS中,然后再通过Spark SQL访问,可以保证在存储层与数据分析层都能很好地支持分布式处理,从而保证系统的水平伸缩。当对大规模数据集进行分析处理时,可以通过水平增加更多的节点来满足高性能的实时查询要求。

我们曾经比较了Parquet方案与MySQL方案,在同等配置下前者的性能要远远优于后者,且Spark对Parquet的支持也要远远好于MySQL。

为了更好地提升性能,我们还计划在HDFS层之上引入Tachyon,充分发挥内存的优势,减少磁盘IO带来的性能损耗。

前端的技术选型

前端的技术选型则为React + Redux。选择React的原因很简单,一方面我们认为这种component方式的前端开发,可以极大地提高UI控件的重用,另一方面,我们认为React这种虚拟DOM的方式在性能上还是有一定优势的。此外,React的学习曲线也并不高,很容易上手。我们招了3个大学还未毕业的实习生,JS基础非常薄弱,在我们的培养下,一周后就可以慢慢开始完成React Component开发的小Story了。

最初,我们唯一的一位前端选择了使用CoffeeScript来开发React,但是在项目早期,我们还是忍痛去掉了这些代码,改为使用ES 6。毕竟随着ES 6乃至ES 7的普及,JS的标准已经变得越来越合理,CoffeeScript的生存空间似乎被压缩了。

在前端技术选型方面,我们经历了好几次演变。从CoffeeScript到ES 6,从Reflux到Redux,每次变化都在一定程度上增加了工作量。我在文章《技术选型的理想与现实》中讲述的就是这一事实。

在这篇文章中,我们选择了Reflux。然而现在,我们最终还是决定采用了Redux,不过我们也是到了最近才算真正用好的Redux。在后面的技术实践中,我会在介绍Redux的使用。

结论: 技术负责人一个非常重要的能力要求就是——善于做出好的技术决策。选择技术时,并不能一味追求新技术,也不能以自我为中心,选择“我”认为好的技术。而应该根据产品的需求场景、可能的技术风险、团队成员能力,并通过分析未来的技术发展趋势综合地判断。

技术决策不可能一成不变,而需要与时俱进。如果发现决策错误,应该及时纠正,不要迟疑,更不要担心会影响自己的技术声誉。

2技术团队的管理

下面进入第二个话题:技术团队的管理。

我曾经在我的微信公众号上写过一篇文章《我想要的研发团队》。在这篇文章中,我谈到了我理想中的研发团队特征:
  • 他们是自我组织的,每个人都是具有独立人格的个体,承担任务的自治单元。
  • 他们是勤于学习乐于分享的,每个人都是知识的吞吐器,是无私激情的演讲家与布道者。
  • 他们是多姿百态的,每个人都有自己鲜明的个性,却又能彼此宽容,承认各人自有不凡。
  • 他们是追求卓越的,每个人如此地热爱技术,将设计与编码技艺若生命般珍惜,以追求合乎情理的极致为研发目标。
拥有这样的团队是一种奢侈,我现在的技术团队远没有达到这个标准,幸运地是:有部分成员正在接近这个标准了。然而,问题也正出在这里。

对于那些追求卓越技术的团队成员而言,对于代码质量会有一种近乎偏执的追求,同时内含的Geek范儿,使得他们更乐于引入更新的技术以及更新的技术理念。于是矛盾出现:

如何权衡项目进度与代码质量之间的关系?

创业团队一个致命的问题就是要求产品研发要“快”,如果说还有另外一个要求,那就是要“更快”。像我们这样面向企业客户的产品,在竞争对手步步紧逼的情况下,出不来产品,就意味着赚不了钱,甚至于生存不下去。市场团队成员压力山大,自然就会把进度压力转移到技术负责人头上。

身为技术负责人的我,身为一位要求代码高质量的技术人的我,就需要在这二者之间权衡。我会尝试着去平衡这种矛盾,通过快速迭代、快速发布、明确划定MVP来缓解进度压力,进而为提升代码质量预留出时间与空间。换言之,在我能够承担的职责范围内,我可以放任那些具有自组织能力的Geek们放肆地玩技术!

然而,团队在对待进度与代码质量的态度上,没有发出一致的声音。于是,不幸地分成了两个阵营。不仅仅是进度的矛盾,在技术理念上也存在较大的分歧。正如各种语言粉喜欢互撕一般,在面对技术选型、代码重构等诸多方面,团队成员也开始了轻度的互撕。

为了这个,我没少费精力,直到现在没出大问题,估计还是我之前积攒的人品在发挥效果。

这就需要谈到我写的另一篇文章《团队文化的谜题》。在这篇文章我写道:

正所谓“江山易改,本性难移”,若要打造合理的团队文化,不建议去尝试改变团队成员的性格。若有人真与团队文化缺乏违和感,不如痛快地将其“踢”出团队。许多团队之所以僵化,就在于团队负责人舍不得壮士断腕,又或者对那种“食之无味弃之可惜”的鸡肋成员不能痛下“杀手”。当然,更为主动的方式是在组建团队时,挑选符合文化期望的成员。

我觉得我是未卜先知。

然而,知易行难,创业团队就这几个“宝贝”,开掉一个也不是件容易的事儿。何况任何事情没有绝对的“对”与“错”。站在管理者的角度来讲,我不是一个喜欢走极端的人。例如我们不能只追求进度,粗暴简单地把功能实现就完事儿,产品的外部质量与内部质量问题,其实都可能是“技术债”;但我们也不能太追求代码的内部质量,允许存在一些坏味道,只是为了快速实现。归根结底,还是成本与收益的考量!

这种矛盾在团队是公开的,但矛盾不是私怨,平日里大家一起吃饭,喝酒,倒也算是好哥们儿。何况,我认为你并不能指望你的工作同事都能成为你的朋友、兄弟!只要大家认可自己正在从事的事业,还保有创业的热情即可!

为了更好地消除不必要的矛盾,并及时改进团队的氛围以提高战斗力,我们会不定期的开展“回顾会议(Retro Meeting)”。例如在上一次的回顾会议中,我们针对技术决策的纷争,总结了几条原则:
  • 提出问题必须是对事不对人
  • 有想法就要公开提出,然后让大家来一起讨论做决策;讨论时需要设置timebox,如果争执不休,就让技术负责人判断(背黑锅)
  • 一旦做出决策,所有团队成员都必须follow这个决定
结论:
  • 没有完美的人,也没有完美的团队
  • 认可自己正在从事的事业,保持“初心”
  • 要制定规则,而不是通过权力去干涉
  • 合则留,不合则分,强扭的瓜不甜

3研发团队总结的技术实践

我们的技术实践

第三个话题是介绍我们团队总结的技术实践。与大多数团队相比,因为我们使用了小众的Scala,就可以算得上是“捞偏门”了,所以总结的技术实践未必适合大家。这些实践也是在产品研发中逐渐演化的,没有专门总结,一些实践可能非常细节,也就是说可能很low,需要大家多多担待!

关于Scala

先说说Scala。两年前我还在ThoughtWorks的时候,与同事杨云(大魔头)在一个Scala的大数据项目,利用工作之余,我结合了一些文档整理了一份Scala编码规范,放在了github上。大家可以去看看:
https://github.com/agiledon/scalacodingconvention

我们的产品后端全部由Scala进行开发。对于编写Scala代码,我的要求很低,只有两点:
  • 写出来的代码尽可能有scala范儿,不要看着像Java代码
  • 不要用Scala中理解太费劲儿的语法,否则不利于维护
对于Scala编程,我们还总结了几条小原则:
  • 将业务尽量分布到小的trait中,然后通过object来组合
  • 多用函数或偏函数对逻辑进行抽象
  • 用隐式转换体现关注点分离,既保证了职责的单一性,又保证了API的流畅性
  • 用getOrElse来封装需要两个分支的模式匹配
  • 对于隐式参数或支持类型转换的隐式调用,应尽量让import语句离调用近一些;对于增加方法的隐式转换(相当于C#的扩展方法),则应将import放在文件头,保持调用代码的干净
  • 在一个模块中,尽量将隐式转换定义放到implicits命名空间下,除非是特别情况需要放到package object中
  • 在不影响可读性的情况下,且无需封装任何行为,可以考虑使用tuple,而非case class

关于AKKA

我们产品用的AKKA并不是太深入,仅仅使用了AKKA的基本功能。主要用于处理前端发来的数据分析消息,相当于一个dispatcher,也承担了部分消息处理的职责,例如对消息包含的元数据进行解析,生成SQL语句会,发送给Spark的SqlContext。分析的结果则以Future的方式返回给Spray。

也有几条小原则:
  • actor接收的消息可以分为command和event两类。命名时,前者用动宾短语,表现为命令请求;后者则使用过去时态,体现fact的本质。
  • 产品需要支持多种数据源,不同数据源的处理逻辑放到不同的模块中,我们利用actor来解耦

2ea8284869be062e88a52c9b472a6fa800c702e78c3a80e3a9d704a99bbd8a0731d0d94553bd1e0d

* 注意actor的sender不能离开当前的ActorContext * 采用类似Template Method模式的方式去扩展Actor

0f6cf0f73ba25d0c0bad2b114c124933f0a0b658

* 或者以类似Decorator模式扩展Actor

68fd95a9bf6dcbc1c83d440e2ae0ef0277e4466e

* 考虑建立符合项目要求的SupervisorStrategy * 尽量利用actor之间的协作来传递消息,这样就可以尽量使用tell而不是ask

关于Spark SQL

目前的产品特性还未用到更高级的Spark功能。针对一些特殊的客户,我们计划采用Spark Streaming来进行流处理,除此之外,核心的数据分析功能都是使用Spark SQL。
以下是我们的一些总结:
  • 要学会使用Spark Web UI来帮助我们分析运行指标;另外,Spark本身提供了与Monitoring有关的REST接口,可以集成到自己的系统中;
  • 考虑在集群环境下使用Kryo serialization;
  • 让参与运算的数据与运算尽可能地近,在SparkConf中注意设置spark.locality值。注意,需要在不同的部署环境下修改不同的locality值;
  • 考虑Spark SQL与性能有关的配置项,例如spark.sql.inMemoryColumnarStorage.batchSize和spark.sql.shuffle.partitions;
  • Spark SQL自身对SQL执行定义了执行计划,而且从执行结果来看,对SQL执行的中间结果进行了缓存,提高了执行的性能。例如我针对相同量级的数据在相同环境下,连续执行了如下三条SQL语句:
第一次执行的SQL语句:
sql SELECT UniqueCarrier,Origin,count(distinct(Year)) AS Year FROM airline GROUP BY UniqueCarrier,Origin

第二次执行的SQL语句:
sql SELECT UniqueCarrier,Dest,count(distinct(Year)) AS Year FROM airline GROUP BY UniqueCarrier,Dest

第三次执行的SQL语句:
sql SELECT Dest , Origin , count(distinct(Year)) AS Year FROM airline GROUP BY Dest , Origin

观察执行的结果如下所示:
e58ce9f90bdebe4ad04e5e784979c99b3395cb5e
观察执行count操作的job,显然第一次执行SQL时的耗时最长,达到2s,而另外两个job执行的时间则不到一秒。
  • 针对复杂的数据分析,要学会充分利用Spark提供的函数扩展机制:UDF((User Defined Function)与UDAF(User Defined Aggregation Function);

关于React + Redux

关于React + Redux的使用,我们一开始并没有用好它。不过,随着对React + Redux的逐渐熟悉,结合社区的一些实践,我们慢慢体会到了其中的一些好处,也摸索出一些好的实践。
3486ae271b306b61f1bcc0dbca009e03859b7af0
    遵循组件设计的原则,我们将React组件分为Component与Container两种,前者为纯组件。

组件设计的原则

  • 一个纯组件利用props接受所有它需要的数据,类似一个函数的入参,除此之外它不会被任何其它因素影响;
  • 一个纯组件通常没有内部状态。它用来渲染的数据完全来自于输入props,使用相同的props来渲染相同的纯组件多次,
  • 将得到相同的UI。不存在隐藏的内部状态导致渲染不同。
  • 在React中尽可能使用extends而不是mixin;
  • 对State进行范式化,不要定义嵌套的State结构,不同数据的相互引用都通过ID来查找。范式化的state可以更有效地利用Store里存储空间;
  • 如果不能更改后端返回的模型,可以考虑使用normalizr;但在我们的项目中,为了满足这一要求,我们专门修改了后端的API。因为采用了之前介绍的元数据架构,这个修改主要影响到了REST路由层和应用服务层的部分代码;
  • 遵循Redux的三大基本原则;
Redux的三大基本原则
  • 单一数据源
  • State 是只读的
  • 使用纯函数来执行修改

在我们的项目中,将所有向后台发送异步请求的操作都封装到service中,action会调用这些服务。我们使用了redux-actions的createAction创建dispatch需要的消息:

ad94b750ef288c6b67014091b06727360214bef0

在Reducer中,通过redux-actions的handleAction来处理action,避免使用丑陋的switch语句:
b30c0073c07ce1dc57f6e8eaa264c1672069bbbc
在Container组件中,如果Store里面的模型对象需要根据id进行filter或merge之类的操作,则交给selector对其进行封装。于是Container组件中就可以这样来调用:
691fb459991d4da5d76e5ecece33382c4bbb212a
  • 使用eslint来检查代码是否遵循ES编写规范;为了避免团队成员编写的代码不遵守这个规范,甚至可以在git push之前将lint检查加入到hook中:
echo "npm run lint" > .git/hooks/pre-push chmod +x .git/hooks/pre-push

Spray与REST

我们的一些总结:
  • 站在资源(名词)的角度去思考REST服务,并遵循REST的规范;
  • 考虑GET、PUT、POST、DELETE的安全性与幂等性;
  • 必须为REST服务编写API文档,并即使更新;

ab08ff629daebc62b221930dd2d7d4652ddbf4b3

  • 使用REST CLIENT对REST服务进行测试,而不能盲目地信任Spray提供的ScalatestRouteTest对客户端请求的模拟,因为这种模拟其实省略了对Json对象的序列化与反序列化;
  • 为核心的REST服务提供健康服务检查;
d0da74604e1ec900699983f04c30bdca6402383e
  • 在Spray中,尽量将自定义的HttpService定义为trait,这样更利于对它的测试;在自定义的HttpService中,采用cake pattern(使用Self Type)的方式将HttpService注入;
  • 我个人不太喜欢Spray以DSL方式编写REST服务,因为它可能让函数的嵌套层次太深;如果在一个HttpService(在我们的项目中,皆命名为Router)中,提供的服务较多,建议将各个REST动作都抽取为一个返回Route对象的私有函数,然后利用RouteConcatenation的~运算符拼接起来,以便于阅读:

09e06425ebe19dfd3c6f96c74ebeb8c963258b69


  • Spray默认对Json序列化的支持是使用的是Json4s,为此Spray提供了Json4sSupport trait;如果需要支持更多自定义类型的Json序列化,需要重写隐式值json4sFormats;建议将这些隐式定义放到Object中,交由Router引用,而不是定义为trait去继承。因为并非Router都使用Json格式,由于trait定义的继承传递性,可能会导致未使用Json格式的Router出现错误;
  • Json4s可以支持Scala的大多数类型,包括Option等,但不能很好地支持Scala枚举以及复杂的嵌套递归结构,包括多态。这时需要自定义Serializer,具体做法可以参考我在知乎专栏上的文章:https://zhuanlan.zhihu.com/p/20810315?refer=insight

Q&A

Q1. 为什么选择了Scala?
A1: 这个其实在分享中我已经介绍了原因。主因还是因为Spark吧。使用Spark的语言包括Java、Python、R以及Scala。我们的团队成员都不熟悉R,个别熟悉Python。Java的语法比较Scala而言,还是太冗长了。而且Spark的源码是Scala!

Q2. 您说您将来的产品要用clojure,处于什么方面考虑的?
A2: 只是随便这么一说。但是产品在将来需要提供DSL支持函数表达式。目前还没有开始设计,可选的语言包括Scala,但是现在没有确定。提到了Clojure,只是因为我们有团队成员是Clojure粉[Smile]


分享者简介

张逸-BigEye科技联合创始人与技术负责人,从事大数据分析的产品研发,主要研发方向为敏捷BI和基于Spark的大数据分析。之前就职于ThoughtWorks,作为Lead Consultant为客户提供架构设计、大数据分析、持续交付、代码质量、敏捷管理等咨询服务。著译作包括《软件设计精要与模式》、《WCF服务编程》、《Java设计模式》、《恰如其分的软件架构》、《人件》等。微信公众号:YiYan_OneWord


                                                        中生代技术群微信公众号

                                                da9312524921e637b684eed7bf3249db58f7badc

版权声明:本文内容由阿里云实名注册用户自发贡献,版权归原作者所有,阿里云开发者社区不拥有其著作权,亦不承担相应法律责任。具体规则请查看《阿里云开发者社区用户服务协议》和《阿里云开发者社区知识产权保护指引》。如果您发现本社区中有涉嫌抄袭的内容,填写侵权投诉表单进行举报,一经查实,本社区将立刻删除涉嫌侵权内容。

分享:
官网链接