美文网首页即时通讯
Android即时通讯系列文章(3)数据传输格式选型:资源受限的

Android即时通讯系列文章(3)数据传输格式选型:资源受限的

作者: 星际码仔 | 来源:发表于2021-06-14 14:54 被阅读0次

    「椎锋陷陈」微信技术号现已开通,为了获得第一手的技术文章推送,欢迎搜索关注!

    前言

    跟PC时代的传统互联网相比,移动互联网得益于移动设备的便携性,仅短短数年便快速地渗透到了人们生活、工作的各个方面。虽然通信技术和硬件设备在不断地更新升级换代,但就目前而言,电量、流量等对于移动设备来讲仍属于稀缺资源。

    参与过Android系统版本升级适配工作的开发人员,也许可以很明显地感受到,近年来Android系统每一个更新的版本都是往更省电、更省流量、更省内存的方向靠拢的,比如:

    • Android 6.0 引入了 低电耗模式 和 应用待机模式
    • Android 7.0 引入了 随时随地低电耗模式
    • Android 8.0 引入了 后台执行限制
    • Android 9.0 引入了 应用待机存储分区
      ...

    移动应用向网络发出的请求时主要的耗电来源之一,除了发送和接收数据包本身需要消耗电量外,开启无线装置并保持唤醒也会消耗额外的电量。特别是对于即时通讯这种网络交互频繁的应用场景来讲,数据传输大小是必须要考虑优化的一个方面,要尽量做到减少冗余数据,提高传输效率,从而减少对电量、流量的损耗。

    二进制数据相对于可读性更好的文本数据而言,数据冗余量小,数据排列更为紧凑,因而体积更小,传输速度更快。但是要使用自定义二进制协议的话,就意味着需要自己定义数据结构,自己做序列化反序列化工作,版本兼容也是个问题。基于时间成本与技术成本的考虑,我们决定采用Protobuf帮我们完成这部分工作。

    什么是Protobuf?

    Protobuf,全称Protocol Buffer(协议缓冲区),是Google开源的跨语言、跨平台、可扩展的结构化数据序列化机制。与XML、JSON及其他数据传输格式相比,Protocol更为轻巧、快速、简单。我们只需在.proto文件中定义好数据结构,即可利用Protobuf编译器编译生成针对各种平台、语言的数据访问类代码,轻松地在各种数据流中写入和读取结构化数据,尤其适用于数据存储及网络通信等场景。

    总结起来即是:

    优点:

    1. 数据大小:以独特的Varint、Zigzag编码方式及T-L-V数据存储方式实现数据压缩
    2. 解析效率:以高效的二进制格式实现数据的自动编码和解析
    3. 通用性:跨语言、跨平台
    4. 易用性:可用Protobuf编译器自动生成数据访问类
    5. 可扩展性:可随着版本迭代扩展格式
    6. 兼容性:可向后兼容旧格式编码的数据
    7. 可维护性:多个平台只需共同维护一个.proto文件

    缺点:

    可读性差:缺少.proto文件情况下难以去理解数据结构

    既然是数据传输格式选型,那么免不了与其他数据传输格式进行比较,我们常见的与服务端交互的数据传输格式莫过于XML与JSON。

    • XML

      可扩展标记语言(Extensible Markup Language),是一种文本类型的数据格式,以“<”开头,“>”结束的标签作为主要的语法规则。XML的设计侧重于作为文档描述,但也被广泛用于表示任意的数据结构。

    优点:

    1. 可读性好
    2. 可扩展性好

    缺点:

    1. 解析代价高,对它进行编码/解码会给应用程序带来巨大的性能损失
    2. 空间占用大,有效数据传输率低(大量的标签)

    从事Android开发的你肯定对Android的轻量级持久化方案SharedPreference不陌生,SharedPreference即是以xml为主要实现,不过目前Android官方已建议使用DataStore作为SharedPreference的替代方案,DataStore则是以ProtoBuf为主要实现。

    • JSON

    JavaScript对象表示法(JavaScript Object Notation),是一种开放标准文件格式以及数据交换格式,以文本形式来存储和传输由属性值对及数组组成的数据对象,常见于与服务器的通信。

    优点:

    除了拥有与XML相同的优点外,由于不需要像XML那样严格的闭合标签,因此有效数据量传输率更高,可节约所占用的带宽。

    ProtoBuf实现

    以Gradle形式添加ProtoBuf依赖项

    1. 项目级别的build.gradle文件:
    dependencies {
        ...
        // Protobuf
        classpath 'com.google.protobuf:protobuf-gradle-plugin:0.8.8'
    }
    
    1. 模块级别的build.gradle文件:
    apply plugin: 'com.google.protobuf'
    
    android {
        sourceSets {
            main {
                // 定义proto文件目录
                proto {
                    srcDir 'src/main/proto'
                }
            }
        }
    }
    
    dependencies {
        def PROTOBUF_VERSION = "3.0.0"
    
        api "com.google.protobuf:protobuf-java:${PROTOBUF_VERSION}"
        api "com.google.protobuf:protoc:${PROTOBUF_VERSION}"
    }
    
    protobuf {
        protoc { artifact = 'com.google.protobuf:protoc:3.2.0' }
        plugins {
            javalite {
                artifact = 'com.google.protobuf:protoc-gen-javalite:3.0.0'
            }
        }
        generateProtoTasks {
            all().each {
                task -> task.plugins { javalite {} }
            }
        }
    }
    

    在proto文件中定义要存储的消息的数据结构

    首先,我们需要在{module}/src/main/proto目录下新建message_dto.proto文件,以定义我们要存储的对象的数据结构,如下:

    1.png

    在定义数据结构之前,我们先来思考一下,一条最基础的即时通讯消息应该要包含哪些字段?这里以生活中常见的收发信件为例子:

    信件内容自然我们最关心的——content
    谁给我寄的信,是给我还是给其他人的呢?——sender_id、target_id
    为了快速检索信件,我们还需要一个唯一值——message_id
    是什么类型的信件呢?是信用卡账单还是情书呢?——type
    如果有多封信件,为了阅读的通顺我们还需要理清信件的时间线——timestamp

    以下就是最终定义出的message_dto.proto文件,接下来让我们逐步去解读这个文件:

    syntax = "proto3";
    
    option java_package = "com.madchan.imsdk.lib.objects.bean.dto";
    option java_outer_classname = "MessageDTO";
    
    message Message {
        enum MessageType {
            MESSAGE_TYPE_UNSPECIFIED = 0;    // 未指定
            MESSAGE_TYPE_TEXT = 1;   // 文本消息
        }
        //消息唯一值
        uint64 message_id = 1;
        //消息类型
        MessageType message_type = 2;
        //消息发送用户
        string sender_id = 3;
        //消息目标用户
        string target_id = 4;
        //消息时间戳
        uint64 timestamp       = 5;
        //消息内容
        bytes content          = 6;
    }
    
    
    声明使用语法
    syntax = "proto3";
    

    文件首行表明我们使用的是proto3语法,默认不声明的话,ProtoBuf编译器会认为我们使用的是proto2,该声明必须位于首行,且非空、非注释。

    指定文件选项
    option java_package = "com.madchan.imsdk.lib.objects.bean.dto";
    

    java_package用于指定我们要生成的Java类的包目录路径。

    option java_outer_classname = "MessageDTO";
    

    java_outer_classname指定我们要生成的Java包装类的类名。默认不声明的话,会将.proto 文件名转换为驼峰式来命名。

    此外还有一个java_multiple_files选项,当为true时,会将.proto文件中声明的多个数据结构转成多个单独的.java文件。默认为false时,则会以内部类的形式只生成一个.java文件。

    指定字段类型
        //消息唯一值
        uint64 message_id = 1;
    

    也许你注意到了,针对消息唯一值message_id和消息时间戳timestamp我们采用的是uint64,这其实是unsigned int的缩写,意味无符号64位整数,即Long类型的正数,关于无符号整数的解释如下:

    计算机里的数是用二进制表示的,最左边的这一位一般用来表示这个数是正数还是负数,这样的话这个数就是有符号整数。如果最左边这一位不用来表示正负,而是和后面的连在一起表示整数,那么就不能区分这个数是正还是负,就只能是正数,这就是无符号整数。

        enum MessageType {
            MESSAGE_TYPE_UNSPECIFIED = 0;    // 未指定
            MESSAGE_TYPE_TEXT = 1;   // 文本消息
        }
        //消息类型
        MessageType message_type = 2;
    

    而描述消息类型时,由于消息类型的值通常只在一个预定义的范围之内,符合枚举特性,因此我们采用枚举来实现。这里我们先简单定义了一个未知类型和文本消息类型。

    需要注意的是,每个枚举定义都必须包含一个映射到零的常量作为其第一个元素,以作为默认值。

    其他的数据类型请参考此表,该表显示了.proto 文件中所支持的数据类型,以及自动生成的对应语言的类中的相应数据类型。

    https://developers.google.com/protocol-buffers/docs/proto3#scalar

    分配字段编号

    你可能会觉得奇怪,每个字段后带的那个数字是什么意思。这些其实是每个字段的唯一编号,用于在消息二进制格式中唯一标识我们的字段,一旦该编号被使用,就不应该再更改。

    如果我们在版本迭代中想要删除某个字段,需要确保不会重复使用该字段编号,否则可能会产生诸如数据损坏等严重问题。为了确保不会发生这种状况,我们需要使用reserved标识保留已删除字段的字段编号或名称,如果后续尝试使用这些字段,ProtoBuf编译器将会报错,如下:

    message Message {
      reserved 3, 4 to 6;
      reserved "sender_id ", "target_id ";
    }
    

    另外一件我们需要了解的事情是,ProtoBuf中1到15范围内的字段编号只占用一个字节进行编码(包括字段编号和字段类型),而16到2047范围内的字段编号则占用两个字节。基于这个特性,我们需要为频繁出现(也即必要字段)的字段保留1到15范围内的字段进行编号,而对于可选字段而采用16到2047范围内的字段进行编号。

    添加注释

    我们还可以向proto文件添加注释,支持// 和 /* ... */ 语法,注释会同样保留到自动生成的对应语言的类中。

    使用ProtoBuf编译器自动生成一个Java类

    一切准备就绪后,我们就可以直接重新构建项目,ProtoBuf编译器会自动根据.proto文件中定义的message,在{module}/build/generated/source/proto/debug/javalite目录下生成对应包名路径的Java类文件,之后只需将该类文件拷贝到src/main/java目录下即可,我们完全可以用Gradle Task帮我们完成这项工作:

    // 是否允许Proto生成DTO类
    def enableGenerateProto = true
    // def enableGenerateProto = false
    
    project.tasks.whenTaskAdded { Task task ->
        if (task.name == 'generateDebugProto') {
            task.enabled = enableGenerateProto
            if(task.enabled) {
                task.doLast {
                    // 复制Build目录下的DTO类到Src目录
                    copy {
                        from 'build/generated/source/proto/debug/javalite'
                        into 'src/main/java'
                    }
                    // 删除Build目录下的DTO类
                    FileTree tree = fileTree("build/generated/source/proto/debug/javalite")
                    tree.each{
                        file -> delete file
                    }
                }
            }
        }
    }
    

    通过阅读自动生成的MessageDTO.java文件可以看到,Protobuf编译器为每个定义好的数据结构生成了一个Java类,并为访问类中的每个字段提供了sette()r和getter()方法,且提供了Builder类用于创建类的实例。

    用基于Java语言的ProtoBuf API写入和读取消息

    到这里我们先把前面定义好的消息数据结构同步到MessageVO.kt,保持两个实体类的字段一致,至于为什么这样做,而不直接共用一个MessageDTO.java,下一篇文章会解释。

    data class MessageVo(
        var messageId: Long,
        var messageType: Int,
        var sendId: String,
        var targetId: String,
        var timestamp: Long,
        var content: String
    ) : Parcelable {
        constructor(parcel: Parcel) : this(
            parcel.readLong(),
            parcel.readInt(),
            parcel.readString() ?: "",
            parcel.readString() ?: "",
            parcel.readLong(),
            parcel.readString() ?: ""
        ) {
        }
    
        override fun writeToParcel(parcel: Parcel, flags: Int) {
            parcel.writeLong(messageId)
            parcel.writeInt(messageType)
            parcel.writeString(sendId)
            parcel.writeString(targetId)
            parcel.writeLong(timestamp)
            parcel.writeString(content)
        }
    
        override fun describeContents(): Int {
            return 0
        }
    
        companion object CREATOR : Parcelable.Creator<MessageVo> {
            override fun createFromParcel(parcel: Parcel): MessageVo {
                return MessageVo(parcel)
            }
    
            override fun newArray(size: Int): Array<MessageVo?> {
                return arrayOfNulls(size)
            }
        }
    

    现在,我们要做的就是以下两件事:

    1. 将来自视图层的MessageVO对象转换为数据传输层MessageDTO对象,并序列化为二进制数据格式进行消息发送。
    2. 接收二进制数据格式的消息,反序列化为MessageDTO对象,并将来自数据传输层的MessageDTO对象转换为视图层的MessageVO对象。

    我们把这部分工作封装到EnvelopHelper类:

    class EnvelopeHelper {
        companion object {
            /**
             * 填充操作(VO->DTO)
             * @param envelope 信封类,包含消息视图对象
             */
            fun stuff(envelope: Envelope): MessageDTO.Message? {
                envelope?.messageVo?.apply {
                    return MessageDTO.Message.newBuilder()
                        .setMessageId(messageId)
                        .setMessageType(MessageDTO.Message.MessageType.forNumber(messageType))
                        .setSenderId(sendId)
                        .setTargetId(targetId)
                        .setTimestamp(timestamp)
                        .setContent(ByteString.copyFromUtf8(content))
                        .build()
                }
                return null
            }
    
            /**
             * 提取操作(DTO->VO)
             * @param messageDTO 消息数据传输对象
             */
            fun extract(messageDTO: MessageDTO.Message): Envelope? {
                messageDTO?.apply {
                    val envelope = Envelope()
                    val messageVo = MessageVo(
                        messageId = messageId,
                        messageType = messageType.number,
                        sendId = senderId,
                        targetId = targetId,
                        timestamp = timestamp,
                        content = String(content.toByteArray())
                    )
                    envelope.messageVo = messageVo
                    return envelope
                }
                return null
            }
        }
    }
    

    分别在以下两处消息收发的关键节点调用,便可完成对消息传输的序列化反序列化工作:

    MessageAccessService.kt:

    /** 根据MessageCarrier.aidl文件自动生成的Binder对象,需要返回给客户端 */
    private val messageCarrier: IBinder = object : MessageCarrier.Stub() {
        override fun sendMessage(envelope: Envelope) {
            Log.d(TAG, "Send a message: " + envelope.messageVo?.content)
            val messageDTO = EnvelopeHelper.stuff(envelope)
            messageDTO?.let { WebSocketConnection.send(ByteString.of(*it.toByteArray())) }
            ...
        }
        ...
    }
    

    WebSocketConnection.kt:

    /**
     * 在收到二进制格式消息时调用
     * @param webSocket
     * @param bytes
     */
    override fun onMessage(webSocket: WebSocket, bytes: ByteString) {
          super.onMessage(webSocket, bytes)
          ...
          val messageDTO = MessageDTO.Message.parseFrom(bytes.toByteArray())
         val envelope = EnvelopeHelper.extract(messageDTO)
         Log.d(MessageAccessService.TAG, "Received a message : " + envelope?.messageVo?.content)
         ...
    }
         
    

    下一章节预告

    在上面的文章中我们留下了一个疑问,即为何要拆分成MessageVO与MessageDTO两个实体对象?这其实涉及到了DDD(Domain-Driven Design,领域驱动设计)的问题,是为了实现结构分层之后的解耦而设计的,需要在不同的层次使用不同的数据模型。

    不过,像文章中那种使用get/set方式逐一进行字段映射的操作毕竟太过繁琐,且容易出错,因此,下篇文章我们将介绍MapStruct库,以自动化的方式帮我们简化这部分工作,敬请期待。

    「椎锋陷陈」微信技术号现已开通,为了获得第一手的技术文章推送,欢迎搜索关注!

    参考

    Protocol Buffers官网
    https://developers.google.com/protocol-buffers/

    Protocol Buffers基础:Java
    https://developers.google.com/protocol-buffers/docs/javatutorial

    Protocol Buffers维基百科
    https://en.wikipedia.org/wiki/Protocol_Buffers

    如何选择即时通讯应用的数据传输格式
    http://www.52im.net/thread-276-1-1.html

    强列建议将Protobuf作为你的即时通讯应用数据传输格式
    http://www.52im.net/forum.php?mod=viewthread&tid=277&highlight=Protobuf

    Protobuf通信协议详解:代码演示、详细原理介绍等
    http://www.52im.net/forum.php?mod=viewthread&tid=323&highlight=ProtoBuf

    理论联系实际:一套典型的IM通信协议设计详解
    http://www.52im.net/thread-283-1-1.html

    Android序列化:手把手带你分析 Protocol Buffer使用 源码
    https://blog.csdn.net/carson_ho/article/details/70902349

    相关文章

      网友评论

        本文标题:Android即时通讯系列文章(3)数据传输格式选型:资源受限的

        本文链接:https://www.haomeiwen.com/subject/laqoeltx.html