最近在开发中遇到一个Protostuff序列化问题,在这记录一下问题的根源;分析一下Protostuff序列化和反序列化原理;以及怎么样避免改bug。
1. 问题描述
有一个push业务用到了mq,mq的生产者和消费者实体序列化我们用的是Protostuff方式实现的。由于业务需要,我们要在一个已有的枚举类添加一种类型,比如:
1 public enum LimitTimeUnit { 2 NATURAL_DAY { 3 @Override 4 public long getRemainingMillis() { 5 Date dayEnd = DateUtils.getDayEnd(); 6 return dayEnd.getTime() - System.currentTimeMillis(); 7 } 8 };18 /**19 * 距离当前单位时间结束剩余毫秒数. 20 * @return21 */22 public abstract long getRemainingMillis();23 24 }
中添加一个类型 NATURAL_MINUTE :
1 public enum LimitTimeUnit { 2 NATURAL_MINUTE { 3 @Override 4 public long getRemainingMillis() { 5 return 1000 * 60; 6 } 7 }, 8 9 NATURAL_DAY {10 @Override11 public long getRemainingMillis() {12 Date dayEnd = DateUtils.getDayEnd();13 return dayEnd.getTime() - System.currentTimeMillis();14 }15 };25 /**26 * 距离当前单位时间结束剩余毫秒数. 27 * @return28 */29 public abstract long getRemainingMillis();30 31 }
消费端项目添加了这个字段升级了版本,但是消费者在有些项目中没有升级,测试的时候看日志没有报错,所以就很happy上线了回家睡个好觉。第二天测试找到我问:为什么昨晚我收到那么多push...不是限制每天限制只能收到...?我:哦,这是以前的逻辑吗?...好的,我看看!佛系开发没办法!
2. 定位问题
打开app快速(一分钟内)按测试所说的流程给自己搞几个push,发现没有问题啊!然后开始跟测试磨嘴皮,让他给我重现,哈哈,他也重现不了!就这样我继续撸代码...安静的过了五分钟。测试又来了...后面发送的事大家自己YY一下。
快速找到对应生产者代码,封装的确实是 NATURAL_DAY,那只能debug消费者这边接收的代码。发现消费者接收到是 NATURAL_MINUTE!看到这里测试是对的,本来限制一天现在变成一分钟!!!是什么改变这个值呢?mq只是一个队列,保存的是字节码,一个对象需要序列化成字节码保存到mq,从mq获取对象需要把字节码反序列化成对象。那么问题根源找到了,是序列化和反序列化时出了问题。
3. Protostuff序列化过程
该问题是Protostuff序列化引起的,那么解决这个问题还得弄懂Protostuff序列化和反序列化原理。弄懂原理最好的办法就是看源码:
1 public class ProtoStuffSerializer implements Serializer { 2 3 private static final Objenesis objenesis = new ObjenesisStd(true); 4 private static final ConcurrentMap<Class<?>, Schema<?>> schemaCache = new ConcurrentHashMap<>(); 5 private ThreadLocal<LinkedBuffer> bufferThreadLocal = ThreadLocal.withInitial(() -> LinkedBuffer.allocate()); 6 7 @Override 8 public <T> byte[] serialize(T obj) { 9 Schema<T> schema = getSchema((Class<T>) obj.getClass());10 11 LinkedBuffer buf = bufferThreadLocal.get();12 try {13 // 实现object->byte[]14 return ProtostuffIOUtil.toByteArray(obj, schema, buf);15 } finally {16 buf.clear();17 }18 }19 20 @Override21 public <T> T deserialize(byte[] bytes, Class<T> clazz) {22 T object = objenesis.newInstance(clazz); // java原生实例化必须调用constructor. 故使用objenesis23 Schema<T> schema = getSchema(clazz);24 ProtostuffIOUtil.mergeFrom(bytes, object, schema); // 反序列化源码跟踪入口25 return object;26 }27 28 private <T> Schema<T> getSchema(Class<T> clazz) {29 Schema<T> schema = (Schema<T>) schemaCache.get(clazz);30 if (schema == null) {31 // 把可序列化的字段封装到Schema32 Schema<T> newSchema = RuntimeSchema.createFrom(clazz);33 schema = (Schema<T>) schemaCache.putIfAbsent(clazz, newSchema);34 if (schema == null) {35 schema = newSchema;36 }37 }38 return schema;39 }
这是我们实现Protostuff序列化工具类。接下来看一下 ProtostuffIOUtil.toByteArray(obj, schema, buf) 这个方法里面重要代码:
1 public static <T> byte[] toByteArray(T message, Schema<T> schema, LinkedBuffer buffer) 2 { 3 if (buffer.start != buffer.offset) 4 throw new IllegalArgumentException("Buffer previously used and had not been reset."); 5 6 final ProtostuffOutput output = new ProtostuffOutput(buffer); 7 try 8 { 9 // 继续跟进去10 schema.writeTo(output, message);11 }12 catch (IOException e)13 {14 throw new RuntimeException("Serializing to a byte array threw an IOException " +15 "(should never happen).", e);16 }17 return output.toByteArray();18 }
1 public final void writeTo(Output output, T message) throws IOException2 {3 for (Field<T> f : getFields())4 // 秘密即将揭晓5 f.writeTo(output, message);6 }
RuntimeUnsafeFieldFactory这里面才是关键:
@Overridepublic void writeTo(Output output, T message) throws IOException { CharSequence value = (CharSequence)us.getObject(message, offset); if (value != null) // 看这里 output.writeString(number, value, false); }
跟踪到这里,我们把一切谜题都解开了。原来Protostuff序列化时是按可序列化字段顺序只把value保存到字节码中。
4. Protostuff反序列化过程
以下是反序列化源码的跟踪:ProtostuffIOUtil.mergeFrom(bytes, object, schema) 里面重要的代码:
1 public static <T> void mergeFrom(byte[] data, T message, Schema<T> schema)2 {3 IOUtil.mergeFrom(data, 0, data.length, message, schema, true);4 }
1 static <T> void mergeFrom(byte[] data, int offset, int length, T message, 2 Schema<T> schema, boolean decodeNestedMessageAsGroup) 3 { 4 try 5 { 6 final ByteArrayInput input = new ByteArrayInput(data, offset, length, 7 decodeNestedMessageAsGroup); 8 // 继续跟进 9 schema.mergeFrom(input, message);10 input.checkLastTagWas(0);11 }12 catch (ArrayIndexOutOfBoundsException ae)13 {14 throw new RuntimeException("Truncated.", ProtobufException.truncatedMessage(ae));15 }16 catch (IOException e)17 {18 throw new RuntimeException("Reading from a byte array threw an IOException (should " +19 "never happen).", e);20 }21 }
1 @Override 2 public final void mergeFrom(Input input, T message) throws IOException 3 { 4 // 按顺序获取字段 5 for (int n = input.readFieldNumber(this); n != 0; n = input.readFieldNumber(this)) 6 { 7 final Field<T> field = getFieldByNumber(n); 8 if (field == null) 9 {10 input.handleUnknownField(n, this);11 }12 else13 {14 field.mergeFrom(input, message);15 }16 }17 }
1 public void mergeFrom(Input input, T message)2 throws IOException3 {4 // 负载给字段5 us.putObject(message, offset, input.readString());6 }
5. 总结
通过protostuff的序列化和反序列化源码知道一个对象序列化时是按照可序列化字段顺序把值序列化到字节码中,反序列化时也是按照当前对象可序列化字段顺序赋值。所以会出现 NATURAL_DAY 经过序列化和反序列化后变成 NATURAL_MINUTE。由于这两个字段类型是一样的,反序列化没有报错,如果序列化前的对象和反序列化接收对象对应顺序字段类型不一样时会出现反序列失败报错。为了避免以上问题,在使用protostuff序列化时,对已有的实体中添加字段放到最后去就可以了。