协议解析领域特定语言(domain specific language for Protocol parsing)

协议解析领域特定语言(domain specific language for Protocol parsing)

四月 17, 2018 阅读 1126 字数 7801 评论 0 喜欢 1

领域特定语言定义:

Domain-Specific Language:A computer programming language of limited expressiveness focused on a particular domain.
(针对某一特定领域,具有受限表达性的一种计算机程序设计语言。)

领域特定语言包含4个关键要素:

  1. 计算机程序设计语言(computer programming language)

    人们用DSL指挥计算机去做一些事。同大多数现代程序设计语言一样,其结构设计成便于人们理解的样子,但它应该还是可以由计算机执行的语言。

  2. 语言性(language nature)

    DSL是一种程序设计语言,因此它必须具备连贯的表达能力——不管是一个表达式还是多个表达式组合在一起。

  3. 针对领域(domain focus)

    和通用程序设计语言(General Purpose Language)提供广泛的能力相比,DSL只支持特定领域所需要特性的最小集。使用DSL,无法构建一个完整的系统,相反,却可以解决系统某一方面的问题。

  4. 受限的表达性(limited expressiveness)

    只有在一个明确的小领域下,这种能力有限的语言才会有用。这个领域才使得这种语言值得使用。

二进制物联网协议大致由以下6种数据类型组成:

数据类型 解释
BYTE 无符号单字节整形(字节,8位)
WORD 无符号双字节整形(字节,16位)
DWORD 无符号四字节整数(双字,32位)
BYTE[N] N字节
BCD[N] 8421码,N字节
STRING GBK编码

领域特定语言中前两种要素由实现领域特定语言程序提供,后两种要素作为能不能为具体问题域建立领域特定描述语言依据。二进制协议的解析属于特定领域,且所有协议的消息内容基本由以上6种类型排列组合而成。符合受限表达性特点。因此为协议解析设计一套专有的描述语言以简化前期协议解析开发难度,节省后期维护成本。

从实现方式上DSL可分为两类:

1.外部DSL

外部DSL是一种“不同于应用系统主要使用语言”的语言。外部DSL通常采用自定义语法,不过选择其他语言的语法也很常见(XML就是一个常见选择)。宿主应用的代码会采用文本解析技术对使用外部DSL编写的脚本进行解析。

2.内部DSL

内部DSL是一种通用语言的特定用法。用内部DSL写成的脚本是一段合法的程序,但是它具有特定的风格,而且只用到了语言的一部分特性,用于处理整个系统一个小方面的问题。用这种DSL写出的程序有一种自定义语言的风格,与其所使用的宿主语言有所区别。

虽然外部DSL具有语言无关性,通用性较强。但相比于内部DSL,实现较为复杂,且需要额外的语义解析步骤,性能会受一定损耗,协议解析对时间较为敏感,因此最后决定实现内部DSL。在内部DSL实现宿主语言上选择了Scala。

Scala是建立在JVM平台上一种支持多范式(multi-paradigm)的静态编程语言,集成面向对象编程和函数式编程的各种特性。拥有丰富全面的抽象机制,兼容现有的Java程序且可以调用现有的Java类库。可以和JVM上其他语言(Java、Cloujre、Jruby、Groovy等)进行交互,极大提升了内部DSL通用性,这些特性汇集在一起,使Scala成为一种非常适用于内部DSL设计的JVM语言。

##传统协议解析和使用DSL协议解析对比
传统的二进制协议解析一般采用字节数组移位操作实现对协议内容的解析。示例代码如下:

       /*判断msgId回复消息*/
       // 设置长度信息
       // 双字节,0x00-不分包,数据不加密
        int msgProps = 0x00 | msgLength;
        byteList.set(2, (byte) ((msgProps >> 8) & 0xFF));
        byteList.set(3, (byte) (msgProps & 0xFF));

        // 计算并填充校验码
        byte check = 0x00;
        for (Byte byteObject: byteList) {
            check ^= byteObject.byteValue();
        }
        byteList.add(check);

        // 转义
        List<Byte> defaultByteList = new ArrayList<>();
        defaultByteList.add((byte) 0x7E); //标识位(0x7E)
        for(Byte byteObject: byteList) {
            if(0x7D == (byteObject.byteValue() & 0xFF)) {
                // 0x7D->0x7D0x01
                defaultByteList.add((byte) 0x7D);
                defaultByteList.add((byte) 0x01);
            } else if(0x7E == (byteObject.byteValue() & 0xFF)) {
                // 0x7E->0x7D0x02
                defaultByteList.add((byte) 0x7D);
                defaultByteList.add((byte) 0x02);
            } else {
                defaultByteList.add(byteObject);
            }
        }
        defaultByteList.add((byte) 0x7E); //标识位(0x7E)

        // add to buffer
        byte[] responseBytes = new byte[defaultByteList.size()];
        for (int i = 0; i < responseBytes.length; i++) {
            responseBytes[i] = defaultByteList.get(i);
        }

传统的解析方式最大的问题是协议解析开发相对比较困难,编写出的代码即使加上详细的注释也比较难理解,后期调试更改困难。优势是理论上位操作速度比较快,在性能上有优势。(但由于Java本身是垃圾自动回收机制,位操作过于底层,难以理解掌握,稍有不慎就可能导致内存泄漏,以及解析错误。即使用C语言和C++这类可以自由操控内存的语言,由于指针的复杂性,很难保证不出现和Java解析相同的内存泄漏问题。)

相较于传统的字节数组位移解析协议,我们利用Scala函数式语言特性,首先将程序中计算的描述与实际运行分离开,在宿主语言基础上实现一套协议数据类型描述元语言。以交通部808协议终端注册为例:

二进制协议DSL元语言声明:

/**
    * BCD8表示的字符串的Codec对象。BCD格式为:手机号。
    * */
  lazy val bcd2PhoneNumber:Codec[String] = lpbcd(16).xmap(l=> l.toString,s=> s.toLong)

  /**
    * BYTE[3]表示的秒数的Codec对象。格式为:时/分/秒。
    */
  lazy val bytes2SecondCodec: Codec[Int] = (uint8 :: uint8 :: uint8).xmapc(t => t.head * 3600 + t.tail.head * 60 + t.tail.tail.head)(pt => pt / 3600 :: (pt % 3600) / 60 :: pt % 60 :: HNil)

  /**
    * DWORD表示的经纬度的Codec对象。单位:0.000001°/bit,偏移:0°。
    */
  lazy val dword2PositionCodec: Codec[Double] = int32.xmap(l => l / 1000000d, d => (d * 1000000).toInt)

  /**
    * DWORD表示的经纬度的Codec对象。单位:0.000001°/bit,偏移:0°,参数:dir,正数为北纬或者东经/负数为南纬或者西经。
    */
  lazy val dword2PositionWithDirCodec: Int => Codec[Double] = (dir: Int) => uint32.xmap(l => l * dir / 1000000d, d => Math.abs(d * 1000000).toLong)

有了以上这套协议类型描述元语言之后,我们就可以在它的基础上利用面向对象的继承封装特性随意组合出具体协议内容描述,下面以标准808协议终端类型注册为例:

部标808协议终端注册消息体数据格式:

起始字节 字段 数据类型 描述及要求
0 省域ID WORD 标示终端安装车辆所在的省域,0保留,由平台取默 认值。
2 市县域ID WORD 标示终端安装车辆所在的市域和县域,0保留,由平 台取默认值。
4 制造商ID BYTE[5] 5个字节,终端制造商编码。
9 终端型号 BYTE[20] 20个字节,此终端型号由制造商自行定义,位数不足时,后补“0X00”
29 终端ID BYTE[7] 7个字节,由大写字母和数字组成,此终端ID由制造商自行定义,位数不足时,后补“0X00”。
36 车牌颜色 BYTE 车牌颜色,按照JT/T415-2006的5.4.12。 未上牌时,取值为0。
37 车辆标识 STRING 车牌颜色为0时,表示车辆VIN; 否则,表示公安交通管理部门颁发的机动车号牌。

根据Scala定义协议DSL元语言描述终端注册源码:


/** * 消息基类 * * @param messageId 消息id * @param encrypt 是否加密 * @param phoneNumber 终端手机号 * @param sequence 消息流水号 * @param spiltCnt 消息包总数 * @param splitIdx 消息包序号 **/ sealed abstract class Jtt808Message(val messageId: Int, val encrypt: Boolean = false, val phoneNumber: String, val sequence: Int, val spiltCnt: Option[Int] = None, val splitIdx: Option[Int] = None ) extends PrettyPrint with ReallyEquals with Serializable def singleMessageCodec[L constant(BitVector.zero)) :: ("encrypt" | ignore(2) ~&gt; bool) :: ("length" | variableSizeBytes(uint(10), ("phoneNumber" | bcd2PhoneNumber) :: ("sequence" | uint16) :: ("detail" | codec), -3 - 8)) } /** * 终端注册 * * @param provinceId 省域ID * @param citiesId 市县域ID * @param manufactureId 制造商ID * @param terminalType 终端型号 * @param terminalID 终端ID * @param plateColor 车牌颜色 * @param vehicleId 车辆标识 **/ case class Jtt808MessageRegister(override val encrypt: Boolean = false, override val phoneNumber: String, override val sequence: Int, provinceId: Int, citiesId: Int, manufactureId: String, terminalType: String, terminalID: String, plateColor: Int, vehicleId: Array[Byte] ) extends Jtt808Message(MessageId.MessageRegister, encrypt, phoneNumber, sequence) with Jtt808MessageUp object Jtt808MessageRegister { implicit lazy val jtt808MessageRegisterCodec: Codec[Jtt808MessageRegister] = Jtt808Message.singleMessageCodec { ("provinceId" | uint16) :: ("citiesId" | uint16) :: ("manufactureId" | fixedSizeCString(5)) :: ("terminalType" | fixedSizeCString(20)) :: ("terminalID" | fixedSizeCString(7)) :: ("plateColor" | uint8) :: ("vehicleId" | byteArrayCodec) }.as[Jtt808MessageRegister] }

其中消息基类Jtt808Message为类808协议固定的消息头(Message Header),Jtt808MessageRegister为用DSL描述的终端注册协议解析体内容,从协议定义到源码实现可以发现,采用DSL虽然只能专注于特定的领域,但是相比于GPL,其在特定领域内抽象表达能力更强,采用这种方式将协议内容类型定义与源码实现相对应,只要程序员拿到协议定义文件,就可以轻松理解这段代码,而不用关心具体底层位操作实现,极大的提升了开发效率和后期代码交流维护成本。这种将业务逻辑描述和具体实现细节分开的方式,极大缓解了因为位操作产生内存泄漏的风险。(因为具体实现独立于具体协议声明,相对代码量较小。即使具体实现中出现和传统解析模式相同的位操作内存泄漏问题,也极易排查和修改)
##DSL 中的 Monad 化结构
抽象组合得越好,DSL的可读性就越强。当针对“运算”进行抽象时,函数式编程提供的组合能力要超过面向对象编程模型。这是因为函数式编程将各种运算都当做纯数学函数来使用,不产生改变状态的副作用。如果函数与改变状态分离,它就成了一种不依赖于任何外部上下文,可以单独验证的抽象。借助函数式编程提供的数学模型,运算可以被组合成一些函数式的组合体。函数的组合性意味着我们可以用简单的构件块搭建出复杂的抽象。

这点不光可以应用在DSL构建中,利用函数式编程的特性,在日常的面向对象编程中适当引入其编程思想,就能极大的提升编码速度、代码可读性、扩展性以及后期可维护性。

下面就以简单的函数式求和逐步演进过程来阐述函数式编程超越面向对象编程的强大组合能力。

/**
 * 求和函数(sum function)
 * @version 1.0
 **/

scala&gt; def sum(xs: List[Int]): Int = xs.foldLeft(0) { _ + _ }
sum: (xs: List[Int])Int

scala&gt; sum(List(1, 2, 3, 4))
res3: Int = 10

/**
 * 将求和操作抽象为方法
 **/

 scala&gt; object IntMonoid {
         def mappend(a: Int, b: Int): Int = a + b
         def mzero: Int = 0
       }

defined module IntMonoid

/**
 * 求和函数(sum function)
 * @version 2.0
 */

def sum(xs: List[Int]): Int = xs.foldLeft(IntMonoid.mzero)(IntMonoid.mappend)
sum: (xs: List[Int])Int

scala&gt; sum(List(1, 2, 3, 4))
res5: Int = 10

/**
 * 将求和操作抽象为方法泛型化
 **/

scala&gt; trait Monoid[A] {
         def mappend(a1: A, a2: A): A
         def mzero: A
       }
defined trait Monoid

scala&gt; object IntMonoid extends Monoid[Int] {
         def mappend(a: Int, b: Int): Int = a + b
         def mzero: Int = 0
       }
defined module IntMonoid

/**
 * 求和函数(sum function)
 * @version 3.0
 * 将求和函数参数泛型化,支持所有类型(General Type)。
 **/

scala&gt; def sum[A](xs: List[A], m: Monoid[A]): A = xs.foldLeft(m.mzero)(m.mappend)
sum: [A](xs: List[A], m: Monoid[A])A

scala&gt; sum(List(1, 2, 3, 4), IntMonoid)
res8: Int = 10

/**
 * 求和函数(sum function)
 * @version 4.0
 * 最终版本利用Scala implicit parameter(隐式参数)
 * 将在程序执行上下文中获取一个隐式实现,
 * 避免每次传入 m: Monoid[A]参数。
 **/

scala&gt; def sum[A: Monoid](xs: List[A]): A = {
         val m = implicitly[Monoid[A]]
         xs.foldLeft(m.mzero)(m.mappend)
       }
sum: [A](xs: List[A])(implicit evidence$1: Monoid[A])A

scala&gt; sum(List(1, 2, 3, 4))
res10: Int = 10

/**
 * 现在求和函数(sum function)将变得非常通用,我们可以随意追加其他的Monoid实现。例如String
 **/

trait Monoid[A] {
  def mappend(a1: A, a2: A): A
  def mzero: A
}

object Monoid {

  implicit val IntMonoid: Monoid[Int] = new Monoid[Int] {
    def mappend(a: Int, b: Int): Int = a + b
    def mzero: Int = 0
  }

  implicit val StringMonoid: Monoid[String] = new Monoid[String] {
    def mappend(a: String, b: String): String = a + b
    def mzero: String = ""
  }

}

def sum[A: Monoid](xs: List[A]): A = {
  val m = implicitly[Monoid[A]]
  xs.foldLeft(m.mzero)(m.mappend)
}

defined trait Monoid
defined module Monoid
sum: [A](xs: List[A])(implicit evidence$1: Monoid[A])A

scala&gt; sum(List("a", "b", "c"))
res12: String = abc

从上面演进的过程可以发现,函数式模式提供了比OO设计模式更好的可重用性。每个函数模式都有两个独立部分:

  1. 完全通用且可重用的代码我们称之为代数 (例如:sum定义),在所有使用模式的上下文中它都是不变的。

  2. 特定的上下文实现(例如:StringMonoid、IntMonoid),在所有应用模式的实例中都各不相同。我们称之为代数的解释程序

函数式编程本质在于纯函数的能力:在混合上加入静态类型,就拥有了代数抽象——在类型上操作的函数,且遵循某些法则;使函数在类型上泛化,就拥有了参数属性;函数变成多态的之后,就具备了更高的可重用性,在特化类型中不会泄漏任何实现细节,至此就得到了免费的定理。与传统OO最大的区别是,函数式编程认为,类型的含义是由它与其他类型的关系决定的,而非内在形式(Internal Representation)。

发表评论

电子邮件地址不会被公开。 必填项已用*标注