引言
本文首要目的是为基于CWAP框架的应用开发者(基于CWAP架构的TS系统下)提供可供参考的文献资料,特别针对当前正在开发Trading Support系统的开发者。本文将指导开发者如何写出具有艺术气息的优美代码——一个结构整洁清晰、功能完整稳定、缺陷较少、可扩展的构件(模块),例如ts-s-session各类型市场场次日期统一管理模块、ts-u-fcs报价行情流量控制模块(支持每秒万次报价簿消息,数万笔报价行情)、ts-s-jdbc数据库操作模块和ts-s-ric中心统一编码模块等;其次本文的重要目的是概括说明CWAP构件设计原理和应遵守的设计原则,并指出构件设计要解决的主要问题,以及构件开发过程应遵循的方法论和技术路线,以指导具体实践工作;最后本文的也是对自己 2017年所做的架构工作进行一番总结,以帮助自己梳理工作思路,继往开来,砥砺奋进。文中啰啰嗦嗦,杂糅了大量自己的工作反思、编程经验和人生感悟,请阅读者包容海涵。
从价值取向上讲,本文不会告诉开发者:NULL在判断时到底是应该写在判断条件的前面还是判断条件的后面,这种代码优化类书籍和博客经常会鼓吹到的开发建议。(ps :高能预警,本文信息量很大)
作为一个有想法的架构师,不求你理解我,只希望团队能够形成一致的愿景和展现出强大的凝聚力。作为一个有态度的架构师,不求为人圆润光滑,但求团队所有人包括自己在做事的态度、能力、实践过程上始终和结果相匹配,步调保持一致。
想要理解如何写好一个构件模块,首先必须弄清楚,正在开发的构件设计和开发的最终目标到底是什么。一般意义上,一个组件模块追求的技术目标是高内聚,低耦合,可扩展,易理解,但是这个目标过于形而上,落实到具体的实践过程中往往缺乏依归和遵照。本文重点强调的则是尽可能的以业务为中心,去贯彻和落实DDD的设计思想和原则,通过领域模型表达业务需求和业务规则。这要求开发者必须首先尊重业务,逐步了解业务,深入业务的细枝末节,通过独立思考和在与需求人员不断沟通过程中,将心中对需求的困惑逐个解开,然后在自己的大脑中形成一张比较清晰完整的业务逻辑图谱。了解业务的最高境界就是能够对未来业务发展的走向做出合理预期,并且在代码层面为这个预期预留好空间(这个要求有点高,必须依赖开发者对业务的思考和洞察),例如每家机构的交易量会达到多少,是否要在代码层面预留位置可以插入必要的特殊业务逻辑,以支持满足未来可能出现的频繁交易的特殊机构的特殊业务需求,需求的细微不同可能导致不同的建模方向,以成交为核心建模还是以用户为核心进行模型建设。
当然编程最基本的是必须优先确保代码可读性,没有可读性,就没有可维护性,可读性是代码功底的真正体现。没有可读性代码的传承和发展就无从谈起。开发者不可能一直都在写同一个业务模块,反过来一直在写同一段业务逻辑的开发者必然也不会有大的进步。当开发者离场之后,业务仍然要正常运作,需求要不断调整,所以必然需要有后继开发者接手并继续维护遗留的代码模块。如果你的代码没有任何可读性,只有一种结局,那就是被推倒重写。后继开发者会在背后吐槽前任的代码,而前任可能并不知晓,如果未来两三年之后,你听说自己过去写的狗屎代码被别人完全扔掉重写了,你会是怎样一种心情呢!要想不被骂,只能在走之前,尽量擦净自己的屁股,当然要擦净自己的屁股,并不是一件容易的事情。开发者可以是贾跃亭,不去主动承担责任和义务,但对于项目团队来说必须有方法和机制保证整体的利益和稳定,也就是领导者无为,而制度有为。这就要求构件的开发必须遵守最佳实践方法,也就是采用面向DDD编程。即便开发者对DDD仍然似是而非,没关系,你只要愿意尝试用面向对象的思维方式思考问题,解决问题,就会慢慢掌握DDD的真谛。DDD本身绝非只是一种设计思想和方法论,它可落地可实施,存在清晰、可约束的框架范式和基础设施(欲了解,请参考另两篇文章:《开发即设计》和《事件统一框架和持续开发体系》)。
本文开题立意有点大,其实并不大,正所谓小中见大,管中窥豹,希望你能从阅读的过程中,慢慢咀嚼,慢慢体会到:架构师或者设计团队通过识别业务合理划分之后,将一个小小的模块交予你手之后,你需要通过怎样的努力让一个很小的模块经历风雨,淬火重生,烨烨生辉。构件的生长(演化式架构的最小演化单元)过程如同一颗小树苗经历风吹雨打,烈日寒冬终成参天大树,成为整个系统的脊梁柱(构件的蜕变过程)。不积跬步无以至千里,人与人、代码与代码的差距是日积月累表现出来的,只有经过积累和需求的不断挑战之后,构件才能发挥出其以不变应万变的能力。如果这一蜕变过程能够通过组件发布的release note表达出来,构件的成长过程能够被所有关心的人观察到,岂不是棒极了。
构件设计的动因和构件开发者应秉持的心态
构件设计的原因也是构件设计的目的,正是为了给开发者呈现面向对象的可实施的路线图,为设计者提供及时了解开发情况的必要窗口,为业务和需求人员提供可以与技术人员无缝衔接的统一语言,同时又不会大大增加开发者和项目管理者的负担,通过设计为项目建设提供基础的支持,从设计和开发制度层面确保项目建设的质量水平。
“我特喜欢郭德纲说的那句话,就是无论什么东西,都是门在山下,你要真正入了门你发现无止境。那个特别可怕,如果你渴望去学习更多,了解更多的话,你的一辈子都不够,你花费了所有的精力,你都觉得不会满足。”
下文所指的规范主要是模块内编程规范,模块间设计规范将会在另起一篇拙作讲解。作为构件的开发者,希望你能保持如下心态:
- 保持好奇之心,夯实基础。stay foolish ,stay hungry。愿意花时间钻研技术难题和技术细节,专注眼下,躬身而为,不要好高骛远,让自己站在坚实的技术和业务知识围合的底座上,而非总想寻求巨人的肩膀。
- 精益求精,不要随便。开发者必须对自己的代码编写的质量有很高的要求,这个要求应该比项目本身所需要的要求更高,这样开发者才能从项目中获得更多收益。项目的要求往往都是基本标准,大部分人能够实现的标准,所以如果要超越大多数人,开发者就必须对自己有更高的要求。开发者在项目中应该能够主动推动自己、同伴以及其他项目组的人员协同自己合作解决工作中遇到的各种挑战和问题,这样你就有可能成为一个有协作经验的架构师。总之一个人做事的时候,关键不取决于你自己有多牛、多能干,关键取决于你能调动多少资源来帮助你做成这一番事业。一般来讲,软件工程师的发展方向:匠人和架构师,管理除外(任何角色都可以向管理努力,但没有天赋做不好管理)。
- 乐于分享,舍得付出。舍得花时间为项目组排忧解难,为其它开发者提供建议和必要的支持,为需求和测试人员讲解业务知识,提供必要文档资料,有主观能动性,主动在业余技术活动中为各种社区(Github Apache SegmentFault等)贡献问题和解决方案,包括代码和经验。
代码复杂性的根源
代码复杂性和性能问题是构件实践过程中要解决的核心问题,其中代码复杂性问题尤为突出。
代码复杂性首要来源是业务的复杂性。对于只有一个业务的简单场景,一个类捅到数据库(用servlet+datasource实现)无疑是最佳选择,代码易读性很好,业务实现也比较简单可靠。但大部分的系统都是从单一业务开始的,随着时间的推移,需要支持的业务越来越多,越来越复杂,各种难以实现的业务需求必须由业务人员和开发人员去权衡利弊,相互妥协,于是代码里面开始出现大量的if-else判断逻辑和贯穿不同业务模块之间复杂的调用关系,先前的几个类早已不足以表达清楚代码的逻辑,这个时候代码就会开始变得越来越混乱,越来越难以维护和发展,对于精明的开发者来说,这个时候应该有一个强烈的信号,必须花点时间和功夫,切出一个新分支,开发重构自己代码。目前流行的Spring MVC为开发者提供了一种技术层面的半成品解决方案,controller+service+dao是绝大多数java初级开发者基本上都能够掌握的开发框架(即便真实的开发过程中,仍然有很多开发人员不能很好的遵守和运用,例如在bean对象中直接书写操作数据库的逻辑,或操作同一数据库的同一张表的数据逻辑被分散在很多类中)。Spring的规范是一种缺乏强约束的规范,并不能够真正解决复杂业务场景下,代码逻辑的混乱的问题。庞大的代码越写越复杂,难以维护和阅读,所有的bean对象都被一个巨大的容器统一管理,在业务边界变得更加模糊之后,任何一点业务逻辑的调整都可能带来更多新的缺陷。如此一个灾难性的巨大单体应用(巨婴)就诞生了,它非常脆弱,缺乏有效的免疫机制,任何一点变动都可能要它性命。尽管所有开发者都在向工程谨慎小心的提交代码,也都遵循了MVC的范式要求,但是业务的复杂性已经将controller层、service层和dao层冲击的支离破碎,层中层和循环调用现象不可避免的产生。这时开发者不得不小心翼翼,花费更多精力去组织和调整类和类、方法和方法之间的关系,以确保达到设计要求。这时设计和规范已成为开发者的巨大的包袱,压的开发者喘不过气来,气氛高度紧张,工作精力全部被耗在这方面了。更可悲的是,项目工程经历了各种测试(ST、UAT和SIM)和缺陷修复过程之后,巨婴终于达到了一个临界的稳定状态,项目经理和技术经理精疲力竭,并且已意识到任何一个新增的业务、技术需求或变更都很可能成为压垮这头骆驼的最后一根稻草,唯恐避之不及,诚惶诚恐。
所谓贫血模型,是指Model 中,仅包含状态(属性),不包含行为(方法),采用这种设计时,需要分离出DB层,专门用于数据库操作。所谓充血模型,是指Model 中既包括状态,又包括行为,是最符合面向对象的设计方式。
面向数据和过程的开发范式无法应对复杂多变的业务场景是代码复杂性的内在成因。面向数据和过程的建模方法无法应对日益复杂的业务场景和业务逻辑。面向过程的核心是事务脚本(围绕功能,以功能为中心)。事物脚本将所有逻辑组织在一个单一过程之中,与数据库同步交互,每次业务请求都有自己的事务脚本,并且入口一般是一个类的公共方法。绝大多数的程序员掌握的编程范式就是应用事务脚本编程,熟悉的架构正是三层架构(一种说法是UI、BLL、DAL,另一说法是MVC)。编程的时候业务逻辑代码混杂在DAL层,致使数据访问层充斥着大量孤立的业务逻辑,难以复用,即便复用,也只是抽出公共方法,以供多处调用,而且很多开发者会把公共方法定义成静态方法(static),每个DAL中的类就像一个单元,只为某一功能实现,也就是上面所说的“单一过程”,因而业务逻辑都实现在数据访问层了,真正的业务逻辑核心层就成了一个空架子,业务对象就成了贫血模型,完全变为数据结构体。这种设计非常糟糕,其实绝大多数时候,开发者都是操着面向对象的语言干着面向过程的勾当。面向对象本质上不单指编程语言,更重要的是开发者解决问题的思维方式。这种思维方式强调开发者在解决业务问题中,真正利用从现实世界中观察到的模型或者抽象出的概念去解决业务问题。思考时应从这些抽象出的模型中筛选出和当前业务场景匹配,满足当前或未来业务需求的抽象模型作为程序编写的目标,最终依据所选的抽象模型编写面向对象的业务代码,并不断强化和完善这一模型。OOP强调把数据(属性)和操作(服务)结合为一个不可再分割的系统单位(即对象),对象的外部只需要知道它做什么,而不必知道它如何做。对象的属性和服务结合为一个不可分的系统单位,并尽可能隐蔽对象的内部细节。譬如以TS报价行情切片机制为例,就需要我们根据业务特点,借助现实世界的某种模型进行匹配和抽象,最终经过多个方案讨论和实践之后,确定了运用“回转寿司模型”进行报价行情切片机制的建模的方案。
秦人不暇自哀,而后人哀之;后人哀之而不鉴之,亦使后人而复哀后人。
开发者责任缺失和代码编写过程中随意而为是代码复杂性的外部成因。开发者在代码编写过程中往往不能顾及到他人感受,自以为是,随心所欲,为所欲为,缺乏监督和审查机制。在缺少框架支撑,没有硬性规范约束和代码复核机制的情况下,代码的质量无从保证。这里规范和约束非常非常非常的重要(重要事情说三遍),但也是最容易被无视的点,甚至是有些开发者已经形成思维定势,拿着自己不知从哪些歪门邪道的编程书籍或者博客学到的编程观点来肆意破坏开发约定和规范。其结果就是导致整体架构的consistency被严重破坏,代码的可维护性急剧下降,后人永远在为前人擦屁股,在吐槽前人写的代码,架构本身形同虚设,国将不国。无规矩不成方圆,规范绝对不只是类、变量和方法的命名问题,也不只是分包问题,规范是一种代码编写范式,是一种跳脱出前人悲剧的防范机制。主要目的是帮助(了解这个基础规范和规则的)开发者,以一种既定的路径阅读、掌握既有模块包含的设计信息并持续开发,通过Code快速的理解业务,定位问题,并找到可以执行的变化点和扩展点。所以说,开发者必须对自己编写的代码承担应有的责任和义务,责任是确保代码的规范和质量,义务是整个团队对你开发的组件模块并允许集成到整体项目中的认可和激励。开发者必须有大局意识,否则就极有可能沦为平庸的工程师。而平庸的工程师在不久的将来一定会被AI技术替代掉,毕竟作坊式的开发体系是阻碍人类进入信息智能社会的最大障碍。
技术架构层面和代码管理层面缺乏约束机制,导致劣质代码疯狂横行,软件整体日渐崩坏。没有严格遵循TDD的开发范式,没有单元测试和回归测试,尤其是项目基本功能上线后,代码重构阶段和代码维护阶段,大量低质量代码的提交,未经过充分验证,甚至遗漏掉第三方测试的代码很可能导致系统缺陷激增。理想很丰满,现实很骨感,规范的执行是个大问题,最好能在架构层面进行约束,例如在我们架构中,定时任务必须继承某个统一父类,你不继承,就不能实现定时任务功能。但是架构的约束毕竟存在于技术开发和演化过程中,在架构不是很完善的时候,很多约束细节无法通过技术手段做到,例如在src/main/resources下面不应有log4j.properties文件,测试代码应该写在src/test/java下面,不要写在src/main/java里面,通过不断的提醒开发人员去改正是远远不够的,必须通过技术手段去扫描交付的代码,并对问题形成报告,以警示开发者,最重大的警告就是禁止开发者提交的代码合并到主仓库中。但是更多的约束还是要靠Code Review来检查,因为所有工作都交由技术来实现,意味着开发技术的成本和工作量会陡增。总之,应对架构约束进行近似严苛follow,确保了系统的consistency,最终形成了一个规整架构体系和保证交付高质量的源码。
项目进度和项目管理压力是破除代码复杂性的最大障碍。企业领导往往在项目发展过程扮演既想要熊掌又想要鱼的贪婪者,然而在面对棘手的问题的时候又往往揣着明白装糊涂,毫无同理心的责怪下属的无能。经常会眉毛胡子一把抓,甚至责怪下属的无能,但是这就是一个有为的领导,只有这样才能将项目推进下去,因为领导本身是没有能力直接帮你解决问题的,问题的解决只能靠下属,领导能给你的是资源。作为项目经理不应跟着领导屁股后面拍马屁,见风使舵,而不清楚事情的轻重缓急,领导指导哪儿,就做什么,最后只能搬起石头砸自己的脚。项目经理必须考虑清楚对领导负责的前提是对自己所管理的项目负责。其必须弄明白什么是项目要达成的首要目标,什么是次要目标,在什么节点完成哪些工作任务,安排什么人在什么时候做什么事情,并且在什么时候验收工作的具体进度。在大多数情况下,项目前一步完成的事情为后一步起到了铺垫作用,后一步做的事情很可能推倒前一步所做的努力,但是这就是项目最真实的面貌。项目经理关心的问题往往受制于领导关心的问题,导致项目经理无法将资源集中到关注代码质量工作上。项目进度和经济资源一样是相对稀缺的,在以外包开发商为开发主力的公司,优质的开发者同样稀缺,在面临这些困难的情况下,往往代码的管控就会松懈,代码的质量就难以保证,如果所有项目都是这样的情况下推进,工作反复会很多,问题缺陷会不断,最终项目绩效不好看,人员离职率也会增加,于公司来说,自然也不会有什么积累,公司的人才也只能是墙头草,见风就歪,野蛮生存。
今天偷懒没写的代码,迟早都是要还回来的,只不过是由你还,还是别人还的问题罢了。
项目管理者和开发者沆瀣一气,随波逐流的人生态度和慵懒堕落的能力导致代码永久腐败。项目管理者和开发者的心态是经常被忽略的一点。坏的心态例如,对他人开发的代码漠不关心,即便自己必须call别人开发的代码,也视而不见,置若罔闻;或者项目管理者只关心项目的进度,对代码和需求开发进度的整体掌控完全依赖于自觉。最终导致大家一起沉沦,没人再关注代码的质量,项目建设的稳定性也就无从谈起。用一个更恰当的词汇表达这一现象,就是公共地悲剧。我们虽然已经观察到别人代码开始有坏味道,负责任的开发者闻到后应帮助指正或者亲自动手修改,大多数开发者往往采用随波逐流的态度,既不帮助错误者纠正代码的不合规之处,反到在别人坏味道的基础上继续编写自己的代码,默认这样的局面。久而久之,系统就变得复杂、难以维护,永久腐败。归根结底,开发者的自我堕落无疑是导致软件复杂性的终极成因。如果人人都能做到先之以身,后之以人,以身作则,肯花功夫锤炼自己的代码,积极修复别人的代码,那么我们相信软件的复杂性根本上都不是问题。历史潮流浩浩荡荡,如果开发者不能做到自省,那么只有一种命运就是遵从行业的命运,在行业兴起的大潮中可能滥竽充数,在行业逐渐走向夕阳的大潮中原形毕露,最后自食其果。
CWAP架构内涵
面对软件的复杂性,我们必须有所为(设计和开发技术架构:CWAP),有所不为(设计和实施更严苛却无意义的人工审核流程)。
软件的本质是知识工作,而软件开发的过程,就是开发者学习与成长的过程,软件建设过程的重要性是要远大于结果的,这一点和工业化时代的工厂模式是截然不同的。急于求成、好高骛远,只会让软件更早的停止进步,并拖慢软件建设整体进度。
分型构件架构是CWAP的真谛。CWAP框架由S(Support),U(Unit),DP(Dynamic Process)和DW(Dynamic Web)四种类型的工程的组合体,这一点和SpringBoot/SpringCloud体系完全不同。SpringBoot是一个可使用Java构建微服务的微框架,SpringBoot的基本单元是进程,而CWAP的基本单元是构件,进程可以独立运行,而构件必须与其他构件一起协作才能提供服务,从思路和理念上讲,两者都是倡导大家尽量将功能拆分,将服务粒度做小,或者实际上粒度更细,但前者使之可以独立承担对外服务的职责,后者从部署成本角度看,还是强调尽量复用进程资源,把内聚的业务放在同一个进程中,减少部署和测试成本。2018将借鉴SpringBoot经验,将使每一个构件模块可以支持单独部署,以更好的为测试和集成服务。但总体管理角度来讲,CWAP较SpringBoot有优势,对于架构师来说重要的是可控程度更强,以进程为粒度直接交给开发者去开发功能避免不了协作冲突的问题,进程往往可能需要多个开发者协作,而特性团队难以真正建立,会导致这种弊端的爆发,协作过程中无形增加了成本,所以以构件为粒度是协作的最佳粒度。
标准化生产和交付服务。CWAP提供了两种标准和规范的交付Java工程的形式,定义了规范,这里DP和DW工程起到了组装和管理所需S和U构件工程以及必要的第三方工程的职责。从功能看DP和DW工程都能满足B/S架构所需技术要求,DP工程是基于自研的cwap-s-daemon的微服务轻量内核,通过插件机制(Plugin)提供应用扩展的架构模式。DP工程是由CWAP团队按照组件化架构的风格创新开发实现,可约束和可定制性都非常好,较依赖于Tomcat或者Jboss容器的DW工程相比,更加灵活和方便,而且整个工程结构更加规范,出问题的几率更小,排查问题和解决问题的难度都更小,在此推荐使用DP工程。DW/DP工程的整体粒度范围介于Monolith服务和Microservice微服务之间。
约束力、掌控力是CWAP框架核心力量所在。框架在设计和开发时优先应重点关注约束力和掌控力的获取,这才是企业架构的重点所在,目的是确保开发人员按照一致的规范进行协作,提供多种技术可能性在目前阶段绝非CWAP框架的重点。企业架构,开放你永远开放不过开源工程,能在第三方开发工程做开源开发,谁会在开源架构上做开源呢。应该将模块与其它模块的交互完全交由主框架统一维护和控制。为开发者提供必要的技术手段,又尽量避免开发者滥用这些技术,典型的滥用场景就是Spring的@Transactional注解,开发者经常性的会对所有的Service类中的函数都带上该注解。
配置文件的配置和管理是CWAP框架的精华之一。框架在设计和研发时,就考虑到了所在公司技术开发和交付的实际情况,并针对项目组之前实际工作过程中遇到的配置文件杂乱无章,随意放置,环境配置信息与业务逻辑信息杂糅在一起,没有明确的配置项使用说明,导致后期软件的维护和升级成本极高,所以框架在初始时就明确了去配置化方向,约定优于配置(Convention Over Configuration),也就是去掉不必要的配置,只保留真正可能会被修改到的配置信息,这些配置信息主要是环境变量。SpringBoot去配置化本身对于开发者也是有利的,开发者不再将关注点分散到如何实现各种功能配置。配置化导向会促使代码逻辑写的非常复杂,难以阅读,必须结合配置信息才能理解代码本身的业务含义。对于每一个模块中的“功能开关”,则通过默认不需配置的配置项进行控制,例如构件中的Watcher类,一般默认都是开启的,只有在特殊情况下,才需要配置到配置文件中。
正常书写Java风格的代码,书写有创造性的代码,书写高质量的代码,书写高性能的代码,书写符合业务需求的代码,不必关心技术框架的存在与否。刚接触CWAP开发框架代码的开发者可能不习惯于框架为什么和Spring开发风格非常不同?为什么Spring不建议,现在就可以根据自己的需要创建类(当然对于掌握DDD的开发者来说,并非随意)?为什么不建议使用Spring容器管理业务的Bean?为什么不要求必须使用Spring的注解风格编写代码?
组件化开发组织形式是基于CWAP框架开发的团队协作的精髓所在。团队人员规模增长,会带来两方面的挑战:代码量非线性增长和组件依赖复杂度。业务逻辑在多人并行开发情况下,是一个巨大的问题,业务逻辑的复杂性很可能体现在组件之间交互的复杂性,代码量的增加又让后续任何修改和新功能的实现变得越来越困难,增加人数成为唯一选择,但是又会让问题的变得越来越复杂。通过模块化封装软件的复杂度,最后通过搭积木的方式组装,组装让协作变成可能。软件模块化设计在复杂应用中是必须的设计方式。构件化和模块在架构层面会面临如下问题:很多大型团队在进行模块化设计时过度追求技术上的解耦,前期能够画出一张漂亮的系统模块图。但随着业务的持续变化和演进,这样的模块化就成了累赘,没有起到降低复杂度的作用,最后反而成了代码在各个模块重复的根源。通过模块化,并且为每个工程创建一个独立的仓库,TS彻底解决了如下痛点:频繁的代码冲突及合并问题,多文件原子提交及回滚问题,跨团队代码共享和依赖管理问题。重要的是整个开发过程中不需要维护专门的代码合并人员。开发者对自己的负责的构件以及对外的接口负责,在架构允许的前提下,通过接口与其它构件开发者进行协同。
构件类型U、S划分为构件体系整体健康生长提供可能。 CWAP框架开发的系统是由一个个包含具体业务或者技术功能含义的u或者s构件组成的,也就是每个构件的职能和边界是明确的,设计原则是:u调用s,u调用u,s调用s,禁止s调用u,避免出现循环依赖。U工程体现了系统能够完成什么业务功能,S工程则体现了系统具有完成什么功能的能力,为业务赋能,显然S工程是整个架构体系的支柱。但并不意味着通过U和S进行层次的划分,U和S只是体现了构件能力和定位的不同。构件之间的依赖关系通过Maven的GAV来维护。
分层概念天然体现在业务逻辑之中,无须刻意刻画,避免过度分层设计是CWAP框架另一精华所在,所以构件之内不要刻意分层,重点关注各个类和对象的作用。这一点和传统架构思想是完全不同的,虽然构件之内仍有分包设计,但是并代表分层。俗话说的好,All problemsin computer science can be solved by another level of indirection(计算机科学领域的任何问题都可以通过增加一个间接的中间层来解决)。分层最大的好处就是关注点分离,让每一层只解决该层关注的问题,从而将复杂的问题简化,起到分而治之的作用。我们平时看到的MVC,pipeline,以及各种valve的模式,都是这个道理。但不是层次越多越好,越多越灵活。过多的层次不仅不能带来好处,反而会增加系统的复杂性和降低系统性能。就拿ISO的网络七层协议来说,你这个七层分的很清楚,很好,但也很繁琐,四层就够了。
- 开放接口层:可直接封装 Service 方法暴露成 RPC 接口;通过 Web 封装成 http 接口;进行 网关安全控制、流量控制等。
- 终端显示层:各个端的模板渲染并执行显示的层。当前主要是 velocity 渲染,JS 渲染, JSP 渲染,移动端展示等。
- Web 层:主要是对访问控制进行转发,各类基本参数校验,或者不复用的业务简单处理等。
- Service 层:相对具体的业务逻辑服务层。
- Manager 层:通用业务处理层,它有如下特征:1) 对第三方平台封装的层,预处理返回结果及转化异常信息;2) 对Service层通用能力的下沉,如缓存方案、中间件通用处理; 3) 与DAO层交互,对多个DAO的组合复用。
- DAO 层:数据访问层,与底层 MySQL、Oracle、Hbase 等进行数据交互。
- 外部接口或第三方平台:包括其它部门RPC开放接口,基础平台,其它公司的HTTP接口。
TS系统实际需要的层次:
- Interface层:Controller,REST,Job,Handler,Provider…
- Domain层:核心业务写模型和读模型
- Plugin层:Service,Handler,Builder…
测试代码主要针对Domain层进行测试。
根据业务来划分构件和模块,禁止基于技术和分层设计来划分构件和模块,尽量将业务域划分细致,严格确保模块的边界。形成正确、敏捷的代码管理机制,小步快跑,变长痛为短痛。
模块间禁止通过Redis等共享缓存或者数据库表等数据中间件共享数据。缓存只可以在模块内缓存,这一规定和技术约束将极大的保证缓存数据的可靠性和架构的整洁性,避免数据互相打架,以及降低未来需求不断变化过程中,可能导致的对之前缓存不清楚,而产生的各种坑,因多个业务的耦合而导致数据在某些条件下发生为问题。
构件设计
构件愿景和命名
公司有公司的愿景,个人有个人的职业愿景,项目有项目的发展愿景,构件亦有构件设计和开发的愿景。任何一个持续成长的公司最终都要解决系统、组织和流程的扩展性问题,任何一个有着良好规划的构件,开发过程中都要逐步解决功能需求、代码质量、性能需求、稳定性、复用性和扩展性和分布式等问题。
自古纨绔少伟男,自古雄才多磨难,这句话之于构件开发过程一点也不过。
愿景就是目的,构件愿景就是对构件需求、设计和开发过程以及未来应用前景的设计和规划。它是对构件未来的生动描绘,是一个一揽子计划,定义了构件的理想状态和可以度量的实施标准。通常来说,构件的愿景都是由拆借构件的架构师来赋予的,但是优秀的开发者往往能够在构件的开发过程中大大超过架构师的预期,极大提升构件本身的价值和地位。
总体来说,一个功能模块的开发过程分为三个阶段:
- 满足业务功能需求和特殊规则约束的MVC过程化初级阶段(国家资本主义阶段,此时的组件只能称之为初级的功能模块或组件模块)。
- 已经逐步剥离和抽象出核心业务逻辑,借助设计模式凝练了核心代码,并且实现读写分离,添加有效缓存的性能优化过渡阶段(社会主义初级阶段,此时的组件可以称之为完整的功能模块或组件模块)。
- 形成由聚合根、实体和值对象等领域概念构成的领域模型,完全内存化运行业务逻辑,事件驱动模型,性能和功能都达到极致稳定的高级阶段(共产主义阶段,此时的组件因能够满足业务快速变化的要求,称之为真正意义上可复用的构件,这里的复用是指通过有限的修改和扩展,且不影响到整体的稳定性的情况下,就能支撑变化的业务需求,那种所谓性能越高,代码越脆弱的狗屁逻辑,这里也就不再辩驳了,因为他和共产主义精神是背道而驰的)。
明确了构件愿景之后,就要为构件提供一个辨识度非常高的名称,例如ts-s-tradinghour,而范例就是ts-s-common。鬼知道ts-s-common是什么鬼东西,TS系统竟然真有这种鬼东西。一个缺乏架构设计能力或缺乏基于整体考量的开发者就可能命名出这种糟糕的构件名称。
读写分离和补偿机制
这里用大量的篇幅去讲读写分离,开发者在编写代码过程中无论多么重视读写分离都不过分,重要的事情说三遍,重视读写分离,重视读写分离,重视读写分离。读写分离不单单是指数据库层面的读写分离(用于极大程度的缓解X锁和S锁争用),也不仅仅为了性能优化,更重要的读写分离为优化代码,抽象真正有价值的领域模型提供了可能,这样开发者就不必,非常着急一下子就尝试进入共产主义,可以一点一点的让每个模块逐步进入最佳状态,所以说读写分离是一个过渡状态,中间阶段。
读写分离是一种重要的思想,小到一个具体的集合是否需要读写分离CopyOnWriteArrayList或者CopyOnWriteArraySet;中到识别出一个模块中那些类和方法是业务过程中的读过程(Read Proces),哪些过程是业务逻辑中的写过程;大到整个服务层面实现读写分离,例如TS的做法划分查询服务(DQS DPS)和核心处理服务(CSS FCS JSS),以及介于两者之间的(AMS MC)。
这里重点强调的是并非只有读多写少的场景才适合读写分离技术。开发者在编程时应借鉴CQRS的理念去编写程序(在技术框架上目前尚未支持CQRS的情况下),将读写模型分开,将写逻辑中的前置或者后置查询逻辑和纯粹的读过程分开,一般意义上的读逻辑就是用户查询统计信息,用户查询消息等操作。读写分离能够避免不同操作类型的代码耦合,为后期领域化建模,核心业务逻辑的维护,尤其包括添加缓存、调整业务规则、修改业务展示模型等带来方便,避免任何小的改动都导致大的问题,甚至产生巨大的隐患。
开发者必须在编写模块代码的最初阶段就应该分清楚读和写的代码,最好将读和写隔离在不同的类中,例如WriteService(Update or Save or Delete)和ReadService(Query),其中WriteService单独维护针对写过程的读方法,不要怕代码和ReadService的代码的重合,因为后期随着性能的优化,这两段读操作会变得显著的不同。
从数据层面讲,读写分离是为保证数据库数据的一致性,我们要求所有对于数据库的更新操作都是针对主数据库的,但是读操作是可以针对从数据库来进行。大多数站点的数据库读操作比写操作更加密集,而且查询条件相对复杂,数据库的大部分性能消耗在查询操作上了。主从复制数据是异步完成的,这就导致主从数据库中的数据有一定的延迟,在读写分离的设计中必须要考虑这一点。以博客为例,用户登录后发表了一篇文章,他需要马上看到自己的文章,但是对于其它用户来讲可以允许延迟一段时间(1分钟/5分钟/30分钟),不会造成什么问题。这时对于当前用户就需要读主数据库,对于其他访问量更大的外部用户就可以读从数据库。我们通过适当放弃一致性,实现了负载能力的提升。在一些实时性要求不高的场合,我们适当放弃一致性要求。这样就可以充分利用多种手段来提高系统吞吐量,例如页面缓存、分布式数据缓存、数据库读写分离、查询数据搜索索引化。在面向事务脚本的开发方法中,写的一端需要保证事物,所以一般数据存储为第三范式,读的一端一般都是反范式可以避免Join操作,这样我们只需要把数据存储为第一范式。大部分的系统里写数据要远远少于读数据,并且一般都是每次修改很少的一部分数据,所以在写这端扩展都不是特别紧迫,读数据基本都远大于写数据的次数, 所以扩展就更重要。 我们很难建立同一个Model 既能给写数据和读数据公用而且能够保证性能都比较好的实现,所以读写模型应该分离。查询端由于只是读数据,那么所有的方法应该都是返回数据,而且返回的数据就是界面直接需要的DTO, 这样可以减少传统的方法中把DomainModel映射为ViewModel或者DTO,这样的层级间转换,从而大大提升性能,同时可以减少传统的领域里的一些混乱。写端由于把读分离出去,所以我们就只关注写,那么我们写这一段需要保证事物,数据输入的验证,另外一般写这一端都不需要及时的看到结果,所以大部分都需要一个void方法就可以,那么让我们系统异步就更加方便。这样使系统的扩展性大大增强。
ts-s-jdbc提供了数据库层面的读写分离功能。
应用层面的读写分离目前最佳实践就是CQRS。当我在一些系统中使用CQRS后,很多地方代码大大简化,比如我所有的写操作都是一个Command, 那么我定义一个UICommand, 让所有的Command集成这个,那么我可以在这个UICommand里做一些通用的处理,比如Validation,同时我只需要定义一个CommandBus, 然后把对应的CommandBus分发到对应的Handler里(我前面几篇有实例代码),那么代码的耦合度大大降低。由于读这一端直接读数据,而且对数据库没有任何操作,那么我们可以根据UI定义对应的DTO, 那么开发的时候我们可以用Mock数据,至于数据怎么存的,那么我们随后只要添加一层Thin Data Layer即可,实际上当我们使用CQRS后,很多时候我们把数据保存的时候都直接保存Denormalize的,那么从数据里直接查询单表的数据就可以拿到页面需要的数据,大大提升读取数据的性能,同时代码也会极其的简化,开发读这一段代码的开发人员甚至都不需要对业务有太多了解。
最后是补偿机制,当我们在写的一端尝试使用异步机制,尝试放弃事务时,我们最好的解决方案就是引入补偿机制,例如消息中心的补偿机制,为了性能,我们不可能一下子,向几万个用户同时发一条消息,并记录消息在用户的消息盒子中的各种奇葩状态,所以并根据这些奇葩状态,选择断线重推,隔日还要推送等需求。所以我们必须先向在线的用户推送消息,再异步向离线的用户推送消息,并对为推送消息的用户进行必要的补偿。
去层次化设计
去层次化设计的核心目的就是为了避免过度封装。然项目中要开发的构件很多,但是各个构件还是有一定的共同点的,就类似于MVC的分层结构,有model、view、controller、service、dao,辅助构件也是有个规范来支撑的,辅助的开发也是基于mvc的开发思想开发的,但是mvc不能完全满足现在的开发模式,因为辅助的页面和第三方js是写在各个构件里面的,构件和构件之间的调用也不能用mvc的思想来设计,所以就需要有特定的模式支持,这个模式是除了mvc(controller,model,service)模式,还有一些加载基础资源的设计,如注册基础资源(XXXXXComponent.java)、有辅助常量类(XXXXHelper.java)的设计、有提供构件引用的接口类(XXXXProvider.java)的设计、有给终端提供rest接口类(XXXXRest.java)的设计,还有辅助异常类(XXXXExcepion.java)的设计。MVC模式,这里就不多说了,主要包括controller,entity,service层,controller主要是与页面交互,处理http请求,使用@Controller注解, @RequestMapping 保证访问的地址能映射到具体的方法上;entity是实体对象层,包括表的实体对象和视图对象,如果提交给页面的数据就是XXXXVO.java,表的实体对象命名为XXXXPO.java;service层主要实现核心业务逻辑,事务控制也在这一层实现,数据库的增删改都在这里实现;
去中心化设计
有时候我们不得不利用中心化的技术,来实现去中心化的设计和管理。
团队的协作最好是去中心化的,而监管则是中心化的,这样才能形成最有战斗力,最小内耗的格局。这同样要求架构最好能够形成去中心化的格局,不要产生单点,例如有人喜欢用父工程统一管理构件的版本,这样虽然方便,但是却非常愚蠢,因为首先项目工作的重点是迭代,而不是最终发布和开源,所以通过技术手段确保每个构件依赖其他构件的版本是最新的,而非确保父工程依赖的构件是最新的。
数据字典、国际化等去配置化设计都是去中心化的设计,这样大家不再互相打架,连缓存和数据库最好都是去中心化的,也就是最终形成每个构件有独立的数据区域的格局。
构件设计和开发基础篇
模块设计和开发过程中,少不了要定义各种接口,为自己或者他人提供服务,系统集成要定义接口,前后台掉调用也要定义接口。接口定义一定程度上能反应程序员的编程功底。CWAP框架强调的就是面向接口和面向服务编程。一个模块与外部的交互,从技术管理角度最好应该由其它构件统一提供,不应该直接交由三方库来实现。
这一部分将介绍如何写一个构件中具体的代码。
组件的第一个类:资源注册类
资源注册类(XXXComponent.java,根据规范约定:XXX是构件名称的第三位英文名称)是一个组件模块编写过程中必须写的第一个类(First Class),也是框架定义的核心类(Core Class)。注册时系统进程启动之时完成的第一个初始化动作,所有模块的注册过程都是在启动阶段根据评估出来的依赖顺序进行初始化的,注册的主要意义是向底层各个业务和技术容器注册必要的事件、信息和行为。具体的说,注册类定义了组件在被加载到主框架、主容器中的对外行为和内部初始化行为以及业务扩展行为。
此类类似Guice的MainConfig类概念,通过编码形式而非XML配置形式将业务逻辑定义和配置在此,实现真正的即插即用效果,如果是基于XML,你不得不在引入或者删除一个组件时大量修改主的XML文件,这样做成本会非常高,也极易产生错误。CWAP只是用基于Spring基础框架实现了Guice实现的概念。
请开发者必须注意的是在模块的其它类中,除了Controller和Rest类不建议其它类使用Spring框架提供的概念和bean行为,尽管框架没有技术手段限制这种行为,除非把Spring框架代码从CWAP中全部剔除,当然这样的话就意味着完全要造一个新轮子,习惯了Spring主流开发方式的开发者可能无法快速适应新框架。
CWAP框架底层提供了资源的核心抽象概念Resource,资源是框架管理和扩展能力的核心来源,并且提供了不同阶段记载不同资源的入口。必须注意的是注册类提供了4个阶段的行为,包括Spring启动之前的行为,Spring容器bean初始化之后的行为、Spring容器初始化完成之后的行为,以及进程成功启动之后的行为。开发者可以充分利用主容器的生命周期特性,安排组件内部模型的初始化时机。
- 向主框架注册组件本身的信息,以便主框架能够感知到模块存在。
- 如果组件属于业务组件或者存在一些配置管理界面,以满足业务场务或者技术场务在必要的时候使用,则需要将组件下私有的界面和静态资源注册并启动时复制到主框架中,以便主框架能够发现和统一管理这些资源。
- 向主框架的ts-s-jdbc模块注册访问的数据库Schema和数据库Table信息。框架的模块最终目标是面向领域和微服务的,禁止开发者开发的组件滥权地访问和使用与自身业务无关的数据资源。模块注册的数据操作信息,只有经过审核之后(为检验设计进行检验),才能被主框架授权使用。这一机制为架构师掌握系统的全局提供了重要的手段和机制,也为未来进一步领域化和为服务化提供了可能。
- 菜单和权限有关的资源注册,如果模块组件提供了可供访问的菜单资源和URL资源和特定权限用户访问的必须进行注册,这样主框架才能发现和授权他人使用这些资源。
- 实现定时任务功能,如果模块需要提供定时任务或批处理功能,必须将定时任务信息告知主框架,主框架通过cwap-s-job和ts-u-job来管理配置和启停定时任务,当然在实际过程中,定时任务一般是以独立系统和进程(多活或者热备方式)形式提供服务。
- 注册与主流程接口交互的各种Business Handler,譬如与IMT和DEP交互的IMT Handler和DEP Handler或者和各种市场报价业务相关的处理Quote Handler。
- 注册与各个交互模块行为事件有关的Event Listener,譬如各种交易和非交易场次变更时需要根据变更事件执行特定行为的业务需求。
- 注册其它构件定义的依赖和交互资源信息,TS系统目前是由150多个具体的模块组成(不包含依赖的第三方库包),每个模块在物理上都是独立的jar包。未来仍可能扩展出更多的库包,每个模块都定义和完成了某一个具体的业务功能需求和若干相关业务场景,一个业务功能可能需要其它具体实现包或者依赖于或者影响到其它业务场景,所以可能提供了相关依赖的代码级注册需求,故扩展需要从这里开始。
- init方法是在bean初始化执行,主要目的是在bean初始化之时建立起各模块所属对象之间的关联关系。onEventApplication方法是在容器启动之后执行,主要是做数据库有关的操作和初始化缓存有关的操作(类和方法命名与spring保持一致,避免歧义)等适合在所有bean初始化完成之后的操作。
打辅助:辅助常量类和数据字典组件
辅助常量类(XXXHelper.java,根据规范约定:XXX是构件名称的第三位英文名称),主要定义一些组件模块代码里必须使用的常量资源和静态工具方法,这里有2个必须声明的特定的资源常量,PLUGIN-ID(构件声明)和PLUGIN-MAPPING(构件资源映射跟路径)。未来规划在主框架底层抽象出Plugin和Version类来统一维护组件与版本有关的信息。
对于一般模块来说,因为业务功能范围被限定的非常小,毫无必要再书写特定的Constant常量类,私有的业务常量最好定义在对应的业务类中就好,采用就近原则,方便其它开发者阅读代码,公共的常量特别是其它组件可能用到的非业务领域的常量都最好定义在辅助常量类中。一些非直接与业务相关的工具方法也最好直接定义在辅助常量类中。该类的主要意义是告诉其它开发者阅读该模块时,只要打开该类,就能知道组件对外暴露的公共定位和公共行为。
对于业务常量如果这些常量是需要扩展的,譬如外币拆借市场的货币和外汇期权市场的货币对的精度和排序问题在没有统一的需求约束的情况下就可以通过数据字典进行维护管理。数据字典主要用于动态维护业务常量信息。ts-s-dict为开发人员提供了数据的标准定义和访问方式,开发人员可以将自动生成的访问方式写在辅助常量类中,交易中心元数据管理平台主要针对系统间的标准数据定义,而系统内业务扩展数据的定义最好采用数据字典统一维护。ts-u-dict提供视图,供开发者对数据信息进行统一的维护和管理。
常量类原则上不可以在模块间共享,开发者识别清楚模块内公共常量和类中私有常量。如果必须在模块间共享常量,那么唯一且最佳方案就是使用数据字典。数据字典的信息在每次进程启动之时,被加载到内存中,数据信息不可变,大大提高了应用组件的性能。
协作的基石:接口类
接口存在的核心意义是为某个问题提供一个即调即用的解决方案。接口重点在于解决问题,而不是接口的扩展性,如果这两者搞反了,接口开发者和接口使用者都会非常痛苦。接口中,方法的设计一定要力求简洁,而且要见名知意,仅把必要的工作交给接口使用者去做,接口传入的参数必须保持简洁,如果需要传入较多参数,最好以一个接口参数定义类的形式传参,杜绝使用Array、Map或者List这种数据接口传参。接口使用者不应只有阅读接口文档才能知道传入的参数和返回的参数的用意。面向接口编程是以接口为媒介,实现调用接口者和接口实现者之间的解耦,但是这种解耦程度不是很高,如果接口发生变化,双方代码都需要变动,明确的语义至少让变动过程变得不再痛苦。
Provider接口类(XXXProvider.java,根据规范约定:该类必须实现CWAP框架定义的Provider接口类)提供了方法级的接口调用,未来在微服务MSA架构成熟和准许在CWAP框架中集成之后,通过代理模式实现远程RPC调用。该类必须采用静态内部类(SingletonHolder)实现的单例模式和Facade门面模式进行设计,其所有公共方法都是对外提供的API服务,该类不由Spring容器管理,所以使用单例模式进行设计,避免重复创建接口对象。坦白的说,目前Provider类设计的并非很好,接下来主框架会将Provider类进行统一注册管理和权限管理,接口的使用反映在业务上就是业务之间的依赖关系,而需求文档绝大多数情况下难以厘清业务之间的全部依赖关系,但代码是不会骗人的,所以将Provider接口进行统一管理和维护,为下一步开发即设计思想的具体落实提供了重要的支撑。
接口类实现单例模式主要目的是控制实例数的创建和多线程对接口类的影响,所以Provider类最好是无状态的,并硬性约束开发者避免提供带状态的API接口类。程序在启动之时,会扫描所有接口类,并更新接口信息。
接口存在的核心意义并不是扩展性,接口的确定性才是接口存在的核心价值,所以必须禁止接口传入和传出参数定义成Map这种流氓做法。接口返回结果必须定义成一个语义明确,结构清晰的业务对象,相较传统的Bean对象,接口返回的业务对象不可以存在变量的setter方法,必要的变量必须通过构造参数传入。接口必须提供容错机制,避免使用者传入错误的、不合法的参数而导致处理异常。接口在遇到复杂的业务处理时,应该考虑业务异常和技术异常的返回问题,保证接口返回正确。
REST接口类和Controller接口类(XXXRest.java,根据规范约定:该类必须继承GenericRest类)为外部用户通过HTTP请求访问内部应用程序提供了可能性。REST的接口提供的是JSON交互形式,而Controller层提供了JSP动态界面交互形式,可以直接通过界面进行人机交互。建议开发者不要将包含复杂的业务处理逻辑的外部接口交由Controller接口类实现,最好是由REST接口实现,这样做适应性会更好,即便以后前后台进行了完全的分离,也不会影响到接口本身。REST接口返回的SimpleMessage对象,并经过JSON序列化。Controller接口返回的对象是ModelAndView(Spring框架定义的),统一了返回对象和输入参数,以便前置AOP逻辑。对于开发者来说,不建议开发者设计和实现AOP,也就是要去AOP化,这一点和Spring的理念是不同的。AOP如果由开发人员单独实现,往往存在失控的可能,开发者会滥用这一权力,将过多的特定业务逻辑交由AOP实现,降低整体的可维护性和性能。所以如果需要拦截逻辑,烦请开发者在具体的Controller逻辑中实现,原则上并不是每个请求或者大部分请求都需要的拦截逻辑,数据的前置和后置处理逻辑,还是写在Controller中更合适。不怕Controller代码复杂一点,不知道有些开发者追求Controller代码的干净和代码行数少是为了什么,如果为了代码写的整洁,那么Service不是会变得更乱吗?
Controller和REST的方法尽量使用标准的传入参数,包括HttpServletRequest和HttpServletResponse,或者@RequestBody String jsonBody(尽管不建议开发者如此使用)。将获取到的参数逐个获取和进行必要检验是一种美德,不怕代码写的多,接口程序的鲁棒性才是关键。CWAP框架不建议前台向后台通过Json形式提供数据参数。这会为前置过滤和安全校验带来困难,也会降低整体处理性能,毕竟多了一道序列化和序列化过程。
开发者应该在每个接口的最外层通过try-catch捕获业务执行过程中抛出的业务异常和其它异常,不要让统一异常处理拦截器处理异常,因为只有开发者最清楚,自己开发的业务异常应该以何种方式正确处理,应该向用户返回什么信息,对于用户和开发者来说最易识别异常错误是正常的还是异常的,换句话说,不要试图让框架帮你擦屁股,自己的事情自己含着泪也要做完整了。总之,不要让错误穿透整个防线,暴露给用户,虽然框架确实是最后一道防线。
最后开发者在开发REST和Controller时,一定要想清楚该暴露的接口是否需要权限校验,防止水平越权和垂直越权的发生。特别是对于成交明细查询、成交单下载这些重要的私有信息和数据必须进行安全校验。
时间和事件驱动的任务
许多业务需求可以抽象成任务,任务之间可能存在时间依赖关系和条件依赖。任务的执行通过时间或者事件驱动实现。
时间驱动顾名思义,就是在指定的时间点发生的任务,一般通过(分布式)定时任务框架实现。事件则代表过去发生的事件,事件既是技术架构概念,也是业务概念,这里我们重点强调的是业务概念。以事件为驱动的编程模型称为事件驱动架构(EDA)。EDA是一种以事件为媒介,实现组件或服务之间最大松耦合的方式,调用者和被调用者互相不知道对方,两者消息队列耦合。
辅助定时作业类(XXXXJob.java, 根据规范约定:该类必须继承CWAP框架定义的GenericQuartzJob类,并佩戴上禁止并行执行@DisallowConcurrentExecution和持久化状态@PersistJobDataAfterExecution的声明注解)是定时任务执行的接口,这个接口是一种特化的接口,虽然不直接被外部访问。某些业务需求,最好的实现方式可能就是定时任务,比如定时发布人民币中间价、定时清除失效的报价数据、日终定时或者应急的闭市结转业务等。异常类的接口方法中不建议书写具体的业务逻辑代码,只做触发执行状态的判断和执行之后状态数据的处理和接力。CWAP框架一大优势就是提供了定时任务所需的必要的强大功能,避免了技术人员通过配置文件维护大量的定时任务。否则非常容易出问题,配置文件的调整和测试也不比界面操作来的方便。
Job类在TS系统中由JSS定时任务调度进程驱动执行,双进程保证高可用和定时任务的分配均衡。定时任务管理界面能够友好化的配置和解析Cron表达式规则,能够优化的添加一些特定内容,最重要的是能够通过界面执行定时任务的启动、停止、暂停和恢复以及应急等操作。这个非常重要,传统的配置文件,对于测试和技术管理来说都不够友好。并且能够监控任务的执行状态。程序在启动之时,会扫描所有定时任务类,并更新接口信息。
事件驱动的任务来说,是目前TS系统做的非常不够好的地方,接下来的项目中将重点完善之。从事件领域角度分析,如果应用中的事件由多个步骤组成,并且需要调度这些事件,那么“调度员”的模型抽象和拓扑就很有帮助。例如,一个处理股票交易的单独事件可能需要以下步骤,首先验证交易,然后检查股票交易是否符合不同的监管条例,指派交易给一个经纪人,计算佣金,最后把交易放置给指定的经纪人。所有这些步骤要求有一定的事件调度机制去决定步骤的顺序以及哪些步骤要顺序或者并行执行,ts-s-task的规范就是完成这件事情的,而每个步骤实际上应该是独立子领域,领域之间的事件交互通过调度引擎(Scheduler Engine)完成。事件领域模型由4个主要部分组成:事件队列,事件调度员(event mediator),事件通道,事件处理器。事件流程按照:客户发送初始事件到事件队列,事件队列传递初始事件给事件调度员,事件调度员在收到初始事件后,把初始事件转化为多个小事件,然后通过异步发送这些小事件给事件通道,实现调度。每个小事件都是执行业务的一个步骤,其对应的每个事件处理器监听事件通道,当收到事件后,执行指定的业务逻辑来处理业务。
正在CFETS-EUA构件设计规划中,事件驱动(调度引擎)和领域模型的结合是一个重大的研究课题,也是真正可以着手建设基础设施的落地点。
业务模型
经典的MVC设计模式,其中 M(数据模型)、V(视图)、 C(控制器),其中数据模型中放是主要的业务代码和数据库交互现实情况是大部分人在控制器层写了太多的业务代码,导致一个控制器的方法变的越来越难维护,为什么开发者不把这些业务代码放到数据模型中呢?因为绝大多数框架里,一个数据模型会对应一个数据表,是一一对应的关系,而且一个数据模型也默认继承ORM操作类,也就是说在大多数框架里,模型被定义为了表的操作类,因为大家都在心照不宣的做着基于数据库的面向过程的开发方法。这时候开发者就会困惑,数据模型是这样的话,那么在数据模型里放的业务代码应该是跟对应的表相关的才是合理的。然而一些业务往往不是用表的种类来拆封的,这个时候开发者写程序就会很纠结,有些代码放到数据模型里感觉不合适,放到控制器里又觉的累赘,这时候就应该引入新的一层,这就是“Service(服务层)“。
Service数据服务类在传统MVC框架中,是真正的核心,主要承载具体的业务逻辑,独立Service层的意义是为实现业务逻辑在代码级别的统一和管理,业务逻辑写在一起方便开发人员归纳整理,提取出可以被重复使用的类和函数。
但是,在CWAP框架下Service层不承担代码复用职责,Service本身也无法完成真正的领域模型的抽象。开发者天然习惯在Service层实现事物脚本操作,而且绝大多数事务也是加在Service中。这对于组件化框架来说,不同组件间的Service就应该禁止互相调用,也就是说Service不是接口,是组件私有的,开发人员最好将数据库的操作逻辑直接写在Service中,不要在剥离出单独的DAO层,不应追求数据库操作的复用,正如前面所提的困惑,组件化之后,绝大多数情况下,模型和数据表是对应的。Service不需要定义访问接口,只需要关注事务脚本和业务过程就好,如果需要事务,请勇敢使用编程式事务,不要怕代码多,可读性和可测试性才是重中之重,可读性不是指代码形式的整洁和凝练,而是能让人一眼就看明白具体做了什么事情。
通过将业务拆分成不同的服务,拆分的服务粒度已经足够小,一个服务基本上只完成一个业务功能,各种业务需求基本通过组件间交互完成。
真正的核心业务逻辑,最终应交由领域模型实现,而Service将逐步演变成与领域模型进行数据交互的实现类,也就是DDD中的Repository。
传统的架构中Service最大的弊端是随着应用逻辑的越来越复杂,最终导致Service之间的边界变得模糊,Service之间产生复杂的调用关系,导致Service中不得不再分层,开发者不得不绞尽脑汁,小心翼翼处理复杂的代码调用关系。
Service数据服务类是面向数据的,几乎没有复用价值,它的设计应该针对不同数据库操作特征进行个性化实现,不应为了通用性,牺牲数据库操作的性能,SQL的性能主要评估两个阶段Execute阶段和Fetch阶段,开发者在创造Service的时候,应该权衡考量数据库性能的优化要求:
- 减少数据库交互次数,减少不必要的数据库访问,利用batch操作减少交互,对数据做预处理;
- 减少网络传输交互的数据量,每次业务操作只返回真正需要的字段信息;
- 减少数据库CPU消耗,尽量少的排序操作,将不必要的排序和数据处理操作交由应用实现。数据量大的分页操作交由数据库完成,数据量少的分页操作,最好由应用自行解决,通过嵌套查询尽量早的过滤数据,避免类型转换;
- 适当的应用索引,没有索引会导致大数据量下读性能的下降,否则会大大降低写的性能,所以需要开发者权衡利弊,在应用层分离读逻辑和写逻辑,在数据库层分清主键,经常变化的字段信息和基本不变的字段信息,以及考虑清楚是否需要进行冗余设计,是否可以将同一数据模型的不同的信息放入不同的表中,也就是切分表;
- 数据库表结构和应用程序设计时应紧紧围绕真实需求场景,不要互相迁就,最终导致读逻辑的复杂化,毫无必要的连表操作和排序操作,终将导致数据库访问性能大大降低。
Service数据服务类中不要添加数据缓存逻辑,重要的事情说三遍,开发者绝不可以将缓存逻辑写在Service层,开发者绝不可以将缓存逻辑写在Service层,开发者绝不可以将缓存逻辑写在Service层。因为Service层一旦添加缓存逻辑,将带来实际执行和测试时分析的复杂性,为正常业务逻辑测试和添加了缓存的业务逻辑测试带来了不必要的麻烦,后期排查和解决问题时,同样会导致不得不弄清楚到底是缓存问题导致数据不对,还是业务逻辑处理错误导致数据不对。
Service类终究只是一个过渡类,最终所有的Service都会演化成Thin Service。
工具类是一种特殊的业务模型,其存在的意义是为了代码复用,例如截取一定长度的消息或者对用户集合进行分组。对于构件的开发者来说,识别真正有用的工具类其实并非易事,构件所使用的工具类被其它构件复用场景并不多,主要是内部业务逻辑处理时会调用,而且处理也是定制的,模块间的复用只由API完成,也就是如果必须复用工具方法,请使用接口提供该方法。不同业务功能模块之间除了通过业务功能接口调用实现依赖,基本上不存在技术复用的情况和需求。
从构件功能来说,目前并无统一提供工具方法的构件定位,TS架构本身也反对存在这样的构件存在,因为这样就会引入集中化依赖,导致不得不为满足各个被依赖构件,而在构件内代码优化时进行妥协,也可能导致很多开发者都必须参与工具构件代码的维护过程中,如果工具类方法写不好,很可能导致维护难度剧增,工具类中出现很多根本不必要的重载方法,因而不建议开发者编写工具类,包括授权别人调用自己的工具方法。对于字符串处理工具类、日期处理工具类或者文件工具类,完全可以交由Apache或者Google提供的工具包来处理,绝大多数情况下不需要开发人员编写复杂的工具类,这些三方包已经完全能够满足通用需求。编写工具本身的目的就是为了复用,为了复用的目的将会导致开发者投入过多的精力如何满足更广泛的调用需求,这样做是得不偿失的,其实上复用的价值往往不大。
所以开发人员在模块内代码开发时,直接将工具处理逻辑,靠近具体的业务代码去写就好了,这样既增强了代码的可读性,也为后期代码逻辑基于业务模型角度抽象提供了可能性。
异常、国际化和日志
辅助业务异常类(XXXXException.java, 根据规范约定:该类必须继承TS系统定义的GenericTsRuntimeException类或者GenericTsException类,并补充具体的异常定义信息)主要用于保证程序的健壮性和业务逻辑的完整性,不过能通过正常途径(方法分支调用或者抽象类模型结构)解决业务逻辑分支问题的,最好不要通过异常机制进行业务逻辑控制,如果非要这么做,最好针对性的捕获异常,并针对性的处理异常。不过异常类最强大的作用是保证程序和业务逻辑的鲁棒性。所以说不要小看异常类哦。异常类的作用体现在帮助你梳理代码的业务分支逻辑,当业务不是模块内部逻辑可以继续的时候,就应该用抛出异常的形式尽早终止错误的分支逻辑。
异常类的主要作用:
- 使得业务逻辑更加清晰和完整。
- 辅助会为每一个异常类和指定的key自动生成唯一的错误码,不由开发人员或者业务需求人员定义错误码,可以保证为争取错误码而争吵,以及降低维护管理的困难。错误码对应具体的异常信息,通过异常字典表可以快速帮助业务场务人员和技术人员根据用户反馈的异常错误码或者日志中记录的异常错误码定位问题。
- 将不必要告知用户的技术异常转换为对应的业务异常,例如将数据库异常转换为具体业务模块的数据异常等(取决于业务需求和开发者定义)返回给用户。
开发者必须尊重和敬畏异常类的规范使用,尽量将有业务含义的异常信息暴露给用户。在使用异常类时必须注意:
- 不要在根本不会出现异常的地方使用try catch包裹执行代码。
- 尽可能的细化异常,尤其依据具体的模块在进程启动之后的生命周期之中以及发生的业务场景中细化。譬如定义具体的初始化异常和定义具体的访问日期超过需求边界异常等。
程序在启动之时,会扫描所有异常类,并更新异常信息。
异常的使用往往是结合国际化一同进行的。国际化信息一般框架都是通过配置文件进行管理和维护,TS则采取基于数据库中心化的管理模式去维护和管理国际化配置信息,ts-u-exception构件就是为了满足技术场务维护异常信息而设计开发的。当应用进程启动时,从数据库将所国际化信息和异常信息加载到内存中。从实际业务需求看,交易系统本身就是以英文和数字为主的系统,所以后台需要进行国际化的场景不多。
实际在开发、生产和交付过程中,使用数据库比使用配置文件管理要方便很多,开发人员不必每次修改国际化提示信息都要修改配置文件,保证主的DP或者DW工程中的配置文件由基础架构团队统一管理。如果需要国际化配置信息,只需要通过管理界面或者SQL脚本更新就好,一但交付生产,如果发现问题,需要及时改正,也不必在走紧急变更流程,由技术场务在获得测试和运维人员同意之后,直接调整即可。ts-s-i18n、ts-u-i18n两个构件为国际化功能提供了很好的支持和缓存支持,请开发者严格遵守规范使用两个构件提供的功能来实现国际化功能,勿要在代码中硬编码国际化信息,导致后期维护成本升高。毕竟按照现有的交付模式,修改数据的成本比交付配置文件和代码的成本低很多。
日志是追踪排查问题的主要工具。但日志对软件性能有很大的影响,而且要使用好日志这个工具绝非易事。首先大家在打印日志的时候必须用占位符{} 传入参数,不可以用+符号拼接字符串,这样可以10倍提升日志输出性能。
'' 禁止:logger.debug("hello world: " + name);
'' 必须:logger.debug("hello world: {}", name);
为了性能,slf4j、log4j2和Disruptor的日志组合无疑最佳搭档,相比传统的日志输出性能有极大的提升,log4j2之所以能秒杀一切日志组件,是因为它支持异步输出日志。TS禁止开发人员的业务构件直接使用slf4j的API或者log4j的API,必须使用ts-s-log提供的TsLogger日志输出器。TsLogger定位为日志输出的Client,既可以向本地输出日志,也可以通过Kafka或者Redis向远端监控服务器输出日志。ts-s-log还会通过JVM字节码增强技术,支持服务的调用链跟踪功能(与Skywalking深度整合)。该构件的存在为后期分布式日志追踪和复杂的日志控制等需求提供可能。大家都清楚,更换日志系统是一件伤筋动骨的事情,ts-s-log的目的是让开发人员不改动一行代码,就可以确保整个TS系统完成日志切换和日志功能的提升和调整。目前不需要改动配置,不需要重启进程,该构件就可以支持日志临时升级功能,以便开发者追查特殊问题,这一功能是使用纯粹的第三方日志构件无法做到的。
对于模块来说,如何打印日志是一种艺术。开发者必须意识到日志的重要意义:
- 问题追踪,通过日志发现问题;
- 状态监控,通过实时分析日志,可以监控系统的运行状态,做到早发现问题、早处理问题;
- 安全审计,通过对日志进行分析,可以发现是否存在非授权的操作等;
以及日志级别的意义:
- ERROR:问题已经影响到软件的正常运行,并且软件不能自行恢复到正常的运行状态,此时需要输出该级别的错误日志。告知运维人员,如果检测到此类错误,需要开发人员现场排查问题。
- WARN:与业务处理相关的失败,此次失败不影响下次业务的执行,通常的结果为外部的输入不能获得期望的结果,通过警告形式告知运维人员关注此类问题,并适时通知业务和开发人员关注。
- INFO:系统运行期间的系统运行状态变化,或关键业务处理记录等用户或管理员在系统运行期间关注的一些信息,实际情况下,开发者应能从INFO日志中直接发现主要问题。
- DEBUG:一般情况下对于不太重要的需要序列化的日志信息,使用DEBUG级别,并且前置加上布尔判断(isDebugEnabled),提供尽量完整的软件调试信息,和跟踪代码执行的分支逻辑。如果INFO级别的日志不能反映部分问题,开发人员临时开启DEBUG级别的日志排查程序运行中的故障。
日志打印的关键点:
- 系统启动和创建环境变量;
- 异常捕获;
- 方法获得不期望的结果或者代码走到不期望的分支;
- 关键操作和业务流程,例如数据库操作、缓存操作、读写文件系统、调用一个定时任务,进行成交或者开闭市等核心业务操作流程;
- 状态发生切换时;
好的日志有助于开发者及早发现可能存在的问题,将程序正常情况下不应该走到的分支逻辑通过警告或者错误级别日志打印,告诫开发者程序是否走在预期的道路上。这一点非常重要,或许你现在体会不到,一旦大难临头,可能救你一命。
好的日志内容最重要的是让运维人员一看就懂,让测试人员一看就懂,避免给自己带来麻烦,每次都要你自己去根据日志排查问题,别人一告诉你日志内容,你就能告诉它问题结果,或者运维人员直接就知道问题原因或者知道如何寻求帮助,这样的效果是最好的。这就要求开发人员必须非常非常非常重视日志输出的内容和效果。理想的日志内容就是用中文写出一句完整的信息——告知当前究竟发生了什么,它的上下文业务内容是什么,如何定位当前的问题,以及如何寻求帮助,必要的时候要做出警示。除非你英文能力非常强,能够写出非常流畅的英文语句,否则坚决反对开发者中英混杂输出摸不到头脑,只能对着代码看输出原因的日志。好的日志,例如:未能从数据库TSDEV.TSLOG表中获取到外汇期权市场成交明细日志配置信息,对于日志推送会产生影响,请运维人员检查数据库连接是否正常,或者联系技术人员排查数据问题;从NTPII系统获取的成交报告信息不完整,或者成交价格为空,TS系统不能完成正常的成交业务处理,请联系NTPII系统开发人员排查相关问题。坏的日志:日志配置sql end 。好的日志连在一起是一个故事,通过terminal终端tailf日志时,输出的日志能够连贯成章,让观察者赏心悦目。坏的日志在屏幕上刷时,如同应用在拉屎,满屋臭味,不知所以。
ts-s-log还支持特殊应用场景的日志:
- 定时任务日志TsJobLogger:将关键的定时任务执行状态信息直接记录到数据库中,以备直接监控。
- 事件任务日志TsEventLogger:将关键的异常信息直接记录到数据库中,例如成交明细生成失败,以备直接监控。
- 场务操作日志TsAdminLogger:将场务人员对系统的所有操作直接记录到数据库中,以备审计。
ts-u-log支持日志监控配置信息和关键异常日志的过滤查询。基于JAVA的分布式追踪系统Sky Walking是比较不错的开源实现,ts-s-log构件后期将重点参考该构件的设计原理和实现。
监控界面
在CWAP框架中每个模块独自维护模块所需的静态资源和动态web页面。按照规范要求,如果该模块有定义界面,必须在Component注册类中声明。目前TS场务端的实现方式使用了JSP技术,考虑到开发效率和开发成本,并未落实前后分离的技术路线,但前后分离才是未来的趋势,而组件中的JSP页面真正的作用应该体现在一致的落地技术监控需求上,也就是为技术场务提供必要的操作交互接口。
当前创建界面的作用主要目的是满足场务管理人员的交互需求,同时也为技术人员提供监控模块运行的可视化手段。开发者应该尽量将监控功能前移内置到组件设计和开发过程中,在开发时尽量抽象模型,至少为监控留有接口,最典型的监控模式正是观察者模式,为了不影响性能,最好采用异步方式进行监控调用,而不是依靠周边设施提供不够精确的监控功能。例如日志构件ts-s-log提供了一个界面可以监控日志监控的信息,可以控制日志的输出,正常情况下,生产环境和三方测试环境是不允许开DEBUG模式的,但是通过点击按钮,开发者可以临时降低日志的级别,打印出必要的问题追踪信息。ts-u-dispatcher提供了消息监控界面,可监听所有通过推送器发往统一终端和场务端的消息事件。从这个意义上讲,监控界面的预留非常必要,对于以模块为边界划分任务职责的体系来说,每个开发者都可以根据自己的业务需求,设计和开发需求要求之外的必要界面,以帮助自己快速定位问题,跟踪构件的执行情况。这是非常有益的尝试。
注释、规范和约束
代码优秀到不用写注释就是一种鬼话,好的设计都是没有注释就是鬼逻辑。只要将心比心,每个开发者都清楚代码注释的重要性。当你面对的是别人的代码时,没有注释再好代码估计你读起来都会觉得头痛。如果你需要改别人的代码,又怕改出Bug,你的小心脏都在担惊受怕。这就要求开发人员必须:
- 养成良好的代码注释习惯,边写代码边注释,及时的记录下自己写代码过程中的思路;
- 养成代码和注释同时对待,改完代码及时更正注释(代码是鱼,注释就是水,有了正确的注释,鱼才能更好的生存);
- 提升自己对代码的解释能力,用精炼的语言表达出代码的核心价值所在;
注释是对意图的阐释,对程序员的警示。比如有一段非常危险的代码,一定要通过注释将这种危险传达给其它程序员。注释也是对某段代码作用或功能的放大。好的注释美化不了糟糕的代码。写好注释的前提当然是优先写好代码,通过代码传达意图,通过注释明确意图和传递思维逻辑。
当代码正在开发过程中,暂未完成的功能请使用TODO注释。有时某段代码由于某种原因暂时不能写,或者为赶工,写的代码是临时性的,这时可以使用TODO注释标记,告知自己和其它人这里的代码是临时代码,接下来需要重点注意。当然开发者应尽快完成未完成的代码或者去掉临时代码。
好的注释要求开发者尽量维护好版权信息,特别是作者和开发时间信息。
代码的可读性是编程的最高要义,因为开发者必须心中有他人,存良心。
Programs must be written for people to read, and only incidentally for machines to execute.
这里总结出TS的开发规范,也是CWAP的开发规范,包括一些公共的组件类和代码设计,为的是避免出现不必要的隐患。以下是一些常用的规范和约束
- 数据库操作原则上不应包含复杂的业务逻辑,数据库操作的主要目的是存取数据。只是你的数据绝大多数情况下仍然是存放在Oracle或者Mysql这样的关系型数据库中,而非NewSql或者NoSql中。开发者在设计SQL语句时应该关注的是执行时间和抓取时间的总性能之间寻求平衡和最优。在表查询中,一律不要使用*作为查询的字段列表,需要哪些字段必须明确写明。 否则将增加查询分析器解析成本和导致增减字段容易与 resultMap 配置不一致。
- 关于注释和日志,作为一个英语功底不好的开发者,最好不要逼自己写一些蹩脚难懂的英文注释和日志,这样在排查问题和阅读代码过程中,很可能既困扰自己,又误导它人,程序的日志最好是面向事件过程和面向结果的。好的代码本身就是注释,这话是对的,但这仅限于逻辑简单直白的代码,对于核心逻辑较为复杂的代码必须有足够的注释,否则接盘侠就要花很多时间精力去理解,甚至会发疯谩骂前任开发者,我想这也不是你所想的吧。当然不管你代码写的有多好,注释都是必要的。
- 关于配置信息,请注意CWAP的核心原则包含约定优于配置和即插即用,无须配置,与业务有关的配置信息最好使用数据字典ts-s-dict或者ts-u-dict构件,如果非常简单不变的业务配置信息,则可以直接在类中定义枚举值或者值对象enum。框架本身只提供一个可以扩展的配置文件,框架自身的配置信息放置在主配置文件中,开发者不应该使用该配置文件。cwap-config-ext.properties文件只配置与环境有关的配置信息,例如测试环境服务器只有4个核,那么线程数最好开到4个,而生产环境服务器是40核,所以我们为了性能最优可能将开的线程数调整到40个,或者诸如服务器IP地址,端口地址,性能参数等。
- 关于分支判断,分支判断超过3个时,最好使用switch或者根据业务需求定义策略类实现,如果存在嵌套的分支判断最好通过DEBUG日志说明分支的行进情况信息。或者通过策略模式进行分支降级处理,以大大优化代码的执行性能,将代码的执行复杂度从O(N2)将为O(1)。
- 避免模块代码堕落到复杂的层次世界中,模块内的代码需要有结构,但无需有层次。层次不必然带来代码清晰。代码的清晰首先是阅读的清晰,其次是执行的高效,再其次才是结构的清晰。模块内关注的核心也绝非层次。至少层次不应该是组件模块的开发者应该关注的核心问题,架构是有层次的,架构的层次主要体现在模块之间根据业务关系组成的业务网络(之后,将有一文专门介绍什么是业务网络,如何识别领域模型)中。坚决反对无必要的代码嵌套,不用担心写重复的代码。操作必须保证连续性,例如写一个sql语句 不管它本身有多么复杂,原则上都不应该嵌套成多个方法去拼接sql语句,读你的sql语句就能读懂。
- 性能优化的前提是保证业务逻辑的正确和代码的可读与凝练,否则优化性能将会是一件痛苦的事情。
- 确保提供给外部的接口是准确可靠地。
- 不应有不明确的类和方法定义。最好不要用Common Util Base等这些根本不知道在讲什么业务的词汇来命名。
- 如无真正的必要,杜绝使用static声明变量和方法。
- 关于基础模型的定义,对于类似货币和货币对这样的元对象模型,应该提供专门的底层基础包
- 关于CWAP的package命名规范,构件名最好选取能表达构件业务功能的英文单词全拼,或者多个英文单词联合,例如:centralparityprice(人民币中间价)
- 原则上静态常量只能在构件内发挥作用,不可以传递到其它构件模块中,为构件的升级和调整埋下隐患。
com.cfets.系统名.s.构件名 :com.cfets.cwap.s.job
com.cfets.系统名.u.构件名 :com.cfets.ts.u.vacation
- 关于特殊的注册行为譬如为了避免大量无关的依赖直接引入,和DPS推送器有关的Handler是通过技术场务界面又开发人员直接注册完成的。
- 真正需要的核心业务操作才使用事务,绝大多数场景应该不需要事务,在业务功能开发时应考虑失败情况,并从业务角度权衡有无必要采用补偿措施,而不是一股脑的都采用事务处理。事务会影响数据库的QPS,还必须考虑如何进行回滚,并做测试验证。不正确的回滚,不如没有事务。
- 反对可变参数编程。
其它编程规约参照《阿里巴巴Java开发手册1.3.1》。
构件设计与开发升华篇
从缓存到InMemory
孙子说:古之所谓善战者,胜于易胜者也。
缓存无疑是解决性能的工具箱中,最好用的工具,是减轻负载的首选。但是缓存对于绝大多数开发者也是比较难以正确运用好的手段。
一般应用程序产生性能的问题的主要根源在于IO操作,特别是数据库IO操作、共享缓存的IO操作、本地文件的IO操作或者循环迭代或者递归操作都非常消耗性能。一般的应用程序都不太会涉及到CPU密集型操作,所以应用程序优化的重点就在于尽量避免IO操作。运用缓存,减少IO操作就成为性能优化的最佳选择。所谓缓存,就是将程序或系统经常要调用的对象存在本地内存、堆外内存、共享缓存或者内存数据库中,以便使用时可以快速调用,不必再去传统持久化型数据库(Oracle、Mysql)中获取。这样做可以减少系统开销,提高业务处理性能。
TS的开发者可以发现自己的实际开发过程中,就是在面向接口的编程,设计一个接口,然后实现这个接口,为被人提供服务,这个和Java中的interface不是一个东西,请大家清楚。缓存应该做在接口层。缓存的具体设计应该是先设计缓存接口层,然后再创建实现类(如果缓存很简单,不定义接口也没有任何问题),具体的缓存是选取redis共享缓存机制,还是本地内存机制,需要具体视情况而定,能用内存解决的问题,最好不要用共享缓存,例如节假日数据,其实放在本地内存是比较不错的选择。
- Interface层:Controller,REST,Job,Handler,Provider…
- Domain层:核心业务写模型和读模型
- Plugin层:Service,Handler,Builder,Factory,Repository…
缓存的操作基本方法包括添加、检索和更新缓存。对于构件模块来说规范要求缓存类必须被定义成XXXCache,缓存类中应包含具体的缓存逻辑,不要将缓存定义成纯技术缓存,也就是简单的getter和setter以及clear,ts-s-redis提供了key-value和key-map形式的缓存结构,是缓存操作的技术构件,开发者应该基于该构件实现自己的业务Cache。譬如Cache的接口定义如下,Guava也提供了诸多具有使用价值的缓存模型。
- cacheSwapTradeInfo
- getCachedSpotTradeInfo
- isTradeInfoCached
- getLatestSwapTradeInfo
- clearTradingInfoInCache
- getInstitutionInfoWithTradeInfo
- …
命名的明确会保证缓存的信息在阅读和逻辑上是清晰的,同时也暗示开发者在开发缓存逻辑时,将缓存的逻辑和业务逻辑本身结合在一起。
缓存的添加一般是在网络层和应用层之间,例如在Filter层添加缓存,针对具有幂等性操作的请求进行请求结果的缓存,另一种是在应用层和数据库层之间添加数据缓存,例如Mybatis和Hibernate的二级对象缓存。如果一个数据块不被经常更新,但是又被频繁访问,就非常适合加缓存,例如节假日数据、场次日期数据、机构信息数据等。
对于构件应用缓存来说,自己管理自己负责的数据的缓存无疑是最佳的选择,例如针对成交报告转成交明细的场景,获取交易本方和对手方的机构信息是必须的,如果在成交明细模块做机构信息缓存,那么效果和可靠性上显然不如由机构信息管理构件提供机构信息的缓存好。添加缓存首先必须明确缓存到底是面向用户(最终数据),还是面向中间数据。缓存数据的更新是由什么业务发起。开发者必须注意,缓存只能加在provider类、Controller类、Rest类等接口类中,不要在Service类或者领域模型中添加缓存,否则就是你傻。
缓存的设计和实现并非必须使用Redis这种key-value形式的共享缓存技术,缓存的运用必须结合实际业务场景和架构特征定制设计。内存缓存在应该优先被考虑到,如果缓存在内存中,也不是必须使用HashMap这种数据结构,需要开发者根据需求定夺,例如使用B Tree ,使用固定大小的队列,或者LRU Map等。
- 如果应用场景存在数据访问频率分布的,又必须考虑内存占用的可以使用LRU Map。
- 如果应用场景重点是缓存最新数据的,可以考虑无阻塞固定大小的队列。
- 如果存在根据范围搜索的场景可以考虑各种Tree的数据结构。
如果连缓存都解决不了的性能问题,就要考虑其它手段了,日志异步化是bug级的性能提升方法,因为传统应用的日志输出都是同步日志,所以通过日志异步化技术可以立竿见影的起到性能倍级提升的效果,但是一旦日志提升后的效果与需求和实际业务场景还有很大差距的时候,我们就必须深入考虑程序的性能问题了。
- 从异步线程角度考虑,可以考虑线程池和Fork/Join框架,将一个任务分成若干子任务去进行,然后将结果汇总,类似map-reduce ,只不过这个框架是在进程层面的性能优化。
- 如果是内存占用过快和Full GC导致的性能问题,优化重点就是排查程序是否存在内存泄露的点,尽量不要创建新对象,尤其是复杂对象,尽量复用对象。这要求开发人员对自己每次创建复杂类要有明确的意图说明。
- 抓住问题的七寸,拆分业务处理过程。将核心过程同步处理,将其它分支业务逻辑通过异步补偿技术处理,例如通过MQ消息事件和RPC调用等方式处理其它的部分。
- 如果单台服务器已经无法满足性能需求,那就要考虑分布式架构了,构件这时就要重点关照分布式问题了。
开发者已经习惯了数据库的存在,基本上一言不合就找数据库。
前面我们说的缓存都是面向数据的,所以叫缓存,接下来我们领域化建模之后,所有的业务领域对象也就是跑在内存上面了,其承载的信息也自然就完全在内存中了,所以这时构件就完全可能达到极致的性能。这就是InMemory。
在InMemory模式中,开发者的开发思路不得不转变成TDD驱动,并基于内存来实现默认实现,测试应该首先是基于默认实现进行测试的。
具体实践案例:请参考AxonFramework3Demo(Github)
Watcher资源监视类
目前为止,我们设计出来的构件仍然是死的,这个说法的依据基本上我们的构件仍然不是自己管理自己的线程和自己的状态,接口的执行和状态所在线程取决于调用者所在的线程。这时的状态信息基本上还是通过持久化形式维护的。当然这里有一个争论,领域模型是否应该完全运行在应用线程内。
Watcher类顾名思义就是构件的监视类,其设计思想源于Zookeeper。它原则上是占有独立的调度线程,用于监控构件的运行状态信息和内存状态信息,这就意味着构件的状态信息这时是基于内存维护的。特别是缓存的定期检测和更新也可以通过Watcher类完成。Zookeeper 并不是用来专门存储数据的,它的作用主要是用来维护和监控你存储的数据的状态变化。通过监控这些数据状态的变化,从而达到基于数据的集群管控。在构件开发过程中类,Watcher类的初始化在Spring容器初始化完成之后进行。
在TS实际应用场景中,当场次日期变化时,Watcher类能够主动更新内存中维护的场次日期数据结构,Watcher类能够监控数据源的连接状态,JVM内存和线程的占用等,Watcher类甚至能够监视自身服务状态是否情况。在应用开发过程中,如果需要检测到特定条件发生时触发特定的业务功能,都应该使用Watcher类完成。Watcher类在监控到异常信息时,应该通过日志或者其他方式及时报警。
目前正在考虑由CWAP框架提供统一的Watcher接口,共用Watcher线程,避免线程频繁切换对系统资源的消耗。
Context上下文生命周期管理类
天下大事合久必分,分久必合,总体上是向着合的方向发展的。
对于Spring框架而言,ApplicationContext就是Spring定义的容器,打个比方,Context就像是一家公司,BeanFactory则是公司的工厂,除了工厂,公司还有翻译,仓库以及办公场所等等。基于Spring框架的应用开发就是通过集中式的容器工厂来统一管理各种业务对象。Spring支持子容器的概念,但是对于CWAP的架构设计者来说,这样并不能很好的定义和实现框架赋予的新概念,所以交由各个构件自定义Context容器,独自管理自己所属对象的生命周期是目前最佳的解决方案。虽然这样做会导致程序编码的增加,但是Context类的存在让程序的调用更加自然,更具设计感,也让所有核心模型的创建有一个起点和归宿。完整的Context能够提现业务的特点。
交由构件自身管理自身定义和实现的业务对象的生命周期,对于开发者来说要求更高,但是对于构件来说,则是构件自身掌握自身命运的良好开端。目前正在考虑交由CFETS-EUA框架提供构件管理容器类的基础实现接口。一个Context容器应该实现定义的Closeable、LifeCycle接口。
为什么要自己实现Context上下文容器呢?对于构件来说,最重要的目的是表达具体的业务需求,不同的业务需求其抽象模型的设计并不太相同,一个无差别的Spring IOC容器会导致很多Provider和Cache,Provider和Service,Controller和Service等的调用处理逻辑变得复杂和难懂,通过统一的上下文容器将构件内部的所有核心模型统一交由Context来维护和管理,对开发者来说,开发者将不得不认真思考内存的使用问题,开发者才能有更大的灵活性和聚焦于业务本身。
Context上下文容器是构件发展过渡阶段的必然产物,帮助开发者理解构件内部模型的真正关系。采用单例模式,实现对象创建和对象生命周期的管理。
事件和观察者模式
与事件有关的概念有两个:观察者模式和事件溯源机制。前者强调一种能够用于观察事件变化并作出适当响应的设计模式,后者强调通过记录完整的事件信息,并在必要的时候重放事件来还原对象的状态信息。事件的重要价值,能够还原模型变化的始末,以便将来审计活动。
In computing, an event is an action or occurrence recognized by software, often originating asynchronously from the external environment, that may be handled by the software.Computer events can be generated or triggered by the system, by the user or in other ways. Typically, events are handled synchronously with the program flow, that is, the software may have one or more dedicated places where events are handled, frequently an event loop. —Wikipedia
从系统角度出发,上游系统发布一个消息事件之后,TS将进行一系列的操作,如果上有给的数据没有ID,TS最好在接口出生成一个唯ID,以便跟踪整个处理流程。在具体的模块设计与开发过程中,开发者应该重点关注行为事件,专注领域模型中实体的状态。
事件驱动架构模式是一个非常流行的异步分布模式,可生成高可扩展性应用。而且它也具有强适应能力,可被用于小程序或者大型复杂程序。事件驱动架构是由高耦合度、单一目的的事件处理模块构成,这些模块异步接收、处理事件。
事件驱动架构模式有两种主要拓扑结构,“调度员”(mediator)和“经纪人”(broker)拓扑结构。“调度员”拓扑结构通常用在一个事件中由多个步骤组成,而你需要通过中央“调度员”模块去调度这些步骤。然而“经纪人”结构是当需要执行一系列事件链,而不需要中央“调度员”模块。
以下展示基于事件编程的几个核心观点:
- 事件实现解耦:事件消息的生产者完全不必关心消费者。
- 事件作为记录:一旦我们将实体的所有有趣的状态转换表示为事件,我们就可以使用这些事件来记录该实体发生了什么以及在何时发生了什么。以事件替代状态,因为状态和事件是同一个意思,事件发生意味状态改变,状态改变也意味着有事件发生了。
- 事件是真实世界的工作方式:在任何时候在我们的系统的某个部分可能发生了什么事情,这种行为发生的影响对我们系统的其他部分还够不明显。我们在这里使用“最终一致性”和“不同步”等词语,它们是因为难以推理而赢得声誉。
- 事件是业务职责的边界:对于开发者来说,有了事件,它终于可以做到不必要掌握系统业务的全貌,而坦然的去写自己的任务,一般一个任务就是完成一项功能职责,这一职责所产生的事件花10分钟大概就能想清楚,有了事件边界,基本就可以着手写代码了。
从构件到领域建模
该范式用以支撑和约束开发过程,使得开发工作总体保持在正确的路线上。反之亦如是,只有存在一个框架范式作为前提条件,DDD才能算作一个有意义的可实践的方案。
构件存在的重要意义是稳定性,特别是保证核心概念和使用方式的稳定性。技术很多变的,而业务不常变,基于技术封装组件,它的生命周期会很比较短,无法保证构件的稳定性,而基于业务概念封装组件它的生命周期就会变长,因为业务逻辑的演化不是一天两天的,往往一脉相承,所以构件要想保证稳定性,必须基于业务进行设计和建模。
建立领域模型最重要的方法就是抽象。抽象就是从许多事物中舍弃个别的、非本质的特征,抽取共同的、本质性的特征。抽象是形成概念的必须手段。抽象原则有两方面的意义:第一,尽管问题域中的事物是很复杂的,但是分析员并不需要了解和描述它们的一切,只需要分析研究其中与系统目标有关的事物及其本质性特征。第二,通过舍弃个体事物在细节上的差异,抽取其共同特征而得到一批事物的抽象概念。这要求开发者在分析需求时能够做到,抓住需求的本质问题。
模型是一种简化,它是对现实的解释,并把与解决问题密切相关的方面抽象出来,而忽略无关的细节。模式是用于解决信息超载问题的工具。模式是一种知识形式,它对知识进行有选择的简化和有目的的结构化。适当的模型可以使人理解信息的意义,并专注于问题相关的信息。对象是对现实世界实体的模拟,因而能更容易地理解需求,即使用户和分析者之间具有不同的教育背景和工作特点,也可很好地沟通。
领域建模是对构件的彻底升华。领域建模是组件模块架构从I型传统分层架构向V型架构转变的必由之路。这种转变比ThoughtWorks推崇的分层架构更加彻底。领域模型是领域驱动的核心。采用DDD的设计思想,业务逻辑不再集中在几个大型的类中,而是由大量相对小的领域对象(类)组成,这些类具备自己的状态和行为,每个类是相对完整的独立体,并与现实领域的业务对象映射。
如果有人将领域模型中的实体类细分为4种类型:BO、VO、DTO、DO、PO。那么我建议你直接拿把锤子锤死他。从贫血模型到充血模型,对于复杂的业务场景,当然首选充血模型。例如Uesr和UserService的贫血模式,不是自洽的,需要其它类的参与来保证业务规则,一旦属性调整,不得不调整有关的类,极可能导致问题传递放大。而业务规则封装在对象自身之中,则能够保证对象行为和状态结果的自洽。
领域模型的核心组成元素:
- 实体:实体必须有唯一的ID,这个ID最好是一个技术ID,和业务无关,以不变应万变。实体有生命周期,有状态(用值对象来描述状态),实体通过ID进行区分。
- 值对象,值对象没有独立的生命周期,通过属性判断相等性,因此在开发时可以用基本变量、常量、枚举类或者重写了hashcode和equals方法的类来表示值对象。值对象附属于某一个实体。
- 聚合根,聚合是一组相关对象的集合,聚合根作为聚合内唯一可以进行数据修改和访问的单元。每个聚合都会有一个聚合根和聚合的边界Boundary。
从构件重构到协作开发
真正的危机,来源于在正确的时间做不正确的事。没有在正确的时间,为下一步做出积累,这才是危机的根源。
重构一般是指把代码优化,便于再修改和开发的一种过程。重构并不是说,我把原来的代码全部推翻,重改架构,不是的,那叫重写,不叫重构。重构更像是装修,重写是推倒重盖,重构应该发生在代码调整过程中,不断重构的目标是为了提升代码的质量和构件的稳定性,保证构件的生命力。
重构要求开发者对自己构件发展有一个比较清晰的认识和掌握,根据构件的愿景来合理自我安排重构的工作任务,而不要期待架构师或者项目管理者给予关注。重构的时机往往发生在缺陷不断产生的时候,就可能意味着你需要静下心来考虑是不是因为代码的腐朽导致问题的增多。
人这一辈子没法做太多的事情,所以每一件都要做得精彩绝伦。
协作就是在和不同水平和不同知识结构的人打交道,和不同质量的代码打交道,共同推动整个项目的持续推进。协作开发的最核心要求就是每个开发者应自觉保证原子提交。只有遵守原子提交,才能保证构件在开发过程中,遇到多版本并行开发时,保证正确的合并和回滚,为多版本计划调整的快速响应能力赋能。
我们所说的原子提交是每次提交都能够实现一个小功能、小需求或者小业务场景,解决一个缺陷或者一个需求变更。建立原子提交习惯是让每个开发人员都形成团队集体意识的一个过程。如果完全从每个人自己的效率出发,则这样的要求会显然引入额外的成本。
代码的分支管理包括master+大版本号和release+大版本号两条主线,开发人员最好以特性团队或者公司为限,fork自己的私有的构件代码仓库,例如huateng、admin、deal的仓库。开发人员在自己的私有仓库开发和私有分支上开发代码,在经过单元测试之后向主仓库的master分支发起merge request请求,由审核人员审核代码请求之后合并到master分支,同时进入sonar中,sonar反馈静态代码质量,然后由审核人员将构件模块新代码提交到release发布分支,整个代码的发布和进入中央仓库的操作由配置部署人员管理维护。权责明确,是TS整体开发质量管理的关键。
协作的过程中开发者应该扮演遵奉者(Confirmist)角色,当上游或者接口尚未开发好时,要自己事先预留模拟接口。开发者最好允许程序能够根据模拟接口进行契约测试。契约测试是一种针对外部服务的接口进行的测试,它能够验证服务是否满足消费方期待的契约。
协作过程产生的一些核心概念:
- 代码库(repository):存放代码的仓库。
- 代码提交:在本地完成代码之后,将代码提交到代码仓库中,如果是Git,则提交本地仓库中,最好每日将代码同步到远端服务器中fork的镜像仓库中。
- 代码审查(check):在代码需要合并到远端代码服务器的开发代码库之前,代码库的管理者或者其他审查者会对代码进行全方位的审查和验证,并将符合要求的代码合并到目标代码库,或者拒绝不符合要求的代码提交。
- 构建(build):通过预处理、编译、链接、打包等步骤将源代码或者资源文件构建成目标文件。
- 部署(deployment):将软件产品部署到各个测试环境和生产环境。
在稳定和重构的天平上,稳字当头。首先从局部到整体,为配合新平台建设工作,保证核心交易引擎的高效、稳定和可持续发展,根据六大平台系统规划,建设TS系统有利于精简交易系统功能。TS系统作为新一代核心系统之一,保证其稳定性是重中之重。这就要求代码的管理必须稳字当头;当架构师把所有周边问题都已经定义和解决清楚,也就是把边界问题解决之后,我们就可以大胆的深入核心,把核心做的更加强壮和稳定。
总结
代码很多人都会写。但是,能把代码写到优美,把结构设计的足够灵活,并且令人赞叹的人却很少。尤其在中国这样的大环境里。大部分人都是为了生活而奔波。编程只不过是一份工作而已。请记住:无论任何时候用心写代码吧,绝不会吃亏,也绝不会上当。用心写代码的标准体现在代码质量上,代码是否覆盖了足够的单元测试(至少主流程),代码是否针对所有接口提供了契约测试,代码是否进行了合理的抽象,是否实现了接口和实现的剥离,并提供了默认内存级别的实现。在我们追逐云计算、深度学习、区块链这些技术热点的时候,静下心来问问自己我们是不是真的掌握了OOD,掌握了安身立命的根本,编程能力;在我们强调工程师要具备业务Sense,产品Sense,数据Sense,算法Sense等等的时候,是不是忽略了对工程能力的要求。现在仍然很多同学不了解SOLID原则,不懂设计模式,不会画UML图,或者只是知道,但从来不会运用到实践中,不能实操TDD的开发,让自我无限陷入编写代码、产生缺陷、修复缺陷的循环中不能自拔。
通常在一个项目新起的时候,项目代码的可读性,维护性都会做的很好。但在项目的维护阶段,由于项目外延的进一步膨胀,伴随不同背景不同能力的开发人员的进场和离场,代码的可读性和可维护性就会渐渐的变差,这个是一个项目进行过程中不可避免的,聪明的团队通常会通过一些手段比如代码质量扫描,代码review等手段来降低代码的腐化速度,还会在在需求的开发过程中安排一定资源的代码重构的任务,去不断清理和割除腐化的代码,而一个好的代码架构,也会在一定程度上制约开发人员“生产”腐化代码的可能,从而降低了代码的腐化速度。一个好的开发架构绝不只是一个类似Spring的技术框架,它的设计重点是通过观察开发者的行为,通过技术手段落实重要的约束规则检查,确保所有人遵从代码架构的规则编写代码,通过规则的制约,可以很好的制止一些恶劣的代码的产生。
当前开发管理的主要问题:
- 开发者竞相陷入到不断迭代的泥沼中不能自拔,缺陷不论如何努力都修不完,浪费了开发者大量的时间,这个问题是需要解决的。
- 沟通成本是最高的成本。沟通问题占据了开发者大量的时间。
- 单元测试从架构支持上,到开发者实际工作中都做的不够好,太依赖于集成测试和测试环境。
- 管理体系在失去强力的领导能力之后,渐渐失范和堕落,项目推进能力大打折扣,项目的火车依赖惯性继续推进,随时有可能停滞。
- 公司自有人员的培养没有到位,没有充分调动起自有人员的积极性。
绿蚁新醅酒,红泥小火炉。晚来天欲雪,能饮一杯无? --白居易
虽然有很多问题,我们也需要新的技术手段或者管理制度安排来不断改进开发过程。没有思想和不做总结是最可怕的。目前TS的开发管理体系去努力尝试解决项目开发过程中如何设计和开发各个组件,如何定义异常,如何处理页面数据,如果与外界交互,如何推进人员职责落实和开发质量等,不过还有很多不足,也需要新的思路和能力慢慢推进继续改进。整个开发体系是在不断实践过程和斗争过程中逐步形成的,几乎每个团队的开发体系都是大同小异,存在自己显著的特征,这些特征往往存在的价值都是为了解决特定的问题。
最后,有幸在这一年里为辅助的模块开发建立起了一套基本的开发和配套体系。
但心里仍有很多矛盾,坦白说已经很久都忙于事务性的问题之中,不能自拔,很久没有继续进步了,接下来我需要静下心类思考一些更有价值的东西,继续追求自己非技术领域的理想。
参考文献
架构即未来
你写的代码,是别人的噩梦吗?
软件无用论
程序员,如何摆脱平庸?
为什么有些程序员悄无声息渡过35岁中年危机
CWAP框架白皮书
团队开发框架实战—CQRS架构
如何构建一个交易系统
代码管理核心技术与实践
程序员你为什么这么累
阿里巴巴Java开发手册
数据库性能优化详解
事件是一等公民
如何评价曾国藩
SpringBoot揭秘快速构建微服务体系
下一篇准备总结一下TS的开发管理过程和经验教训,敬请期待!!!
网友评论