Liking cljdoc? Tell your friends :D

解析与构建二进制报文的通用库

一个用来解析和生成字节报文的库,可用于解析和构建通信报文,只需要两个步骤:

  1. 用defpacket宏定义规约的结构
  2. 调用parse解析二进制报文得到由各字段值构成的映射,或调用build将由各字段值构成的映射转换为二进制报文

开始使用

先看一个简单示例。

首先定义规约格式:

(ns your.ns.name
  (:require [packet.core :refer [defpacket defslots parse build desc]]
            [packet.types :refer [u]]))
  
(defpacket foo
  (skip-until 0x68) ;跳过无关字节直到遇到有效报文起始标志0x68
  a (u 2) ;a是字段的名称,u是预定义的类型名称,表示无符号整数,最后的2表示本字段占用2字节的空间
  b (u 1)
  0x68)

defpacket定义规约结构,foo是自定义的规约名称,后面是各个槽位的定义。

上面格式要求报文以1字节的0x68开头,后面跟着2字节的无符号整数(默认为低字节在前),接着是1字节的无符号整数,最后以0x68结尾。

定义好规约格式后,就可以用parse来解析字节流:

(parse foo [1 2 0x68 11 0 22 0x68 3 4])
;=> {:data [1 2 104 11 0 22 104 3 4]
     :start 2
     :pos 7
     :obj {:a 11, :b 22}
     :pbl {:a [3 2] :b [5 1]}}

parse按foo规定的报文结构解析一个字节序列,得到解析结果,解析结果中:

  • :data 原始数据
  • :obj 解析出来的各字段的业务值(不一定是数值,可以是任何类型),用一个映射表示,由于映射的无序特性,字段的显示顺序可能与规约中定义的顺序不同
  • :start 有效报文的起始位置
  • :pos 解析结束后的字节位置(最后一个字节之后的位置)
  • :pbl 各字段的位置和长度,可在交互式环境中展示字段所在的报文位置。对重复字段的内部字段,记录的是最后一次重复时的位置和长度。对位字段内部的各位段,记录的是整个位字段的起始位置、起始位序号、位数,如果字节序为高字节在前,还有整个位字段的长度。

解析过程如果出现异常情况,如提供的报文不满足规约要求,则会抛出异常。

要按定义顺序显示各字段的值,可使用desc函数:

(desc foo *1)
;=> ({:name :a, :val 11}
     {:name :b, :val 22})

desc除了可按顺序显示各字段的内容外,还有其他作用,见友好显示一节。

如果已知各字段的值,要构建对应的报文,可用build函数:

(build foo {:a 11, :b 22})
;=> {:data [104 11 0 22 104]
     :start 0
     :pos 5
     :obj {:a 11, :b 22}
     :pbl {:a [1 2] :b [3 1]}}

返回映射中的:data对应的是构建的报文内容。

下面介绍详细用法

定义规约结构

用defpacket宏定义规约的结构,语法为

(defpacket name & slots)

其中name是规约名,slots是规约报文各个组成部分(以下称为槽位或字段)的描述,按先后顺序依次排列。槽位和字段在含义上有细微差别,如下

  • 槽位:报文中的特定位置,在这个位置是什么类型的数据,针对的是规约格式
  • 字段:具体报文中该槽位上放置的内容,针对的是具体报文

本文中并不特别区分这两个概念,经常互换使用,请读者自行分辨。

有两种方式描述一个槽位:

  • 0x68 任意具体字节值,表示该位置必须出现这个字节
  • name (type & args) 字段名称和类型信息,其中
    • name 表示字段名称的符号,字段名不能包含.和$字符,也不能以%和#和?字符开头。字段名称不是必须的。
    • type 字段类型。放置字段类型的这个位置称为类型位置,注意不是函数调用。
    • args 类型参数,如字段长度、系数等。类型参数不是必需的,视具体类型而定。

在类型位置使用的字段类型中,借用了一些与clojure库的函数(或宏)同名的符号,如=、not=、when、case、cond、repeat等, 它们出现在类型位置时表示的是字段类型,它们出现在表达式的函数位置时表示的是通常的函数或宏,注意不要混淆。

本库在packet.types空间下预定义了一些基本类型,如下所示,其中n表示长度,即字段所占用的字节数

  • (u n) 表示无符号整数
  • (hex n) 与上面一样表示无符号整数,但在友好显示时会用16进制显示其值
  • (i n) 表示有符号整数
  • (ubcd n) 压缩bcd编码的无符号整数,每一个字节表示两位10进制数
  • (bcd n) 压缩bcd编码的有符号整数,最高字节的最高位为1表示负数,因此最高字节最大能表示的数的绝对值为79
  • (decimal n coef) 用有符号bcd码表示的浮点数,该浮点数是bcd码对应的整数乘以系数coef得到的
  • (decimal> n coef) 用无符号bcd码表示的非负浮点数,该浮点数是bcd码对应的无符号整数乘以系数coef得到的
  • (cstr n) 以0字节结束的字符串类型,字符串的最大长度为n,此时没有最后的0字节
  • (lstr n) 第一个字节表示实际字符串的长度,可有效存储的字符串的最大长度为n-1
  • (dstr n) 用以0字节结束的字符串存储的正负浮点数
  • (ipaddr) 用4个字节存储的ip地址,业务类型为java.net.InetAddress

除ipaddr的长度固定为4外,所有类型的默认长度均为1,这些类型在使用时可省略n参数。 对三个字符串类型来说,使用默认长度或指定的长度小于等于1,都表示不定长度,即存储完整的字符串为止。

这些字段都可以在类型定义中指定默认值,包括后面介绍的组合类型和位类型。指定默认值的作用是在构建报文时可以不指定该字段的值。 举例如下:

(u :default 20) ;在:default后紧跟默认值

基本类型须带名字空间使用,除非在当前名字空间中userefer它们

除以上基本类型外,还有一些特殊类型和组合类型以及位类型,它们没有名字空间,可直接使用。

特殊类型

除上面的基本类型外,本库还定义了一些特殊类型,下面用举例来说明它们的用法。

skip-until

跳过字节直到满足一定的条件,通常用在规约定义的第一个槽位,用于寻找报文的有效起始位置。

用法举例:

  • (skip-until 0x68 0x10) 跳过字节直到遇到连续的0x68、0x10字节为止
  • (skip-until 0x68 0x10 a b ? ? a b 0x68) 跳过字节直到遇到一个9字节的模式,其中第一二九字节分别为0x68,0x10和0x68, 其中的符号表示任意字节,除问号外,相同的符号表示相同的字节。在本例中,第三与第七字节相同,第四与第八字节相同,第五六字节可任意且不必相同。 该槽位的字段值由前面的固定字节决定,在本例中,其值为0x6810,不受字节序的影响。分析位置越过了所有前置的固定字节,在本例中,下一个槽位位于a处。
  • (skip-until (one-of 0xe5 0x10 0x68)) 跳过字节直到遇到0xe5或0x10或0x68之一

跳过一个或多个无关字节,用法举例:

(skip 3)

上例表示跳过3个字节。如果不提供参数,则跳过1个字节。构建时用0填充。

reserved

保留字段,用法举例:

(reserved 0x55 0x66)

上例表示解析时跳过两个字节,构建时用0x55和0x66填充这个槽位。

padding

对齐字段,用法:

(padding n v)

使得下一个字段位于n字节的边界上,v是构建时用于填充的字节,如果不提供则用0填充

举例如下:

(defpacket foo
  (u 5)
  (padding 4) ;填充合适数量(这里是3)的字节使得后面的a字段位于第8字节的位置。
  a (u 2))

=

冗余字段,用法举例:

(= a)

表示长度和内容均与a字段相同。a必须是之前定义的一个字段。定义这样的字段主要是提供报文有效性检验的一种手段。

not=

用法举例:

(not= 0x10 0x20) ;参数可以是一个或多个字节

表示当前位置不能是连续的0x10、0x20,否则解析失败。通常用于option内部的第一个槽位,表示option字段如果存在,其第一个字节不会是0x10。

raw

原始字段,用法举例:

  • (raw :len 10) 该类型将返回10个字节的原始内容
  • (raw :len expr) 该类型将返回由expr的计算值指定的多个字节的原始内容。

如果expr是前面定义的某一个字段的四则运算,则在构建报文时可以不提供那个字段的值,而由本字段的实际长度反向计算出那个字段的值。

该类型的字段必须有名称,构建时需提供顺序的字节集合。

length-of

其他字段的长度,可以通过表达式转换,用法举例:

(length-of a b +4 :len 2) ;a字段与b字段的长度之和加4,用两字节存储
(length-of expr1 expr2 ... :len 2) ;多个表达式的值之和

上面的表达式中引用的字段是指该字段的长度,而不是其值。单纯的字段符号可视为最简单的表达式。

本字段的值默认用u格式存储,如果要改成其他格式,可以用:type参数指定, 例如,要采用无符号压缩bcd码存储a字段与b字段的长度之和,可以如下设置:

(length-of a b :type packet.types/ubcd)

构建时该字段的值可以省略。

length-of-all

当前报文段(指由defslots和defpacket定义的报文段)的总长度。

只有在defslots和defpacket中可以使用length-of-all类型的字段。

用法举例:

(length-of-all -6 :len 2)

表示该字段本身占2字节,-6为调整值,即报文中的值为当前报文段的总长度减6。 解析后得到的字段值为当前报文段的总长度,即报文中的值加6。 构建时该字段的值可以省略。

与length-of字段一样,本字段默认用u格式存储,可以用:type参数改变数值存储的格式。

enum

枚举类型,可用向量或映射提供枚举值,用法如下:

(enum {17 :tcp 11 :udp})
(enum [:a :b :c])

如果提供的是映射,键是报文中存储的值,值为业务值。如果提供的是向量,向量中的值是业务值,报文中存储对应的序号值。

与length-of字段一样,本字段默认用u格式存储,可以用:type参数改变数值存储的格式。

checksum

当前报文段的校验和字段,用法举例:

(checksum :len 2 :from 6 :to 0 :checksum f) 

上例表示的校验和字段本身占2字节,从当前报文段开头第6字节开始,到本字段之前为止,用校验函数f计算校验和。 from参数如果为0或省略,表示从当前报文段开始位置开始,to参数如果为0或省略,表示到本字段之前为止。 from参数和to参数可以是一个字段名,表示计算的起止字段(包含这两个字段)。 也可用from-after和to-before指定起止位置,此时不包含指定的字段。

如果不提供校验函数,则直接将范围内的所有字节相加。

只有在defslots和defpacket中可以使用checksum类型的字段。

success

不消耗内容,总是成功的类型。这个类型极少使用,通常只用在one-of字段的最后,用于保证one-of字段不会失败。用例如下:

(one-of 0x68 0xe5 (success))

上例表示当前字段要么是0x68,要么是0xe5,如果都不是,则忽略这个字段,就像这个字段不存在一样。

fail

不消耗内容,总是失败的类型。这个类型极少使用,通常只用在case或cond类型的:else部分,用于在前面的条件均不满足的情况下,case或cond字段会失败(即抛出异常)。用例如下:

(case di
  0 ...
  1 ...
  2 ...
  :else (fail "some reason" expr))

上例中,如果di的值不是0/1/2之一,则抛出异常。异常是由(ex-info "some reason" {:arg expr的值})构建的。

组合类型

组合类型表示其他字段有条件地存在、或重复、或选择等。

下面出现的expr均表示一个普通clojure表达式,里面函数位置的符号与这里定义的类型无关,就是通常的clojure函数、宏、或特殊形式。 表达式里面可以使用与字段名相同的符号,代表对应字段的值。当然这些字段必须在当前字段之前定义。 不要担心这些符号没有事先用def定义。实际上,本库会自动将它们转换为提取其值的函数。当然这是实现细节,使用者不必深究。 如果要引用的字段处于不同的层次,则用.分开各个层次。也可以引用非字段值,这样的值必须通过环境变量提供。 如果引用的是重复字段(见下面的repeat),则使用其最后一个值。 表达式中函数只能出现在函数位置,其他位置的符号均被当成字段值或环境值,也就是说,表达式中不能使用高阶函数,即以函数为参数的函数。实际上,我们通常不会用到高阶函数,如果你真的需要,请告诉我。

(slots name :len expr)

嵌入先前由(defslots name ...)定义的字段组。:len expr可以省略。

如果expr是前面定义的某一个字段的四则运算,则在构建报文时可以不提供那个字段的值,由本字段的实际长度反向计算出那个字段的值。

(packet name :len expr)

嵌入先前由(defpacket name ...)定义的另一个规约的报文。:len expr可以省略。

如果expr是前面定义的某一个字段的四则运算,则在构建报文时可以不提供那个字段的值,而由本字段的实际长度反向计算出那个字段的值。

该类型的字段自身必须有名称,这是解析结果的安放之处。

(group & slots)

将一些字段组合成一个组,目的可能是为这个组起个名称,或者是形成一个局部范围。

(when expr & slots1 :else & slots2)

当表达式计算结果为真值时,应用slots1对应的槽位,否则应用slots2对应的槽位。 :else和slots2可以省略,此时如果expr计算结果为假,则忽略本字段。举例如下:

dir (u)
(when (= dir 1)
  a (u)
  b (bcd 2)
  :else
  c (decimal 3 0.001)
  d (i 2)
  e (bcd))

当dir的值为1时,应用a、b两个字段,否则应用c、d、e三个字段

(option & slots)

可选字段,即如果slots中任一字段无法正常解析就忽略本字段。 通常它内部的第一个子字段为固定字节字段或not=字段,用于判断本字段是否已实际出现。举例如下:

(option
  (not= 0xaa)
  a (u)
  b (bcd))

上例表示,如果当前解析位置的字节不是0xaa,就解析a、b字段,否则忽略本字段。当然,如果解析a、b字段的过程中出错,也忽略本字段。 实际上,option内第一个字段在语法上并无特殊性,上述效果是not=字段本身的效果所致,该字段不消耗内容,只是检查当前字节的内容。

在构建时,如果没有提供某个子字段的值且该值无法自动推断,则不构建整个option字段。

(repeat expr & slots)

如果expr计算出一个整数,则重复应用slots对应的次数。 否则在expr计算结果为真时应用slots,并再次计算expr,并当结果为真时再次应用slots,如此重复。

如果expr是前面定义的某一个字段的四则运算,则在构建报文时可以不提供那个字段的值,而由本字段的实际重复次数反向计算出那个字段的值。

该类型的字段必须有名称,因为解析结果是一个数组(即使只有一个值甚至没有值),必须有地方安放这个数组。

repeat字段的expr还可以引用预定义的变量&0,这个特殊变量表示报文的当前位置。 还有一类特殊符号是字段名符号前面加一个&符号,表示这个字段的字节位置。

有时候报文中没有一个字段明确表示重复次数,但有一个与报文长度有关的字段, 并且这个字段计及的报文范围涵盖了重复字段。假设该字段名为len,并且假设涵盖范围之前的字段长度为outer-before, 涵盖范围之内处于重复字段之后的字段的长度为inner-after,则重复条件为:

(< (+ &0 inner-after) (+ len outer-before))

其中inner-after和outer-before都可能是一个表达式,举例如下:


(defpacket aname
  p (i 4)
  data-len (length-of data)
  data (when (>= p 0)
         ...
         a (repeat (< (+ &0 (if (= p 0) 2 0)) ;if表达式是b字段的长度
                      (+ date-len &data))
             ...)
         b (when (= p 0)
             (u 2))))
  ...

(case expr v1 (slots1) v2 (slots2) ... :else (slots))

首先计算expr,应用与该值对应的slots,如果没有对应的值,则应用:else对应的slots,如果没有提供:else字段,则忽略整个case字段。

注意:与clojure核心库中的case不同的是,默认槽位必须放在:else之后

如果多个值对应同样的slots,可以用这样的形式:

(case expr 
  (v1 v2 v3) (slots1) 
  v4 (slots2) 
  ...)

如果某个值对应的槽位是其他多个值对应的槽位的叠加,则可用这样的形式:

(case expr 
  1 (slots1) 
  2 (slots2) 
  3 (slots3) 
  4 (slots4)
  5 (slots5)
  7 (slots7)
  9 (++ 1 - 5 7) 
  ...)

表示9对应的槽位是1、2、3、4、5、7各槽位的顺序叠加。

(cond expr1 (slots1) expr2 (slots2) ... :else (slots))

依次计算各个表达式expr,如果某个结果为真,则应用对应的slots,否则应用:else对应的slots,如果没有提供:else字段,则忽略本cond字段。

(one-of slot1 slot2 ...)

依次应用各个槽位,直到某一个成功,余下的槽位不应用。如果均不成功,则总体失败。

忽略后面的字段

如果在当前字段解析(构建)成功后需要忽略后面的字段,可在字段类型前面加上break。例如

(break when expr ...)

当解析(构建)完when字段并且消耗(产生)了部分报文内容后,when字段后与它同级别的其他字段将不再解析(构建)。

通用参数

任何类型都可用一个字符串参数来指定该字段的标题,用于友好显示。

任何类型都可用一个向量、或映射、或函数字面量负责对字段值进行解释,提供人可读的信息。

任何类型都可由:default指定默认值。指定了默认值的字段在构建报文时可以不指定该字段的值。

任何类型都可由:len指定长度。通常用一个引用了某个其他字段的表达式指定长度,此时在解析时如果该表达式的求值结果为0,则会跳过该字段;在构建时则会根据实际长度设置那个被表达式引用的字段的值。 基本类型不需要使用:len来指定长度,它们可以在类型名称后直接指定长度。

位类型

某些字段是由一个或多个位段组成的,这时可用位类型,如下所示:

(bits 2 ;2表示位类型的长度,总共占2字节共16位
  :big-endian ;表示高字节在前,可简写为:be,省略时默认为低字节在前
  df (0 :enum {0 :disable 1 :enable}) ;表示df位段占用第0位。位序号从0开始,它是低字节的最低位。低字节是第一个字节还是最后一个字节由:big-endian标志决定。
  ofs (15 3 :default 0 :pad 3) ;表示ofs位段占用第3到15位,默认值为0,业务值要在其后补3个0
  (when expr ;根据条件选用的字段,这是唯一允许出现在位字段中的类型
    ...
    :else
    ...) 
  )

位段中可有:enum引入一个映射或向量,用于报文中的值与业务值之间的转换。

对位字段来说,总是默认低位处于低字节,即第0位是第一字节的最低位,除非有:big-endian标志,此时第0位是最后一个字节的最低位。

对位段,:pbl中记录的信息为位类型第一个字节的位置(与字节序无关),起始位序号,以及位长度。如果字节序是高字节在前,则还有一个位类型的长度,即整个位类型的字节数。

层次关系

一个槽位可以包含多个子槽位,如

(defpacket foo
  a (u 1)
  b (when (= a 1)
     b1 (u 1)
     b2 (u 2)))

表示b字段仅在a字段为1时才存在,如果b字段存在的话,则它包含b1和b2两个字段共3个字节。

解析结果中b字段的值以

{:a 1
 :b {:b1 ...
     :b2 ...}}

的形式存在。如果不需要这个多余的层次,则在定义报文结构时可省略b,如下所示:

(packet.core/defpacket foo
  a (u 1)
  (when (= a 1)
     b1 (u 1)
     b2 (u 2)))

此时解析结果为

{:a 1
 :b1 ...
 :b2 ...}

重复应用

对于需要重复使用的字段组,用defslots定义,如下所示

(defslots name & slots)

slots的格式同defpacket。定义之后,可用(slots name)嵌入到其他defslots或defpacket的定义中

解析报文

调用形式为

(parse name bs :start start :env env)

其中

  • name 前面用defpacket定义的规约名
  • bs 一个实现了IndexedBytes协议(可以只实现其读取部分)的对象
  • start 从bs中第start个字节开始解析(它不一定是报文的有效起始位置)
  • env 一个映射,提供了规约定义中引用的非字段的值

IndexedBytes协议定义了三个方法:

  • (size [this] "返回可供读取的字节数量")
  • (getb [this index] "返回指定位置处的字节")
  • (setb [this index val] "设置指定位置处的字节,返回更新后的对象")

如果只用于解析,bs的类型可以只实现前两个方法。如果要用于构建,则必须实现所有方法。

本库已经将该协议扩展到字节数组、java.nio.ByteBuffer、io.netty.buffer.ByteBuf和 实现了clojure.lang.Indexed接口的类(如向量)上,可以直接使用这些对象传递报文。

注意:如果用向量构建报文,它必须是transient的可变类型

对java.nio.ByteBuffer和io.netty.buffer.ByteBuf来说,解析并不消耗字节,即不会移动其当前位置(即不会改变position或readerIndex。 构建时也不会移动当前位置(即改变position或writerIndex),需要根据返回结果中的pos自行设置limit或writerIndex以限制其有效读取范围。

解析结果是一个映射,其中的主要字段有

  • :obj 各个字段的值构成的映射
  • :start 报文的有效起始位置
  • :pos 解析后的位置
  • :pbl 各字段的字节位置(包括位位置)和长度

多字节的整数默认低字节在前,如果要改为高字节在前,可动态绑定packet.utils/big-endian为true,如下方式:

(binding [packet.utils/*big-endian* true]
  (parse ...))

(binding [packet.utils/*big-endian* true]
  (build ...))

友好显示

parse解析后得到一个反映字段值的映射,由于映射数据类型自身的特点,各项的顺序可能与报文定义不一致, 本库提供了一个desc函数可对解析结果进行友好显示,各项显示的顺序与字段定义一致。

字段定义中出现的第一个字符串总是作为字段的标题。其他字符串,如果有的话,是类型的参数,如果类型不需要这个参数,将抛出异常。

为了解释字段值的含义,在字段类型定义中可通过:desc引入一个向量,映射,或字面量函数,用来对业务值进行解释。

  • 向量仅适用于业务值是连续的非负整数的情况
  • 映射的键为业务值(不是报文中的数值),可以是任意数据类型
  • 函数最多有一个参数,这个参数就是业务值(不是报文中的数值)。在函数中可以引用其他字段的值,比如可以根据其他字段的值对本字段的值进行不同的解释

(defpacket 规约名
  a (u "方向" :desc ["上" "下"])
  b (i "开关" :desc {0 "off" -1 "on"})
  c (decimal 2 "速度" :desc #(str % "米/秒")))

a字段值为0时显示"上",为1时显示"下" b字段值为0时显示"off",为-1时显示"on" c字段显示为"...米/秒"

构建报文

调用形式为

(build name obj :dst dst :start start :env env)

其中

  • name 前面用defpacket定义的规约名,
  • obj 字段业务值构成的映射,键是字段名符号或对应的关键字。注意提供的值是业务值而不是报文中的数值。业务值可能是一个复杂的对象,由字段类型决定。
  • dst 存放结果的地方,start是dst中开始存放结果的起始位置。dst应该是完整实现了IndexedBytes协议的非持久类型。提供的dst应该具有足够的容量容纳待构建的报文。dst可以省略,如果省略,则用暂态向量(transient vector)存放结果
  • env 环境变量,就是一些预设的值,包括字段值和非字段值

返回一个映射,包括

  • :data 构建的报文内容
  • :start 起始位置
  • :pos 最后的位置
  • :obj 字段值,可能补充了自动计算的原未提供的值
  • :pbl 各字段的位置和长度

提供的映射中可以不提供某些字段的值,这些字段有以下几种情况:

  • 表示其他字段的重复次数,即某个repeat字段的expr就是该字段
  • 表示其他字段的长度,这些字段的值可由其他字段的实际长度计算而得。这些字段可以是length-of类型或length-of-all类型,也可以是slots/packet/raw类型的:len参数指定的字段
  • 有些字段在规约定义时指定了默认值,不提供时使用默认值
  • 校验和字段,这种字段将根据报文内容自动计算

除了这些类型的字段以及固定内容的字段外,其他字段都应该有名称,否则,解析时无法返回其值,构建时无法指定其值。

如果提供的dst是暂态向量,必须从:data中读取构建的报文,不能从原始的暂态向量中读取。

如果dst是java.nio.ByteBuffer或io.netty.buffer.ByteBuf类型,构建时不会移动当前位置(即改变position或writerIndex),需要根据返回结果中的pos自行设置limit或writerIndex以限制其有效范围。

(let [buf (java.nio.ByteBuffer/allocate 100)
      ctx (build m buf)]
  (.limit buf (:pos ctx)) ;设置最后的写位置,也就是可读取的最后位置
  ...)
	
(let [buf (io.netty.buffer.Unpooled/buffer 100)
      ctx (build m buf)]
  (.writerIndex buf (:pos ctx)) ;设置最后的写索引,也就是可读取的最后位置
  ...)

自定义类型

一个类型定义了两方面,一是数据的字节存储形式,二是数据的业务类型。定义自己的类型就是定义二者相互转换的方式。

如果要定义自己的类型,可用packet.types/defsimpletype定义,如下所示:

(defsimpletype name [len & args] f1 f2)
  • name 类型名,类型名不得以数字结尾,因为结尾的数字在使用时会被解释为字节长度
  • len 类型长度,如果是符号,则表示长度在使用时指定,如果使用时未指定,则默认长度为1。如果len为整数,表示该类型的长度是固定的,使用时可以不指定长度,但是如果指定的话,则必须是该整数
  • args 类型参数,它们是使用时需要对应提供的参数,下面两个函数可以使用它们
  • f1 字段解析函数,从字节序列中计算业务值,业务值可以是任意类型,如日期对象。形式为(fn [bs] ...),其中bs为字节序列,返回业务值
  • f2 字段构建函数,将业务值转换为指定长度的字节序列。形式为(fn [v len] ...),其中v为业务值,len为字段长度,返回字节序列

注意,如果nil是有效的业务值,这两个函数要能正确地处理

使用时长度可与类型连写,中间省略空格,如(u 2)可写为(u2),系统会自动在数字前插入空格,像我这样的懒人会喜欢省略空格的写法。

实际上,标准类型都是这样定义的,举例如下:

u

;;无符号二进制整数
(defsimpletype u [len] u/bytes->uint u/uint->bytes)

上面的u代表packet.utils命名空间,下同。

decimal

;;用有符号整数表示的小数,以coef为转换系数
(defsimpletype decimal [len coef]
  (fn [bs] (-> bs u/bytes->int (* coef) double))
  (fn [v len] (-> v (/ coef) math/round (u/int->bytes len))))

使用时提供长度与系数,使用形式如下(用3字节整数表示的带两位小数的定点浮点数):

(decimal 2 0.1)

为了使类型名称更有业务含义,可以如下定义(voltage是电压的意思):

(defmacro voltage [] `(decimal 2 0.1))

有了上面的定义,则可以如下使用

(voltage)

ipaddr

(defsimpletype ipaddr [4]
  (fn [bs] (java.net.InetAddress/getByAddress (byte-array bs)))
  (fn [ip len] (if (nil? ip)
                 (repeat 4 0)
                 (->> (str/split (.getHostAddress ip) #"\.")
                      (map parse-long)
                      (map unchecked-byte)))))

在packet/types空间中定义了打印方法,将java.net.InetAddress对象打印为 #ip "x.x.x.x" 的形式。同时也定义了对应的读取器方法ip-reader,可视需要自行使用。

时间类型

如果用bcd码保存年月日时分秒,并且业务值用java.util.Date类型来表示时间,则可用deftimetype来定义时间类型,用法如下:

(deftimetype name format)

举例如下:

(deftimetype a-name "mhDMY")

表示一个时间类型,存储的字节依次为分、时、日、月、年的bcd码,共5个字节。这里的对应关系为:

  • Y 年,如果有两个连续的Y,表示用两字节表示4位年,如2023。如果只有一个Y,则没有世纪年,如23。
  • M 月
  • D 日
  • h 时
  • m 分
  • s 秒

java.util.Date类型的字面量格式为#inst "2021-05-10T09:15:00.000-00:00"表示,并总是采用0时区, 如果换成东八区的时间,则为#inst "2021-05-10T17:15:00.000+08:00"。 前导的0不能省略。日期要完整,时间后面的部分可省略。如#inst "2023-04-02T05"是有效的日期。

字节序

对于多字节的字段,默认为低字节在前。如果要改为高字节在前,可以如下处理

(binding [packet.utils/*big-endian* true]
  (parse ...))
  
(binding [packet.utils/*big-endian* true]
  (build ...))

多字节的位字段的字节序不受此控制,总是默认低字节在前,除非在位字段的长度信息后用:big-endian(或:be)改为高字节在前。

预处理与后处理

在defpacket的名称之后,定义各字段之前,可以用一个映射定义预处理函数和后处理函数,这个映射的形式为

(defpacket foo
  {:prep (fn [bs start] ...) ;预处理函数,正式解析前调用
   :postp (fn [bs start] ...) ;后处理函数,构建完成后调用
  }
  ...)

这里bs是实现了IndexedBytes协议的对象,start是当前提供的报文的开始位置(对预处理来说,它不一定是有效起始位置,有效起始位置需要自行寻找)

注意:预处理函数不能破坏原始数据,必须返回一个新的实现了IndexedBytes协议的数据结构, 原因是如果报文嵌入在底层规约报文中,改变原始数据会破坏底层规约的校验计算结果, 但如果你没有这种应用场景,也可以直接修改原始数据。

示例

在test/packet/example目录下定义了一个简化的ip协议,以及一个电表DLT645规约和计量自动化终端规约,可以参考。

许可协议(License)

Copyright © 2023-01-18

This program and the accompanying materials are made available under the terms of the Eclipse Public License 2.0 which is available at http://www.eclipse.org/legal/epl-2.0.

This Source Code may also be made available under the following Secondary Licenses when the conditions for such availability set forth in the Eclipse Public License, v. 2.0 are satisfied: GNU General Public License as published by the Free Software Foundation, either version 2 of the License, or (at your option) any later version, with the GNU Classpath Exception which is available at https://www.gnu.org/software/classpath/license.html.

Can you improve this documentation?Edit at git repository

cljdoc is a website building & hosting documentation for Clojure/Script libraries

× close