<rt id="bn8ez"></rt>
<label id="bn8ez"></label>

  • <span id="bn8ez"></span>

    <label id="bn8ez"><meter id="bn8ez"></meter></label>

    Jack Jiang

    我的最新工程MobileIMSDK:http://git.oschina.net/jackjiang/MobileIMSDK
    posts - 494, comments - 13, trackbacks - 0, articles - 1

    本文由騰訊PCG后臺開發工程師的SG4YK分享,進行了修訂和和少量改動。

    1、引言

    近日學習了 Protobuf 的編碼實現技術原理,借此機會,正好總結一下并整理成文。

    接上篇由淺入深,從根上理解Protobuf的編解碼原理》,本篇將從Base64再到Base128編碼,帶你一起從底層來理解Protobuf的數據編碼原理。

    本文結構總體與 Protobuf 官方文檔相似,不少內容也來自官方文檔,并在官方文檔的基礎上添加作者理解的內容(確保不那么枯燥),如有出入請以官方文檔為準。

    學習交流:

    (本文已同步發布于:http://www.52im.net/thread-4093-1-1.html

    2、系列文章

    本文是系列文章中的第 篇,本系列總目錄如下:

    3、寫在前面

    在上篇《由淺入深,從根上理解Protobuf的編解碼原理》中,我們已經由淺入深地探討了Protobuf的編解碼技術實現實現,但實際上在初學者看來,Protobuf的編碼原理到底源自哪里又去向何方,看起來還是有點蒙,我們或許可以從Protobuf與經典字符編碼技術的關系上能更好的理解這些。

    好了,不賣關子了。。。

    實際上,Protobuf 的編碼是基于變種的 Base128的。

    在學習 Protobuf 編碼或是 Base128 之前,我們先來了解下 Base64 編碼。

    4、什么是Base 64

    4.1 技術背景

    當我們在計算機之間傳輸數據時,數據本質上是一串字節流。

    TCP 協議可以保證被發送的字節流正確地達到目的地(至少在出錯時有一定的糾錯機制),所以本文不討論因網絡因素造成的數據損壞。

    但數據到達目標機器之后,由于不同機器采用的字符集不同等原因,我們并不能保證目標機器能夠正確地“理解”字節流。

    Base 64 最初被設計用于在郵件中嵌入文件(作為 MIME 的一部分):它可以將任何形式的字節流編碼為“安全”的字節流。

    何為“安全“的字節?先來看看 Base 64 是如何工作的。

    4.2 工作原理

    假設這里有四個字節,代表你要傳輸的二進制數據:

    首先將這字節流按每 6 個 bit 為一組進行分組,剩下少于 6 bits 的低位補 0:

    然后在每一組 6 bits 的高位補兩個 0:

    下面這張圖是 Base 64 的編碼對照表:

    對照 Base 64 的編碼對照表,字節流可以用ognC0w來表示。

    另外:Base64 編碼是按照 6 bits 為一組進行編碼,每 3 個字節的原始數據要用 4 個字節來儲存,編碼后的長度要為 4 的整數倍,不足 4 字節的部分要使用 pad 補齊,所以最終的編碼結果為ognC0w==。

    任意的字節流均可以使用 Base 64 進行編碼,編碼之后所有字節均可以用數字、字母和 + / = 號進行表示,這些都是可以被正常顯示的 ascii 字符,即“安全”的字節。絕大部分的計算機和操作系統都對 ascii 有著良好的支持,保證了編碼之后的字節流能被正確地復制、傳播、解析。

    注:下文關于字節順序內容均基于機器采用小端模式的前提進行討論(關于大小端字節序,可以閱讀《面試必考,史上最通俗大小端字節序詳解》)。

    5、什么是Base 128

    Base 64 存在的問題就是:編碼后的每一個字節的最高兩位總是 0,在不考慮 pad 的情況下,有效 bit 只占 bit 總數的 75%,造成大量的空間浪費。

    是否可以進一步提高信息密度呢?

    意識到這一點,你就很自然能想象出 Base 128 的大致實現思路了:將字節流按 7 bits 進行分組,然后低位補 0。

    但問題來了:Base 64 實際上用了 64+1 個 ascii 字符,按照這個思路 Base 128 需要使用 128+1 個 ascii 個字符,但是 ascii 字符一共只有 128 個。

    另外:即使不考慮 pad,ascii 中包含了一些不可以正常打印的控制字符,編碼之后的字符還可能包含會被不同操作系統轉換的換行符號(10 和 13)。因此,Base 64 至今依然沒有被 Base 128 替代。

    Base 64 的規則因為上述限制不能完美地擴展到 Base 128,所以現有基于 Base 64 擴展而來的編碼方式大部分都屬于變種:如 LEB128(Little-Endian Base 128)、 Base 85 (Ascii 85),以及本文的主角:Base 128 Varints。

    6、什么是Base 128 Varints

    6.1 基本概念

    Base 128 Varints 是 Google 開發的序列化庫 Protocol Buffers 所用的編碼方式。

    以下為 Protobuf 官方文檔中對于 Varints 的解釋:

    Varints are a method of serializing integers using one or more bytes. Smaller numbers take a smaller number of bytes.

    即:使用一個或多個字節對整數進行序列化,小的數字占用更少的字節。

    簡單來說,Base 128 Varints 編碼原理就是盡量只儲存整數的有效位,高位的 0 盡可能拋棄。

    Base 128 Varints 有兩個需要注意的細節:

    • 1)只能對一部分數據結構進行編碼,不適用于所有字節流(當然你可以把任意字節流轉換為 string,但不是所有語言都支持這個 trick)。否則無法識別哪部分是無效的 bits;
    • 2)編碼后的字節可以不存在于 Ascii 表中,因為和 Base 64 使用場景不同,不用考慮是否能正常打印。

    下面以例子進行說明 Base 128 Varints 的編碼實現。

    6.2 舉個例子

    對于Base 128 Varints 編碼后的每個字節,低 7 位用于儲存數據,最高位用來標識當前字節是否是當前整數的最后一個字節,稱為最高有效位(most significant bit, 簡稱msb)。msb 為 1 時,代表著后面還有數據;msb 為 0 時代表著當前字節是當前整數的最后一個字節。

    下面我們用實際的例子來更好的理解它。

    下圖是編碼后的整數1:1 只需要用一個字節就能表示完全,所以 msb 為 0。

    對于需要多個字節來儲存的數據,如 300 (0b100101100),有效位數為 9,編碼后需要兩個字節儲存。

    下圖是編碼后的整數300:第一個字節的 msb 為 1,最后一個字節的 msb 為 0。

    要將這兩個字節解碼成整數,需要三個步驟:

    • 1)去除 msb;
    • 2)將字節流逆序(msb 為 0 的字節儲存原始數據的高位部分,小端模式);
    • 3)最后拼接所有的 bits。

    6.3 對整數進行編碼的例子

    下面這個例子展示如何將使用 Base 128 Varints 對整數進行編碼。

    具體過程是:

    • 1)將數據按每 7 bits 一組拆分;
    • 2)逆序每一個組;
    • 3)添加 msb。

    需要注意的是:無論是編碼還是解碼,逆序字節流這一步在機器處理中實際是不存在的,機器采用小端模式處理數據,此處逆序僅是為了符合人的閱讀習慣而寫出。

    下面展示 Go 版本的 protobuf 中關于 Base 128 Varints 的實現:

    // google.golang.org/protobuf@v1.25.0/encoding/protowire/wire.go

     

    // AppendVarint appends v to b as a varint-encoded uint64.

    funcAppendVarint(b []byte, v uint64) []byte{

     switch{

     casev < 1<<7:

      b = append(b, byte(v))

     casev < 1<<14:

      b = append(b,

       byte((v>>0)&0x7f|0x80),

       byte(v>>7))

     casev < 1<<21:

      b = append(b,

       byte((v>>0)&0x7f|0x80),

       byte((v>>7)&0x7f|0x80),

       byte(v>>14))

     casev < 1<<28:

      b = append(b,

       byte((v>>0)&0x7f|0x80),

       byte((v>>7)&0x7f|0x80),

       byte((v>>14)&0x7f|0x80),

       byte(v>>21))

     casev < 1<<35:

      b = append(b,

       byte((v>>0)&0x7f|0x80),

       byte((v>>7)&0x7f|0x80),

       byte((v>>14)&0x7f|0x80),

       byte((v>>21)&0x7f|0x80),

       byte(v>>28))

     casev < 1<<42:

      b = append(b,

       byte((v>>0)&0x7f|0x80),

       byte((v>>7)&0x7f|0x80),

       byte((v>>14)&0x7f|0x80),

       byte((v>>21)&0x7f|0x80),

       byte((v>>28)&0x7f|0x80),

       byte(v>>35))

     casev < 1<<49:

      b = append(b,

       byte((v>>0)&0x7f|0x80),

       byte((v>>7)&0x7f|0x80),

       byte((v>>14)&0x7f|0x80),

       byte((v>>21)&0x7f|0x80),

       byte((v>>28)&0x7f|0x80),

       byte((v>>35)&0x7f|0x80),

       byte(v>>42))

     casev < 1<<56:

      b = append(b,

       byte((v>>0)&0x7f|0x80),

       byte((v>>7)&0x7f|0x80),

       byte((v>>14)&0x7f|0x80),

       byte((v>>21)&0x7f|0x80),

       byte((v>>28)&0x7f|0x80),

       byte((v>>35)&0x7f|0x80),

       byte((v>>42)&0x7f|0x80),

       byte(v>>49))

     casev < 1<<63:

      b = append(b,

       byte((v>>0)&0x7f|0x80),

       byte((v>>7)&0x7f|0x80),

       byte((v>>14)&0x7f|0x80),

       byte((v>>21)&0x7f|0x80),

       byte((v>>28)&0x7f|0x80),

       byte((v>>35)&0x7f|0x80),

       byte((v>>42)&0x7f|0x80),

       byte((v>>49)&0x7f|0x80),

       byte(v>>56))

     default:

      b = append(b,

       byte((v>>0)&0x7f|0x80),

       byte((v>>7)&0x7f|0x80),

       byte((v>>14)&0x7f|0x80),

       byte((v>>21)&0x7f|0x80),

       byte((v>>28)&0x7f|0x80),

       byte((v>>35)&0x7f|0x80),

       byte((v>>42)&0x7f|0x80),

       byte((v>>49)&0x7f|0x80),

       byte((v>>56)&0x7f|0x80),

       1)

     }

     returnb

    }

    從源碼中可以看出:protobuf 的 varints 最多可以編碼 8 字節的數據,這是因為絕大部分的現代計算機最高支持處理 64 位的整型。

    7、Protobuf支持的數據類型

    7.1 概述

    Protobuf 不僅支持整數類型,下圖列出 protobuf 支持的數據類型(wire type)。

    在上一小節中展示的編碼與解碼的例子中的“整數”并不是我們一般理解的整數(編程語言中的 int32,uint32 等),而是對應著上圖中的 Varint。

    當實際編程中使用 protobuf 進行編碼時經過了兩步處理:

    • 1)將編程語言的數據結構轉化為 wire type;
    • 2)根據不同的 wire type 使用對應的方法編碼(前文所提到的 Base 128 Varints 用來編碼 varint 類型的數據,其他 wire type 則使用其他編碼方式)。

    {obj}  -> {wire type} -> {encoded bytestream}

    uint32-> wire type0 -> varint

    int32-> wire type0 -> varint

    bool-> wire type0 -> varint

    string-> wire type2 -> length-delimited

    ...

    不同語言中 wire type 實際上也可能采用了語言中的某種類型來儲存 wire type 的數據。例如 Go 中使用了 uint64 來儲存 wire type 0。

    一般來說,大多數語言中的無符號整型被轉換為 varints 之后,有效位上的內容并沒有改變。

    下面說明部分其他數據類型到 wire type 的轉換規則。

    7.2 有符號整型

    Protobuf中有符號整型采用 ZigZag 編碼來將 sint32 和 sint64 轉換為 wire type 0。

    下面是 ZigZag 編碼的規則(注意是算術位移):

    (n << 1) ^ (n >> 31)  // for 32-bit signed integer

    (n << 1) ^ (n >> 63)  // for 64-bit signed integer

    或者從數學意義來理解:

    n * 2       // when n >= 0

    -n * 2 - 1  // when n < 0

    下圖展示了部分 ZigZag 編碼的例子:

    如果不先采用 ZigZag 編碼成 wire type,負值 sint64 直接使用 Base 128 Varints 編碼之后的長度始終為ceil(64/7)=10bytes,浪費大量空間。

    使用 ZigZag 編碼后,絕對值較小的負數的長度能夠被顯著壓縮:

    對于 -234(sint32) 這個例子,編碼成 varints 之前采用 ZigZag 編碼,比直接編碼成 varints 少用了 60%的空間。

    當然,ZigZag 編碼也不是完美的方法。當你嘗試把 sint32 或 sint64 范圍內所有的整數都編碼成 varints 字節流,使用 ZigZag 已經不能壓縮字節數量了。

    ZigZag 雖然能壓縮部分負數的空間,但同時正數變得需要更多的空間來儲存。

    因此,建議在業務場景允許的場景下盡量用無符號整型,有助于進一步壓縮編碼后的空間。

    7.3 定長數據(64-bit)

    Protobuf中定長數據直接采用小端模式儲存,不作轉換。

    7.4 字符串

    以字符串"testing"為例:

    Protobuf編碼后的 value 分為兩部分:

    • 1)藍色:表示字符串采用 UTF-8 編碼后字節流的長度(bytes),采用 Base 128 Varints 進行編碼;
    • 2)白色:字符串用 UTF-8 編碼后的字節流。

    8、Protobuf的消息結構

    Protobuf 采用 proto3 作為 DSL 來描述其支持的消息結構。

    就像下面這樣:

    syntax = "proto3";

     

    message SearchRequest {

      stringquery = 1;

      int32page_number = 2;

      int32result_per_page = 3;

    }

    設想一下這樣的場景:數據的發送方在業務迭代之后需要在消息內攜帶更多的字段,而有的接收方并沒有更新自己的 proto 文件。要保持較好的兼容性,接收方分辨出哪些字段是自己可以識別的,哪些是不能識別的新增字段。要做到這一點,發送方在編碼消息時還必須附帶每個字段的 key,客戶端讀取到未知的 key 時,可以直接跳過對應的 value。

    proto3 中每一個字段后面都有一個 = x,比如:

    stringquery = 1;

    這里的等號并不是用于賦值,而是給每一個字段指定一個 ID,稱為 field number。消息內同一層次字段的 field number 必須各不相同。

    上面所說的 key,在 protobuf 源碼中被稱為 tag。

    tag 由 field number 和 type 兩部分組成:

    • 1)field number 左移 3 bits;
    • 2)在最低 3 bits 寫入 wire type。

    下面展示一個生成 tag 例子:

    Go 版本 Protobuf 中生成 tag 的源碼:

    // google.golang.org/protobuf@v1.25.0/encoding/protowire/wire.go

     

    // EncodeTag encodes the field Number and wire Type into its unified form.

    funcEncodeTag(num Number, typ Type) uint64{

        returnuint64(num)<<3 | uint64(typ&7)

    }

    源碼中生成的 tag 是 uint64,代表著 field number 可以使用 61 個 bit 嗎?

    并非如此!

    事實上:tag 的長度不能超過 32 bits,意味著 field number 的最大取值為 2^29-1 (536870911)。

    而且在這個范圍內,有一些數是不能被使用的:

    • 1)0 :protobuf 規定 field number 必須為正整數;
    • 2)19000 到 19999: protobuf 僅供內部使用的保留位。

    理解了生成 tag 的規則之后,不難得出以下結論:

    • 1)field number 不必從 1 開始,可以從合法范圍內的任意數字開始;
    • 2)不同字段間的 field number 不必連續,只要合法且不同即可。

    但是實際上:大多數人分配 field number 還是會從 1 開始,因為 tag 最終要經過 Base 128 Varints 編碼,較小的 field number 有助于壓縮空間,field number 為 1 到 15 的 tag 最終僅需占用一個字節。

    當你的 message 有超過 15 個字段時,Google 也不建議你將 1 到 15 立馬用完。如果你的業務日后有新增字段的可能,并且新增的字段使用比較頻繁,你應該在 1 到 15 內預留一部分供新增的字段使用。

    當你修改的 proto 文件需要注意:

    • 1)field number 一旦被分配了就不應該被更改,除非你能保證所有的接收方都能更新到最新的 proto 文件;
    • 2)由于 tag 中不攜帶 field name 信息,更改 field name 并不會改變消息的結構。

    發送方認為的 apple 到接受方可能會被識別成 pear。雙方把字段讀取成哪個名字完全由雙方自己的 proto 文件決定,只要字段的 wire type 和 field number 相同即可。

    由于 tag 中攜帶的類型是 wire type,不是語言中具體的某個數據結構,而同一個 wire type 可以被解碼成多種數據結構,具體解碼成哪一種是根據接收方自己的 proto 文件定義的。

    修改 proto 文件中的類型,有可能導致錯誤:

    最后用一個比前面復雜一點的例子來結束本節內容:

    9、Protobuf中的嵌套消息

    嵌套消息的實現并不復雜。

    在上一節展示的 protobuf 的 wire type 中,wire type2 (length-delimited)不僅支持 string,也支持 embedded messages。

    對于嵌套消息:首先你要將被嵌套的消息進行編碼成字節流,然后你就可以像處理 UTF-8 編碼的字符串一樣處理這些字節流:在字節流前面加入使用 Base 128 Varints 編碼的長度即可。

    10、Protobuf中重復消息的編碼規則

    假設接收方的 proto3 中定義了某個字段(假設 field number=1),當接收方從字節流中讀取到多個 field number=1 的字段時,會執行 merge 操作。

    merge 的規則如下:

    • 1)如果字段為不可分割的類型,則直接覆蓋;
    • 2)如果字段為 repeated,則 append 到已有字段;
    • 3)如果字段為嵌套消息,則遞歸執行 merge;

    如果字段的 field number 相同但是結構不同,則出現 error。

    以下為 Go 版本 Protobuf 中 merge 的部分:

    // google.golang.org/protobuf@v1.25.0/proto/merge.go

     

    // Merge merges src into dst, which must be a message with the same descriptor.

    //

    // Populated scalar fields in src are copied to dst, while populated

    // singular messages in src are merged into dst by recursively calling Merge.

    // The elements of every list field in src is appended to the corresponded

    // list fields in dst. The entries of every map field in src is copied into

    // the corresponding map field in dst, possibly replacing existing entries.

    // The unknown fields of src are appended to the unknown fields of dst.

    //

    // It is semantically equivalent to unmarshaling the encoded form of src

    // into dst with the UnmarshalOptions.Merge option specified.

    funcMerge(dst, src Message) {

     // TODO: Should nil src be treated as semantically equivalent to a

     // untyped, read-only, empty message? What about a nil dst?

     

     dstMsg, srcMsg := dst.ProtoReflect(), src.ProtoReflect()

     ifdstMsg.Descriptor() != srcMsg.Descriptor() {

      ifgot, want := dstMsg.Descriptor().FullName(), srcMsg.Descriptor().FullName(); got != want {

       panic(fmt.Sprintf("descriptor mismatch: %v != %v", got, want))

      }

      panic("descriptor mismatch")

     }

     mergeOptions{}.mergeMessage(dstMsg, srcMsg)

    }

     

    func(o mergeOptions) mergeMessage(dst, src protoreflect.Message) {

     methods := protoMethods(dst)

     ifmethods != nil&& methods.Merge != nil{

      in := protoiface.MergeInput{

       Destination: dst,

       Source:      src,

      }

      out := methods.Merge(in)

      ifout.Flags&protoiface.MergeComplete != 0 {

       return

      }

     }

     

     if!dst.IsValid() {

      panic(fmt.Sprintf("cannot merge into invalid %v message", dst.Descriptor().FullName()))

     }

     

     src.Range(func(fd protoreflect.FieldDescriptor, v protoreflect.Value) bool{

      switch{

      casefd.IsList():

       o.mergeList(dst.Mutable(fd).List(), v.List(), fd)

      casefd.IsMap():

       o.mergeMap(dst.Mutable(fd).Map(), v.Map(), fd.MapValue())

      casefd.Message() != nil:

       o.mergeMessage(dst.Mutable(fd).Message(), v.Message())

      casefd.Kind() == protoreflect.BytesKind:

       dst.Set(fd, o.cloneBytes(v))

      default:

       dst.Set(fd, v)

      }

      returntrue

     })

     

     iflen(src.GetUnknown()) > 0 {

      dst.SetUnknown(append(dst.GetUnknown(), src.GetUnknown()...))

     }

    }

    11、Protobuf的字段順序

    11.1 編碼結果與字段順序無關

    Proto 文件中定義字段的順序與最終編碼結果的字段順序無關,兩者有可能相同也可能不同。

    當消息被編碼時,Protobuf 無法保證消息的順序,消息的順序可能隨著版本或者不同的實現而變化。任何 Protobuf 的實現都應該保證字段以任意順序編碼的結果都能被讀取。

    以下是使用Protobuf時的一些常識:

    • 1)序列化后的消息字段順序是不穩定的;
    • 2)對同一段字節流進行解碼,不同實現或版本的 Protobuf 解碼得到的結果不一定完全相同(bytes 層面),只能保證相同版本相同實現的 Protobuf 對同一段字節流多次解碼得到的結果相同;
    • 3)假設有一條消息foo,有幾種關系可能是不成立的(下方會接著詳細說明)。

    針對上述第 3)點,這幾種關系可能是不成立的:

    foo.SerializeAsString() == foo.SerializeAsString()

    Hash(foo.SerializeAsString()) == Hash(foo.SerializeAsString())

    CRC(foo.SerializeAsString()) == CRC(foo.SerializeAsString())

    FingerPrint(foo.SerializeAsString()) == FingerPrint(foo.SerializeAsString())

    11.2 相等消息編碼后結果可能不同

    假設有兩條邏輯上相等的消息,但是序列化之后的內容(bytes 層面)不相同,原因有很多種可能。

    比如下面這些原因:

    • 1)其中一條消息可能使用了較老版本的 protobuf,不能處理某些類型的字段,設為 unknwon;
    • 2)使用了不同語言實現的 Protobuf,并且以不同的順序編碼字段;
    • 3)消息中的字段使用了不穩定的算法進行序列化;
    • 4)某條消息中有 bytes 類型的字段,用于儲存另一條消息使用 Protobuf 序列化的結果,而這個 bytes 使用了不同的 Protobuf 進行序列化;
    • 5)使用了新版本的 Protobuf,序列化實現不同;
    • 6)消息字段順序不同。

    12、參考資料

    [1] Protobuf官方編碼資料

    [2] Protobuf官方手冊

    [3] Why do we use Base64?

    [4] The Base16, Base32, and Base64 Data Encodings

    [5]Protobuf從入門到精通,一篇就夠!

    [6]如何選擇即時通訊應用的數據傳輸格式

    [7]強列建議將Protobuf作為你的即時通訊應用數據傳輸格式

    [8]APP與后臺通信數據格式的演進:從文本協議到二進制協議

    [9]面試必考,史上最通俗大小端字節序詳解

    [10]移動端IM開發需要面對的技術問題(含通信協議選擇)

    [11]簡述移動端IM開發的那些坑:架構設計、通信協議和客戶端

    [12]理論聯系實際:一套典型的IM通信協議設計詳解

    [13]58到家實時消息系統的協議設計等技術實踐分享

    (本文已同步發布于:http://www.52im.net/thread-4093-1-1.html



    作者:Jack Jiang (點擊作者姓名進入Github)
    出處:http://www.52im.net/space-uid-1.html
    交流:歡迎加入即時通訊開發交流群 215891622
    討論:http://www.52im.net/
    Jack Jiang同時是【原創Java Swing外觀工程BeautyEye】【輕量級移動端即時通訊框架MobileIMSDK】的作者,可前往下載交流。
    本博文 歡迎轉載,轉載請注明出處(也可前往 我的52im.net 找到我)。


    只有注冊用戶登錄后才能發表評論。


    網站導航:
     
    Jack Jiang的 Mail: jb2011@163.com, 聯系QQ: 413980957, 微信: hellojackjiang
    主站蜘蛛池模板: 亚洲久悠悠色悠在线播放| 高清永久免费观看| 青青青国产色视频在线观看国产亚洲欧洲国产综合 | 无遮挡呻吟娇喘视频免费播放| 亚洲中文字幕无码专区| 91香蕉国产线在线观看免费| 久久亚洲精品成人无码| 亚洲精品无码mv在线观看网站| 亚洲人成免费网站| 免费国产高清毛不卡片基地| 亚洲福利视频一区| 免费在线观看黄网| 国产麻豆视频免费观看| 中文字幕看片在线a免费| 亚洲AV无码专区在线亚| 亚洲色自偷自拍另类小说| 成年丰满熟妇午夜免费视频| 国产日韩AV免费无码一区二区| 亚洲国产午夜精品理论片在线播放 | 中文无码亚洲精品字幕| 亚洲熟妇无码八AV在线播放| 日韩一级视频免费观看| 一级毛片免费观看| 和老外3p爽粗大免费视频| 亚洲国产精品ⅴa在线观看| 久久精品国产亚洲AV麻豆网站| 亚洲情侣偷拍精品| 全免费一级午夜毛片| 亚洲视频免费播放| 在线观看免费无码专区| 日本高清免费中文在线看| 在线观看日本亚洲一区| 久久精品国产亚洲77777| 国产亚洲精aa成人网站| 免费日本黄色网址| 午夜视频免费观看| 无码乱肉视频免费大全合集| 99视频免费观看| 久久久久国色av免费看| 中文成人久久久久影院免费观看| 免费一级特黄特色大片|