高质量代码的设计之路

引言:本文依据笔者从业10年的经历,对如何设计出高质量代码的经验总结。文章观点鲜明,不喜可喷。

前言

本文是笔者从业10年的经历,依据自己和身边优秀伙伴对如何高质量代码设计的日常交流,所进行的所见所闻、所思所想的经验总结。笔者在文中试图描述如果构建一个高质量的Java工程应用。文章首先介绍日常总结出来的设计原则,然后以代码工程的组织规范描述每一部分代码应当如果编写,异常、日志、单元测试应当有何种要求,最后根据常见的技术问题描述设计心得。希望对新人或者工作中代码风格举棋不定的老人给予一定的设计思路。

本文观点非常鲜明,如果有文中不赞成的观点,欢迎留言来喷。

第一部分:代码设计的基本原则

约束优先

“约束优先”应当是代码设计的首要原则,目的是为了让功能的运行始终处在预期之下。所有设计的出发点都应当首先考虑约束,所有的优化都应当顺从约束的调整。

一种常见的错误出发点是:设计是为了让代码功能更加优雅强大,比如简洁、灵活、复用性强、易扩展、修改友好等。优秀的他们常常会绞尽脑汁,为某个功能设计抽象的框架,并大量使用继承、多态、Stream、设计模式、消息队列等代码技术。但代码优雅强大的评价来自当前的功能和未来的需求,在缺少经验和需求前瞻性下,为了设计而设计可能只会给代码添乱。

正确的思路应当是以约束为原则,让外部可能的调用方式不能偏离设计预期。为什么要使用继承,因为要约束子类必要的属性和行为;为什么要封装,因为要约束调用功能的方式;为什么要使用设计模式,因为要约束未来的扩展方式 … …等等。 在架构设计、业务规则处理时也应当有同样的思考,没有约束的系统同高级的Excel没有区别。

约束优先的第一个反面观点是灵活性,约束优先意味着代码只提供了有限的功能,不能满足代码设计者“自豪”的功能幻想。如何看待灵活性与约束的关系呢?
笔者认为,约束并不排斥灵活性,灵活性也应当以约束为前提。好的设计应当是底层最灵活,通过层层封装,越往上约束越强。约束并没有失去灵活性的设计,只是隐藏在底层,按需提供能力。就笔者经验来看,所有的“坑”都来自于非预期的调用上,非预期的调用让代码失去了调整的机会,这才是失去灵活性的罪魁祸首。

约束优先的第二个反面观点是代码的稳定性,需求的增加和调整意味着要同步调整约束范围,变更、扩容、发布、验证接踵而至。如何看待稳定性与约束的关系呢?
笔者认为,为了约束可以牺牲代码稳定性。在DevOps、敏捷开发等各种概念的加持下,系统发布不应当成为一个问题。如果满足管理规范的情况下出现变更困难,应当将矛头指向发布流水线或者系统架构,而不应当是自己的代码。调整约束也是为了明确预期。

约束优先而保证的调用预期,不仅是功能的预期,同样还有接入方的预期。这意味着在架构设计上,我们的系统应当被入口网关模块和出口网关模块所包裹,内部结构不为外部组织所见(插一句:组织架构决定系统架构)。常见的、有问题的、但又往往奉为经典的设计是使用消息队列将系统触发的事件消息为不特定的外部组织的系统订阅消费,意味着任何需要消费的系统,可以不经声明就消费该消息。这种设计使得系统的边界处在一个缺少预期的状态,而且感知消费方要远比感知调用方要困难的多,这使得有关该消息的代码将无法重构(因为难以联系上预期的消费方)。推荐的做法应当是架构内部使用消息队列,而对外通知上有专门的通知网关按订阅身份进行通知,如果后续需要重构升级,按身份和通知记录联系消费方即可。当然这种设计可能会带来资源和用工上的浪费,但比起组织间的相互扯皮推诿,这种牺牲是有价值的。

减少缩进

如何评价代码写得好呢?网上有很多优化代码的文章,例如抽象设计、模块化设计等各种代码设计技巧,SRP、OCP、LSP、DIP等各种设计原则,设计模式的巧用,各种设计规范等等。这些文章和经验不具有标志性,它们都不是让代码写好的充分条件,也不能因技巧的使用就能对代码有更优的评价。

直观来看,缩进越少,代码越好。

缩进是评价代码最直观、最稳定的标志,当你使用各种技巧让代码易于理解时,最明显的感官就是缩进的减少,分支、循环、回调等都是优化的目标。因为人更擅长的是线性思维,顺序的描述、偶尔加上简单的例外,是大脑最擅长的思维方式。在对代码的阅读上,每增加一个缩进,相当于让人的大脑多开了一个线程,而每递进一次缩进,相当于让人的大脑多开了一倍线程,但人的大脑并不擅长多线程工作。因此,各种设计原则或技巧的本质都是调整缩进。

常见的优化思路是:利用数据结构代替控制结构,让代码行文逻辑顺序化,例如使用Map或枚举代替分支,使用List代替循环等等;而例外,常常使用卫语句的方式来描述。

有人可能会反问,毫无设计、冗长的流水账代码是否也是好代码呢?笔者认为,比起不当的设计,流水帐的代码就是好代码,因为它没有创造代码结构,所有的内容都依次展示出来。流水账式的代码更有利于重构,反而胡乱的设计往往令优化者抓狂。

语义明确

设计应当贴近需求描述,代码应当有直观且确定的业务语义。

需求的描述隐含了背后的领域知识,未来的功能扩展也是领域知识的延申,而领域知识往往是稳定的,因此好的设计应当稳稳地贴合需求的描述,顺应需求的改变。因此程序员们不仅要按需求完成代码设计,还要引导需求方简明流畅的表达诉求,未来的变动也要在当前的表达上从容地修改。

具体到代码层面,代码的顺序可以流畅的翻译成业务逻辑,变量和字段应当由明确而单一的业务语义,对应的取值也要直观的反映业务术语。常见不推荐的做法包括:含义模糊,例如在对象中增加多个ext之类未定的扩展字段,这种设计除了能减少变更次数,没有任何价值;字段多义,例如使用数据入库时间来同时表达创建和开始,消息体的状态字段同时表达处理状态和业务状态,这会导致后续调整时混杂不必要的分支设定;值义不直观,例如使用整型表示枚举(非常常见),应当使用更直观的字符串表示枚举。

语义明确第一个难以调和的点是繁冗性。过于明确的语义表达可能会产生过长的命名设计,影响观感。因此设计时应当提炼领域术语,尽可能使用较短的命名方式。

语义明确第二个难以调和的点是抽象性。过于贴合需求的设计可能在抽象性上有所不足,影响后续扩展,导致灵活性变差。因此设计时应当将非领域部分的、通用的技术知识单独设计,与业务领域相关的部分相隔离,形成一定的抽象能力。

分层封装

代码应当具有层次性,越往下能力越灵活,越往上功能越明确;层与层之间、层内子层之间的封装上要有一定隔离性,降低层外变动对层内影响。

分层设计应当从底层入手,在底层提供最为通用的原子能力,往上层逐步整合能力,增加约束,最终形成功能,每一层都明确完成一定的任务。
分层需要注意的点在于层数的控制。过多的层数会导致代码冗长、实现成本高,过少的层数会导致代码混在一起,难以评估变更影响。分层的设计思路并不由实现代码的多少而定,而是由分层的重要性来定。例如Web接口层并不推荐直接调用功能,而是只在Web接口层做用户状态的卸载,功能的实现由下层提供,虽然用户状态的卸载代码很短,但依然值得单分一层。

封装并不是简单将实现用一段方法函数包裹起来,封装应当具有彻底性,方法函数的入参和出参主要针对封装的这一层。笔者见过接口入参层层渗透到底层方法,特别影响功能调试和测试用例编写。
封装需要注意的点在于隔离的彻底程度。太彻底的隔离可能导致大量的工作都在层层间的对象转换上,浪费工作量,因此在封装上要仔细把握,在价值和代价上取平衡。

集中收口

集中收口的主要目的是为了管理上的便利性,相似的功能应当统一在一处实现、统一由一处提供。

集中收口是非常重要的设计思路,小到类的实现、大到架构设计,都应有所体现。收口的作用有已下几点:1)集成不同的能力来源,统一管理;2)对外提供一致的能力服务,屏蔽不同来源的差异。同时在对外接口设计上,也应当将相似功能归纳在一起,避免提供多种相似的接口,减少接口的数量,让每个接口的能力特点更加鲜明。

笔者认为,一切有利于管理的设计都应当被采用。如果采用的设计会影响其他能力,那么管理上的优势应当被优先采用。例如,在架构上,集中收口也是常见的设计思路,而集中收口带来的隐患是单点故障,这就要求收口之处需要对可靠性做出一定的设计。

复杂度适当

任何一步实现都有代价,实现的复杂度应当配得上其功能的重要程度。

复杂度适当是揣测实现方案的主要动机,小到字段设计、大到功能模块,也应当有所体现。对于重要的功能,我们可以浓墨重彩,增加设计逻辑;对于不重要的功能,我们应当能简就简,不应在此处消耗过多的精力。

复杂度的提升让功能更加强大的同时,使用的复杂度也往往会同步提升。对于不重要的功能,复杂度的提升往往带来维护的不变。笔者见过很多系统,边缘功能的设计显得极其富有经验,每使用一次功能,感觉就像举行了一次隆重的仪式。但是主体功能却显得非常单薄生硬,只能体现代码书写时穿凿附会的设计理念。

复杂度适当的反面观点是可扩展性,如果前期没有识别到部分功能的重要程度,那么简单的设计是否会导致功能不利于扩展呢?
笔者认为,只有简单的才容易扩展,复杂的一定不容易扩展。这种观点的基础是,如果增加功能就可以扩展,那么简单的设计更容易增加;如果改变功能才能扩展,那么简单的设计更容易重构。

按流量特征实现架构设计

架构上划分模块的本质是流量特征的不同,领域模型的不同只是表象。

领域设计是当下非常流行的微服务架构设计思路,但是领域设计最大问题是,对于微服务拆分到什么样的领域颗粒度是合适的,缺少明显的预期,这里没有理性的评价指标,靠的全是感觉。拆的太粗,不能体现微服务的优势;拆的太细,浪费资源影响发布。在实践中,多数都是拆分的过分细致,与其业务规模完全不匹配。

以笔者经验,流量特征是拆分的最佳评判标准。流量特征包括调用量的规模、调用的方式、调用的先后顺序等等,合理的安排流量在系统内的分布,才是最佳的设计思路。这并不与领域设计相冲突,反而才是领域设计的最佳实践。即使前期有部分模块没有拆分清楚,那么因为流量特征相似,再次拆分也会很容易,不会留“坑”。

以弹性思维优化分布式思维

最终的设计目标只有一个:仅对目标系统进行简单的水平扩展就可以完成容量调整。

分布式计算最大的问题就是部署结构复杂,各系统间有复杂的数据依赖。如果某个系统访问量提升,并且系统耗时有一定的不确定性,那么其他关联的系统将如何调整容量呢?这是分布式计算难以回答的问题,往往只能通过增加资源、加强监控、制定应急策略来保证可靠运行。由于被依赖系统的不确定系统,容量的提升不再以简单的水平扩容就能解决,需要协调各方资源,并且在大流量到来时心惊胆战。

弹性思维的核心在于非必要不依赖。应当尽可能的减少对外部系统的依赖(尤其是中间件),从而达到简化系统架构的目的。笔者经历中常遇到这么一拨人,一提分布式就要接ZK、一提缓存就要接Redis、一提异步就要接Kafka、一提任务调度就要接Job中间件… …,完全不考虑必要性和风险,最后的结果极有可能导致发布变更变得极其复杂,微服务的便利性荡然无存。事实上,大部份的功能需求在良好的设计后,都可以脱离大部分中间件完成功能。并且依赖的系统越少,自身的稳定性越高(这是个简单的概率问题),何乐而不为呢?

弹性思维另外一个特点在于节点平权。可以设想一下,假如系统中所有的节点都是一样的,只是因为流量特征的不同而扮演不同的微服务角色,那么当遇到流量冲击时,可以无脑扩容,根据每个角色系统的压力的不同而调整角色中节点的数量,那么扩容是不是就是一件非常容易的事情呢?现实中,严格的平权难以做到,但这种思路可以简化应急策略。

第二部分:数据表的设计规范

纯物理字段

物理主键、创建时间、更新时间,是所有表都必须包含的字段,并且这三个字段不能参与代码逻辑。

所有的表模型都必须包含物理主键创建时间更新时间三个字段,例如阿里推崇的idgmt_creategmt_modified(为啥创建时间不是过去式,笔者一直没搞清楚),并且这三个字段不能参与任何代码逻辑,不能有任何业务语义,纯纯的物理字段。其中,物理主键用来保证插入效率,创建时间更新时间表示数据变动时间,用来给予最低限度的排障线索。

一种常见的错误设计,以一条任务数据的创建时间作为该任务的开始时间,这种设计混淆了数据创建时间和业务开始时间,违背了语义明确原则,当有需要人工插入或处理任务时,将丧失实际处理时间的表达,一旦人工操作需要调整,很难评估影响范围。

逻辑主键

绝大部分情况下,表模型应当有独立的、字符串类型的逻辑主键字段,该字段可被代码逻辑处理。

因为物理主键不参与代码逻辑,因此非边缘功能的表模型需要额外的逻辑主键。通常,逻辑主键字段应当配合ID生成器生成,笔者常用的ID生成器包括固定长度的随机字母无dash的UUID时间戳转36进制编码(有自增特性,适合数量大的流水类数据)等;也可以组合ID生成器,扩展数据有效范围;或者插入模型中其他字段的值,标记数据归属;根据需要,部分表模型在逻辑主键前可以添加业务语义前缀,例如项目使用proc_、任务使用task_、产品使用prod_等,体现语义明确原则,这样哪条数据出现问题可以快速明确是哪部分功能出现问题,节约排障时间。

如果表模型建立在大数据平台,那么任何表都必须要建立独立的逻辑主键字段(可不做唯一索引,而做事后检查),这样做是为了识别因JOIN产生的重复数据,并且在生成模型时应当克制使用DISTINCT进行数据去重,这样可以及早暴露模型或SQL问题。

唯一约束

所有的表模型都应当设计唯一约束,保证数据干净整洁。

任何数据都具有某种或某几种唯一性,除了主键约束外,应当设计可能的唯一约束,放开容易收缩难,以当前的严谨换取未来的灵活,体现约束优先原则。切不可为了眼前的便利性,而随意放开约束。现在系统中,数据要么代码的一部分(例如配置类数据),要么是代码的结果(例如业务类数据),特别是现在企业普遍重视数据价值,保证数据整洁是每个程序员的应有之义。

与该原则相反的常见设计是“软删除”,例如deleted、is_deleted等标记字段,它们被设计的构想是为了满足“不轻易删除数据”的原则,而软删除一定会破坏唯一约束。
笔者认为,数据可以有状态字段,但软删除应当始终杜绝使用。软删除就是错误的设计,它的本质是混淆了管理态和运行态的数据区分,这是它的“原罪”。有些人可能会反驳,软删除可以用做运维回滚。可是,帮助运维回滚的手段太多了,没必要选择最不利于代码开发的软删除模式。

枚举字段

枚举字段建议使用字符串类型,用富有含义的文本表达枚举值。

常见的设计是使用整型或布尔型表示枚举字段,再配上详细的设计文档,表面上看非常规范。但这种规范是反人性的,没人记得住的规范,没有遵守的价值。字段的值应当尽可能地体现值的含义。

多义字段

一个字段不可以表达多种含义。

混用含义的设计通常出现在表示时间和状态的字段上,例如数据的各种业务时间,不能只用创建时间和更新时间来表达;再如数据的当前状态,可能存有各个维度、各种视角的状态,也要分开表示。

表模型和字段的规模

设计时应当有意识降低降低表模型和表字段的数量,每个表、每个字段都应当值得去建立。

这里体现的是复杂度适当原则。表模型和字段的太多会造成后期维护管理上的不便,因而在前期设计上就应当考虑一定的抽象性,越是精巧的设计,模型也必然越少。

模型间关联

模型间的关联关系应使用逻辑外键代替物理外键。

不应当使用任何物理外键,而是在代码中使用逻辑外键。代码中使用逻辑外键体现的是集中收口原则,不应将关联的逻辑概念同时在SQL和代码中体现,而应当只在代码中体现。同时在应用请求数据时,也应当尽量避免的使用LeftJoin等SQL语句,而是将关联逻辑放入代码中处理。

模型间关系应当以“一对多”关系为主,“一对一”关系应当审视必要性,“多对多”关系应当审视正确性。

“一对多”的关系表示模型间的层级关系,是常见且必要的设计;“一对一”的关系应当仔细考察两个模型间的独立性,如果两个模型关联性较强,那么往往合并成一个模型更加合适;“多对多”的关系对代码开发是极不友好的,应当检查两个模型间是否缺失设计了中间模型,通过加入一个中间模型为两者建立“一对多”,变相达到“多对多”关系的表示。

多个模型间的关系可能具有传递性,建议模型的外键字段应当尽可能的全。

多个模型可能依次存有关联关系,例如一篇文章有多个段落,一个段落有多个句子。那么设计时,应当将外层模型的主键向下传递,每一层的模型都应该同所有的外层模型进行关联,可以增加查询的便利性。这里其实做的是“宽表”处理,这种字段的增加是有价值的。

不应当专门建设模型之间的关联关系映射表,除非模型间的关系本身就是独立的概念。

受过去Restful风格的影响,一度曾经流行的设计思路是模型尽可能独立、不关联其他模型,再专门提取两个模型主键,建立映射表,命名上一般会叫xxx_yyy_mapping。笔者不推荐这种设计方式,因为关系本身就是模型的重要属性,如果关系不是一个可以独立使用的概念,那么从模型中分离出来,并不值得浪费一张表。

数据模型的宽表设计

如果依赖外表的数据简单、稳定,可以将常用且强关联性字段值直接放入模型中,做宽表处理。

宽表是当前企业常用的表模型设计方式,目的在于减少表关联带来的查询损耗,提高数据使用上的独立性。传统的三范式设计虽然数据关系紧密,结构冗余低,但是使用上有很多不便,也不适合大规模数据的存取。当下流行的是应用查询数据尽可能少的使用LeftJoin等SQL关键字,那么宽表的设计可以加快数据查询。宽表的问题在于可能存在数据不一致的问题,因而只能将稳定的数据,这一点需要在设计时考虑影响,并在后期运维中留意。

不确定模型的纵表设计

如果模型的字段易变、稀疏、不稳定,却又很重要,可以考虑稳定部分建立主表,再增加一张纵表对主表模型进行增强。

纵表就是key-value表,通常由模型逻辑主键、字段名、字段值所组成,使用时依据主表主键,将附加字段全部查出来。纵表的问题就是使用上较为复杂,适用在对模型大量字段都有较强索引要求的场合。如果索引要求特别低,那么下面的字段JSON化可能更加合适。

复杂模型的字段JSON化

模型应当具有内聚性,非必要不开新表、非必要不开新字段。

数据模型的内聚性也是其独立性的表现,有些数据模型可能比较复杂,完整地表达模型需要多层嵌套。这里要斟酌数据模型的子模型更像一个属性还是更像一个模型,如果更像一个属性,只是表达上较为复杂,那么可以将整个属性JSON转换后作为一个字段设计来设计;如果表模型有很多细碎的小字段,也可以考虑合并成一个JSON字段。

事实上,评价一个属性值不值得单开一个字段,主要考察有没有索引需求和查询需求。如果没有,那么在非高并发场合,所有字段全部JSON化,是否依然合适呢?虽然不建议这么做,但使用上并无不妥。笔者也见过整个模型JSON存储,但索引字段额外单开的设计,也不会有纰漏。因此,劝各位放弃模型规范设计的执念,字段里存对象也不是不合适的。

这一点上,MongoDB会有天然的优势,但主流数据库依然是关系型数据库,好的消息是,越来越多的关系型数据库愿意原生支持JSON字段。

第三部分:Java工程的组织规范

在工程组织规范上,笔者借鉴了蚂蚁Sofa4的多bundle设计并有所调整。虽然spring-boot的闪亮登场直接给sofa4判了死刑,sofa团队转向设计sofaboot和sofalite,但作为蚂蚁深度定制的spring-mvc框架,还是许多有可圈可点之处。

在这里插入图片描述

Core层模块

数据持久化层

数据操作应当遵从简单直接的原则,为领域模型层提供最原子的数据操作。

笔者依然推荐使用myBatis,有许多人可能比较倾向使用JPA,但JPA的数据操作不够透明直接,且方法名是动态的,如果想对数据操作进行统一的管理,那么只能使用反射技术,不利于集中收口原则的实现。以笔者体感经历,JPA只适用于快速搭建的小型项目。

DAO的写法

DAO只能写 7 + N。

7 指的是create/update/delete/get/query/pageQuery/count这7种必要且固定的语句,N 指的是当前7种不能满足,或者满足代价太高时,进行额外的定制化语句。要注意的是,很多人喜爱的“批量插入”,也应当归入 N 中,因为大部分场景并不需要单独建立批量插入语句,可以在上层用循环和单条创建来实现。

Mapper的写法

mapper实现了dao的 7 + N,WEHERE条件语句中要体现数据库的索引约束。

update/delete语句中要按主键操作,get要用choose枚举出可能的唯一约束,query/pageQuery/count要if分布出所有期望的条件并完全一致。额外要注意分库分表的影响,需要体现分库分表字段。

领域模型写法

领域模型应当继承模型基类,包含必要字段。

领域模型和持久化模型是否要拆分成两层,即拆分成VO和DO,笔者认为,如果领域模型需要对持久化模型进行较多的操作,应当拆分;其余情况可以不用拆分,不然会有大量的重复工作在数据转化上。了解Java开发的朋友应当知道Java关于数据对象的术语:PO、DO、TO、DTO、VO、BO、DAO、POJO等各种O,这种术语真是吃饱撑的,对领域建模没有贡献。应当缩减工程中纯技术的术语数量。

查询对象写法

应当为 get 和 query/pageQuery/count 建立两个查询对象,查询对象使用 Builder 写法,且默认构造函数需要私有化。

应当建立单条查询get和列表查询query/pageQuery/count的通用查询条件对象,所有的查询要收口到这两个对象上。单条查询的查询对象应当包含所有的唯一索引字段,列表查询的查询对象应当反映所有索引字段以及其他可能用于查询的字段和条件。构造上应当使用Builder写法,保证每次查询的时候都创建一个新的查询对象,防止一次执行多次查询时出现干扰。

核心层服务

数据持久化层之上需要有核心层(core-service),提供真正的数据操作服务。

核心层服务主要对数据模型进行补充操作、组合操作,不能直接将数据持久化层暴露到外层模块,否则这些操作将扩散到外层模块上;同时如果数据持久化层约束做的不理想,也不能调整,那么在核心层服务上还可以对约束进行补救,这些操作也不应扩散到外层服务上。

核心层服务应当在数据操作前进行基本的断言,保证数据插入不会存在问题。核心层服务还应当包含若干模型字段的枚举,约束字段的取值。

逻辑主键生成

每个模型对应一个核心层服务,每个核心层服务包含对这个模型对主键生成规则。

Common模块应当提供IdGeneratorUtil,用来为数据的创建提供唯一ID生成规则,例如固定长度的随机字母无dash的UUID时间戳转36进制编码等;如果领域模型太多,可以考虑增加唯一ID前缀,方便示意。

简单模型核心层服务的写法

简单模型的核心层服务应当写 8 + N + k。

8 指的是 create/save(create-or-update)/update/delete/get/query/pageQuery/count,与数据持久化层的 7 类似,只是多了一个创建或更新。N 对应数据持久化层的 Nk 表示为上层服务额外创建的若干快捷访问方法,例如每次构造查询条件的Builder比较繁琐,那么常用的查询条件可以直接作为参数,写在 k 里,内部为这些快捷访问构造Builder。

复杂模型核心层服务的写法

多个简单模型混合而成的复杂模型的核心层服务应当写 3 + k。

复杂模型的核心层服务首先依赖简单模型的服务,并在此基础上封装3个基本操作 import/export/clearimport一次性把复杂模型包含的所有数据刷到数据里(统一删除重建),export将复杂模型包含的所有数据全部查出来,并进行则,clear则将复杂模型包含的数据全部删掉。

注意事务处理。

配置数据的加载

配置数据应当尽量持久化在单独的数据表或配置中心上,可以例外;但所有的配置数据应当只通过一个专门的核心层服务进行访问,且每个配置的命名应当登记在一个枚举上,不应有例外。

除了必须写在application等配置文件里的配置数据,其他配置数据都应当保存在system_config表里,尤其不能写在服务器的环境变量里。有些企业内部提供了配置中心中间件(如apollo),如果有强力的实时生效要求,可以使用配置中心将最新配置同步给所有节点。但笔者依然要强调,配置中心的作用应当是可替代的。如果使用这种中间件已经到了无法替代的程度,那么要审查代码设计是否存在问题了,因为配置本身没有实现核心功能,不应当复杂到影响应用发布的程度。

核心层服务SystemConfigCoreService提供所有配置数据的访问,该服务的加载完成表示所有配置数据加载完成,可以在加载后打印配置到日志上。除此之外,其他服务使用配置数据时,不应在使用SystemConfigCoreService以外的访问方式。常见的访问方式包括@Value注解访问application配置,应当杜绝。

本地缓存加速

由于缺少LeftJoin等SQL关联语句的加持,如果想减少数据库访问次数,可以建立本地缓存服务,将常用数据表加载到本地缓存。

始终不建议多表关联,因为关联的组合情况太多太多了,无法在数据持久化层进行约束,也不利于只在代码层面体现业务逻辑;同时关联小数据表并不能体现访问效率上的优势,关联大数据又潜在存在访问效率上的劣势,比较鸡肋。但并发高时,多次查询数据也可能出现查询效率低等情况,可以在核心层服务上建立缓存服务。

本地缓存的写法比较固定,也可以专门写一个LocalCacheManager类帮助各个缓存层服务管理自己的缓存,提供缓存清除等管理服务。使用本地缓存的另一个好处是,可以方便的做数据库故障的本地容灾。可以保存两份缓存,一份定时淘汰缓存用于正常读取数据,一份永不淘汰缓存用于数据库故障时的本地降级。

Integration层模块

Integration层负责实现与其他应用和系统的接口对接,为上层服务提供依赖的调用方法。

所有与外部应用的对接都应当写在这里,这一层做的工作都是脏活累活,主要的目的就是为上层模块提供切合项目风格、可直接调用的接口,将外部接口的复杂性消化在本层中。

常规接口的对接

应当为每个对接的功能都单独设计一个Client客户端。

如果是HTTP对接,那么要定义接口的入参出参,在Client内部解析HTTP的报文、状态码、结果码,返回自定义的模型或内部异常。HTTP对接暴露出来的接口不建议为Header、Param、Body定义不同的入参,上层不应该感知调用的细节;也不建议使用FeignClient的动态代理模式,FeignClient应当是由对端RPC的Jar包封装好的,而不是自己封装的。

如果是SDK对接,即使SDK设计的非常优雅,也依然要自定义数据模型,防止SDK的内部类污染上层的调用。SDK对接通常要初始化SDK客户端,本层不建议采用面向对象的方式,不应当设计或使用SDK客户端类里定义的方法,而是应当将SDK客户端作为参数传入自定义的Client接口里。

如果是三方对接,通常要对获取的Token进行管理。并发量不大的情况下,可每次请求都重新获取Token;并发量很大时,要考虑在Client内部设计刷新机制,或者在调度层定时调度任务,管理Token的刷新。

如果是RPC对接,通常内部提供的接口和本项目风格一致,但即便如此,依然要单独设计Client客户端将RPC与上层模块隔离开,方面后续做拦截器集中处理对外调用的问题。

语义混乱接口的对接

应当努力为上层提供语义友好的方案,可视情况考虑本地缓存或线程变量调整语义的实现。

有些时候对接的接口可能设计的比较恶心,调用一圈还要回头,在以前调用过的接口返回中找找有没有合适的结果。Client应当将这种恶心的逻辑消化在内部,努力提供富有业务语义的接口。思路上可以考虑采用本地缓存,或者线程变量。线程变量的使用是为了让上层模块误以为自己在调用远程接口,但实际上之前已经调用过,取出之前的结果即可。(屎总是要有人吃的,你不吃就得让用户或同事吃。如果遇到不得不使用线程变量的方式,说明这屎真的太难吃了,笔者从业经历中还能吃到一次,真是三生有幸。

Biz模块

Biz层是真正的功能实现层,也是代码的核心所在。

减少缩进

使用数据结构代替控制结构,可以减少缩进的产生。

减少缩进,不仅有利于代码阅读,也有利于后续单元测试的用例覆盖。如果是算法类代码,那么控制结构本身就是算法内容,无需考虑替换;如果是业务代码,那么通过对控制结构增加业务语义,可以使用数据结构来表示控制结构。

常见的代替策略有:1)使用卫语句做方法的条件检查;2)用Map或枚举代替分支结构;3)使用Stream代替循环结构。其中Map或枚举代替分支,是常常被忽略的技巧。例如可以将不同的业务场景分别用不同的枚举进行定义,枚举关联相关的处理方法,那么通过获取枚举就可以直接进行处理,而不必在代码中显式进行条件判断。

减少class文件

不应死板的照搬设计模式的示例代码,使用函数式思维可以在使用设计模式的同时,大量减少class文件。

class文件太多会导致代码过于琐碎,不利于理解,也不利于管理。通常在设计时,会考虑各种设计模式,让代码的功能分类更加清晰,扩展更加容易。但很多文章介绍设计模式的例子纯粹是“杀鸡用牛刀”,工厂方法、责任链模式、建造者模式、装饰模式等都有类似的问题,定义的类太多了,现实中不是所有的问题都是复杂问题。很多设计模式都可以使用函数式的方式进行改写,改写后会非常简洁,例如工厂方法可以使用Map,责任链模式可以使用List,建造者模式和装饰模式可以使用高阶函数等。

减少class文件并不与OOP的基本原则相冲突。这里不应过分考虑单一职责原则和开闭原则,这只在多人协作同一个工程中才有那么一丁点的作用。现在的开发模式都是一到两人负责一个代码工程,甚至一人负责多个,没有人能一次写出最佳的代码,代码要经常修改才具有生命力。

面向修改的设计

无论代码是在上线前还是上线后,需求都不可能不改,好的代码不应当排斥修改,并且通过修改增强能力。

程序员不应当对需求的变动有所挣扎。需求修改的原因有以下三种:1)市场或业务的调整;2)前期没有引导需求方,导致需求方的想法过于随意或不坚定;3)前期沟通中的备选方案。每一种原因都不是程序员反驳的理由,这不是PUA,现实中也很少有对抗成功的案例。

  1. 切合业务语言的描述。代码行为应当和业务语言一样顺序且连续。这里要注意在实现多种业务策略时,常采用“控制反转”的技术,而“控制反转”只适合书写框架,要审视反转后是否破坏了业务描述的连续性。
  2. 有关算法部分单独实现。算法部分较为复杂,在业务交流过程中,往往压缩成若干词汇,那么应当单独实现这些词汇,让调用时也有类似压缩的效果。
  3. 相关的复杂逻辑集中在一起。复杂逻辑往往是修改需求的重灾区,这里要考虑不应过分设计而使得复杂逻辑分散,导致需求调整想梳理现状却难以下手。

批量功能的设计

应当先完整实现单一功能,再在基础上实现批量功能。

对批量功能的设计,常见的错误做法是,批量做第一步、再批量做第二步、依次类推。应当先针对单个实现每一步的操作,然后再设计批量执行,宁可牺牲一部分性能。批量操作的特点在于,每一步执行都套了一个遍历的外壳,并且结果存在部分成功的特殊状态。因此要将批量的操作集中在最外层,并且只体现一次,逻辑的调整分内外分别调整。混在一起的话,改起来可老费劲了。

性能优化滞后考虑

不应过早考虑性能优化问题,优化性能的代码有100种写法,但好代码的写法只有那么几种。提前优化是万恶之源。

Facade层模块

纯RPC接口设计

Facade层模块只负责提供RPC接口定义,为外部应用提供远程调用能力。

Facade层模块有三项作用:1)暴露可供外部使用的数据模型、请求报文和响应报文;2)定义自身应用提供可以暴露出来的业务接口,交由其他模块实现;3)为图案对内其他微服务应用提供调用自身应用的业务接口定义,约束调用行为;4)暴露核心层服务的数据接口,接入管理后台。

数据模型

不应将核心层模块定义的数据模型直接暴露到Facade里,而应重新定义相同或相似的数据模型。

核心层模块定义的数据模型是应用自身运行的核心概念,如果将核心概念暴露到外部,那么将导致核心概念无法轻易调整,不利于程序功能演进。因而Facade层和核心层要分层封装,定义出外部可用的模型。如果内部数据模型产生变化,也方便知道暴露出来的接口如何才能兼容过去的逻辑。

业务接口

Facade层的业务接口是无状态接口,建议一个方法对应一个请求报文和响应报文,并应当设计成仅依靠请求体参数就可以完成接口调用的形式。

Facade层的接口是用户或外部系统的准调用接口,但还不是调用入口,无状态的设计是为了保证无论如何发起调用,接口都可以完成功能调用。常见的状态包括用户的登录态、外部调用的鉴权状态、系统环境状态、调用来源识别等等。可能这些状态不能以参数的形式传入,那么这些状态的卸载应当在上层完成后,再以参数的形式传入到Facade的实现层。总之Facade层一般不解析状态。

请求报文和响应报文应当要包含requestId之类的请求唯一标识参数,对本次请求做出识别标记,其他如果有可以识别请求来源的参数也应当被设计定义。

动态代理

可以考虑在Facade模块中增加动态代理,外部应用加载Facade的Jar包时,可以直接引用RPC接口。

Facade主要用于方便团队内部微服务应用间的远程调用,如果团队内部有统一对接规范,那么facade可以Jar包内部直接集成远程调用的实现,而不必让其他微服务应用再次开发。例如,比较简单的系统常常使用@FeignClient加拦截器完成远程调用实现,但这种实现应当是Facade包内部集成好的,而应该由调用方开发实现。

Bootstrap层模块

真正的应用入口

Bootstrap层模块负责启动应用,执行测试用例,并提供接口调用的真正调用入口。

调用入口包括Web接口、Api接口、定时任务调度、异步消费任务等,Bootstrap层应当区分出不同的调用来源、调用行态的流量特征,并在请求体上为流量打上明确的标记。

流量入口层

流量入口层仅负责状态卸载和流量标记,并调用Facade层的实现接口,完成功能调用。同时还需要日志记录入口流量的请求响应的状态、耗时等调用情况。

流量入口层(例如Controller)需要识别线程上下文参数,负责状态卸载,例如用户接口需要进行用户态识别、操作鉴权等操作,并为调用Facade层的实现类组织完整的参数。不同的调用流量应当为流量打上标记,用来后续排障分析。

流量入口层对不同的流量来源还需要设计不同的拦截器,并在拦截器中统一对每次的调用情况进行日志记录,为运维实现调用统计、耗时分析、成功率分析等基础日志数据。

Facade实现层

Facade实现层仅做参数校验、参数转化、异常转化,并调用或组合Biz层的业务接口。Facade层是所有暴露方法的汇集地,应当记录调用的请求和响应报文,报错日志,以及状态、耗时等调用情况。

Facade实现层用来将请求报文转化成内部的的模型和调用入参,为Biz层的业务接口提供可靠的调用参数,并将Biz层返回的调用结果转化成对外的响应参数。

Facade实现层应当包含一个拦截器:1)对未初始化requestId的流量进行requestId初始化;2)打印Facade接口的入参、出参或异常日志;3)将内部的异常转化成错误码和错误信息响应报文;4)记录每次调用的状态和耗时等日志信息。

Facade实现层还会为Core层提供数据管理、配置管理、缓存管理等功能,主要用于管理后台的接入,用于数据订正、配置推送、缓存刷新等功能。

前置Nginx

对应用的观察和保护不能完全依靠应用自身,最好在应用的入口前放置一个Nginx代理,结合Nginx访问日志对应用实现完备的监控。

应用对于观察已经流入到具体接口的流量都能实现很好的观察手段,但是对于流量在主机IO/Java进程/Sevlet容器之间的耗费,应用本身就很难做观察了。工作中也常遇到调用方已经感知到接口耗时增高,但在应用本身的日志里却很难体现的情况。如果条件允许,那么最好能给应用放置一个前置的Nginx,并将access.log/error.log日志上传到监控平台上。

第四部分:异常、日志、单元测试规范

异常规范

内部的非受检运行态异常、以及异常的错误码枚举定义在Common模块里。

  • Core层主要进行数据操作断言,应当以IllegalArgumentException异常为主。
  • Integration层负责识别外部调用的异常行为,包括调用过程中的受检与非受检异常、错误码等,并转化成内部定义的非受检异常。
  • Biz层如果需要抛出异常,应当以内部定义的非受检异常为主。
  • Facade实现层捕获断言异常、内部定义异常和未知异常,并将按错误码枚举组织响应体报文。

日志规范

日志以调用入口传入或生成的requestId作为MDC标识进行索引,并生成相关的运行日志和各种摘要日志,排查问题时应当以摘要日志进行入手排查。

GC日志
应当使用-Xloggc打印GC日志,排查由于FullGC导致的时间停顿问题。
应用运行日志和应用错误日志
常规的运行日志统一打印在app.log里,错误日志打印在error.log里,并在拦截器里保证报错信息不会丢失。
接口对接调用日志
应当对Integration层的调用前后都记录接口访问结果,如果这部分日志太多,也可以从app.log里分离出来。
线程池日志
应当在Bootstrap模块专门启动一个定时任务ThreadPoolMonitor,用来定期扫描应用内部的线程池的使用情况,

摘要日志

摘要日志是最重要的日志,对接口调用前后的日志打点,记录请求id、唯一标识、调用来源、是否成功、错误码、耗时等,是排查错误的利器。

只要是耗时敏感的或者功能重要的方法都可以记录摘要日志,通过分析摘要日志,从而定位异常的请求,再根据请求id查询运行日志,来定位具体问题。一般流量入口层、Facade实现层、Integration层都需要独立的摘要日志,其他代码里重要的或高频访问的方法也可以记录摘要日志。

摘要日志可能经常调整或增加摘要字段,实现技巧是,可以在日志调用处将摘要需要的参数都塞入Map入参里,而在摘要日志的实现类里对字段是否展示、展示顺序进行调整,并在最后加强制一个结束标记。

单元测试规范

涉及复杂算法的方法可以单独进行单元测试,而对于业务接口,单元测试的入口应当只在Facade的实现层,直接在入口完成用例覆盖。

在Facade入口编写测试用例,可以更好地梳理出外部调用的各种可能Case,也更能反映应用的真实调用情况。在往上是流量入口层,如果在一层编写测试用例,那么对调用环境和状态的模拟会很花时间,但代码却不复杂,覆盖收益很低。在代码覆盖上,应该重点针对Biz层,对于Integration层和Core层都可以进行Mock,假定必然返回某些数据;如果测试环境允许,也可以不Mock,全部按集成测试的方式去执行,当然这种要求太高。

正常来说,一个方法的测试用例不用太多就可以完成一定比例的代码覆盖。如果发现用例很难提升覆盖度,那么要考虑Biz层的代码结构是否需要优化。

一般单元测试的规范都是按类来写,一个类一个单测文件,一个方法若干个测试用例。即使在TDD的教徒们如此推崇单元测试的潮流下,笔者依然鲜明反对这种方式,原因有三:

  1. 按类编写会产生大量的测试文件和用例,想要从中找到对某个业务有影响的一连串Case真的很困难,好的项目应当利于管理;
  2. 写起来太麻烦太耗时,没人爱写这么啰嗦的测试代码,多数都是应付了事,好的工程应当顺应人性;
  3. 对内部代码进行非功能性的重构或优化时,改起来真的很痛苦,大量的精力花在调整用例上,好的代码应当易于修改。
    不要只片面强调用例对代码质量的保护,保护质量所占用的工作量远小于适配修改所占用的工作量。如果这种质量的保护是建立在牺牲代码灵活性的条件下,那么真心建议测试教徒们再重新找一条路。

第五部分:常见问题的处理思路

前后端合作

在工作边界上,前端应当只负责展示,而后端需要把所有展示相关的数据准备好,不应当让前端去理解或实现任何非展示的数据处理逻辑。

前端受限于开发模式,在代码中难以从形式上区分展示逻辑和业务逻辑。如果后端只考虑数据的存取,而对数据处理部分推迟在前端实现,那么一旦需要调整业务逻辑或者排查数据问题,就会相当被动,很难快速完成业务逻辑的梳理。因而后端对待前端,应当像“保姆般”的工作。区分前后端的工作边界,关键就看前端是否不用进一步思考展示逻辑的来源。

曾经有一段时间Restful特别盛行,后端逢人必谈“资源描述”,为了完美切合Restful描述模式,不愿意在接口上多增加任何一点逻辑,小心思就是:反正数据都有了,前端就看你自己的了。这种出发点是不负责任的。Restful提供了资源视角的统一接口风格,接触者可以不通过费心搞到接口文档就能理解资源提供方的接口描述,这种模式适合开放互联网时代。然而现在的互联网已经违背了建设的初衷,封闭是互联网圈地的主旋律,Restful剩下的贡献只有报文的JSON格式了;另外HttpMethod的操作语义太少,描述复杂操作就得放在Header里(例如MongoDB的原生维护接口),如果想要提取调用的全部信息,对接口做横向的统一处理,那么path/header/method/param/body都要挨个查一遍,真是怎么不舒服怎么来。

任务调度的锁控制

在任务调度上,所有节点应当通过并发锁来平权执行任务,不应当区分活跃节点和备节点。

多数人在任务调度的处理上,为了防止任务重复执行,一般在集群中选择一个节点作为活跃节点专门负责执行任务,一旦活跃节点出现故障,就自动或手动切换到下一个节点。这种做法不利于充分利用计算资源,也不利于调度任务的大量增长,非平权节点的集群在运行维护上也是一种负担。正确的设计应当是集群中的所有节点都可以执行任务,如将待执行任务存入数据库,通过并发锁让唯一一个节点获得执行权。数据库的事务隔离一般选择“读已提交”的模式,从业经历中,笔者遇到过四种并发锁控制的方式(不限于任务调度场合),包括长锁、短锁、软锁、分布式锁。

长锁
操作数据开始时进行加锁,直到操作完成释放该锁。这种锁颗粒度太大,而且既然选择任务调度,那处理时间都不会很短,易影响性能,一般不用。
短锁
短锁比较简洁,也是笔者常用的方式。一般在设计上会对待处理数据增加一个处理状态字段,在代码操作分为五步:查询 -> 加锁 -> 更新 -> 检查 -> 处理。1)查询待处理状态的数据;2)对待处理数据尝试加锁;3)如果获得锁,就按数据主键和待处理状态更新为处理中状态;4)再次获取数据,检查状态是否是处理中,如果是,表示获取该数据的操作权,同时释放锁;5)完成数据处理,并将状态更新为已处理。
软锁
软锁是一种乐观锁,软锁设计的初衷是保证数据库无锁运行,深受互联网公司的喜爱。一般在设计上会对待处理数据增加一个锁ID字段,在代码操作和短锁类似,分为四步:查询 -> 加锁 -> 检查 -> 处理。1)查询为上锁数据;2)对为上锁数据的锁ID字段更新一个随机版本号;3)再次查询该数据的锁ID字段是否与自己更新的一致,如果一致,表示获得操作权;4)完成数据处理,并将锁ID置为空。
为了防止软锁处理异常,导致数据锁ID不能释放,通常还需要配备一个定时的补偿任务,及时清理锁ID。
分布式锁
分布式锁是通过ZK或Redis等中间件获取锁的一种方式。优点是使用简单,加锁是内存操作,适合高并发场合;缺点是有环境依赖,需要中间件支持。笔者还是希望对中间件的依赖要持审慎态度,不要臆想自己开发的就是高并发。

多版本切换

多版本切换的最优解就是管理态与运行态数据分离。

诸如审批生效、编辑预览提交、创建新版本发布之类的功能,具有共同的特征:对已生效的数据,经过确认才生效新数据,或者人工切回曾经的数据。这种都称为多版本切换。一种的错误设计方式是在表内增加一个版本字段和生效字段,每次指定版本或者使用生效版本数据进行操作。如果遇到版本数据涉及多张表,或者遇到回滚、预览、再次编辑等操作,很容易产生版本错乱的bug。

推荐的做法是,生效的数据用专门的表存储,历史版本数据用另外的表存储并带上版本号。生效的数据是对外提供功能需要的数据,需要仔细设计和保障;而历史版本数据主要用于管理,关注的用户比较固定,应当与生效数据隔离开,如果数据模型比较复杂,那么历史版本数据也可以一个JSON将数据全部存下来,也不会有问题。

多站点设计

如果建设的通用平台系统,那么多站点设计比多环境设计更加合理。

这是当前很多企业受限于自己设计的技术架构而不曾关注的点。如果某个应用依赖某些平台型的应用(例如Devops、开放平台、数据中台等非中间件应用),那么在研发流程上,通常的做法都是平台提供测试环境和生产环境,分别供应用研发过程中对接自身的测试环境和生产环境。这种方式的问题有三点:1)要专门设计平台测试和生产环境的通路,实现应用研发过程中的平台配置的发布流程;2)平台自身的研发过程和应用研发过程是不同步的,这就导致应用对平台在各个环境的稳定性要求特别高,平台可能要耗费更多精力在测试环境维护上,测试环境不在具有测试的意义;3)一旦应用有多环境的诉求,例如预发环境、AB环境等,那么平台就要实现更复杂的环境对接逻辑。

更好的做法是平台只提供生产环境供其他应用对接,并且在生产环境上划分不同的接入站点,诸如测试站点、预发站点、压测站点、生产站点。不同的站点除了系统配置有所区别、站点集群规模有所区别外,每个站点完全一致。这样平台自身的测试环境仅用于自身测试,应用的测试直接接入生产站点,不再和平台的测试环境绑定。这样做的优势是保证依赖的平台环境没有差别,减少上线的未知阻塞点;平台也可以根据不同站点进行充分的灰度测试。

本地容灾降级

外部依赖的可用性会影响到自身应用的可用性,去除非要依赖的强依赖模式。

如果是有状态的应用,集中的存储组件(如Redis、数据库)可能是必要的依赖,对于无状态应用,经过产品和代码的合理设计,可以在灾难时无依赖运行。去依赖的设计包含手动和自动,都应当体现在代码里。

数据型依赖
如果应用依赖其他组件提供数据(例如配置数据、用户数据等),那么可以考虑将所有数据或访问过的数据缓存在本地内存,当依赖组件故障时,使用本地最新缓存。理由是依赖组件故障期间数据不太可能发生变化,未访问过的数据在故障期间也大概率不会被访问。
权限型依赖
如果应用的流量依赖其他组件识别是否可以放行(例如安全策略、用户权限等),那么可以考虑将曾经查询过的策略缓存在本地,如果缓存没有命中,那么就直接放行。理由是故障期间流量特征是稳定的,很难碰巧遇到新的流量攻击。
指令型依赖
如果应用依赖外部组件来改变自身的运行状态(例如配置中心中间件、应用配置表等),那么可以考虑三层降级:中间件 -> 本地接口 -> 本地配置文件。指令推送优先走中间件,如果中间件故障就请求集群所有节点的接口,如果节点无法登陆就降级到本地配置文件重启计算节点和应用。
处理型依赖
如果应用依赖外部组件完成业务处理(如事务提交处理等),那么可以考虑三层降级:外部依赖 -> 集中存储 -> 本地文件。优先提交到外部依赖,如果外部依赖故障就提交到集中存储,如果集中存储故障,就保存到本地文件,等待依赖恢复时,分批次提交处理。这种故障本质上无法处理流量请求,选择这种降级方式有赖产品的设计。

应急方案

应急应当首先考虑如何对流量操作,例如切流、摘流、限流等,其他方案通通不是最佳手段。

有些企业或部门对应急要求很高,包括编制详细的应急步骤(恨不得细化到鼠标的点击位置),定期举行盛大的演练(究竟是演还是练),妄图依靠故障时的按部就班来实现应急处置,这种期望往往收效甚微,不符合人性。故障突发时就是要争分夺秒,大家七嘴八舌,面对复杂的应急方案没有人不会慌张。应急处置就要“一针灵”,一步就到位,满足这种要求的只有流量操作。通过监控各种流量的指标统计,不仅故障容易定位,应急操作也非常简单。

因而在代码设计时应当要考虑突发故障的应急策略,要努力向流量操作的方向靠拢。如果是大流量应用,那么通过监控很容易识别故障;如果是流量不大、但是又很关键的应用(比如小企业内部),那就必须要人为增加流量的频率(例如拨测)。笔者在从业经历中,有些人会有下意识的迟疑,应急方案会考虑重新发布等看起来也很简单的操作,这种迟疑不应该有。笔者在上文中反复强调要按流量设计,要识别流量的特征,要对不同的流量打上不同的标记,正是基于最终应急的考量。

写在最后的话

代码的价值体现在流量上,而不在设计上。

代码本身没有价值,只是为业务服务而已,该换就换、该扔就扔。有使用量的代码即使再垃圾,也依然比没有用量的代码更有价值。在代码设计上应当从轻从快,尽快接住市场提供的流量。程序员们也应当走出“技术追求优雅”的象牙塔,不要陷入DDD/TDD/Serverless等各种技术概念,少一点套路,多一点真诚,将更多的视野放在市场和业务上。

评论
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

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

抵扣说明:

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

余额充值