TLV 编码解析

252 阅读10分钟

TLV 编码解析

问题背景

TLV(Tag-Length-Value) 是一种常见且高效的数据编码方案。一个 TLV 数据单元由三个部分组成:

  1. T (Tag - 标签) : 标识数据的类型。
  2. L (Length - 长度) : 定义数据内容的长度。
  3. V (Value - 内容) : 实际的数据。

本问题要求解析一个以十六进制字符串形式表示的 TLV 码流。

码流格式与解析规则

给定的码流是一个连续的十六进制字符串,其中每两位字符代表一个字节。解析遵循以下规则:

  1. 基本结构:

    • T 域: 码流的前 2 个字节(4 个十六进制字符)。
    • L 域: 紧随 T 域的 4 个字节(8 个十六进制字符)。
    • V 域: 紧随 L 域,其字节数由 L 域的值(设为 len)决定。
  2. 字节序 (Endianness) :

    • 所有多字节字段(如 T 域和 L 域)均采用大端序(Big-Endian) 。这意味着高位字节存储在内存的低地址。例如,4 字节的长度 0000000a 代表十进制的 10。
  3. 节点类型 (由 T 域决定) :

    • 通过检查 T 域的**最高有效位(Most Significant Bit, MSB)**来判断 V 域的内容类型:

      • MSB = 0: 该 TLV 是一个非嵌套(或称原始)节点

        • V 域的内容是字符串
        • L 域的值 len 表示该字符串的字节长度。
        • 这是我们需要提取并输出的目标内容。
      • MSB = 1: 该 TLV 是一个嵌套(或称构造)节点

        • V 域本身包含 一个或多个 完整的、连续的子 TLV 结构。
        • L 域的值 len 表示其 V 域内所有子 TLV 的总字节长度

任务要求

给定一个十六进制 TLV 码流 hexStream,请递归解析该码流,并按解析顺序输出所有非嵌套 TLV 节点的内容。


输入格式

  • hexStream: 一个字符串,代表完整的 TLV 码流。

    • 由字符集 [0-9a-f] 组成。
    • 长度为偶数,且 12 <= hexStream.length <= 20000
    • 输入保证是一个合法的、完整的 TLV 结构。
    • 对于非嵌套节点,其 V 域长度 len 的范围是 [0, 256],且内容均为可见的 ASCII 字符。

输出格式

  • 一个字符串列表,其中每个字符串代表一个解析出的非嵌套 TLV 节点,格式为:"level:content"

    • level: 该 TLV 所在的嵌套级别(十进制整数)。根节点的级别为 0,其直接子节点的级别为 1,以此类推。取值范围 [0, 20]
    • content: 最终解析出的 V 域字符串。如果 V 域长度为 0,则 content 为空字符串(例如 "1:")。

提示信息

  • 可见字符: ASCII 码值从 32 到 126 的字符,包括空格、数字、英文字母和标点符号。

  • 大端序 vs. 小端序: 计算机存储多字节数据的方式。例如,整数 65538 的十六进制是 0x00010002

    • 大端序存储为: 00 01 00 02

    • 小端序存储为: 02 00 01 00

      本题使用大端序。


样例说明

样例 1

  • 输入: "00000000000a68656c6c6f20776f7264"

  • 输出: ["0:hello word"]

  • 解释:

    1. 解析级别 0:

      • T 域: 0000。最高位是 0,表示这是一个非嵌套节点。
      • L 域: 0000000a。大端序,转换为十进制为 10。这表示 V 域有 10 个字节。
      • V 域: 读取接下来的 10 个字节(20 个十六进制字符)68656c6c6f20776f7264
      • 解码 V 域: 将十六进制 68 转为字符 h65 转为 e ... 20 转为空格 ... 最终得到字符串 "hello word"
    2. 输出结果: 级别为 0,内容为 "hello word",格式化为 "0:hello word"

样例 2

  • 输入: "c0000000003f00000000000a68656c6c6f20776f7264c0000000001000000000000a68656c6c6f20776f726400000000000a68656c6c6f20776f7264000000000003545454"

  • 输出: ["1:hello word", "2:hello word", "1:hello word", "1:TTT"]

  • 解释:

    1. 解析级别 0 (根节点) :

      • T 域: c000 (二进制 1100...)。最高位是 1,表示这是一个嵌套节点。
      • L 域: 0000003f,十进制为 63。V 域包含 63 字节的子节点数据。
      • V 域: 开始递归解析这 63 字节的内容,当前嵌套级别进入 1
    2. 解析级别 1 (第一个子节点) :

      • T 域: 0000。最高位 0 -> 非嵌套节点
      • L 域: 0000000a (10)。
      • V 域: 解码为 "hello word"
      • 记录输出: 1:hello word
      • 此节点总长为 T(2) + L(4) + V(10) = 16 字节。从 V 域的 63 字节中消耗 16 字节。
    3. 解析级别 1 (第二个子节点) :

      • T 域: c000。最高位 1 -> 嵌套节点

      • L 域: 00000010 (16)。其 V 域包含 16 字节的子节点。

      • 进入 级别 2 进行解析。

        • 解析级别 2: T=0000(非嵌套), L=0000000a(10), V="hello word"
        • 记录输出: 2:hello word
      • 此级别1节点总长为 T(2) + L(4) + V(16) = 22 字节。从 V 域的 63 字节中再消耗 22 字节。

    4. 解析级别 1 (第三个子节点) :

      • T 域: 0000。最高位 0 -> 非嵌套节点
      • L 域: 0000000a (10)。
      • V 域: 解码为 "hello word"
      • 记录输出: 1:hello word
      • 此节点总长为 16 字节。
    5. 解析级别 1 (第四个子节点) :

      • T 域: 0000。最高位 0 -> 非嵌套节点
      • L 域: 00000003 (3)。
      • V 域: 545454 解码为 "TTT"
      • 记录输出: 1:TTT
      • 此节点总长为 T(2) + L(4) + V(3) = 9 字节。
    6. 结束: 级别 0 的 V 域 63 字节已全部解析完毕 (16 + 22 + 16 + 9 = 63)。按发现顺序组合最终输出。

import java.nio.charset.StandardCharsets;
import java.util.ArrayList;
import java.util.List;

/**
 * 解决多级嵌套TLV解析问题的实现类.
 * 核心思想是使用递归来处理TLV的嵌套结构。
 */
public class TlvParser {

    /**
     * 主方法,解析一个十六进制表示的TLV码流.
     * @param hexStream 待解析的十六进制字符串
     * @return 按顺序包含所有非嵌套TLV内容的字符串列表
     */
    public List<String> parse(String hexStream) {
        // 将输入的十六进制字符串转换为字节数组
        byte[] data = hexStringToByteArray(hexStream);
        // 用于存储最终结果的列表
        List<String> results = new ArrayList<>();
        // 从0级别开始,对整个字节流进行解析
        parseTlvStream(data, 0, data.length, 0, results);
        return results;
    }

    /**
     * 递归辅助方法,用于解析一个包含一个或多个TLV结构的字节流片段.
     * @param data      完整的字节数组
     * @param offset    当前要开始解析的偏移量
     * @param length    需要解析的总长度 (即当前V域的长度)
     * @param level     当前的嵌套级别
     * @param results   用于收集结果的列表
     */
    private void parseTlvStream(byte[] data, int offset, int length, int level, List<String> results) {
        int currentPos = offset;
        // 循环处理,直到当前V域的所有字节都被解析完毕
        while (currentPos < offset + length) {
            // 解析当前位置的一个TLV单元,并获取下一个单元的起始位置
            currentPos = parseOneTlv(data, currentPos, level, results);
        }
    }

    /**
     * 解析单个TLV单元,并根据其类型决定是递归深入还是提取内容.
     * @return 解析完这个TLV单元后,下一个TLV单元的起始偏移量
     */
    private int parseOneTlv(byte[] data, int offset, int level, List<String> results) {
        // 读取T域 (Tag),2个字节
        int tag = bytesToInt(data, offset, 2);
        // 读取L域 (Length),4个字节
        int len = bytesToInt(data, offset + 2, 4);

        // V域的起始位置
        int valueOffset = offset + 6; // 2字节Tag + 4字节Length

        // 检查Tag的最高位 (对于2字节的short,最高位是第15位)
        // 0x8000 的二进制是 10000000 00000000
        boolean isNested = (tag & 0x8000) != 0;

        if (isNested) {
            // 如果是嵌套类型,则对其V域进行递归解析,嵌套级别+1
            parseTlvStream(data, valueOffset, len, level + 1, results);
        } else {
            // 如果是非嵌套类型(原始数据),则提取其V域内容
            String content = bytesToString(data, valueOffset, len);
            // 按照 "level:content" 的格式添加到结果列表中
            results.add(level + ":" + content);
        }

        // 返回下一个TLV单元的起始位置
        return valueOffset + len;
    }

    // --- 辅助工具方法 ---

    /**
     * 将十六进制字符串转换为字节数组.
     */
    private byte[] hexStringToByteArray(String s) {
        int len = s.length();
        byte[] data = new byte[len / 2];
        for (int i = 0; i < len; i += 2) {
            // 1. 获取高位和低位字符
            //   - s.charAt(i) 是成对字符中的第一个(高位),例如 "1F" 中的 '1'
            //   - s.charAt(i + 1) 是成对字符中的第二个(低位),例如 "1F" 中的 'F'
    
            // 2. 将字符转换为其代表的数值
            //   - Character.digit(char c, int radix) 是一个非常有用的方法。
            //   - Character.digit(s.charAt(i), 16) 会将 '1' 转换为整数 1。
            //   - Character.digit(s.charAt(i + 1), 16) 会将 'F' 转换为整数 15。
    
            // 3. 位运算:移动高位并合并
            //   - (Character.digit(s.charAt(i), 16) << 4)
            //   - << 4 是一个“按位左移”操作,将一个数的二进制表示向左移动 4 位。
            //   - 假设我们处理的是 '1',其值为 1 (二进制 00000001)。
            //   - 左移 4 位后变成 00010000 (十进制的 16)。
            //   - 这个操作的目的是将高位半字节的值移动到字节的高4位位置上。
    
            // 4. 加法合并
            //   - (高位的值) + (低位的值)
            //   - 继续我们的例子:(1 << 4) + 15 = 16 + 15 = 31。
            //   - 在二进制层面看就是:00010000 + 00001111 = 00011111。
            //   - 这样,两个4位的半字节就成功合并成了一个8位的字节。
    
            // 5. 类型转换
            //   - (byte) ...
            //   - 上一步计算的结果是 int 类型,需要强制转换为 byte 类型才能存入字节数组。
    
            // 6. 存入数组
            //   - data[i / 2] = ...
            //   - 当 i=0 时,结果存入 data[0]。
            //   - 当 i=2 时,结果存入 data[1]。
            //   - ... 以此类推,位置正确。
            data[i / 2] = (byte) ((Character.digit(s.charAt(i), 16) << 4)
                                 + Character.digit(s.charAt(i + 1), 16));
        }
        return data;
    }

    /**
     * 从字节数组的指定位置读取指定长度的字节,并将其转换为一个整数(大端序).
     */
    private int bytesToInt(byte[] bytes, int offset, int len) {
        // 因为题目要求最多读取4个字节,所以value为int就行
        int value = 0;
        for (int i = 0; i < len; i++) {
            // 将当前值左移8位,为下一个字节腾出空间
            value <<= 8;
            // 使用位或操作将下一个字节的值合并进来
            // (bytes[...] & 0xFF) 是为了确保将有符号的byte作为无符号数处理
            // 无论 bytes[i] 原本是正数还是负数,这个操作都会将它转换成一个 int,
            // 并且只有最低的8位保留了原字节的值,所有高位都变成了 0。
            // 例如,byte b = -1; (0xFF)。(b & 0xFF)的结果是 int 类型的 255 (0x000000FF),而不是 -1 (0xFFFFFFFF)。
            value |= (bytes[offset + i] & 0xFF);
        }
        return value;
    }

    /**
     * 从字节数组的指定位置读取指定长度的字节,并将其转换为一个字符串 (ASCII编码).
     */
    private String bytesToString(byte[] bytes, int offset, int len) {
        if (len == 0) {
            return "";
        }
        return new String(bytes, offset, len, StandardCharsets.US_ASCII);
    }
}