使用自定义数据包框架进行微服务消息传递

在我的上一篇文章中 ,您了解了为什么使用REST JSON端点强制微服务之间的通信并不总是最好的方法。 在本文中,我将概述一个示例自定义框架解决方案,您可以将其扩展到自己的项目。

由于创建有用的框架的复杂性,我的下一篇文章将介绍如何实现框架。

范例介绍

本文旨在解决的问题是使用自定义数据存储安全地处理通信。 用于实现此框架的语言将是Elixir 。 Elixir提供了一种非常灵活的二进制匹配和操作语法,这将使本文更加简洁。 但是,您可以使用几乎所有语言来实现本文中详述的框架。

为了彻底突出示例,您将创建的框架不仅依赖于HTTP传输协议。 实际上,它甚至不会利用传输控制协议(TCP)。 相反,它将基于用户数据报协议(UDP),并将实现TCP的某些功能。 这将确保数据包的大小绝对最小。

分组协议功能

您将创建的框架将包括几个重要功能,但仍将保持足够的灵活性,以便您可以随意扩展自己的许多功能。 功能包括:

  • 数据包识别签名
  • 数据包的类,方法和属性
  • 长度参数
  • 交易识别码
  • 使用消息完整性系统的安全性

数据包签名

数据包签名在某些协议中称为“魔术Cookie”,是在数据包开头附近找到的一串字节,可用于将其与其他数据包格式区分开。 签名不应太长,但应足够独特。 在此示例中,您将使用简单字符串SHIP 。 这是一个32位值(4个字符的8个字节),这是许多协议的通用大小。

包类,方法和属性

数据包的类,方法和属性是分层的,可标识数据包的用途。

该类是主要的数据包用途。 这会将包区分为request消息, response消息还是error消息。 由于您将不使用TCP,因此您还将需要一个ack消息,该消息是acknowledgement简称。 ack消息将在下一篇文章中详细介绍。

方法

该方法将是您希望在服务器上调用的命令。 由于这是用于数据存储的,因此可用的方法将包括诸如getsetdelete

属性

属性几乎涵盖了您希望从服务器发送或接收的其他所有内容。 这将包括验证所需的usernamepassword ,标识特定服务器实例所需的服务器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错误代码

可以将属性定义为以下两种类型之一: 一个简单valueerror_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

  • 0
    点赞
  • 0
    收藏
    觉得还不错? 一键收藏
  • 0
    评论
评论
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

当前余额3.43前往充值 >
需支付:10.00
成就一亿技术人!
领取后你会自动成为博主和红包主的粉丝 规则
hope_wisdom
发出的红包
实付
使用余额支付
点击重新获取
扫码支付
钱包余额 0

抵扣说明:

1.余额是钱包充值的虚拟货币,按照1:1的比例进行支付金额的抵扣。
2.余额无法直接购买下载,可以购买VIP、付费专栏及课程。

余额充值