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

虽然说是技术风雨,其实不过是一种文艺范儿的描述而已,没有这么夸张,只是在创业的这一年中,整个产品研发过程给了我许多前所未有的体验和启示,所以想借助这个机会大家一起交流,分享自己的一些收获。

这次分享打算从三个方面开始讲解,分别是:
  • 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

  • 0
    点赞
  • 0
    收藏
    觉得还不错? 一键收藏
  • 0
    评论
评论
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

当前余额3.43前往充值 >
需支付:10.00
成就一亿技术人!
领取后你会自动成为博主和红包主的粉丝 规则
hope_wisdom
发出的红包
实付
使用余额支付
点击重新获取
扫码支付
钱包余额 0

抵扣说明:

1.余额是钱包充值的虚拟货币,按照1:1的比例进行支付金额的抵扣。
2.余额无法直接购买下载,可以购买VIP、付费专栏及课程。

余额充值