在我的上一篇文章中 ,您了解了为什么使用REST JSON端点强制微服务之间的通信并不总是最好的方法。 在本文中,我将概述一个示例自定义框架解决方案,您可以将其扩展到自己的项目。
由于创建有用的框架的复杂性,我的下一篇文章将介绍如何实现框架。
范例介绍
本文旨在解决的问题是使用自定义数据存储安全地处理通信。 用于实现此框架的语言将是Elixir 。 Elixir提供了一种非常灵活的二进制匹配和操作语法,这将使本文更加简洁。 但是,您可以使用几乎所有语言来实现本文中详述的框架。
为了彻底突出示例,您将创建的框架不仅依赖于HTTP传输协议。 实际上,它甚至不会利用传输控制协议(TCP)。 相反,它将基于用户数据报协议(UDP),并将实现TCP的某些功能。 这将确保数据包的大小绝对最小。
分组协议功能
您将创建的框架将包括几个重要功能,但仍将保持足够的灵活性,以便您可以随意扩展自己的许多功能。 功能包括:
- 数据包识别签名
- 数据包的类,方法和属性
- 长度参数
- 交易识别码
- 使用消息完整性系统的安全性
数据包签名
数据包签名在某些协议中称为“魔术Cookie”,是在数据包开头附近找到的一串字节,可用于将其与其他数据包格式区分开。 签名不应太长,但应足够独特。 在此示例中,您将使用简单字符串SHIP
。 这是一个32位值(4个字符的8个字节),这是许多协议的通用大小。
包类,方法和属性
数据包的类,方法和属性是分层的,可标识数据包的用途。
类
该类是主要的数据包用途。 这会将包区分为request
消息, response
消息还是error
消息。 由于您将不使用TCP,因此您还将需要一个ack
消息,该消息是acknowledgement
简称。 ack
消息将在下一篇文章中详细介绍。
方法
该方法将是您希望在服务器上调用的命令。 由于这是用于数据存储的,因此可用的方法将包括诸如get
, set
和delete
。
属性
属性几乎涵盖了您希望从服务器发送或接收的其他所有内容。 这将包括验证所需的username
和password
,标识特定服务器实例所需的服务器realm
以及任何请求,响应或错误数据。
包长
定义包长度对于确定包的边界很重要。 通常,数据包头的大小是固定的,但其数据不是固定的。 通过将长度值放置在标题中的设置位置,可以指定并遵守数据包的整个长度。 如果您开始将数据包打包成较小的消息,需要在接收平台上进行重构,则这尤其重要。
分组交易标识符
服务器需要事务处理标识符来确定数据包顺序并确认接收。 同样,由于将不使用TCP,因此重要的是能够识别数据包是否已丢弃。 为此,使用顺序事务标识就足够了。
封包完整性
数据包的完整性只是数据包的真实性。 这包括其身份验证以及是否已被篡改。 如果数据包完整性关闭或用户无权发出请求,则数据包将导致错误响应。
包头结构
如上所述,传输协议分组通常将具有固定的报头。 下图概述了示例的结构;
0 1 2 3
0 1 2 3 4 5 6 7 8 9 0 1 2 3 4 5 6 7 8 9 0 1 2 3 4 5 6 7 8 9 0 1
+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+
| Magic Cookie |
+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+
|1 1| SHIP Message Type | Message Length |
+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+
| |
| Transaction ID (96 bits) |
| |
+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+
魔术饼干
如您所见,数据包以Magic Cookie
开头,或者只是字符串“ SHIP”(标识数据包)。
中缀
接下来,有两个位称为infix
,它只是填充并允许标头按byte aligned
。 这些位既被设置为1,又被用作数据包标识,因为如果它们不是' 1, 1
',则该数据包将不会被识别。
讯息类型
消息类型是数据包类别和方法类型的内插。 它们的格式为:
0 1
0 1 2 3 4 5 6 7 8 9 0 1 2 3
+-+-+-+-+-+-+-+-+-+-+-+-+-+-+
| m0 |c| m1 |c| m2 |
+---------+-+-----+-+-------+
解析时,将类位和方法位组合为两个单独的二进制值,从而创建2位类标识符和14位方法标识符。 这意味着只能有四种类类型(请求,响应,错误和确认),但是可能有16,384种方法类型!
长度
长度是整个数据包的字节数(不是位)。 16位的长度意味着数据包大小的最大长度为65535字节或65.535千字节。 对于单个数据包来说肯定足够大。
交易识别码
交易ID或标识符将是随每个后续数据包递增的顺序数字值。 通常,最好先在0到2,147,483,646之间随机生成第一个选定的交易ID。 在分派数据包时,如果事务ID达到最大值,它将简单地循环回到0并继续增加。
在服务器内,通常通过事务ID和发件人IP地址和端口号的组合来标识数据包。 如果服务器向客户端发送的消息不是响应形式(例如推送消息),则事务ID在所有客户端上都应该是唯一的。 但是,这不是本系列文章中使用的示例的因素。
对于给定的超时时间,事务ID才是唯一的,这对于确定是否应重新发送数据包是必需的。 典型的超时时间可能是20到30秒。
放入代码中
到目前为止,这是很多深入的讨论。 让我们通过一些代码将其付诸实践:
defmodule CodeshipDB.Pkt do
use Bitwise
@pkt_magic_cookie "SHIP"
@infix 3
defstruct class: nil,
method: nil,
transactionid: nil,
integrity: false,
key: nil,
attrs: %{}
end
首先,详细说明数据包的结构。 defstruct
列出组成数据包的值:
-
integrity
字段仅声明数据包中是否包括完整性检查。 这使您可以跳过完整性检查(如果选择)。 -
key
字段将包含用于构建完整性检查的唯一字符串。 下一篇将对此进行解释。 -
attrs
当前是一个空的hash-map
。 这将填充组成数据包主体所需的其他字段。
defstruct
上方是模块属性。 这些是不会改变的值,但是通过在模块的开头进行说明更易于阅读和引用。 @infix
设置为3,相当于二进制文件中的11
。
包属性
现在您已经有了数据包结构,您将需要定义类,方法和属性。
def classes(),
do: [
:error,
:request,
:response,
:ack
]
def methods(),
do: [
:bucket_exists,
:destroy,
:get,
:set,
:update,
:del,
:del_all
]
def attrs(),
do: [
{:bucket, :value},
{:json, :value},
{:data, :value},
{:username, :value},
{:password, :value},
{:realm, :value},
{:message_integrity, :value},
{:error_code, :error_attribute}
]
将这些功能放在上面的模块中。 如您所见,它们定义了四种数据包类型以及您可能希望对数据存储执行的命令。 随着新功能内置到数据存储中,您可以添加到方法列表中。 但是,请确保添加到列表的末尾,因为在其中添加值或更改现有顺序将破坏向后兼容性。
attrs
是编组数据包时使用的属性列表。 它们可以标识为:
属性 | 描述 |
---|---|
桶 | 用来执行任务的存储桶名称 |
json | 请求数据 |
数据 | 响应数据 |
用户名 | 认证用户名 |
密码 | 认证密码 |
领域 | 身份验证唯一服务器标识符 |
message_integrity | 用于验证消息完整性的数据 |
错误代码 | 可能的响应HTTP错误代码 |
可以将属性定义为以下两种类型之一: 一个简单value
或error_attribute
,它是一个值对,定义为元组。 要将字节转换为属性数据,反之亦然,您需要编码器和解码器:
# loop through all attributes and create a decoder function
# and an encoder function
for {{name, type}, byte} <- attrs() |> Enum.with_index() do
case type do
:value ->
defp decode_attribute(unquote(byte), value, _),
do: {unquote(name), value}
defp encode_attribute(unquote(name), value, _),
do: {unquote(byte), value}
:error_attribute ->
defp decode_attribute(unquote(byte), value, _tid),
do: {unquote(name), decode_attr_err(value)}
defp encode_attribute(unquote(name), value, _),
do: {unquote(byte), encode_attr_err(value)}
end
end
# handle unknown attribute decoding
defp decode_attribute(byte, value, _) do
{byte, value}
end
# handle unknown attribute encoding
defp encode_attribute(other, value, _) do
{other, value}
end
# loop through all methods and create a decoder function
# and an encoder function
for {name, id} <- methods() |> Enum.with_index() do
defp get_method(<<unquote(id)::size(12)>>),
do: unquote(name)
defp get_method_id(unquote(name)),
do: unquote(id)
end
# handle unknown method decoding
defp get_method(<<o::size(12)>>),
do: o
# handle unknown method encoding
defp get_method_id(o),
do: o
# loop through all classes and create a decoder function
# and an encoder function
for {name, id} <- classes() |> Enum.with_index() do
defp get_class(<<unquote(id)::size(2)>>),
do: unquote(name)
defp get_class_id(unquote(name)),
do: <<unquote(id)::2>>
end
将以上内容输入模块。 它将存在于函数之外,这意味着Elixir编译器将在编译时执行它,并将为列出的每个类,方法和属性生成一个函数对(编码器和解码器)。 您可能会注意到,存在针对未知属性和方法的处理程序,但对于类却不存在。 这仅仅是因为将满足所有可能的类类型。 2位类标识符只能支持四种类类型,并且您已在类列表中指定了四种。
最后,您将需要一些助手来遍历要编码或解码的属性列表。
# Converts a given binary encoded list of attributes into an Elixir list of tuples
defp decode_attrs(pkt, len, tid, attrs \\ %{})
defp decode_attrs(<<>>, _len, _, attrs), do: attrs # an empty attribute
defp decode_attrs(<<type::size(16), item_length::size(16), bin::binary>>, len, tid, attrs) do
whole_pkt? = item_length == byte_size(bin)
padding_length =
case rem(item_length, 4) do
0 -> 0
_ when whole_pkt? -> 0
other -> 4 - other
end
<<value::binary-size(item_length), _::binary-size(padding_length), rest::binary>> = bin
{t, v} = decode_attribute(type, value, tid)
new_length = len - (2 + 2 + item_length + padding_length)
decode_attrs(rest, new_length, tid, Map.put(attrs, t, v))
end
# Converts a given binary encoded error into an Elixir tuple
defp decode_attr_err(<<_mbz::size(20), class::size(4), number::size(8), reason::binary>>),
do: {class * 100 + number, reason}
# Encodes an attribute tuple into its specific encoded binary
defp encode_bin({_, nil}), do: <<>> # an empty attribute
defp encode_bin({t, v}) do
l = byte_size(v)
padding_length =
case rem(l, 4) do
0 -> 0
other -> (4 - other) * 8
end
<<t::16, l::16, v::binary-size(l), 0::size(padding_length)>>
end
# Encodes a error tuple into its binary representation
defp encode_attr_err({error_code, reason}) do
class = div(error_code, 100)
number = rem(error_code, 100)
<<0::size(20), class::size(4), number::size(8), reason::binary>>
end
上面有一些有趣的注意事项。 error
解码器和encoder
将错误代码(例如404或500)转换为12位二进制代码。 它通过将十进制存储到一个字节中来完成此操作(因为一个字节最多可以存储255个数字,而十进制最多只能存储99个数字),并将数百位存储到一个4位值中。 这样做的主要原因是HTTP代码按百位分组,其中2xx代码是success
代码,3xx是重定向,4xx是客户端错误,5xx是服务器错误。 通过简单地将数百位存储在单独的块中,可以更快地匹配和排序错误消息。
要确定的另一点是处理填充二进制文件的较大编码和解码功能。 数据包成帧通常试图确保所包含的数据是字节对齐的 (8的位倍数),这允许更有效地处理和操作数据包。
讯息完整性
编写外部编码器和解码器功能之前的最后阶段是消息完整性。 这涉及在整个数据包上执行hmac sha1
函数并将其附加到末尾。 但是,由于将完整性应用于数据包的末尾会更改数据包的长度,因此在计算完整性之前,必须更新标头中的length
参数。
# full check of integrity
defp check_integrity(pkt_binary, nil), do: {false, pkt_binary}
defp check_integrity(pkt_binary, key) when byte_size(pkt_binary) > 20 + 24 do
with s <- byte_size(pkt_binary) - 24,
<<message::binary-size(s), 0x00::size(8), 0x08::size(8), 0x00::size(8), 0x14::size(8),
integrity::binary-size(20)>> <- pkt_binary,
^integrity <- hmac_sha1(message, key) do
<<h::size(16), old_size::size(16), payload::binary>> = message
new_size = old_size - 24
{true, <<h::size(16), new_size::size(16), payload::binary>>}
else
_ ->
{false, pkt_binary}
end
end
# Inserts a valid integrity marker and value to the end of a binary
defp insert_integrity(pkt_binary, nil),
do: pkt_binary
defp insert_integrity(pkt_binary, key) do
<<0::2, type::14, len::16, magic::32, trid::96, attrs::binary>> = pkt_binary
nlen = len + 4 + 20
value = <<0::2, type::14, nlen::16, magic::32, trid::96, attrs::binary>>
integrity = hmac_sha1(value, key)
<<0::2, type::14, nlen::16, magic::32, trid::96, attrs::binary, 0x00::size(8), 0x08::size(8),
0x00::size(8), 0x14::size(8), integrity::binary-size(20)>>
end
defp hmac_sha1(msg, hash) when is_binary(msg) and is_binary(hash) do
key = :crypto.hash(:md5, to_charlist(hash))
:crypto.hmac(:sha, key, msg)
end
目前,新的数据包格式通常不使用hmac sha1
,因为现在可以通过一些努力解密sha1
编码。 因此,您可能需要研究将此内容更新为最新版本。 但是, hmac sha1
仍然在整个Internet上非常用于安全性,因此这不是一个可怕的选择。
画龙点睛
等式的最后一部分是编码器和解码器功能。 这些将是您在模块外部调用的函数,用于将二进制字符串转换为解码的数据包并再次返回。
def decode(pkt_binary, key \\ nil) do
{integrity, pkt_binary} = check_integrity(pkt_binary, key)
<<@pkt_magic_cookie, @infix::2, m0::5, c0::1, m1::3, c1::1, m2::4, length::16,
transactionid::96, rest::binary>> = pkt_binary
method = get_method(<<m0::5, m1::3, m2::4>>)
class = get_class(<<c0::1, c1::1>>)
attrs = decode_attrs(rest, length, transactionid)
{:ok,
%__MODULE__{
class: class,
method: method,
integrity: integrity,
key: key,
transactionid: transactionid,
attrs: attrs
}}
end
def encode(%__MODULE__{} = config, nkey \\ nil) do
m = get_method_id(config.method)
<<m0::5, m1::3, m2::4>> = <<m::12>>
<<c0::1, c1::1>> = get_class_id(config.class)
bin_attrs =
for {t, v} <- config.attrs,
into: "",
do: encode_bin(encode_attribute(t, v, config.transactionid))
length = byte_size(bin_attrs)
pkt_binary_0 =
<<@pkt_magic_cookie, @infix::2, m0::5, c0::1, m1::3, c1::1, m2::4, length::16,
config.transactionid::96, bin_attrs::binary>>
case config.integrity do
false -> pkt_binary_0
true -> insert_integrity(pkt_binary_0, nkey)
end
end
函数签名中的key
是可选的,并且取决于您是否要使用消息完整性功能。 这些功能仅构造或解构数据包的二进制表示形式,使属性通过其唯一功能传递,以建立或推导数据包中的数据。
全部踢开
在下一篇文章中,您将看到如何将此包格式与简单的数据存储应用程序一起使用。 但是,目前,您可以通过启动交互式Elixir控制台并输入以下代码来试用该模块:
pkt = %CodeshipDB.Pkt{
class: :request,
method: :set,
transactionid: 1,
attrs: %{
json: "{\"key\":\"my_key\",\"data\":\"abcde\"}"
}
}
payload = CodeshipDB.Pkt.encode(pkt)
pkt == CodeshipDB.Pkt.decode(payload)
您可以引导Elixir控制台(如果已安装),方法是在命令行中输入iex
,然后按Enter
键。 如果您严格按照本文进行操作,则上面的代码应会导致简单的true
响应,这意味着将对数据包数据进行编码,然后将其解码为原始状态。
如果您等不及下一篇文章, 可以在这里找到一个简单但未完成的CodeShip数据存储示例。
翻译自: https://www.javacodegeeks.com/2018/10/custom-framing-microservices-messaging.html