全文字,预计阅读时间26分钟
一、背景介绍
在平时工作中,我们会把通用的代码,合并到一个通用的SDK中,增加大家工作效率,本文主要分享我们在编写SDK时候的准入标准以及相关编码思想。
首先需要回答,为什么要编写SDK?
1.避免重复造轮子
2.减少线上bug概率
1.1避免重复造轮子
好的sdk可以帮助团队省时省力,将相同的功能抽象到一个通用sdk中,前人栽树后人乘凉。
1.2减少线上bug概率
1.经过大家共同的优化出bug的可能性较低,即使出bug,也只需要修改sdk即可;
2.若每个代码库都实现一遍,那每个代码库就都需要修复这个bug;
3.每个同学能力不同,写出代码的质量参差不齐;
二、遵循的设计理念SOLID
在程序设计的领域内,SOLID是由RobertC.Martin提出的面相对象编程以及面向对象设计的五个基本原则的缩写,这五个原则分别是:
-单一职责原则(SingleResponsibilityPrinciple)
-开闭原则(OpenClosePrinciple)
-里氏替换原则(LiskovSubstitutionPrinciple)
-接口隔离原则(InterfaceSegregationPrinciple)
-依赖反转原则(DependenceInversionPrinciple)
在编写SDK中,我们坚信好的代码是设计出来的,而不是编写出来的这一理念,所以在设计之初我们就会按照这五大原则,逐一考量我们是否的设计是否足够优秀,我们是否违背了某项原则。
接下来我们来逐一介绍我们对于这五大原则的理解:
2.1单一职责原则
定义
类的设计,尽量做到只有一个可以引起它变化的原因
理解
单一职责原则的存在是为了保证对象的高内聚、保证同一对象的细粒度。在程序的设计上,如果我们采用了单一职责原则,那么就要注意,开发代码过程中不要设计那些功能五花八门、包含很多很多不相关的功能的类,这样的类我们可以认为是违反单一职责原则的。按照单一职责原则设计的类中,应该有且只有一个改变因素;
举例子
packagephone//只负责调整电话音量typeMobileSoundinterface{//音量+AddSound()error//音量-ReduceSound()error}//只负责调整照片格式typeMobilePhotointerface{//亮度Brightness()error//纵横比AspectRatio()error}
这段代码是对于手机的两个类的构造,在设计的时候,我们将手机的音量控制和手机图片的调整类进行了区分,在同一个类中不同功能的共同载体分别是音量控制和图片调整,这两个类中的功能是不产生依赖的,相互平行的关系,当手机的配置发生改变,那么这两个类中的功能都会产生一定的变化;
如果我们要对其中一个类进行调整,那么只需要在相应的interface中进行功能的优化即可,不会影响到其他功能的结构。
2.2开闭原则
定义:一个软件实体,应该对扩展开放,对修改关闭;
理解
针对于这个问题的产生是来自于对于代码进行维护的时候,如果对旧代码进行修改,那么可能会引入错误,这可能会导致我们对整个功能进行重新架构,并且重新测试;
为了防止这种情况的发生,我们可以采用开闭原则,我们只对代码进行添加功能,扩展类中的功能,而不去修改原先的功能,这样可以避开因为修改老代码而产生的坑(深有体会);
举例子
//只负责调整电话音量,同时具备一键最大和一键最小的功能typeMobileSoundinterface{//音量+AddSound()error//音量-ReduceSound()error//静音Mute()error//一键最大MaxSound()error}
还是这个手机的类,如果后期业务需求增加,要求我们加入静音和一键音量最大的功能,那么我们可以直接在这个音量控制接口中进行功能扩展,而不是去在音量加减的功能里进行修改,这样可以避免因为对内部逻辑的调整而产生的功能架构问题。
注意点
开闭原则是一个非常虚的原则,我们需要在提前预期好变化并做出规划,然后需求的变化总是远远超过我们的预期,遵循这个原则我们应该把频繁变化的部分做出抽象,但却不能将每个部分都刻意的进行抽象;
如果我们无法预期变化,就不要去做刻意的抽象,我们应该拒绝并不成熟的抽象。
2.3里氏替换原则
定义
所有引用基类的地方必须能透明的使用其子类的对象;
理解
子类对象替换父类对象的时候,程序本身的逻辑不能发生变化,同时不能对程序的功能造成影响;
举例
仍然是用手机来举例子,我们定义的手机类中,加入了一个充电功能接口;
-其中包含了无线充电和有线充电两个功能;
typeChargeinterface{//有线充电ChargeWithLine()error//无线充电ChargeWithoutLine()error}
-但是我们并没有想到钛金手机这么一款高端商务上流手机并不具备无线充电的功能;
因为我们定义的父类并不能由子类完全替换,也是手机,但是因为它的功能并不完全具备手机类的功能,所以导致这个问题的产生,那么如何来解决这个问题呢?
我们可以将手机类的充电功能进行拆分,在父类中,我们只定义充电功能,那么我们就可以在子类里面设计具体的充电方式,来完善这个功能的接口。我们在手机类中仅定义了最基础的方法集,通过子接口SpecialPhone添加ChargeWithLine方法;
typeMobilPhoneinterface{Name()stringSize()int}typeNormalChargeinterface{MobilPhoneChargeWithLine()errorChargeWithoutLine()error}typeSpecialChargeinterface{MobilPhoneChargeWithLine()error}
-我们再通过SpecialPhone来提供对MobilPhone的基本实现:
typeSpecialPhonestruct{namestringsizeint}funcNewSpecialPhone(namestring,sizeint)*SpecialPhone{returnSpecialPhone{name,size,}}func(mobile*SpecialPhone)Name()string{returnmobile.name}func(mobile*SpecialPhone)Size()int{returnmobile.size}
-最后,Mobil通过聚合SpecialPhone实现MobilPhone接口,通过提供ChargeWithLine方法实现Mobil子接口:
typeMobilstruct{SpecialPhone}funcNewMobil(namestring,sizeint)MobilPhone{returnMobil{*NewSpecialPhone(name,size),}}func(mobile*Mobil)ChargeWithLine()error{returnnil}
注意点
在项目中,采用里氏替换原则时,尽量避免子类的特殊性,采用该原则的目的就是增加健壮性,在版本升级时也能有很好的兼容性,而一旦有了特殊性,那么代码间的耦合关系就会变得极其复杂;
2.4接口隔离原则
定义
-客户端不应该依赖它不需要的接口;
-类间的依赖关系应该建立在最小的接口上;
理解
-客户端不能依赖于它所不需要使用的接口,这里的客户端我们可以理解为接口的调用方或者使用者;
-尽量保证一个接口对应一个功能模块;
接口隔离原则和单一指责原则的区别是什么?
单一职责原则主要是在业务逻辑层面上,保证一个类对应一个职责,注重的是对于模块的设计;
而接口隔离原则则是主要针对接口的设计,对不同的模块提供不同的接口。
举例
比如下面这个例子,这段代码就是清晰的将接口按照不同的返回值进行了区分,这样在调用的时候就可以避免因为不清楚返回的认证信息状态而造成的逻辑上的错误。
//CarOwnerCertificationSuccessScheme返回认证成功的schemefuncCarOwnerCertificationSuccessScheme()string{returnCertificationSuccessScheme}//CarOwnerCertificationFailedScheme返回认证失败的schemefuncCarOwnerCertificationFailedScheme()string{returnCertificationFailedScheme}//CarOwnerMessageRecallScheme返回车主认证邀请的schemefuncCarOwnerMessageRecallScheme()string{returnMessageRecallScheme}
注意点
同单一职责原则类似:
-在接口设计时,如果粒度太小会导致接口数据太多,开发人员被众多的接口淹没;
-如果粒度太大,又可能导致灵活性降低。无法支持业务变化;
-我们需要深入了解业务逻辑,不可盲目照抄大师的接口;
2.5依赖倒置原则
定义
高层模块不能依赖底层模块,两者都应该依赖于其抽象;
抽象不应该依赖细节;
细节应该依赖抽象;
理解
1.好的抽象更稳定,可以覆盖到更多的细节;
2.具体的业务则要面临着各种方法和细节,非常多样,是不确定不稳定的;
举例
比如下面这个例子,如果我们想要充实手机的功能,那么我们在定义的时候,对手机这个类只定义抽象类,类里面并不涉及到具体的功能设计,具体的功能设计我们要放在功能里面,这样就可以保证类结构的稳定,不会因为功能的调整而导致整个手机类的调整。
packagephonetypePhoneinterface{//音量调整MobileSound//照片调整MobilePhoto}//只负责调整电话音量typeMobileSoundinterface{//音量+AddSound()error//音量-ReduceSound()error}//只负责调整照片格式typeMobilePhotointerface{//亮度Brightness()error//纵横比AspectRatio()error}
注意点
依赖倒置最难支持就是找到抽象,好的抽象可以减少大量的代码,糟糕的抽象,就突增工作量;
关于设计理念,我们坚信好的系统是设计出来的,而不是开发出来的。所以在任何项目的启动开发之前,我们都会进行极其严格的设计评审。
三、遵循的编码原则
3.1稳定、高效
公共库是提供给众多业务方使用的第三方组件,如果公共库运行时程序崩溃,会危及业务方的项目,可能会造成线上事故,所以稳定是一个公共库的基本保证。
3.2暴露异常
异常可以通过日志和接口返回暴露给用户。对于异常情况一定要打日志,方便用户排查具体问题,并且约定错误码,通过错误码和errormessage将错误信息暴露给用户。在公共库内部,所有可能返回错误的地方都不能忽略。
参数校验
用户很有可能无意中给你封装的方法传入了不合法的参数,你的方法要能处理任何不合法的参数,让你的公共库不至于因为传入不合法的参数造成崩溃。
Panic和Recover
在Go语言中,一些非法的操作会导致程序panic,例如访问超出slice边界的元素时,通过值为nil的指针访问结构体的字段,关闭已经关闭的channel等。
当一个panic在调用栈中的没有被recover时,程序终将因堆栈溢出而终止。在编写公共库时,我们不希望某个地方出现异常就导致整个程序崩溃。正确的做法是recover这个panic,转换成error返回给调用方,同时输出到日志,使得公共库及整个工程项目还能保持正常运行。
如示例代码:
funcserver(workChan-chan*Work){forwork:=rangeworkChan{gosafelyDo(work)}}funcsafelyDo(work*Work){deferfunc(){iferr:=recover();err!=nil{log.Println("workfailed:",err)}}()do(work)}
在每个对包外公开的函数和方法都应该recover到内部的panic并且将这些panic转换为错误信息,防止内部panic造成程序终止.
3.3测试
封装好一个函数或者功能模块后,我们需要测试其逻辑的正确性和函数的性能,这就需要单元测试和性能测试。单元测试的重点在于验证程序设计或实现的逻辑是否正确,而压力测试的重点在于测试程序性能,确认程序在高并发的情况下还能保持稳定运行。
单元测试
单元测试就是针对某一个函数或者进行测试,通过各种可能的样例(即函数的输入和期望的输出)验证函数各分支是否都得到了预期的结果,或者可能的问题都能被预知并提前处理。通过单测能解决以下问题:
-提早发现发现程序设计或实现上的逻辑错误,便于及时定位解决,确保每个函数是可运行的,运行结果是正确的。
-一次编写,多次运行。编写好一个函数的单元测试后,后续对函数的修改或重构,都可以使用此前编写的单测来确保修改后的函数依然保持正确的逻辑,防止手动测试遗漏一些边界情况,给代码留下隐患。
压力测试
压力测试的重点是测试程序在高并发的情况是否还能保持稳定运行,以及测试程序能够承受的并发量。公共库除了保证逻辑正确外,还应该保证其在高并发的情况下还能正常运行。
3.4保持向下兼容
公共库应该版本向下兼容,即使更新了公共库版本的用户,其此前使用旧版本公共库的项目代码还能正常运行。
-公共库对外暴露的接口应该避免改动,其函数签名不应该再改动,对函数逻辑的修改应该让用户无感知。
-如果此前的接口确实无法满足需要,那么可以升级一个库版本,在新版本中开发一个新的接口,而不是在原版本中直接对接口进行修改。
3.5减少外部依赖
公共库应该尽量减少外部依赖,应该避免使用不稳定的外部依赖。在封装公共库时,也应该精简代码与功能,以最精简的形态提供最核心的功能,避免公共库的体积过大
3.6易用性
统一调用
SDK应主动封装复杂的调用方式,屏蔽底层复杂逻辑,尽量提供给使用方简单的调用形式。让使用者能在几行代码中实现功能,减少使用者在调的流程和对参数的理解成本。同时提供友好的提示,便于使用者调用调试。
这些需要在接口设计阶段做到极致,不仅要调用简单,又在提供基础功能的基础上,支持扩展定制化。例如,我们在工程中,通常会将仅需一次加载,整个工程生命周期生效的参数配置化,以一个配置文件作统一管理。但实际上配置文件的格式分多种,例如yaml、json、xml等,可扩展性设计在对配置内容的含义、默认的配置项详细说明的基础上,支持多类配置文件的加载......等。
在接口设计时统一风格,可以给使用者留下专业的印象,同时它也可以传递出开发者SDK的设计理念。当出现跨平台同功能的使用时,可沿用平台风格,通过延续风格,让使用者更直观的调用SDK功能。
3.7可理解性
目录结构
代码的目录结构可以定义整个SDK的层次架构,我们可以通过目录大致知晓其内部实现围绕的主题。在目录结构定义上,应命名上直接突出主题,拆分明确,尽量不存在交集,不应让人去猜或等读完源码才知道在做什么。
好的拆分能避免重复代码并方便使用者查找,例如log、doc、util、config、test、build等能让人一眼明确该目录下所做的工作。同时目录层次也尽量不要太深,太深会一定程度上加重使用者负担。
.├──config//配置文件│├──agent.json│├──alarm.json│......├──docs//说明文档│├──introduce││└──index.html│├──api││└──index.html│├──index.html│├──LICENSE.md│......──test//单元测试│├──util││├──