今天看啥  ›  专栏  ›  horseLai

从使用到源码—Gson(下)

horseLai  · 掘金  ·  · 2019-08-21 16:03
阅读 3

从使用到源码—Gson(下)

引言

  • 在上一篇文章中,我们主要从Gson#fromGson#toJson两个方法着手分析了Gson在解析过程中进行了那些处理,以及走了什么流程,具体欢迎查看从使用到源码—Gson(上).
  • 目标:那么通过之前的分析,我们已经知道Gsonjson的解析最终都会通过TypeAdapter<T>来进行readwrite,但是至此我们对Gson的处理并不了解,而只是了解了它经历怎样的过程,因此在本篇文章中,我们希望能够尽可能的分析、了解到Gsonjson解析的核心,也就是JsonReaderJsonWriter
  • 好了,废话就不多说了,开工开工。

JsonReader

一、先用起来再谈原理

  • JsonReader的功能:简单的说就是将Json数据流按字段及其对应的值分解出来,并做好标记,这样调用者就能够轻易地得到Json的字段和值了。我们不妨先通过一个实例来佐证一下,比方说我们通过下面这个ADAPTER来将Json数据转换成Property类,并记录下每个字段的对应的Java数据类型,那么我们可以这样实现一个TypeAdapter
    • 注意两个点JsonReader#peek()case BEGIN_OBJECT:
    private final TypeAdapter<Property> ADAPTER = new TypeAdapter<Property>() {
        @Override
        public void write(JsonWriter out, Property value) throws IOException {
        }
        @Override
        public Property read(JsonReader in) throws IOException {
            in.setLenient(true);
            switch (in.peek()) {  // 注意点一
                case STRING:
                    return new Property<String>().setType(String.class).setValue(in.nextString());
                case NUMBER:
                    return new Property<String>().setType(Number.class).setValue(in.nextString());
                case BOOLEAN:
                    return new Property<Boolean>().setType(Boolean.class).setValue(in.nextBoolean());
                case NULL:
                    Property result = new Property<Object>().setType(Object.class).setValue(null);
                    in.nextNull();
                    return result;
                case BEGIN_ARRAY:
                    List<Property> array = new ArrayList<>();
                    in.beginArray();
                    while (in.hasNext()) {
                        array.add(read(in));
                    }
                    in.endArray();
                    return new Property< List<Property>>().setType(List.class).setValue(array);
                case BEGIN_OBJECT:  // 注意点二
                    ArrayList<Property> obj = new ArrayList<>();
                    in.beginObject();
                    while (in.hasNext()) {    // 是否到了对象末尾
                        String s = in.nextName();   // 读取字段名
                        Property read = read(in);   // 读取字段值
                        obj.add(read.setName(s)) ;
                    }
                    in.endObject();
                    return new Property<ArrayList<Property>>().setType(Object.class).setValue(obj);
                case END_DOCUMENT:
                case NAME:
                case END_OBJECT:
                case END_ARRAY:
                default:
                    throw new IllegalArgumentException();
            }
        }
    }; 
    static class Property<T> {
        String name;
        T value;
        Class<?> type;
   }
复制代码
  • **为啥注意这两个点呢?**在这段代码中,我们是JsonReader的调用者,站在调用者的角度,这两个点是理解这个功能实现的核心点,并且从中我们可以了解、推测到它的使用内部功能实现,在这个思考过程中,再次留下疑问(强忍不点开源码)。
    • 注意点一in.peek()用于从JsonReader中获取当前位置对应的字段类型,叫做JsonToken,它是个枚举类。
    • 注意点二BEGIN_OBJECT标识一个Json对象的起点,这点显而易见,但是需要理解的是,它在上述解析过程中起到了根基的作用,因为除了类似于['a','b','c'...]这种只有值的数组外,都会有Json对象的存在,因此对它的处理在整个程序中至关重要。比方说下面这串Json数据(来自豆瓣),Json对象可以看做是一个数据块,每个数据块中包含若干个以key<->value形式存在的字段及值,而Json数组就是若干个数据块的集合,显然是绕不开Json对象。
            [ {
                      "alt": "https://movie.douban.com/celebrity/1002667/",
                      "avatars": {
                          "small": "https://img3.doubanio.com/view/celebrity/s_ratio_celebrity/public/p1501385708.56.jpg",
                          "large": "https://img3.doubanio.com/view/celebrity/s_ratio_celebrity/public/p1501385708.56.jpg",
                          "medium": "https://img3.doubanio.com/view/celebrity/s_ratio_celebrity/public/p1501385708.56.jpg"
                      },
                      "name": "保罗·路德",
                      "id": "1002667"
                  }, { . . . }
              ] 
    复制代码
  • 那么我们再回头理一理这段逻辑,看似很简单的逻辑,却隐含着坑,因为从JsonReader获取数据,必须遵循先读字段名称,后读字段数据的顺序,多读少读、调换顺序都会出错,这点跟它的内部设计有关,具体原因我们稍后分析,这里先打个断点,留下疑问。
switch (in.peek()) {    
    case STRING:
        return new Property<String>().setType(String.class).setValue(in.nextString());
// . . . 
  case BEGIN_OBJECT:   
      ArrayList<Property> obj = new ArrayList<>();
      in.beginObject();
      while (in.hasNext()) {    // 是否到了对象末尾
         String s = in.nextName();   // 读取字段名
         Property read = read(in);   // 读取字段值
        obj.add(read.setName(s)) ;
      }
     in.endObject();
     return new Property<ArrayList<Property>>().setType(Object.class).setValue(obj);
// . . . 
}
复制代码
  • 如果不遵守原则,大概你会收到以下异常礼包:
Exception in thread "main" java.lang.IllegalArgumentException
复制代码

二、该聊聊原理了

  • 在上面使用分析中,我们大概都了解了作为调用者如何使用JsonReader了,并且知道了需要遵守它的什么规则,但是我们依然不知道这家伙到底怎么实现的,用了什么魔法,因此要扒它的衣服,看看这家伙到底是不是男孩。。在此之前不妨带着以下疑问进行:

    • 它是以什么方式分解Json数据的?跟自己当初思考的解决方案有何不同?
    • 为什么必须严格遵守它的规则,不然就坑我?
    • in.peek()?难道用的栈?用栈怎么存储的?为啥不是Map
    • . . . .
  • 在正式开始之前,我们可以留意一下这些标识,这样有助于我们理解

final class JsonScope { 
    static final int EMPTY_ARRAY = 1;  // 空数组,如:[]
    static final int NONEMPTY_ARRAY = 2;  // 非空数组,如:[a,b]
    static final int EMPTY_OBJECT = 3;     // 如:{}
    static final int DANGLING_NAME = 4;   // 如: 'a':'b' 
    static final int NONEMPTY_OBJECT = 5;   // 如: {'a':'b'}
    static final int EMPTY_DOCUMENT = 6;    
    static final int NONEMPTY_DOCUMENT = 7; 
    static final int CLOSED = 8;  // 遇到了 "];""};""]};"等
}
复制代码
  • 纵观全局,可以看到以下几个数组,stack之前看到了,用来存储是比如数组、对象、空对象等标识,而其他几个我们根据变量名可以大概推测到:
    • buffer极有可能用来缓冲Json数据;
    • pathNamespathIndices两者肯定是下标索引相互对应的,并且很有可能pathIndices记录的是pathNames的长度
public class JsonReader implements Closeable {
  private final char[] buffer = new char[1024];
  private int[] stack = new int[32];
  private int stackSize = 0;
  {
    stack[stackSize++] = JsonScope.EMPTY_DOCUMENT;
  }
  private String[] pathNames = new String[32];
  private int[] pathIndices = new int[32];
// . . . 
}
复制代码
  • OK, 那咱就正式开始吧。我们先从最显眼的in.peek()着手,扒开外衣发现这家伙确实是个栈,毕竟stack都这么明显了,而从栈中取出的是类似于JsonScope.EMPTY_ARRAY的这种东东,用于标识 Json数据当前读取到的位置上属于什么类型。
    • 对于doPeek中的逻辑,为什么如果是EMPTY_ARRAY又赋值为 stack[stackSize - 1] =NONEMPTY_ARRAY呢?
      • 逻辑准备:++默认peeked=PEEKED_NONE,并且每次在结束一个操作,如endArrayendObject后都会将其初始化为PEEKED_NONE,因此每次进行取值、标识操作时,比如begainArray(),那么执行时必定会执行一次doPeek进行出栈当前位置的数据类型++。
      • 有了上面逻辑准备,我们可以假设一个实际场景,对于数据[{'a':'aa'}, {'b':'bb'}],如果我们要从JsonReader中解读它,该做什么?首先需要手动执行begainArray()告诉JsonReader这是个数组,而在begainArray()会先假设这是个空数组EMPTY_ARRAY,那么在下次取key:value时必定也会doPeek,于是发现标识为空数组,而为了能够读取到数组中的数据,就得接着假设它是个非空数组NONEMPTY_ARRAY,于是接着doPeek,读取数组中的数据,读取完成,即遇到]后就结束数组读取了,并标记peeked=PEEKED_END_ARRAY,此时我们需要手动执行endArray告诉JsonReader我们结束了数组读取,于是又会设置peeked=PEEKED_NONE
      • 综上:你会发现,除了需要我们手动标记的数组、对象入口和出口,中间过程就是个来回假设、假设、验证假设的循环过程,这也就是为什么如果是EMPTY_ARRAY又赋值为 stack[stackSize - 1] =NONEMPTY_ARRAY的原因了,这两者都是假设,而验证假设是从if (peekStack == JsonScope.NONEMPTY_ARRAY)开始的。对于解析Json对象类型的话,也是这个逻辑,因此就不再单独拿出来分析了。
public JsonToken peek() throws IOException {
    int p = peeked;
    if (p == PEEKED_NONE) {
      p = doPeek();
    }
    // . . . 
} 

int doPeek() throws IOException {
    int peekStack = stack[stackSize - 1];
    if (peekStack == JsonScope.EMPTY_ARRAY) { // 空数组?
      stack[stackSize - 1] = JsonScope.NONEMPTY_ARRAY;  // 假设它是非空数组,尝试读取看看有没有数据
    } else if (peekStack == JsonScope.NONEMPTY_ARRAY) { 
      int c = nextNonWhitespace(true);  // Look for a comma before the next element.
      switch (c) {
      case ']':
        return peeked = PEEKED_END_ARRAY; 
        // . . . 
      }
    }else if (peekStack == JsonScope.EMPTY_OBJECT || peekStack == JsonScope.NONEMPTY_OBJECT) { // 对象?
      stack[stackSize - 1] = JsonScope.DANGLING_NAME;  // 假设是个类似于'a':'b'的数据
      // Look for a comma before the next element.
      if (peekStack == JsonScope.NONEMPTY_OBJECT) {
        int c = nextNonWhitespace(true);
        switch (c) {
        case '}':
          return peeked = PEEKED_END_OBJECT;
          // . . . 
        }
      }
    } else if (peekStack == JsonScope.DANGLING_NAME) { // 是个类似于'a':'b'的数据?
      stack[stackSize - 1] = JsonScope.NONEMPTY_OBJECT;  // 假设是个非空对象
      // ...
    } else if (peekStack == JsonScope.EMPTY_DOCUMENT) {   // 是个空文档?
      stack[stackSize - 1] = JsonScope.NONEMPTY_DOCUMENT; // 假设是个非空文档
      // ...
    }
     // ...
       
    int c = nextNonWhitespace(true);
    switch (c) { 
    case '[':  // 数组起始位置
      return peeked = PEEKED_BEGIN_ARRAY; 
        
        // . . . 
    } 
}

// 假设遇到的是数组,需要执行这个beginArray()标记这是数组的初始位置
public void beginArray() throws IOException {
    int p = peeked;
    if (p == PEEKED_NONE) {
      p = doPeek();  // 实际每次都会执行
    }
    if (p == PEEKED_BEGIN_ARRAY) {
      push(JsonScope.EMPTY_ARRAY);  // 假设是空数组
      pathIndices[stackSize - 1] = 0; // 初始化计数器
      peeked = PEEKED_NONE; // 初始化出栈类型,保证下一次doPeek()出栈
    } else {
      throw new IllegalStateException("Expected BEGIN_ARRAY but was " + peek() + locationString());
    }
  }

// 压栈时stackSize才会增加,意味着
private void push(int newTop) {
    if (stackSize == stack.length) { // 数组扩容. . .  }
    stack[stackSize++] = newTop;
  }
复制代码
  • 结合上面这段源码以及我们的实际场景,实际上我们已经理清了整个解析过程的逻辑,但是感觉还是缺些什么。那么结合上面的beginArray以及下面的几个方法,我们可以观察到stackSize会在push时自增,而在对象和数组结束时自减,stack、pathIndices、pathNames的关系如下图所示。

    stack、pathIndices、pathNames关系图.png

  • 总的来说就是

    • pathIndices记录的是字段值的数量,也可以看作是元素读取到的位置,需要注意的是:
      • 对于数组,数组中的对象本身不会计入数量,只有当读取到key:valuevalue后才会计数;
      • 对于对象,如果类似于key:{}这种有key对应的对象,{}对象也会算作一个value进行计数,而其内部字段元素则依然按照key:value进行计数。
    • pathNames数组中记录的是每个字段的名称,并且在读取到对象的末尾位置时,就会 将这个元素清空掉
  • 综上: 实际上stack一直在复用stack[0]这个元素,它并不会跟我们当初预想的那样保存每一个元素的数据类型,而只记录当前位置的类型,相应的pathIndicespathNames也是如此。至此,就可以解释为什么我们一定要遵循JsonReader的读取顺序了,因为这家伙只保存了当前位置上的数据类型、字段名称等信息,不按顺序读取铁定出错的,并且调换顺序也是不存在的。

public void endArray() throws IOException {
    // . . .
    if (p == PEEKED_END_ARRAY) {
      stackSize--; // 实际上与push中的stackSize++相对应 
      pathIndices[stackSize - 1]++;  
      peeked = PEEKED_NONE;
    } // . . .
}
 
// 读取读取字段名称
public String nextName() throws IOException {
    // . . . 
    if (p == PEEKED_UNQUOTED_NAME) {
      result = nextUnquotedValue();
    }  // . . .  
    peeked = PEEKED_NONE;
    pathNames[stackSize - 1] = result;  //将字段名称保存到对应于stack栈顶的pathNames中
    return result;
  }
  
// 读取字段值 
public String nextString() throws IOException {
     // . . . 
    if (p == PEEKED_UNQUOTED) {
      result = nextUnquotedValue();
    }  else if (p == PEEKED_NUMBER) {
      result = new String(buffer, pos, peekedNumberLength);
      pos += peekedNumberLength;
    }else if // . . .
    peeked = PEEKED_NONE;
    pathIndices[stackSize - 1]++; // 每个字段值读取完成之后都会自增对应于stack栈顶位置的pathIndices值,表示读取完一对key:value键值对
    return result;
  }

public void beginObject() throws IOException {
    // . . .
    if (p == PEEKED_BEGIN_OBJECT) { // 没有使用到pathIndices和pathNames
      push(JsonScope.EMPTY_OBJECT);
      peeked = PEEKED_NONE;
    }// . . .
  } 
  
public void endObject() throws IOException {
   // . . .
    if (p == PEEKED_END_OBJECT) {
      stackSize--;   //        
      pathNames[stackSize] = null;  // 回收stack[stackSize]对应位置上的空间
      pathIndices[stackSize - 1]++;  
      peeked = PEEKED_NONE;
    } // . . .
  }
  
 public void skipValue() throws IOException { 
    do {     // ... 
      if (p == PEEKED_BEGIN_ARRAY) {
        push(JsonScope.EMPTY_ARRAY); 
      } else if (p == PEEKED_BEGIN_OBJECT) {
        push(JsonScope.EMPTY_OBJECT); 
      } else if (p == PEEKED_END_ARRAY) {
        stackSize--; 
      } else if (p == PEEKED_END_OBJECT) {
        stackSize--; 
      } // ...
      peeked = PEEKED_NONE;
    } while (count != 0); 
    pathIndices[stackSize - 1]++;
    pathNames[stackSize - 1] = "null";
  }
复制代码
  • 至于buffer,作为直接数据缓冲,它的目的是为了方便缓冲、读取即将到来的字符数据,减少使用StringBuilder作为中间对象而直接创建String对象,它的长度需要大于或等于每个token(比如:JsonToken.STRING)对应数据的最大长度。
  • 个人觉得最能体现buffer的上述目的的是fillBuffer(int minimum)方法,粗略的看可知它用于从in数据流往buffer数据中读取数据,但细看的话,这个方法还是比较难理解的(我比较菜~),根据官方注释可以得知,当limit - pos >= minimum时返回true,而当buffer耗尽时返回false
 private boolean fillBuffer(int minimum) throws IOException {
    char[] buffer = this.buffer;
    lineStart -= pos;
    if (limit != pos) {   
      limit -= pos;    
      System.arraycopy(buffer, pos, buffer, 0, limit);   // 这个为什么这样拷贝,没看明白...
    } else {
      limit = 0;
    } 
    pos = 0;
    int total;
    while ((total = in.read(buffer, limit, buffer.length - limit)) != -1) {   // 可看作双指针往buffer的 [limit,buffer.length - limit]区域中写数据,注意到它是从数组两边往中间写的
      limit += total;   // 可写区域[limit,buffer.length - limit]在往中间靠拢
      // if this is the first read, consume an optional byte order mark (BOM) if it exists
      if (lineNumber == 0 && lineStart == 0 && limit > 0 && buffer[0] == '\ufeff') {
        pos++;
        lineStart++;
        minimum++;
      } 
      if (limit >= minimum) {  // 一旦读取到了数据,并且字节数>= minimum 立马返回
        return true;
      }
    }
    return false; 
  } 
复制代码
  • 小结JsonReader作为Gson的核心处理类,设计的很精妙,很灵活,不过也是基于基本的处理思想的优化,通过对它的分析,可以得出以下结论:
    • JsonReader内部是个基于数组(stack)的栈实现,而stack(缓存每个数组、对象(token)的类型)与pathNames(缓存字段名称)和 pathIndices(缓存每个数组、对象中key:value对的数量),三者形成基于数组索引的映射关系,而实际上它们只会缓存一个token的状态,并且重复使用这个位置上的空间,因此当读取到下一个token时,就会覆盖掉上一个token的状态,这也就导致我们使用JsonReader中读取数据时必须遵循先读字段名,后读字段数据的顺序进行,否则就会出错。
    • JsonReader对数据的解析过程实际上是个假设、假设、验证假设的过程,而验证过程会根据Json数据的标记符,也就是诸如{[]}:"等符号进行判断token的起始和终止点,并记录对应的状态,这是一个精致的循环过程,直到所有假设都不成立或者遇到不合法数据时才会终止循环。

JsonWriter

  • 如果老哥你都研究到这里了,那么JsonWriter就比较好理解了。JsonWriter的作用是将Java实体类的数据转换成Json数据,它和JsonReader的内部数据结构基本一致,只是操作逻辑正好可看作是JsonReader的逆向过程,因此这里将不再贴源码分析(感兴趣的老哥可以自行查看)。
  • 在使用JsonReader时,我们必须遵循先读字段名,后读字段数据的顺序进行,而使用JsonWriter同样需要注意这类问题,只是变成了写操作。 例如,这里我们摘一小段"从使用到源码—Gson(上)"中定制TypeAdapter部分的代码, 可以看出,跟JsonReader一样,我们在写操作时,同样需要通过诸如beginObjectendObject这类方法去标记我们当前写入的是Json对象还是其他什么类型,此时JsonWriter内部会将这个标识压栈或者出栈, 原理跟JsonReader是一致的,只是换了个方向而已。
static class MyTypeAdapter extends TypeAdapter<Data> {
 @Override
   public void write(JsonWriter out, Data value) throws IOException
   {
       if (value == null) {
           out.nullValue();
           return;
       } 
       out.setLenient(true);
           out.beginObject();  
           out.name("data");  // 必须先写字段名
           out.value(value.data);  // 再写字段值,否则不能正常写入
           out.name("result");

           out.beginObject();
           out.name("name");
           out.value(value.getResult().name);
           out.name("age");
           out.value(value.getResult().age);
           out.endObject();

           out.endObject();
   } 
 // ...
}

static class Data {
       private String data;
       private User result;
       // 省略set/get/toString
   } 
   static class User {
       private String name;
       private int age; 
       // 省略set/get/toString
  }
复制代码

总结

  • 在本篇文章中,我们依然是从一个实际使用着手,比较详细地分析了JsonReader内部原理,从中我们可以了解到Gson如何分解Json数据、用什么方式解析、以及作为调用者应当遵循什么样的操作规则等。当然还有些细致的问题由于自身菜没有分析透彻的,比如buffer的操作过程等,不过这并不影响我们理解JsonReader的解析原理。
  • 而对于JsonWriter,可以看作是JsonReader的逆向操作过程,两者内部结构几乎一致,调用者都需要遵顼一定的原则才能正常解析,不同的是,JsonWriter作为写操作,并不需要像 JsonReader那样去记录字段名称和key:value数,因此显得更为简洁易懂。
  • OK, 这篇文章可以说是拖了非常久才完结出稿,懒啊,得改得改!!



原文地址:访问原文地址
快照地址: 访问文章快照