一个可以代替冗长switch-case的消息分发小框架
在项目中,我需要维护一个应用层的字节流协议。这个协议的每条报文都是一个字节数组,数组的头两个字节表示消息的传送方向,第三、四个字节表示消息ID,也就是消息种类,再往后是消息内容、时间戳、校验码等……整个消息看起来差不多长这样:
Message Head | Message ID | Content | Timestamp | Checksum |
2 bytes | 2 bytes | n bytes | 8 bytes | 1 byte |
Message ID指定了消息类型,根据不同的消息类型,对Content有不同的解析方法。在处理报文的类中,我们不得不用一个switch-case结构去处理不同类型的报文:
1 switch(messageID){ 2 case 0x01: doSomething(); break; 3 case 0x02: doAnotherThing(); break; 4 case 0x03: doSomeOtherThing(); break; 5 ... 6 }
在某些报文的消息内容中还存在一个子消息ID,这个子ID会定义具体要实现的操作分类。于是在switch-case中,我们还要再加入嵌套的switch-case去处理对应的子ID:
1 switch(messageID){ 2 case 0x01: doSomething(); break; 3 case 0x02: doAnotherThing(); break; 4 case 0x03: 5 byte subID = getSubID(); 6 switch(subID) 7 case 0x01: process1In3(); break; 8 case 0x02: process2In3(); break; 9 ... 10 break; 11 ... 12 }
由于需求一直在变化,我们自身的功能也在演变,协议中经常会增减消息,对同一个消息的处理也常常会变化,渐渐地这个switch-case达到了300多行,而我经常要从这300多行中找到对应的消息去修改它的处理代码,稍不注意就会出错,真是苦不堪言。
一个大神同事提示我,可以用注解解决这个问题。定义一个Handler接口作为消息处理器,再定义一个注解去标示该处理器要处理的消息ID,然后提供一个Util类,利用反射机制读取注解,自动将不同的消息ID和对应的Handler加载到一个Map中,形成一个微型的消息分发处理框架。在这个框架的作用下,上面的switch-case语句变成了这样:
1 //存放消息ID和对应的MessageHandler 2 private Map<Byte, MessageHandler> handlersMap = null; 3 void init() { 4 //读取注解,将下列定义的HANDLER_1,2,3加载到handlersMap中 5 handlersMap = MessageHandlerUtil.loadMessageHandlers(this); 6 } 7 8 //接收消息的回调函数 9 public void onDataReceived(CommData command) { 10 byte commandID = getCommandId(command); 11 handlersMap.get(commandID).process(command);//调用对应的Handler处理 12 } 13 14 //消息ID为01的Handler 15 @Handle(messageID=0x01) 16 private final MessageHandler HANDLER_1 = (command) -> { doSomething(); }; 17 //消息ID为02的Handler 18 @Handle(messageID=0x02) 19 private final MessageHandler HANDLER_2 = (command) -> { doAnotherThing(); }; 20 //消息ID为03的Handler 21 @Handle(messageID=0x03) 22 private final MessageHandler HANDLER_3 = (command) -> { doSomeOtherThing(); };
在这个微型框架之下,要实现报文解析,只需要准备一个HashMap,在初始化时调用MessageHandlerUtil.loadMessageHandlers(this), MessageHandlerUtil这个辅助类会自动将类中所有带@Handle注解的MessageHandler加载到一个Map中并返回,用户在收到消息时,只需要从Map中拿到消息对应的Handler,调用Handler的process方法即可。在增减消息时,只需要增减带@Handle注解的MessageHandler;就算要修改某一个消息的处理,也能比较快速地定位到对应的Handler,在这个独立的Handler里面修改即可,比起之前的switch-case,可以说是更好地遵守了开放-封闭原则(对扩展开放,对更改封闭)。
不得不说,这个框架还是有一点小问题,那就是不能支持子消息的分类。在我负责的业务中,只有一个消息(记为A消息)有子消息,于是我偷懒定义了一个@SubHandle注解,在加载时将@SubHandler注解的MessageHandler加载到另一个Map中,当收到的消息为A消息时,再手动将对应的MessageHandler从第二个Map中取出。这种做法可以说是十分hard code了,还好我负责的业务比较固定,暂时还能用。另外,返回的HashMap是裸露的,用户可以随意修改,看起来似乎也不是很妥当。
周一来上班时,惊喜地发现大神把我的微型框架重构并加入了项目的通用类库中。重构后的框架加强了对数据结构的封装,并且实现了很强的通用性,可以支持三层以上的子消息结构,却只用了两个Map。下面我们就一起来看看他是怎么做的。
首先,还是通过客户代码来看一下这个框架的使用场景。(对于下面的分析,我们都假设消息是一个String类型以使叙述简洁。如果要处理byte数组或其他类型的消息,可以在客户代码中进行转型,或者像大神一样在框架中加入对byte数组的支持,在此就不赘述了)
1 private HandlerDispatcher<String> handlerDispatcher;//消息分发器 2 3 void init(){ 4 handlerDispatcher = new HandlerDispatcherImpl<>(); 5 handlerDispatcher.load(this); //将注解的Handler加载到分发器 6 } 7 8 public void onReceivedMessage(String data) { 9 //第一个char为消息ID 10 handlerDispatcher.getHandler(data.charAt(0)+"").process(data); 11 } 12 13 final Supplier<HandlerDispatcher<String>> supplier = () -> handlerDispatcher; 14 15 @Mapped("1") //第一层,处理消息ID为1的消息 16 final CustomizedHandler A = (data) -> { log("A"); }; 17 18 @Mapped("2") //第一层,处理消息ID为2的消息 19 final ParentHandler<String> B = ParentHandler.build(supplier, (data) -> (data.charAt(1)+"")); 20 21 @Parent("B") 22 @Mapped("1") //第二层,处理消息ID为2,子消息ID为1的消息 23 final ParentHandler<String> B1 = ParentHandler.build(supplier, (data) -> (data.charAt(2)+"")); 24 25 @Parent("A") 26 @Mapped("1") //第二层,处理消息ID为1,子消息ID也为1的消息 27 final CustomizedHandler A1 = (data) -> { log("A1"); }; 28 29 @Parent("B1") 30 @Mapped("1") //第三层,处理消息ID为1,子消息ID为2,子子消息ID为1的消息 31 final CustomizedHandler B11 = (data) -> { log("B11"); };
示例中定义了三层消息的解析器,用@Mapped(value = messageID)注解定义对应的消息ID,只有该注解的是第一层消息的解析器,同时定义了@Mapped和@Parent注解的是第二层及以下的解析器,其中@Parent(value = parentName)定义了其父消息的解析器名称。
除了能支持多层子消息以外,新的设计将消息分发这个过程封装到了HandlerDispatcher类中,更好地符合了单一指责原则,这样业务模块就能更专心地处理业务逻辑了,而HandlerDispatcher的实现类也可以随时根据业务来调整消息分发的逻辑。
至于上文中的Supplier和ParentHandler.build(),此处可能暂时看不懂,但它们并不是很重要,可以先跳过。
既然说到了HandlerDispatcher,我们就来说一下这个小框架的类结构,其实十分简单:
图1:消息分发小框架类图
各个类的关系图中已经比较清楚了,核心类HandlerDispatcher负责加载和分发消息到对应的Handler。框架提供了一个ParentHandler接口供用户在有子消息的场景使用,该接口对上层接口Handler中的process()方法做了默认实现:
1 @Override 2 default void process(T data) { 3 getHandlerDispatcher().getSubHandler(this, resolve(data)).process(data); 4 }
这是整个框架中我觉得比较聪明的地方之一,也或许是面向对象和面向过程的程序员的思维区别之一。在多级消息分发器的结构中,消息分发到下层的逻辑是由上层消息分发器定义的,而不是在用户模块中或者HandlerDispatcher中定义的。也就是说在整个框架中并没有一个全能选手统揽全局,每个对象都像流水线上的工人,只要把产品加工后传给下一个工人,就完成了自己的责任。
图2:消息分发结构示例
如果我们有一个上图所示的消息分发结构,那么顶层和中间层的消息处理器可以使用框架提供的ParentHandler,也可以使用自己定义的处理逻辑。如果使用ParentHandler,则process()方法会从HandlerDispatcher拿到当前Handle的子Handler进行处理,即自动实现了到下层的消息分发;如果用户需要一些特殊处理,比如分发到下层之前先打印出一些信息,或者要分发给多个子消息处理器,则可以继承ParentHandler并重写其中的process()方法,或者定义自己的Handler。
看到这里,我们应该已经基本熟悉了消息分发小框架的结构。下面我们来看一下HandlerDispatcher是如何加载和找到一个对应的Handler的。
在设计加载算法之前,再来复习一下使用场景:对于顶层消息,我们用handlerDispatcher.getHandler(getMessageIDSomehow()).process(data)来处理;顶层以下的消息,按照ParentHandler的默认实现,通过getHandlerDispatcher().getSubHandler(this, resolve(data)).process(data)处理。也就是说,HandlerDispatcher加载所有Handler之后需要达到两个效果, 一是通过messageID拿到顶层Handler,二是通过上层Handler和subID拿到对应的下层Handler。
对于第一点,只要用一个HashMap<String, Handler>就可以实现。第二点有点难办,考虑到消息的层级结构,我们先用一个树来保存不同层级的Handler。在加载时,读取@Parent来获取上下层级的对应关系。TreeNode类的定义如下:
1 public class TreeNode<T> { 2 T data; 3 List<TreeNode<T>> children = new ArrayList<>(); 4 TreeNode<T> parent; 5 6 public TreeNode(TreeNode<T> parent, T data){ 7 Objects.requireNonNull(parent); 8 this.parent = parent; 9 this.data = data; 10 parent.children.add(this); 11 } 12 13 public TreeNode(T data){ 14 this.data = data; 15 } 16 17 public List<TreeNode<T>> getChildren() { 18 return children; 19 } 20 21 public T getData() { 22 return data; 23 } 24 }
图2示意的框架如果用TreeNode装载,可以表示如下:
以上只是简略图,实际上TreeNode<T>是一个泛型类,泛型T代表树节点中存储的数据。为了表示messageID与Handler的对应关系,我们还会设计一个NodeData类把两者都存放起来,而这个NodeData就成为TreeNode<T>中对应的T。
1 class NodeData{ 2 Handler<V> handler; 3 String key; 4 5 public NodeData(String key, Handler<V> handler) { 6 Objects.requireNonNull(key); 7 this.key = key; 8 this.handler = handler; 9 } 10 11 public boolean accept(String anotherKey){ 12 return anotherKey != null && key.equals(anotherKey); 13 } 14 15 public Handler<V> getHandler() { 16 return handler; 17 } 18 }
按照TreeNode去存放后,我们就可以找到某一个父消息的子消息处理器了。但这样要求我们先遍历树,找到对应的父消息处理器;再遍历父消息处理器的孩子节点,找到子消息处理器。为了节省第一步的时间,我们把所有的父消息处理器和它们对应的树节点存在Map中,这样就可以直接按照父消息的id拿到父消息处理器了。
总结起来,我们一共设计了一个树,两个Map。将它们定义在HandlerDispatcherImpl类中作为私有域:
1 private Map<String, Handler<V>> topMap; 2 private Map<Handler<V>, TreeNode<NodeData>> nodeMap; 3 private TreeNode<NodeData> root;
下面我们来看一下HandlerDispatcherImpl中的load方法是怎么把Handler加载到这三个数据结构的。
首先,我们按照刚才的设计,将带注释的域分为两类:带@Parent的和不带@Parent的,分别放入两个数组中。
1 //Scan all fields 2 Class<?> instanceClass = instance.getClass(); 3 Field[] fields = instanceClass.getDeclaredFields(); 4 List<Field> topHandlerFields = new LinkedList<>(); 5 List<Field> subHandlerFields = new LinkedList<>(); 6 7 //Find fields with "@Mapped" annotation, insert them into 8 //topHandlerFields and subHandlerFields 9 for(Field field : fields){ 10 Mapped mapped = field.getAnnotation(Mapped.class); 11 if(mapped == null) continue; 12 Class<?> fieldType = field.getType(); 13 if(!Handler.class.isAssignableFrom(fieldType)) 14 continue; 15 Parent parent = field.getAnnotation(Parent.class); 16 if(parent == null) topHandlerFields.add(field); 17 else subHandlerFields.add(field); 18 }
注意,在这里我们对域类型做了一个判断,只有域是Handler的实现类时才会被加载,否则会被无视。
将所有域加载到两个数组中以后,首先处理顶层消息对应的域,把它们放到topMap中,同时也把对应的树节点存在nodeMap中。
1 //insert topHandlerFields into topMap; link them with tree 2 topHandlerFields.forEach(field -> { 3 String key = extractKeyFromField(field); 4 try { 5 field.setAccessible(true); 6 Handler<V> handler = (Handler<V>) field.get(instance); 7 topMap.put(key, handler); 8 NodeData nodeData = new NodeData(key, handler); 9 TreeNode<NodeData> treeNode = new TreeNode<NodeData>(root, nodeData); 10 nodeMap.put(handler, treeNode); 11 } catch (IllegalArgumentException | IllegalAccessException e) { 12 e.printStackTrace(out); 13 } 14 });
然后处理非顶层消息的处理器域。这里,考虑到用户不一定是按由顶向下的顺序定义的,加载时可能先读取到子处理器,后读取父处理器,这样就无法有效地形成一个完整的树结构。于是我们采用一种循环加载的方式,先加载父节点已在树中的处理器,并将已加载的域放在一个叫processed的链表中做记录;父节点还没有被加载的那些处理器留待下一次循环时再尝试加载。(不得不佩服大神同事的考虑十分周到……)
1 //link subHandlerFields with tree 2 List<Field> processed = new LinkedList<>(); 3 while(processed.size() < subHandlerFields.size()){ 4 subHandlerFields.stream().filter(field -> !processed.contains(field)).forEach(field -> { 5 linkWithParent(field, processed, instance); 6 }); 7 }
1 private void linkWithParent(Field field, List<Field> processed, Object instance) { 2 field.setAccessible(true); 3 Class<?> instanceClass = instance.getClass(); 4 //Firstly, see if we could find parent field 5 String key = extractKeyFromField(field); 6 String parentName = field.getAnnotation(Parent.class).value(); 7 Field parentHandlerField = null; 8 try { 9 parentHandlerField = instanceClass.getDeclaredField(parentName); 10 } catch (NoSuchFieldException | SecurityException e) { 11 log("Dude, your parent " + parentName + " doesn't exist."); 12 processed.add(field); 13 e.printStackTrace(); return; 14 } 15 //Try to get parentHandler, and its corresponding value in nodeMap 16 try { 17 parentHandlerField.setAccessible(true); 18 Handler<V> parentHandler = (Handler<V>) parentHandlerField.get(instance); 19 TreeNode<NodeData> parentNode = nodeMap.get(parentHandler); 20 if(parentNode == null){ 21 log("Parent " + parentName + " of field " + field + " not processed yet. Will wait a while."); 22 return; 23 } 24 //Add subHandler as a child to parentHandler 25 Handler<V> subHandler = null; 26 subHandler = (Handler<V>) field.get(instance); 27 NodeData subHandlerNodeData = new NodeData(key, subHandler); 28 TreeNode<NodeData> childNode = new TreeNode<>(parentNode, subHandlerNodeData); 29 nodeMap.put(subHandler, childNode); 30 processed.add(field); 31 log("Attached " + field); 32 }catch (IllegalArgumentException | IllegalAccessException e) { 33 //should not happen 34 e.printStackTrace(out); 35 processed.add(field); 36 } 37 }
搞定了加载,我们再来看看获取。顶层Handler的获取十分容易,只要从Map拿一下就好了:
1 public Handler<V> getHandler(String key) { 2 return topMap.get(key); 3 }
获取子Handler也不难,先从nodeMap中找到父节点对应的树节点,然后遍历其子节点就可以了。
1 public Handler<V> getSubHandler(Handler<V> parentHandler, String subKey) { 2 TreeNode<NodeData> parentNode = nodeMap.get(parentHandler); 3 final Predicate<TreeNode<NodeData>> SUBHANDLER_ACCEPTS_KEY = (treeNode) -> ( 4 ((NodeData)treeNode.getData()).accept(subKey) 5 ); 6 Result<TreeNode<NodeData>> result = new Result<>(); 7 parentNode.getChildren().stream().filter(SUBHANDLER_ACCEPTS_KEY).findFirst().ifPresent(result::set); 8 return result.isNULL() ? null : result.get().getData().getHandler(); 9 }
至此,我们基本完成了消息加载的小框架的核心代码啦。现在再看看开头的的示例程序中,我们看不懂的那一部分:
1 final Supplier<HandlerDispatcher<String>> supplier = () -> handlerDispatcher; 2 3 @Mapped("2") //第一层,处理消息ID为2的消息 4 final ParentHandler<String> B = ParentHandler.build(supplier, (data) -> (data.charAt(1)+""));
这里有两个问题。第一是ParentHandler.build。其实这是一个静态Util方法,用于快速生成一个ParentHandler的实现类。看一下这个方法的定义:
1 static <T> ParentHandler<T> build(final Supplier<HandlerDispatcher<T>> supplier, Resolver<T> resolver){ 2 ParentHandler<T> parentHandler = new ParentHandler<T>(){ 3 4 @Override 5 public String resolve(T data) { 6 return resolver.apply(data); 7 } 8 9 @Override 10 public HandlerDispatcher<T> getHandlerDispatcher() { 11 return supplier.get(); 12 } 13 14 }; 15 return parentHandler; 16 }
这里的Resolver是一个简单的接口,它的定义如下:
1 public interface Resolver<T> extends Function<T, String>{}
其实它就是一个Function接口,功能是输入消息类型T返回String,即定义一个返回子消息ID的方法。在build方法定义的ParentHandler中,通过resolver.apply(data)即可返回子消息的ID。
第二个问题是supplier。Supplier<T>类型是Java 1.8引进的一个接口,它的功能与Function类似,返回一个T类型,在这里我们定义它为HandlerDispatcher的供应者,它返回的用户定义的一个HandlerDispatcher对象。那么为什么不能直接用HandlerDispatcher对象呢?
我们先来复习一下Java类初始化的顺序:
- 当用户创建某类的对象或使用该类的静态变量/方法时,Java解释器会在classpath中寻找.class文件并加载。
- 进行所有的静态初始化。即某一个类的静态初始化只进行一次。
- 在内存堆中按需分配该对象的内存。
- 初始化该类的所有非静态变量至0或null。
- 按照类中变量的定义对变量进行初始化。
- 调用构造函数。
注意步骤5和6,JVM是先对类中的域进行初始化,再调用构造函数的。在我们的用户模块中,所有的Handler都定义为对象中的域,所以它们会先被初始化。而
1 handlerDispatcher = new HandlerDispatcherImpl<>();
是在构造函数中才被调用的。也就是说,如果我们不使用Supplier,我们必须要这么写:
@Mapped("2") //第一层,处理消息ID为2的消息 final ParentHandler<String> B = ParentHandler.build(handlerDispatcher, (data) -> (data.charAt(1)+""));
可是这里的handlerDispatcher是一个空指针。那么后续再通过handlerDispatcher查找子处理器的时候,会抛出空指针错误。
supplier定义了一个拿到handlerDispatcher对象的方法。虽然在定义supplier时,handlerDispatcher也是空的,但是由于我们传进去的是一个方法,所以没关系。
通过学习大神的代码,除了学到了一点大神的设计思路,还了解了一些Java 1.8的新特性,如流式编程和函数式编程,同时心里也产生了许多疑问。对于这些疑问,就让我们在以后的文章中慢慢道来吧。
消息分发小框架源码:戳这里