架构设计看起来是一个很空洞的话题,一般都是战略定位,战术执行,而本文分享的主要是战术方面的技巧。
目录
1. API与SPI分离
框架或组件通常有两类客户,一个是使用者,一个是扩展者,具体可参看《API/SPI可拓展性设计》
API和SPI,在设计时,尽量把它们隔离开,也就是说,使用者是看不到扩展者写的实现的,比如:一个Web框架,它有一个API接口叫Action,里面有个execute()方法,是给使用者用来写业务逻辑的,然后,Web框架有一个SPI接口给扩展者控制输出方式,比如用velocity模板输出还是用json输出等,
如果这个Web框架使用一个都继承Action的VelocityAction和一个JsonAction做为扩展方式,要用velocity模板输出的就继承VelocityAction,要用json输出的就继承JsonAction,这就是API和SPI没有分离的反面例子,SPI接口混在了API接口中,
合理的方式是,有一个单独的Renderer接口,有VelocityRenderer和JsonRenderer实现,Web框架将Action的输出转交给Renderer接口做渲染输出。
2. 服务域/实体域/会话域分离
任何框架或组件,总会有核心领域模型,比如:Spring的Bean,Struts的Action,Dubbo的Service,Napoli的Queue等等。这个核心领域模型及其组成部分称为实体域,它代表着我们要操作的目标本身,实体域通常是线程安全的,不管是通过不变类,同步状态,或复制的方式,服务域也就是行为域,它是组件的功能集,同时也负责实体域和会话域的生命周期管理,比如Spring的ApplicationContext,Dubbo的ServiceManager等,
服务域的对象通常会比较重,而且是线程安全的,并以单一实例服务于所有调用,
什么是会话?就是一次交互过程,会话中重要的概念是上下文,什么是上下文?比如我们说:“老地方见”,这里的“老地方”就是上下文信息,为什么说“老地方”对方会知道,因为我们前面定义了“老地方”的具体内容,所以说,上下文通常持有交互过程中的状态变量等,会话对象通常较轻,每次请求都重新创建实例,请求结束后销毁。简而言之:把元信息交由实体域持有,把一次请求中的临时状态由会话域持有,由服务域贯穿整个过程。
3. 在重要的过程上设置拦截接口
如果你要写个远程调用框架,那远程调用的过程应该有一个统一的拦截接口,如果你要写一个ORM框架,那至少SQL的执行过程,Mapping过程要有拦截接口,如果你要写一个Web框架,那请求的执行过程应该要有拦截接口,等等,没有哪个公用的框架可以Cover住所有需求,允许外置行为,是框架的基本扩展方式,这样,如果有人想在远程调用前,验证下令牌,验证下黑白名单,统计下日志,如果有人想在SQL执行前加下分页包装,做下数据权限控制,统计下SQL执行时间,如果有人想在请求执行前检查下角色,包装下输入输出流,统计下请求量,等等,就可以自行完成,而不用侵入框架内部,拦截接口,通常是把过程本身用一个对象封装起来,传给拦截器链,比如:远程调用主过程为invoke(),那拦截器接口通常为invoke(Invocation),Invocation对象封装了本来要执行过程的上下文,并且Invocation里有一个invoke()方法,由拦截器决定什么时候执行,同时,Invocation也代表拦截器行为本身,这样上一拦截器的Invocation其实是包装的下一拦截器的过程,
直到最后一个拦截器的Invocation是包装的最终的invoke()过程,同理,SQL主过程为execute(),那拦截器接口通常为execute(Execution),原理一样,当然,实现方式可以任意,上面只是举例。
4. 重要的状态的变更发送事件并留出监听接口
这里先要讲一个事件和上面拦截器的区别,拦截器是干预过程的,它是过程的一部分,是基于过程行为的,而事件是基于状态数据的,任何行为改变的相同状态,对事件应该是一致的,
事件通常是事后通知,是一个Callback接口,方法名通常是过去式的,比如onChanged(),
比如远程调用框架,当网络断开或连上应该发出一个事件,当出现错误也可以考虑发出一个事件,这样外围应用就有可能观察到框架内部的变化,做相应适应。
5. 扩展尽可能职责单一,具有可组合性
比如,远程调用框架它的协议是可以替换的,如果只提供一个总的扩展接口,当然可以做到切换协议,但协议支持是可以细分为底层通讯,序列化,动态代理方式等等,如果将接口拆细,正交分解,会更便于扩展者复用已有逻辑,而只是替换某部分实现策略,当然这个分解的粒度需要把握好。
6. 微核插件式,平等对待第三方
大凡发展的比较好的框架,都遵守微核的理念,Eclipse的微核是OSGi,Spring的微核是BeanFactory,Maven的微核是Plexus,通常核心是不应该带有功能性的,而是一个生命周期和集成容器,这样各功能可以通过相同的方式交互及扩展,并且任何功能都可以被替换,
如果做不到微核,至少要平等对待第三方,即原作者能实现的功能,扩展者应该可以通过扩展的方式全部做到,原作者要把自己也当作扩展者,这样才能保证框架的可持续性及由内向外的稳定性。
7. 不要控制外部对象的生命周期
比如上面说的Action使用接口和Renderer扩展接口,框架如果让使用者或扩展者把Action或Renderer实现类的类名或类元信息报上来,然后在内部通过反射newInstance()创建一个实例,这样框架就控制了Action或Renderer实现类的生命周期,Action或Renderer的生老病死,框架都自己做了,外部扩展或集成都无能为力,好的办法是让使用者或扩展者把Action或Renderer实现类的实例报上来,框架只是使用这些实例,这些对象是怎么创建的,怎么销毁的,都和框架无关,框架最多提供工具类辅助管理,而不是绝对控制。
8. 可配置一定可编程,并保持友好的CoC约定
因为使用环境的不确定因素很多,框架总会有一些配置,一般都会到classpath直扫某个指定名称的配置,或者启动时允许指定配置路径,做为一个通用框架,应该做到凡是能配置文件做的一定要能通过编程方式进行,否则当使用者需要将你的框架与另一个框架集成时就会带来很多不必要的麻烦,另外,尽可能做一个标准约定,如果用户按某种约定做事时,就不需要该配置项。比如:配置模板位置,你可以约定,如果放在templates目录下就不用配了,如果你想换个目录,就配置下。
9. 区分命令与查询,明确前置条件与后置条件
这个是契约式设计的一部分,尽量遵守有返回值的方法是查询方法,void返回的方法是命令,查询方法通常是幂等性的,无副作用的,也就是不改变任何状态,调n次结果都是一样的,比如get某个属性值,或查询一条数据库记录,
命令是指有副作用的,也就是会修改状态,比如set某个值,或update某条数据库记录,
如果你的方法即做了修改状态的操作,又做了查询返回,如果可能,将其拆成写读分离的两个方法,比如:User deleteUser(id),删除用户并返回被删除的用户,考虑改为getUser()和void的deleteUser()。另外,每个方法都尽量前置断言传入参数的合法性,后置断言返回结果的合法性,并文档化。
10. 增量式扩展,而不要扩充原始核心概念
我们平台的产品越来越多,产品的功能也越来越多,平台的产品为了适应各BU和部门以及产品线的需求,势必会将很多不相干的功能凑在一起,客户可以选择性的使用,为了兼容更多的需求,每个产品,每个框架,都在不停的扩展,而我们经常会选择一些扩展的扩展方式,也就是将新旧功能扩展成一个通用实现,我想讨论是,有些情况下也可以考虑增量式的扩展方式,也就是保留原功能的简单性,新功能独立实现,我最近一直做分布式服务框架的开发,就拿我们项目中的问题开涮吧。
比如:远程调用框架,肯定少不了序列化功能,功能很简单,就是把流转成对象,对象转成流,但因有些地方可能会使用osgi,这样序列化时,IO所在的ClassLoader可能和业务方的ClassLoader是隔离的,需要将流转换成byte[]数组,然后传给业务方的ClassLoader进行序列化,为了适应osgi需求,把原来非osgi与osgi的场景扩展了一下,这样,不管是不是osgi环境,都先将流转成byte[]数组,拷贝一次,然而,大部分场景都用不上osgi,却为osgi付出了代价,而如果采用增量式扩展方式,非osgi的代码原封不动,再加一个osgi的实现,要用osgi的时候,直接依赖osgi实现即可。
再比如:最开始,远程服务都是基于接口方法,进行透明化调用的,这样,扩展接口就是,invoke(Method method, Object[] args),后来,有了无接口调用的需求,就是没有接口方法也能调用,并将POJO对象都转换成Map表示,因为Method对象是不能直接new出来的,我们不自觉选了一个扩展式扩展,把扩展接口改成了invoke(StringmethodName, String[] parameterTypes, String returnTypes, Object[] args),导致不管是不是无接口调用,都得把parameterTypes从Class[]转成String[],如果选用增量式扩展,应该是保持原有接口不变,增加一个GeneralService接口,里面有一个通用的invoke()方法,
和其它正常业务上的接口一样的调用方式,扩展接口也不用变,只是GeneralServiceImpl的invoke()实现会将收到的调用转给目标接口,这样就能将新功能增量到旧功能上,并保持原来结构的简单性。
再再比如:无状态消息发送,很简单,序列化一个对象发过去就行,后来有了同步消息发送需求,需要一个Request/Response进行配对,采用扩展式扩展,自然想到,无状态消息其实是一个没有Response的Request,所以在Request里加一个boolean状态,表示要不要返回Response,如果再来一个会话消息发送需求,那就再加一个Session交互,然后发现,原来同步消息发送是会话消息的一种特殊情况,所有场景都传Session,不需要Session的地方无视即可。如果采用增量式扩展,无状态消息发送原封不动,同步消息发送,在无状态消息基础上加一个Request/Response处理,会话消息发送,再加一个SessionRequest/SessionResponse处理。
11. 健壮性设计
1. 检查重复的jar包
最痴呆的问题,就是有多个版本的相同jar包,会出现新版本的A类,调用了旧版本的B类,而且和JVM加载顺序有关,问题带有偶然性,误导性。遇到这种莫名其妙的问题,最头疼,所以,第一条,先把它防住,在每个jar包中挑一个一定会加载的类,加上重复类检查。
static {
Duplicate.checkDuplicate(Xxx.class);
}
public final class Duplicate {
private Duplicate() {}
public static void checkDuplicate(Class cls) {
checkDuplicate(cls.getName().replace('.', '/') + ".class");
}
public static void checkDuplicate(String path) {
try {
// 在ClassPath搜文件
Enumeration urls = Thread.currentThread().getContextClassLoader().getResources(path);
Set files = new HashSet();
while (urls.hasMoreElements()) {
URL url = urls.nextElement();
if (url != null) {
String file = url.getFile();
if (file != null && file.length() > 0) {
files.add(file);
}
}
}
// 如果有多个,就表示重复
if (files.size() > 1) {
logger.error("Duplicate class " + path + " in " + files.size() + " jar " + files);
}
} catch (Throwable e) { // 防御性容错
logger.error(e.getMessage(), e);
}
}
}
2. 检查重复的配置文件
配置文件加载错,也是经常碰到的问题,用户通常会和你说:“我配置的很正确啊,不信我发给你看下,但就是报错”,然后查一圈下来,原来他发过来的配置根本没加载,平台很多产品都会在classpath下放一个约定的配置,如果项目中有多个,通常会取JVM加载的第一个,为了不被这么低级的问题折腾,和上面的重复jar包一样,在配置加载的地方,加上:
Duplicate.checkDuplicate("xxx.properties");
3. 检查所有可选配置
必填配置估计大家都会检查,因为没有的话,根本没法运行,但对一些可选参数,也应该做一些检查。
4. 异常信息给出解决方案
在给应用排错时,最怕的就是那种只有简单的一句错误描述,啥信息都没有的异常信息。比如上次碰到一个Failed to get session异常,就这几个单词,啥都没有,哪个session出错? 什么原因Failed?最好的异常信息,应给出解决方案,比如上面可以给出:"从10.20.16.3到10.20.130.20:20880之间的网络不通,请在10.20.16.3使用telnet 10.20.130.20 20880测试一下网络,如果是跨机房调用,可能是防火墙阻挡,请联系SA开通访问权限"等等,上面甚至可以根据IP段判断是不是跨机房。可以把常见的错误故意犯一遍,看看错误信息能否自我搞定问题,或者把平时支持应用时遇到的问题及解决办法都写到异常信息里。
5. 日志信息包含环境信息
每次应用一出错,应用的开发或测试就会把出错信息发过来,询问原因:用的哪个版本呀?是生产环境还是开发测试环境?哪个注册中心呀?哪个项目中的?哪台机器呀?哪个服务?所以,日志中最好把需要的环境信息一并打进去,最好给日志输出做个包装,统一处理掉,免得忘了。
public void error(String msg, Throwable e) {
delegate.error(msg + " on server " + InetAddress.getLocalHost() + " using version " + Version.getVersion(), e);
}
public final class Version {
private Version() {}
private static final Logger logger = LoggerFactory.getLogger(Version.class);
private static final Pattern VERSION_PATTERN = Pattern.compile("([0-9][0-9\\.\\-]*)\\.jar");
private static final String VERSION = getVersion(Version.class, "2.0.0");
public static String getVersion(){
return VERSION;
}
public static String getVersion(Class cls, String defaultVersion) {
try {
// 首先查找MANIFEST.MF规范中的版本号
String version = cls.getPackage().getImplementationVersion();
if (version == null || version.length() == 0) {
version = cls.getPackage().getSpecificationVersion();
}
if (version == null || version.length() == 0) {
// 如果MANIFEST.MF规范中没有版本号,基于jar包名获取版本号
String file = cls.getProtectionDomain().getCodeSource().getLocation().getFile();
if (file != null && file.length() > 0 && file.endsWith(".jar")) {
Matcher matcher = VERSION_PATTERN.matcher(file);
while (matcher.find() && matcher.groupCount() > 0) {
version = matcher.group(1);
}
}
}
// 返回版本号,如果为空返回缺省版本号
return version == null || version.length() == 0 ? defaultVersion : version;
} catch (Throwable e) { // 防御性容错
// 忽略异常,返回缺省版本号
logger.error(e.getMessage(), e);
return defaultVersion;
}
}
}