一个用来解析和生成字节报文的库,可用于解析和构建通信报文,只需要两个步骤:
先看一个简单示例。
首先定义规约格式:
(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规定的报文结构解析一个字节序列,得到解析结果,解析结果中:
解析过程如果出现异常情况,如提供的报文不满足规约要求,则会抛出异常。
要按定义顺序显示各字段的值,可使用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)
字段名称和类型信息,其中
在类型位置使用的字段类型中,借用了一些与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后紧跟默认值
基本类型须带名字空间使用,除非在当前名字空间中use
或refer
它们
除以上基本类型外,还有一些特殊类型和组合类型以及位类型,它们没有名字空间,可直接使用。
除上面的基本类型外,本库还定义了一些特殊类型,下面用举例来说明它们的用法。
跳过字节直到满足一定的条件,通常用在规约定义的第一个槽位,用于寻找报文的有效起始位置。
用法举例:
(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 0x55 0x66)
上例表示解析时跳过两个字节,构建时用0x55和0x66填充这个槽位。
对齐字段,用法:
(padding n v)
使得下一个字段位于n字节的边界上,v是构建时用于填充的字节,如果不提供则用0填充
举例如下:
(defpacket foo
(u 5)
(padding 4) ;填充合适数量(这里是3)的字节使得后面的a字段位于第8字节的位置。
a (u 2))
冗余字段,用法举例:
(= a)
表示长度和内容均与a字段相同。a必须是之前定义的一个字段。定义这样的字段主要是提供报文有效性检验的一种手段。
用法举例:
(not= 0x10 0x20) ;参数可以是一个或多个字节
表示当前位置不能是连续的0x10、0x20,否则解析失败。通常用于option内部的第一个槽位,表示option字段如果存在,其第一个字节不会是0x10。
原始字段,用法举例:
(raw :len 10)
该类型将返回10个字节的原始内容(raw :len expr)
该类型将返回由expr的计算值指定的多个字节的原始内容。如果expr是前面定义的某一个字段的四则运算,则在构建报文时可以不提供那个字段的值,而由本字段的实际长度反向计算出那个字段的值。
该类型的字段必须有名称,构建时需提供顺序的字节集合。
其他字段的长度,可以通过表达式转换,用法举例:
(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)
构建时该字段的值可以省略。
当前报文段(指由defslots和defpacket定义的报文段)的总长度。
只有在defslots和defpacket中可以使用length-of-all类型的字段。
用法举例:
(length-of-all -6 :len 2)
表示该字段本身占2字节,-6为调整值,即报文中的值为当前报文段的总长度减6。 解析后得到的字段值为当前报文段的总长度,即报文中的值加6。 构建时该字段的值可以省略。
与length-of字段一样,本字段默认用u格式存储,可以用:type参数改变数值存储的格式。
枚举类型,可用向量或映射提供枚举值,用法如下:
(enum {17 :tcp 11 :udp})
(enum [:a :b :c])
如果提供的是映射,键是报文中存储的值,值为业务值。如果提供的是向量,向量中的值是业务值,报文中存储对应的序号值。
与length-of字段一样,本字段默认用u格式存储,可以用:type参数改变数值存储的格式。
当前报文段的校验和字段,用法举例:
(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类型的字段。
不消耗内容,总是成功的类型。这个类型极少使用,通常只用在one-of字段的最后,用于保证one-of字段不会失败。用例如下:
(one-of 0x68 0xe5 (success))
上例表示当前字段要么是0x68,要么是0xe5,如果都不是,则忽略这个字段,就像这个字段不存在一样。
不消耗内容,总是失败的类型。这个类型极少使用,通常只用在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),则使用其最后一个值。 表达式中函数只能出现在函数位置,其他位置的符号均被当成字段值或环境值,也就是说,表达式中不能使用高阶函数,即以函数为参数的函数。实际上,我们通常不会用到高阶函数,如果你真的需要,请告诉我。
嵌入先前由(defslots name ...)
定义的字段组。:len expr
可以省略。
如果expr是前面定义的某一个字段的四则运算,则在构建报文时可以不提供那个字段的值,由本字段的实际长度反向计算出那个字段的值。
嵌入先前由(defpacket name ...)
定义的另一个规约的报文。:len expr
可以省略。
如果expr是前面定义的某一个字段的四则运算,则在构建报文时可以不提供那个字段的值,而由本字段的实际长度反向计算出那个字段的值。
该类型的字段自身必须有名称,这是解析结果的安放之处。
将一些字段组合成一个组,目的可能是为这个组起个名称,或者是形成一个局部范围。
当表达式计算结果为真值时,应用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三个字段
可选字段,即如果slots中任一字段无法正常解析就忽略本字段。 通常它内部的第一个子字段为固定字节字段或not=字段,用于判断本字段是否已实际出现。举例如下:
(option
(not= 0xaa)
a (u)
b (bcd))
上例表示,如果当前解析位置的字节不是0xaa,就解析a、b字段,否则忽略本字段。当然,如果解析a、b字段的过程中出错,也忽略本字段。 实际上,option内第一个字段在语法上并无特殊性,上述效果是not=字段本身的效果所致,该字段不消耗内容,只是检查当前字节的内容。
在构建时,如果没有提供某个子字段的值且该值无法自动推断,则不构建整个option字段。
如果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))))
...
首先计算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各槽位的顺序叠加。
依次计算各个表达式expr,如果某个结果为真,则应用对应的slots,否则应用:else对应的slots,如果没有提供:else字段,则忽略本cond字段。
依次应用各个槽位,直到某一个成功,余下的槽位不应用。如果均不成功,则总体失败。
如果在当前字段解析(构建)成功后需要忽略后面的字段,可在字段类型前面加上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)
其中
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以限制其有效读取范围。
解析结果是一个映射,其中的主要字段有
多字节的整数默认低字节在前,如果要改为高字节在前,可动态绑定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)
其中
返回一个映射,包括
提供的映射中可以不提供某些字段的值,这些字段有以下几种情况:
除了这些类型的字段以及固定内容的字段外,其他字段都应该有名称,否则,解析时无法返回其值,构建时无法指定其值。
如果提供的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)
(fn [bs] ...)
,其中bs为字节序列,返回业务值(fn [v len] ...)
,其中v为业务值,len为字段长度,返回字节序列注意,如果nil是有效的业务值,这两个函数要能正确地处理
使用时长度可与类型连写,中间省略空格,如(u 2)
可写为(u2)
,系统会自动在数字前插入空格,像我这样的懒人会喜欢省略空格的写法。
实际上,标准类型都是这样定义的,举例如下:
;;无符号二进制整数
(defsimpletype u [len] u/bytes->uint u/uint->bytes)
上面的u代表packet.utils命名空间,下同。
;;用有符号整数表示的小数,以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)
(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个字节。这里的对应关系为:
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规约和计量自动化终端规约,可以参考。
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