最近在開發中遇到一個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 * @return 21 */ 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 @Override 11 public long getRemainingMillis() { 12 Date dayEnd = DateUtils.getDayEnd(); 13 return dayEnd.getTime() - System.currentTimeMillis(); 14 } 15 }; 25 /** 26 * 距離當前單位時間結束剩余毫秒數. 27 * @return 28 */ 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 @Override 21 public <T> T deserialize(byte[] bytes, Class<T> clazz) { 22 T object = objenesis.newInstance(clazz); // java原生實例化必須調用constructor. 故使用objenesis 23 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 // 把可序列化的字段封裝到Schema 32 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 IOException 2 { 3 for (Field<T> f : getFields()) 4 // 秘密即將揭曉 5 f.writeTo(output, message); 6 }
RuntimeUnsafeFieldFactory這里面才是關鍵:
@Override public 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 else 13 { 14 field.mergeFrom(input, message); 15 } 16 } 17 }
1 public void mergeFrom(Input input, T message) 2 throws IOException 3 { 4 // 負載給字段 5 us.putObject(message, offset, input.readString()); 6 }
5. 總結
通過protostuff的序列化和反序列化源碼知道一個對象序列化時是按照可序列化字段順序把值序列化到字節碼中,反序列化時也是按照當前對象可序列化字段順序賦值。所以會出現 NATURAL_DAY 經過序列化和反序列化后變成 NATURAL_MINUTE。由於這兩個字段類型是一樣的,反序列化沒有報錯,如果序列化前的對象和反序列化接收對象對應順序字段類型不一樣時會出現反序列失敗報錯。為了避免以上問題,在使用protostuff序列化時,對已有的實體中添加字段放到最后去就可以了。