Fastjson2你開始使用了嗎?來看看原始碼解析

語言: CN / TW / HK

highlight: a11y-dark theme: devui-blue


概述

FastJson2FastJson專案的重要升級,目標是為下一個十年提供一個高效能的JSON庫。根據官方給出的效能來看,相比v1版本,確實有了很大的提升,本篇文章我們來看下究竟做了哪些事情,使得效能有了大幅度的提升。

本篇將採用程式碼測試 + 原始碼閱讀的方式對FastJson2的效能提升做一個較為全面的探索。

一、環境準備

首先,我們搭建一套用於測試的環境,這裡採用springboot專案,分別建立兩個module:fastjsonfastjson2。使用兩個版本進行對比試驗。

程式碼結構如下所示:

image.png

1.1 引入對應依賴

在父pom當中引入一些我們需要使用的公共依賴,這裡為了簡便,使用了 xml <dependency> <groupId>org.projectlombok</groupId> <artifactId>lombok</artifactId> <version>1.18.24</version> </dependency>

在fastjson當中引入fastjson的依賴: xml <dependency> <groupId>com.alibaba</groupId> <artifactId>fastjson</artifactId> <version>1.2.79</version> </dependency> 在fastjson2當中引入fastjson2的依賴: xml <dependency> <groupId>com.alibaba.fastjson2</groupId> <artifactId>fastjson2</artifactId> <version>2.0.8</version> </dependency>

1.2 建立測試類

這裡為了方便,直接使用main方法進行測試。

  • 建立類:Student.java ```java import lombok.Builder; import lombok.Data;

    @Data @Builder public class Student { private String name; private Integer age; private String address;

    public Student(String name, Integer age, String address) {
        this.name = name;
        this.age = age;
        this.address = address;
    }
    

    } ``` * 建立測試main方法:

    ```java /* * 定義迴圈次數 / private final static Integer NUM = 100;

    public static void main(String[] args) { // 總時間 long totalTime = 0L; //初始化學生資料 List studentList = new ArrayList<>(); // 10w學生 for (int i = 0; i < 100000; i++) { studentList.add(Student.builder().name("我犟不過你").age(10).address("黑龍江省哈爾濱市南方區哈爾濱大街267號").build()); } // 按指定次數迴圈 for (int i = 0; i < NUM; i++) { // 單次迴圈開始時間 long startTime = System.currentTimeMillis(); // 遍歷學生資料 studentList.forEach(student -> { // 序列化 String s = JSONObject.toJSONString(student); //字串轉回java物件 JSONObject.parseObject(s, Student.class); }); // 將學生list序列化,之後轉為jsonArray JSONArray jsonArray = JSONArray.parseArray(JSONObject.toJSONString(studentList)); // 將jsonArray轉java物件list jsonArray.toJavaList(Student.class); //單次處理時間 long endTime = System.currentTimeMillis(); // 單次耗時 totalTime += (endTime - startTime); System.out.println("單次耗費時間:" + (endTime - startTime) + "ms"); } System.out.println("平均耗費時間:" + totalTime / NUM + "ms"); } `` 上述程式碼在fastjson和fastjson2的測試中基本相同,唯一不同在於在fastjson2當中,jsonArray.toJavaList方法轉變成了jsonArray.toList`。

二、效能測試

本節將使用上面的程式碼進行測試。在此之前,我們首先需要針對兩個子工程設定相同的堆空間大小128M,以免造成偏差:

image.png

2.1 第一次測試

下面正是開始測試:

  • fastjson結果

    console 單次耗費時間:863ms 單次耗費時間:444ms 單次耗費時間:424ms 單次耗費時間:399ms 單次耗費時間:384ms 單次耗費時間:355ms 單次耗費時間:353ms 單次耗費時間:363ms ... ... 單次耗費時間:361ms 單次耗費時間:356ms 單次耗費時間:355ms 單次耗費時間:357ms 單次耗費時間:351ms 單次耗費時間:354ms 平均耗費時間:366ms 如上所示,除了第一次很慢,第二次變快,到最後基本穩定在360毫秒左右,最終的平均耗時是366ms

  • fastjson2結果

    console 單次耗費時間:957ms 單次耗費時間:803ms 單次耗費時間:468ms 單次耗費時間:435ms 單次耗費時間:622ms 單次耗費時間:409ms 單次耗費時間:430ms ··· ··· 單次耗費時間:400ms 單次耗費時間:641ms 單次耗費時間:403ms 單次耗費時間:398ms 單次耗費時間:431ms 單次耗費時間:356ms 單次耗費時間:362ms 單次耗費時間:626ms 單次耗費時間:404ms 單次耗費時間:395ms 平均耗費時間:478ms 如上所示,首次執行慢,逐步變快,但是後面就出現問題了,怎麼執行的時間這麼不穩定?跨度從390多到640多?這是怎麼回事?平均時間也達到了478ms,反而比fastjson還要慢。

2.2 fastjson2慢的原因?

比較熟悉java的應該都能想到一個問題:由於堆空間大小不夠,導致頻繁發生GC,最終導致處理時間增長?

帶著這個推測,我們使用jvisualVM來看下在fastjson2執行時,記憶體的使用情況,使用如下方式啟動:

image.png

如上所示的啟動放肆會直接開啟jvisualvm的控制面板,選擇Visual GC,最終結果如下所示:

image.png

如上所示有幾處重點,單獨看下:

  • GC次數

    image.png

    如上所示,總共GC了1814次,耗時34.089s,最後一次失敗的原因是記憶體分配失敗。 * Full GC

    image.png

    如上所示,老年代發生了316次GC,耗時27.225s。

通過上面的觀察,基本可以確定由於GC導致了fastjson2整體處理時間變長

2.3 fastjson的GC表現

我們可以再看下fastjson當中的gc是什麼樣的:

  • GC次數

    image.png

    如上可知,fastjson1中發生了1675次gc,與fastjson2相比少了139次,並且時間少了11.55s。

    通過前面測試的結果,fastjson1平均時間366ms,而fastjson2是478ms,分別乘以100次,能夠得到如下的時間差:

$(478100 - 366100)/1000 = 11.2$

與gc時間差11.55相差無幾,那麼我們可以得到一個結論:**fastjson2的效能表現,與堆空間的大小相關!**

2.4 第二次試驗

我們似乎得到了一個結論,但是如何確定是fastjson2的那個方法消耗更多的記憶體空間呢?畢竟我們在測試方法中,呼叫了很多的方法。

所以我們進一步調小記憶體,看看是否會有記憶體溢位呢?

我們將記憶體調整為64M: bash -Xms64m -Xmx64m

執行後發現果然出現了記憶體溢位,並且明確的指出是堆空間記憶體溢位:

console Exception in thread "main" java.lang.OutOfMemoryError: Java heap space at java.util.Arrays.copyOf(Arrays.java:3210) at java.util.Arrays.copyOf(Arrays.java:3181) at java.util.ArrayList.grow(ArrayList.java:265) at java.util.ArrayList.ensureExplicitCapacity(ArrayList.java:239) at java.util.ArrayList.ensureCapacityInternal(ArrayList.java:231) at java.util.ArrayList.add(ArrayList.java:462) at com.alibaba.fastjson2.JSONReader.read(JSONReader.java:1274) at com.alibaba.fastjson2.JSON.parseArray(JSON.java:1494) at com.alibaba.fastjson2.JSONArray.parseArray(JSONArray.java:1391) at com.wjbgn.fastjson2.test.TestFastJson2.main(TestFastJson2.java:43) 通過如上的異常堆疊,發現異常出現在測試程式碼的43行:

image.png

提供debug發現最終異常出現在如下程式碼:

image.png

結論:在toJsonString方法時,發生了記憶體溢位異常。

2.5 第三次實驗

下面我們將記憶體增大,看看是否能夠提升fastjson2的效能。將堆空間大小調整為256M

  • fastjson

    console 單次耗費時間:805ms 單次耗費時間:224ms 單次耗費時間:235ms 單次耗費時間:228ms 單次耗費時間:222ms ... ... 單次耗費時間:191ms 單次耗費時間:196ms 單次耗費時間:193ms 單次耗費時間:194ms 單次耗費時間:192ms 平均耗費時間:198ms 如上所示,發現隨著堆空間增加,fastjson1有較大的效能提升,平均時長在198ms。 * fastjson2

    console 單次耗費時間:671ms 單次耗費時間:496ms 單次耗費時間:412ms 單次耗費時間:405ms 單次耗費時間:315ms 單次耗費時間:321ms ... ... 單次耗費時間:337ms 單次耗費時間:326ms 平均耗費時間:335ms 如上所示,結果在335毫秒,隨著記憶體增加,效能有提升,但是仍然沒有fastjson1快。

通過如上的實驗,我們似乎可以得到如下的結論:在資料量較大時,fastjson的效能還要好於fastjson2!

2.6 第四次試驗

本次測試我們要給足夠大堆空間,看看這兩者的效能表現,此處將堆空間設定成1gbash -Xms1g -Xmx1g * fastjson console 單次耗費時間:943ms 單次耗費時間:252ms 單次耗費時間:156ms 單次耗費時間:155ms ... ... 單次耗費時間:119ms 單次耗費時間:114ms 單次耗費時間:108ms 單次耗費時間:133ms 單次耗費時間:115ms 平均耗費時間:133ms 如上所示,在足夠大的記憶體條件下,fastjson的平均時間達到了133ms

  • fastjson2

    console 單次耗費時間:705ms 單次耗費時間:199ms 單次耗費時間:172ms ... ... 單次耗費時間:101ms 單次耗費時間:124ms 單次耗費時間:96ms 平均耗費時間:119ms

    如上所示,fastjson2處理速度首次高於fastjson。

    2.7 小結

通過前面的測試,我們能夠得到如下的結論:

  • fastjson2相比fastjson確實是有效能提升,但是取決於堆記憶體的大小。

  • 堆空間小的情況下,fastjson的效能表現優於fastjson2。

  • 在適當的情況先,對jvm進行調優,是對應用程式的效能有影響的

  • 我們需要知道,堆空間並非越大越好,空間越大代表著GC處理時間會越長,其表現為應用響應時間的增加。

三、原始碼分析

本節將通過閱讀原始碼的方式簡單瞭解fastjson2的原理,主要分為兩個方面進行閱讀:

  • writer
  • reader

為什麼通過這兩個方面?

fastjson的核心就是將java物件序列化成json(對應writer),以及將json反序列化成java物件(對應reader)。而且其內部正是通過這樣的命名方式去實現的。

3.1 序列化 writer

toJSONString方法

其實所謂的序列化,就是JSONObject.toJSONString的體現,所以我們通過跟蹤其原始碼去發現其原理,注意我寫註釋的位置。

```java /* * Serialize Java Object to JSON {@link String} with specified {@link JSONReader.Feature}s enabled * * @param object Java Object to be serialized into JSON {@link String} * @param features features to be enabled in serialization / static String toJSONString(Object object, JSONWriter.Feature... features) { // 初始化 【ObjectWriterProvider】 ,關注【JSONFactory.defaultObjectWriterProvider】 JSONWriter.Context writeContext = new JSONWriter.Context(JSONFactory.defaultObjectWriterProvider, features);

boolean pretty = (writeContext.features & JSONWriter.Feature.PrettyFormat.mask) != 0;
// 初始化jsonwriter,ObjectWriter會將json資料寫入jsonwriter
JSONWriterUTF16 jsonWriter = JDKUtils.JVM_VERSION == 8 ? new JSONWriterUTF16JDK8(writeContext) : new JSONWriterUTF16(writeContext);

try (JSONWriter writer = pretty ?
        new JSONWriterPretty(jsonWriter) : jsonWriter) {
    if (object == null) {
        writer.writeNull();
    } else {
        writer.setRootObject(object);
        Class<?> valueClass = object.getClass();

        boolean fieldBased = (writeContext.features & JSONWriter.Feature.FieldBased.mask) != 0;
        // 獲取ObjectWriter
        ObjectWriter<?> objectWriter = writeContext.provider.getObjectWriter(valueClass, valueClass, fieldBased);
        // ObjectWriter將資料寫入JSONWriter
        objectWriter.write(writer, object, null, null, 0);
    }
    return writer.toString();
}

} ```

defaultObjectWriterProvider物件

檢視JSONFactory.defaultObjectWriterProvider的內容: java public ObjectWriterProvider() { init(); // 初始化【ObjectWriterCreator】,用來建立【ObjectWriterProvider】 ObjectWriterCreator creator = null; switch (JSONFactory.CREATOR) { case "reflect": //反射 creator = ObjectWriterCreator.INSTANCE; break; case "lambda": // lambda creator = ObjectWriterCreatorLambda.INSTANCE; break; case "asm": default: try {//asm creator = ObjectWriterCreatorASM.INSTANCE; } catch (Throwable ignored) { // ignored } if (creator == null) { creator = ObjectWriterCreatorLambda.INSTANCE; } break; } this.creator = creator; } 如上所示,我們看到此處初始化了ObjectWriterCreator,其實現方式預設是基於ASM的動態位元組碼實現。

另外還提供了 反射lambda 的方式。

到此為止已經獲取到了ObjectWriterProvider,它的作用是用來獲取ObjectWriter的。

getObjectWriter方法

ObjectWriter的作用就是將java物件寫入到json當中,所以我們下面開始關注這一行程式碼的實現: java writeContext.provider.getObjectWriter(valueClass, valueClass, fieldBased); 繼續檢視getObjectWriter方法,檢視關鍵位置程式碼: ```java if (objectWriter == null) { // 獲取creator,此處獲取的是方法開始時預設的【ObjectWriterCreatorASM】 ObjectWriterCreator creator = getCreator(); if (objectClass == null) { objectClass = TypeUtils.getMapping(objectType); } // 此處建立ObjectWriter,內部建立【FieldWriter】 objectWriter = creator.createObjectWriter( objectClass, fieldBased ? JSONWriter.Feature.FieldBased.mask : 0, modules ); ObjectWriter previous = fieldBased ? cacheFieldBased.putIfAbsent(objectType, objectWriter) : cache.putIfAbsent(objectType, objectWriter);

if (previous != null) {
    objectWriter = previous;
}

} ```

createObjectWriter方法

檢視creator.createObjectWriter虛擬碼: java // 遍歷java物件當中的getter方法,獲取屬性名 BeanUtils.getters(objectClass, method -> { ... ... String fieldName; if (fieldInfo.fieldName == null || fieldInfo.fieldName.isEmpty()) { if (record) { fieldName = method.getName(); } else { // 根據getter獲取到屬性名稱 fieldName = BeanUtils.getterName(method.getName(), beanInfo.namingStrategy); } } else { fieldName = fieldInfo.fieldName; } ... ... 在上面的getterName方法獲取到物件的屬性名,找到屬性後,建立對應的【FieldWriter】: ```java //建立該屬性的fieldWriter FieldWriter fieldWriter = createFieldWriter( objectClass, fieldName, fieldInfo.ordinal, fieldInfo.features, fieldInfo.format, fieldInfo.label, method, writeUsingWriter );

// 將屬性名作為key,fieldWriter作為value放入快取【fieldWriterMap】 FieldWriter origin = fieldWriterMap.putIfAbsent(fieldName, fieldWriter); 迴圈過所有的getter方法後,會得到一個全部屬性的List<FieldWriter> fieldWriters集合:java fieldWriters = new ArrayList<>(fieldWriterMap.values()); ``` 再往後,fastjson2會組裝一個動態類:【ObjectWriter_1】,在裡面組裝能夠寫入JSONWriter的各種屬性和方法,以及get屬性獲取:

image.png

定義和初始化此物件的方法如下所示: ```java //定義【ObjectWriter_1】的屬性 genFields(fieldWriters, cw);

// 定義【ObjectWriter_1】的方法 genMethodInit(fieldWriters, cw, classNameType); //定義【ObjectWriter_1】獲取物件屬性的讀取方法 genGetFieldReader( fieldWriters, cw, classNameType, new ObjectWriterAdapter(objectClass, null, null, features, fieldWriters) ); ```

此動態物件的末尾【1】是隨數量增長的。

繼續向下跟蹤到如下方法: java genMethodWrite(objectClass, fieldWriters, cw, classNameType, writerFeatures); 此方法主要的作用是建立【ObjectWrite_1】的write方法,並匹配當前java物件的屬性屬於哪種型別,使用哪種FieldWriter進行寫入。

其內部會輪詢所有的屬性進行匹配,我們的屬性主要是StringInteger,如下: ```java ... ... else if (fieldClass == Integer.class) { // 處理Integer屬性 gwInt32(mwc, fieldWriter, OBJECT, i); } else if (fieldClass == String.class) { // 處理String屬性 gwFieldValueString(mwc, fieldWriter, OBJECT, i); } ... ...

```

  • Integer 在內部處理時,會在動態物件生成名稱是writeInt32的方法。

  • String 內部處理時在動態物件生成方法writeString

再向下會通過以下方法修改寫入不同型別屬性的方法名稱和描述資訊等 java genMethodWriteArrayMapping("writeArrayMapping", objectClass, writerFeatures, fieldWriters, cw, classNameType);

能夠看到,Integer和String的後續處理方法不同: * String java else if (fieldClass == String.class) { methodName = "writeString"; methodDesc = "(Ljava/lang/String;)V"; } * Integer 則是物件"(Ljava/lang/Object;)V"

到此整個ObjectWriter_1物件就設定完成了,使用反射進行建立:

java try { Constructor<?> constructor = deserClass.getConstructor(Class.class, String.class, String.class, long.class, List.class); return (ObjectWriter) constructor.newInstance(objectClass, beanInfo.typeKey, beanInfo.typeName, writerFeatures, fieldWriters); } catch (Throwable e) { throw new JSONException("create objectWriter error, objectType " + objectClass, e); }

回到toJSONString方法

至此我們已經拿到java物件的屬性,併成功建立了【ObjectWriter】:

image.png

再返回toJSonString方法當中,看看Object的後續操作 拿到的ObjectWriter呼叫其【write】方法進行資料寫入: java objectWriter.write(writer, object, null, null, 0);

我們已經知道不同型別屬性使用不同的FieldWriter進行寫入: * String:我們雖然提到過使用的writeString方法,但是你會發現沒有對應的FieldWriter,因為它使用的是JSONWriterUTF16JDK8writeString(String str)方法,不同版本的jdk有不同的Class。

  • Integr:使用FieldWriterInt32writeInt32(JSONWriter jsonWriter, int value)進行寫入。

關於具體的寫入過程就不在介紹了。

小結

官方提供Writer關係圖如下:

image.png

本節主要針對主要流程進行梳理,與上圖對比存在部分未講解流程,感興趣同學參照原始碼自行閱讀。

整個過程較為複雜,簡單描述為:使用ASM動態位元組碼方式作為基礎,通過java物件的getter方法獲取物件的屬性值,構建動態ObjectWriter物件,針對不同的物件屬性,生成不同的寫入方法,最終通過反射進行物件建立,最後進行java物件資料的寫入。

值得一提的是,ObejctWriter物件是會進行快取的,有助於效能的提升。

3.2 反序列化 reader

下面來看看反序列化reader的流程。因為大體流程與writer差不多,所以以下內容不做詳細講解了。

parseObject 方法

```java /* * json轉換java物件 * * @param text json字串 * @param 需要轉換的類 * @return Class / @SuppressWarnings("unchecked") static T parseObject(String text, Class clazz) { if (text == null || text.isEmpty()) { return null; } //建立reader,內部與writer相同,使用ASM動態位元組碼形式建立creater try (JSONReader reader = JSONReader.of(text)) { // 獲取上下文 JSONReader.Context context = reader.context;

    boolean fieldBased = (context.features & JSONReader.Feature.FieldBased.mask) != 0;
    // 獲取ObjectReader
    ObjectReader<T> objectReader = context.provider.getObjectReader(clazz, fieldBased);

    T object = objectReader.readObject(reader, 0);
    if (reader.resolveTasks != null) {
        reader.handleResolveTasks(object);
    }
    return object;
}

} ```

JSONReader.of方法

建立reader物件, ```java public static JSONReader of(String str) { if (str == null) { throw new NullPointerException(); } //建立reader的上下文,內部與writer相同,使用ASM動態位元組碼形式建立creater,包裝成context Context context = JSONFactory.createReadContext(); // jdk8以上版本使用下面的字串處理方式 if (JDKUtils.JVM_VERSION > 8 && JDKUtils.UNSAFE_SUPPORT && str.length() > 1024 * 1024) { try { byte coder = UnsafeUtils.getStringCoder(str); if (coder == 0) { byte[] bytes = UnsafeUtils.getStringValue(str); return new JSONReaderASCII(context, str, bytes, 0, bytes.length); } } catch (Exception e) { throw new JSONException("unsafe get String.coder error"); }

    return new JSONReaderStr(context, str, 0, str.length());
}
// jdk 8 及以下字串處理
final int length = str.length();
char[] chars;
if (JDKUtils.JVM_VERSION == 8) {
    // jdk8字串轉char
    chars = JDKUtils.getCharArray(str);
} else {
    chars = str.toCharArray();
}
// 建立JSONReaderUTF16物件
return new JSONReaderUTF16(context, str, chars, 0, length);

} ```

getObjectReader方法

與getObjectWriter類似,獲取動態的json資料讀取物件。關注重點程式碼: java if (objectReader == null) { // 獲取前面建立的creater ObjectReaderCreator creator = getCreator(); // 建立ObjectReader物件,根據java類的型別 objectReader = creator.createObjectReader(objectClass, objectType, fieldBased, modules); }

createObjectReader方法

關注下面這行程式碼: java // 建立屬性讀取物件陣列 FieldReader[] fieldReaderArray = createFieldReaders(objectClass, objectType, beanInfo, fieldBased, modules); 繼續跟進,發現遍歷java物件的setter方法,此時我們應該能夠想到,向物件設定值的時候,一定是使用的setter方法: java BeanUtils.setters(objectClass, method -> { fieldInfo.init(); // 建立Fieldreader createFieldReader(objectClass, objectType, namingStrategy, orders, fieldInfo, method, fieldReaders, modules); }); createFieldReader方法會獲取java物件當中的屬性,以及set開頭的方法。

處理完物件的屬性和set方法後,會生成ObjectReader物件進行返回:

image.png

此物件包含setterFieldReaders,用於向java物件寫入資料。

回到parseObject

下面看如何讀取json資料到java物件: java object = objectReader.readObject(reader, 0); object內部主要是迴圈遍歷fieldReaders,它內部包含json當中的屬性和物件的set方法:

image.png

正是通過這些屬性和set方法將json的資料放到java物件當中。

首先將物件的屬性和值放到map當中: java valueMap.put(fieldReader.getFieldNameHash(), fieldValue); 通過下面的方法將map轉換成java物件: java T object = createInstanceNoneDefaultConstructor( valueMap == null ? Collections.emptyMap() : valueMap); 內部通過構造器和值去建立一個新的java物件: java return (T) constructor.newInstance(args); 注意:因為這個原因,在java物件當中必須要有一個相應的帶有引數的構造器,否則會報錯。

到此為止就成功拿到轉換後的java物件了。

小結

官方提供的Reader關係圖:

image.png

感興趣的同學可以參考上圖的內容,結合本文提供的流程,自己跟蹤一遍原始碼。

整個過成簡單描述:底層使用ASM動態位元組碼為基礎,通過java物件的setter方法去構建動態的ObjectReader物件,最終通過構造器去建立一個新的java物件

四、總結

關於fastjson2的簡單測試,以及原始碼閱讀到此就告一段落了。

針對fastjson2有以下幾點總結:

  • fastjson2對於fastjson的相容,可以使用下面的依賴: java <dependency> <groupId>com.alibaba</groupId> <artifactId>fastjson</artifactId> <version>2.0.8</version> </dependency> 但是官方也不保證100%相容。
  • 記憶體佔用,通過前面的測試,發現fastjson2有明顯佔用更大記憶體的現象,甚至在相同記憶體條件下,fastjson1可以完美執行,而fastjson2有產生記憶體溢位的風險。
  • Issues:通過官方的Issues能夠發現目前的bug還是比較多的,對於需要穩定性的專案還是不建議嘗試。具體表現如下:

    image.png * 原始碼閱讀難度,這個是我最想吐槽的,全部原始碼幾乎沒有註釋資訊,讀起來還是比較晦澀的。作者希望讀者能夠通過PR的方式補充註釋,也希望更多讀者加入進來,目前關於Fastjson2的原始碼閱讀文章基本為0。

    image.png

拋開上述存在的問題,fastjson2確實有不錯的效能提升,通過官方提供的測試資料可以看得出來,感興趣可以本地實測一下。


到此為止關於fastjson2的介紹就結束了,感謝大家的觀看。

我個人也是摸索著去學習和閱讀,對於有些解釋可能還存在一些誤區和誤讀,希望愛好閱讀原始碼的朋友們幫忙指點出來。本文僅作為大家閱讀原始碼的參考,希望有更多的fastjson2的原始碼閱讀類文章出現,便於大家一起學習。


我正在參與掘金技術社群創作者簽約計劃招募活動,點選連結報名投稿