全網最詳細最齊全的序列化技術及深度解析與應用實戰


file

這篇文章主要給大家講解序列化和反序列化。

序列化是網絡通信中非常重要的一個機制,好的序列化方式能夠直接影響數據傳輸的性能。

序列化

所謂的序列化,就是把一個對象,轉化為某種特定的形式,然后以數據流的方式傳輸。

比如把一個對象直接轉化為二進制數據流進行傳輸。當然這個對象可以轉化為其他形式之后再轉化為數據流。

比如XML、JSON等格式。它們通過另外一種數據格式表達了一個對象的狀態,然后再把這些數據轉化為二進制數據流進行網絡傳輸。

反序列化

反序列化是序列化的逆向過程,把字節數組反序列化為對象,把字節序列恢復為對象的過程成為對象的反序列化

序列化的高階認識

前面的代碼中演示了,如何通過JDK提供了Java對象的序列化方式實現對象序列化傳輸,主要通過輸出流java.io.ObjectOutputStream和對象輸入流java.io.ObjectInputStream來實現。

java.io.ObjectOutputStream:表示對象輸出流 , 它的writeObject(Object obj)方法可以對參數指定的obj對象進行序列化,把得到的字節序列寫到一個目標輸出流中。

java.io.ObjectInputStream:表示對象輸入流 ,它的readObject()方法源輸入流中讀取字節序列,再把它們反序列化成為一個對象,並將其返回

需要注意的是,被序列化的對象需要實現java.io.Serializable接口

serialVersionUID的作用

在IDEA中通過如下設置可以生成serializeID,如圖5-1所示

字面意思上是序列化的版本號,凡是實現Serializable接口的類都有一個表示序列化版本標識符的靜態變量。

1592112197291

圖5-1

下面演示一下serialVersionUID的作用。首先需要創建一個普通的spring boot項目,然后按照下面的步驟來進行演示

創建User對象

public class User implements Serializable {
    private static final long serialVersionUID = -8826770719841981391L;

    private String name;
    private int age;
}

編寫Java序列化的代碼

public class JavaSerializer {
    public static void main(String[] args) {
        User user=new User();
        user.setAge(18);
        user.setName("Mic");
        serialToFile(user);
        System.out.println("序列化成功,開始反序列化");
        User nuser=deserialFromFile();
        System.out.println(nuser);
    }

    private static void serialToFile(User user){
        try {
            ObjectOutputStream objectOutputStream=
                    new ObjectOutputStream(new FileOutputStream(new File("user")));
            objectOutputStream.writeObject(user);
        } catch (IOException e) {
            e.printStackTrace();
        }
    }
    private static <T> T deserialFromFile(){
        try {
            ObjectInputStream objectInputStream=new ObjectInputStream(new FileInputStream(new File("user")));
            return (T)objectInputStream.readObject();
        } catch (IOException | ClassNotFoundException e) {
            e.printStackTrace();
        }
        return null;
    }
}

UID驗證演示步驟

  1. 先將user對象序列化到文件中

  2. 然后修改user對象,增加serialVersionUID字段

  3. 然后通過反序列化來把對象提取出來

  4. 演示預期結果:提示無法反序列化

結論

Java的序列化機制是通過判斷類的serialVersionUID來驗證版本一致性的。在進行反序列化時,JVM會把傳來的字節流中的serialVersionUID與本地相應實體類的serialVersionUID進行比較,如果相同就認為是一致的,可以進行反序列化,否則就會出現序列化版本不一致的異常,即是InvalidCastException。

從結果可以看出,文件流中的class和classpath中的class,也就是修改過后的class,不兼容了,出於安全機制考慮,程序拋出了錯誤,並且拒絕載入。從錯誤結果來看,如果沒有為指定的class配置serialVersionUID,那么java編譯器會自動給這個class進行一個摘要算法,類似於指紋算法,只要這個文件有任何改動,得到的UID就會截然不同的,可以保證在這么多類中,這個編號是唯一的。所以,由於沒有顯指定 serialVersionUID,編譯器又為我們生成了一個UID,當然和前面保存在文件中的那個不會一樣了,於是就出現了2個序列化版本號不一致的錯誤。因此,只要我們自己指定了serialVersionUID,就可以在序列化后,去添加一個字段,或者方法,而不會影響到后期的還原,還原后的對象照樣可以使用,而且還多了方法或者屬性可以用。

tips: serialVersionUID有兩種顯示的生成方式:

一是默認的1L,比如:private static final long serialVersionUID = 1L;

二是根據類名、接口名、成員方法及屬性等來生成一個64位的哈希字段

當實現java.io.Serializable接口的類沒有顯式地定義一個serialVersionUID變量時候,Java序列化機制會根據編譯的Class自動生成一個serialVersionUID作序列化版本比較用,這種情況下,如果Class文件(類名,方法明等)沒有發生變化(增加空格,換行,增加注釋等等),就算再編譯多次,serialVersionUID也不會變化的。

Transient關鍵字

Transient 關鍵字的作用是控制變量的序列化,在變量聲明前加上該關鍵字,可以阻止該變量被序列化到文件中,在被反序列化后,transient 變量的值被設為初始值,如 int 型的是 0,對象型的是 null。

如果我們希望User類中的name字段不序列化,則按照以下方案進行修改。

修改User類

public class User implements Serializable {
    private static final long serialVersionUID = -8826770719841981391L;

    private transient String name;
    private int age;
}

測試效果

public class JavaSerializer {
    public static void main(String[] args) {
        User user=new User();
        user.setAge(18);
        user.setName("Mic");
        serialToFile(user);
        System.out.println("序列化成功,開始反序列化");
        User nuser=deserialFromFile();
        System.out.println(nuser.getName()); //打印反序列化的結果,發現結果是NULL.
    }
}

繞開transient機制

在User類中重寫writeObject和readObject方法。

public class User implements Serializable {
    private static final long serialVersionUID = -8826770719841981391L;

    private transient String name;
    private int age;

    private void writeObject(ObjectOutputStream out) throws IOException {
        out.defaultWriteObject();
        out.writeObject(name);//增加寫入name字段
    }

    private void readObject(ObjectInputStream in) throws Exception{
        in.defaultReadObject();
        name=(String)in.readObject();
    }
}

這兩個方法是在ObjectInputStream和ObjectOutputStream中,分別反序列化和序列化對象時反射調用目標對象中的這兩個方法。

序列化的總結

  1. Java序列化只是針對對象的狀態進行保存,至於對象中的方法,序列化不關心

  2. 當一個父類實現了序列化,那么子類會自動實現序列化,不需要顯示實現序列化接口

  3. 當一個對象的實例變量引用了其他對象,序列化這個對象的時候會自動把引用的對象也進行序列化(實現深度克隆)

  4. 當某個字段被申明為transient后,默認的序列化機制會忽略這個字段

  5. 被申明為transient的字段,如果需要序列化,可以添加兩個私有方法:writeObject和readObject

常見的序列化技術及優劣分析

隨着分布式架構、微服務架構的普及。服務與服務之間的通信成了最基本的需求。這個時候,我們不僅需要考慮通信的性能,也需要考慮到語言多元化問題

所以,對於序列化來說,如何去提升序列化性能以及解決跨語言問題,就成了一個重點考慮的問題。

由於Java本身提供的序列化機制存在兩個問題

  1. 序列化的數據比較大,傳輸效率低

  2. 其他語言無法識別和對接

以至於在后來的很長一段時間,基於XML格式編碼的對象序列化機制成為了主流,一方面解決了多語言兼容問題,另一方面比二進制的序列化方式更容易理解。

以至於基於XML的SOAP協議及對應的WebService框架在很長一段時間內成為各個主流開發語言的必備的技術。

再到后來,基於JSON的簡單文本格式編碼的HTTP REST接口又基本上取代了復雜的Web Service接口,成為分布式架構中遠程通信的首要選擇。

但是JSON序列化存儲占用的空間大、性能低等問題,同時移動客戶端應用需要更高效的傳輸數據來提升用戶體驗。在這種情況下與語言無關並且高效的二進制編碼協議就成為了大家追求的熱點技術之一。

首先誕生的一個開源的二進制序列化框架-MessagePack。它比google的Protocol Buffers出現得還要早。

XML序列化框架介紹

XML序列化的好處在於可讀性好,方便閱讀和調試。但是序列化以后的字節碼文件比較大,而且效率不高,適用於對性能不高,而且QPS較低的企業級內部系統之間的數據交換的場景,同時XML又具有語言無關性,所以還可以用於異構系統之間的數據交換和協議。比如我們熟知的Webservice,就是采用XML格式對數據進行序列化的。XML序列化/反序列化的實現方式有很多,熟知的方式有XStream和Java自帶的XML序列化和反序列化兩種。

引入jar包

<dependency>
    <groupId>com.thoughtworks.xstream</groupId>
    <artifactId>xstream</artifactId>
    <version>1.4.12</version>
</dependency>

編寫測試程序

public class XMLSerializer {

    public static void main(String[] args) {
        User user=new User();
        user.setName("Mic");
        user.setAge(18);
        String xml=serialize(user);
        System.out.println("序列化完成:"+xml);
        User nuser=deserialize(xml);
        System.out.println(nuser);
    }
    private static String serialize(User user){
        return new XStream(new DomDriver()).toXML(user);
    }
    private static User deserialize(String xml){
        return (User)new XStream(new DomDriver()).fromXML(xml);
    }
}

JSON序列化框架

JSON(JavaScript Object Notation)是一種輕量級的數據交換格式,相對於XML來說,JSON的字節流更小,而且可讀性也非常好。現在JSON數據格式在企業運用是最普遍的

JSON序列化常用的開源工具有很多

  1. Jackson (https://github.com/FasterXML/jackson)

  2. 阿里開源的FastJson (https://github.com/alibaba/fastjon)

  3. Google的GSON (https://github.com/google/gson)

這幾種json序列化工具中,Jackson與fastjson要比GSON的性能要好,但是Jackson、GSON的穩定性要比Fastjson好。而fastjson的優勢在於提供的api非常容易使用

引入jar包

<dependency>
    <groupId>com.alibaba</groupId>
    <artifactId>fastjson</artifactId>
    <version>1.2.72</version>
</dependency>

編寫測試程序

public class JsonSerializer{

    public static void main(String[] args) {
        User user=new User();
        user.setName("Mic");
        user.setAge(18);
        String xml=serializer(user);
        System.out.println("序列化完成:"+xml);
        User nuser=deserializer(xml);
        System.out.println(nuser);
    }

    private static String serializer(User user){
        return JSON.toJSONString(user);
    }
    private static User deserializer(String json){
        return (User)JSON.parseObject(json,User.class);
    }
}

Hessian序列化

Hessian是一個支持跨語言傳輸的二進制序列化協議,相對於Java默認的序列化機制來說,Hessian具有更好的性能和易用性,而且支持多種不同的語言

實際上Dubbo采用的就是Hessian序列化來實現,只不過Dubbo對Hessian進行了重構,性能更高

引入jar包

<dependency>
    <groupId>com.caucho</groupId>
    <artifactId>hessian</artifactId>
    <version>4.0.63</version>
</dependency>

編寫測試程序

public class HessianSerializer {
    public static void main(String[] args) throws IOException {
        User user=new User();
        user.setName("Mic");
        user.setAge(18);
        byte[] bytes=serializer(user);
        System.out.println("序列化完成");
        User nuser=deserializer(bytes);
        System.out.println(nuser);
    }

    private static byte[] serializer(User user) throws IOException {
        ByteArrayOutputStream bos=new ByteArrayOutputStream(); //表示輸出到內存的實現
        HessianOutput ho=new HessianOutput(bos);
        ho.writeObject(user);
        return bos.toByteArray();
    }
    private static User deserializer(byte[] data) throws IOException {
        ByteArrayInputStream bis=new ByteArrayInputStream(data);
        HessianInput hi=new HessianInput(bis);
        return (User)hi.readObject();
    }
}

Avro序列化

Avro是一個數據序列化系統,設計用於支持大批量數據交換的應用。它的主要特點有:支持二進制序列化方式,可以便捷,快速地處理大量數據;動態語言友好,Avro提供的機制使動態語言可以方便地處理Avro數據。

Avro是apache下hadoop的子項目,擁有序列化、反序列化、RPC功能。序列化的效率比jdk更高,與Google的protobuffer相當,比facebook開源Thrift(后由apache管理了)更優秀。

因為avro采用schema,如果是序列化大量類型相同的對象,那么只需要保存一份類的結構信息+數據,大大減少網絡通信或者數據存儲量。

引入jar包

<dependency>
    <groupId>org.apache.avro</groupId>
    <artifactId>avro</artifactId>
    <version>1.8.2</version>
</dependency>
<dependency>
    <groupId>org.apache.avro</groupId>
    <artifactId>avro-ipc</artifactId>
    <version>1.8.2</version>
</dependency>

<build>
    <plugins>
        <plugin>
            <groupId>org.springframework.boot</groupId>
            <artifactId>spring-boot-maven-plugin</artifactId>
        </plugin>
        <plugin>
            <groupId>org.apache.avro</groupId>
            <artifactId>avro-maven-plugin</artifactId>
            <version>1.8.2</version>
            <executions>
                <execution>
                    <id>schemas</id>
                    <phase>generate-sources</phase>
                    <goals>
                        <goal>schema</goal>
                    </goals>
                    <configuration>
                        <sourceDirectory>${project.basedir}/src/main/avro</sourceDirectory>
                        <outputDirectory>${project.basedir}/src/main/java</outputDirectory>
                    </configuration>
                </execution>
            </executions>
        </plugin>
    </plugins>
</build>

編寫avsc文件

創建/src/main/avro目錄,專門用來存儲Avrode scheme定義文件。

{
"namespace":"com.gupao.example",
"type":"record",
"name":"Person",
"fields":[
   {"name":"name","type":"string"},
   {"name":"age","type":"int"},
   {"name":"sex","type":"string"}
 ]
}

avsc文件中的語法定義如下:

  • namespace:命名空間,在使用插件生成代碼的時候,User類的包名就是它

  • type:有 records, enums, arrays, maps, unions , fixed 取值,records是相當於普通的class

  • name:類名,類的全名有namespace+name構成

  • doc:注釋

  • aliases:取的別名,其他地方使用可以使用別名來引用

  • fields:屬性

    • name:屬性名

    • type:屬性類型,可以是用["int","null"]或者["int",1]執行默認值

    • default:也可以使用該字段指定默認值

    • doc:注釋

生成代碼

執行maven install

會在main/java目錄下生成Person類。

編寫測試程序

public class AvroSerializer {

    public static void main(String[] args) throws IOException {
        Person person=Person.newBuilder().setName("Mic").setAge(18).setSex("男").build();
        ByteBuffer byteBuffer=person.toByteBuffer(); //序列化
        System.out.println("序列化大小:"+byteBuffer.array().length);
        Person nperson=Person.fromByteBuffer(byteBuffer);
        System.out.println("反序列化:"+nperson);
    }
}

下面這種方式是基於文件的形式來實現序列化和反序列化

public class AvroSerializer {

    public static void main(String[] args) throws IOException {
        Person person=Person.newBuilder().setName("Mic").setAge(18).setSex("男").build();
        /* ByteBuffer byteBuffer=person.toByteBuffer(); //序列化
        System.out.println("序列化大小:"+byteBuffer.array().length);
        Person nperson=Person.fromByteBuffer(byteBuffer);
        System.out.println("反序列化:"+nperson);*/

        DatumWriter<Person> personDatumWriter=new SpecificDatumWriter<>(Person.class);
        DataFileWriter<Person> dataFileWriter=new DataFileWriter<>(personDatumWriter);
        dataFileWriter.create(person.getSchema(),new File("person.avro"));
        dataFileWriter.append(person);
        dataFileWriter.close();
        System.out.println("序列化成功.....");
        DatumReader<Person> personDatumReader=new SpecificDatumReader<>(Person.class);
        DataFileReader<Person> dataFileReader=new DataFileReader<Person>(new File("person.avro"),personDatumReader);
        Person nper=dataFileReader.next();
        System.out.println(nper);
    }
}

kyro序列化框架

Kryo是一種非常成熟的序列化實現,已經在Hive、Storm)中使用得比較廣泛,不過它不能跨語言. 目前dubbo已經在2.6版本支持kyro的序列化機制。它的性能要優於之前的hessian2

zookeeper中使用jute作為序列化

Protobuf序列化

Protobuf是Google的一種數據交換格式,它獨立於語言、獨立於平台。Google提供了多種語言來實現,比如Java、C、Go、Python,每一種實現都包含了相應語言的編譯器和庫文件,Protobuf是一個純粹的表示層協議,可以和各種傳輸層協議一起使用。

Protobuf使用比較廣泛,主要是空間開銷小和性能比較好,非常適合用於公司內部對性能要求高的RPC調用。 另外由於解析性能比較高,序列化以后數據量相對較少,所以也可以應用在對象的持久化場景中

但是要使用Protobuf會相對來說麻煩些,因為他有自己的語法,有自己的編譯器,如果需要用到的話必須要去投入成本在這個技術的學習中

protobuf有個缺點就是要傳輸的每一個類的結構都要生成對應的proto文件,如果某個類發生修改,還得重新生成該類對應的proto文件

使用protobuf開發的一般步驟是

  1. 配置開發環境,安裝protocol compiler代碼編譯器
  2. 編寫.proto文件,定義序列化對象的數據結構
  3. 基於編寫的.proto文件,使用protocol compiler編譯器生成對應的序列化/反序列化工具類
  4. 基於自動生成的代碼,編寫自己的序列化應用

安裝protobuf編譯工具

  • https://github.com/google/protobuf/releases 找到 protoc-3.5.1-win32.zip

  • 編寫proto文件

    syntax="proto2";
    
    package com.gupao.example; 
    option java_outer_classname="UserProtos";
    
    message User {
    	required string name=1;
    	required int32 age=2;
    }
    
    

    數據類型說明如下:

    • string / bytes / bool / int32(4個字節)/int64/float/double
    • enum 枚舉類
    • message 自定義類
    • 修飾符
      • required 表示必填字段
      • optional 表示可選字段
      • repeated 可重復,表示集合
      • 1,2,3,4需要在當前范圍內是唯一的,表示順序
  • 生成實例類,在cmd中運行如下命令

    protoc.exe --java_out=./ ./User.proto
    

實現序列化

<dependency>
    <groupId>com.google.protobuf</groupId>
    <artifactId>protobuf-java</artifactId>
    <version>3.12.2</version>
</dependency>

編寫測試代碼.

public class ProtobufSerializer {

    public static void main(String[] args) throws InvalidProtocolBufferException {
        UserProtos.User user=UserProtos.User.newBuilder().setName("Mic").setAge(18).build();
        ByteString bytes=user.toByteString();
        System.out.println(bytes.toByteArray().length);
        UserProtos.User nUser=UserProtos.User.parseFrom(bytes);
        System.out.println(nUser);
    }
}

Protobuf序列化原理解析

我們可以把序列化以后的數據打印出來看看結果

public static void main(String[] args) {
    UserProtos.User user=UserProtos.User.newBuilder().
            setAge(300).setName("Mic").build();
    byte[] bytes=user.toByteArray();
    for(byte bt:bytes){
        System.out.print(bt+" ");
    }
}
10 3 77 105 99 16 -84 2

我們可以看到,序列化出來的數字基本看不懂,但是序列化以后的數據確實很小,那我們接下來帶大家去了解一下底層的原理

正常來說,要達到最小的序列化結果,一定會用到壓縮的技術,而protobuf里面用到了兩種壓縮算法,一種是varint,另一種是zigzag

varint

先說第一種,我們先來看【Mic】是怎么被壓縮的

“Mic”這個字符,需要根據ASCII對照表轉化為數字。

M =77、i=105、c=99

所以結果為 77 105 99

大家肯定有個疑問,這里的結果為什么直接就是ASCII編碼的值呢?怎么沒有做壓縮呢?有沒有同學能夠回答出來

原因是,varint是對字節碼做壓縮,但是如果這個數字的二進制只需要一個字節表示的時候,其實最終編碼出來的結果是不會變化的。 如果出現需要大於一個字節的方式來表示,則需要進行壓縮。

比如,我們設置的age=300, 這里需要2個字節來存儲。那看一下它是如何被壓縮的。

300如何被壓縮

image-20200614173052807

這兩個字節字節分別的結果是:-84 、2

-84怎么計算來的呢? 我們知道在二進制中表示負數的方法,高位設置為1, 並且是對應數字的二進制取反以后再計算補碼表示(補碼是反碼+1)

所以如果要反過來計算

  1. 【補碼】10101100 -1 得到 10101011

  2. 【反碼】01010100 得到的結果為84. 由於高位是1,表示負數所以結果為-84

存儲格式

protobuf采用T-L-V作為存儲方式

image-20200614173725651

image-20200614173729819

tag的計算方式是 field_number(當前字段的編號) << 3 | wire_type

比如Mic的字段編號是1 ,類型wire_type的值為 2 所以 : 1 <<3 | 2 =10

age=300的字段編號是2,類型wire_type的值是0, 所以 : 2<<3|0 =16

所以按照T-L-V的格式,第一個字段為name,所以它的數據為 {10} {3} {77 105 99},第二個字段為age ,{16} {2} {-82 2}

5.5.3 負數的存儲方式

在計算機中,負數會被表示為很大的整數,因為計算機定義負數符號位為數字的最高位,所以如果采用varint編碼表示一個負數,那么一定需要5個比特位。所以在protobuf中通過sint32/sint64類型來表示負數,負數的處理形式是先采用zigzag編碼(把符號數轉化為無符號數),在采用varint編碼。

sint32:(n << 1) ^ (n >> 31)

sint64:(n << 1) ^ (n >> 63)

比如存儲一個(-300)的值。

  • 修改proto原始文件

    message User {
    	required string name=1;
    	required int32 age=2;
    	required sint32 status=3; //增加一個sint的字段
    }
    
  • 設置一個值

    UserProtos.User user=UserProtos.User.newBuilder().setAge(300).setName("Mic").setStatus(-300).build();
    
  • 此時的輸出結果:10 3 77 105 99 16 -84 2 24 -41 4

我們發現,針對於負數類型,壓縮出來的數據是不一樣的,這里采用的編碼方式是zigzag的編碼,再采用varint進行編碼壓縮。

比如存儲一個(-300)的值

-300

原碼:0001 0010 1100

取反:1110 1101 0011

加1 :1110 1101 0100

n<<1: 整體左移一位,右邊補0 -> 1101 1010 1000

n>>31: 整體右移31位,左邊補1 -> 1111 1111 1111

n<<1 ^ n >>31

1101 1010 1000 ^ 1111 1111 1111 = 0010 0101 0111

十進制: 0010 0101 0111 = 599

這樣做的目的,是消除高位的1,從而形成一個可以被壓縮的數據。針對599再采用varint進行編碼。

varint算法: 從右往做,選取7位,高位補1/0(取決於字節數)

得到兩個字節

1101 0111 0000 0100

-41 、 4

5.5.4 總結

Protocol Buffer的性能好,主要體現在 序列化后的數據體積小 & 序列化速度快,最終使得傳輸效率高,其原因如下:

序列化速度快的原因:

a. 編碼 / 解碼 方式簡單(只需要簡單的數學運算 = 位移等等)

b. 采用 Protocol Buffer 自身的框架代碼 和 編譯器 共同完成

序列化后的數據量體積小(即數據壓縮效果好)的原因:

a. 采用了獨特的編碼方式,如Varint、Zigzag編碼方式等等

b. 采用T - L - V 的數據存儲方式:減少了分隔符的使用 & 數據存儲得緊湊

序列化技術選型

技術層面

  1. 序列化空間開銷,也就是序列化產生的結果大小,這個影響到傳輸的性能

  2. 序列化過程中消耗的時長,序列化消耗時間過長影響到業務的響應時間

  3. 序列化協議是否支持跨平台,跨語言。因為現在的架構更加靈活,如果存在異構系統通信需求,那么這個是必須要考慮的

  4. 可擴展性/兼容性,在實際業務開發中,系統往往需要隨着需求的快速迭代來實現快速更新,這就要求我們采用的序列化協議基於良好的可擴展性/兼容性,比如在現有的序列化數據結構中新增一個業務字段,不會影響到現有的服務

  5. 技術的流行程度,越流行的技術意味着使用的公司多,那么很多坑都已經淌過並且得到了解決,技術解決方案也相對成熟

  6. 學習難度和易用性

選型建議

  1. 對性能要求不高的場景,可以采用基於XML的SOAP協議

  2. 對性能和間接性有比較高要求的場景,那么Hessian、Protobuf、Thrift、Avro都可以。

  3. 基於前后端分離,或者獨立的對外的api服務,選用JSON是比較好的,對於調試、可讀性都很不錯

  4. Avro設計理念偏於動態類型語言,那么這類的場景使用Avro是可以的

版權聲明:本博客所有文章除特別聲明外,均采用 CC BY-NC-SA 4.0 許可協議。轉載請注明來自 Mic帶你學架構
如果本篇文章對您有幫助,還請幫忙點個關注和贊,您的堅持是我不斷創作的動力。歡迎關注「跟着Mic學架構」公眾號公眾號獲取更多技術干貨!


免責聲明!

本站轉載的文章為個人學習借鑒使用,本站對版權不負任何法律責任。如果侵犯了您的隱私權益,請聯系本站郵箱yoyou2525@163.com刪除。



 
粵ICP備18138465號   © 2018-2025 CODEPRJ.COM