feat(gb32960): introduce parser profile registry and rule-based vendor selector

Adds a per-context routing layer for InfoBlock parsers, so different
peers can be routed to different parser sets (e.g. standard GB/T 32960
vs. a vendor extension that adds blocks in the 0x30~0x7F reserved range).

New types in protocol-gb32960:
- Gb32960ParserContext (record): version + vin + platformAccount tuple
- codec/profile/VendorExtensionCatalog: name -> list of vendor parsers
- codec/profile/Gb32960ProfileRegistry: name -> InfoBlockParserRegistry,
  with a default registry built from the standard parsers and one extra
  registry per enabled vendor extension (default + vendor parsers)
- codec/profile/VendorExtensionSelector: interface returning the matching
  extension name (or null) for a given context
- codec/profile/RuleBasedVendorExtensionSelector: top-down first-match
  scan over Gb32960Properties.vendorExtensions with a (account,vin)
  cache keyed on case-insensitive normalized form

Gb32960Properties gains a `vendorExtensions` list with per-entry
`name` + `match` (platformAccounts / vins / vinPrefixes). All matching
fields are OR'd within an entry; entries are scanned top-down with
first-match-wins as documented.

Gb32960BodyParser keeps its legacy single-registry constructor for
back-compat (used by existing tests) and adds a new constructor that
takes the profile registry + selector. The parse loop now lives under
parse(Gb32960ParserContext, ByteBuffer); the legacy parse(version,body)
delegates to it with a minimal context (selector returns null -> default).

This commit only introduces the framework. Vendor parsers, autoconfig
wiring, and decoder/handler context plumbing land in follow-ups.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
This commit is contained in:
lingniu-dev
2026-04-15 16:37:15 +08:00
parent 3b52e6a617
commit 0e6f120ca9
8 changed files with 459 additions and 2 deletions

View File

@@ -1,6 +1,8 @@
package com.lingniu.ingest.protocol.gb32960.codec; package com.lingniu.ingest.protocol.gb32960.codec;
import com.lingniu.ingest.api.spi.DecodeException; import com.lingniu.ingest.api.spi.DecodeException;
import com.lingniu.ingest.protocol.gb32960.codec.profile.Gb32960ProfileRegistry;
import com.lingniu.ingest.protocol.gb32960.codec.profile.VendorExtensionSelector;
import com.lingniu.ingest.protocol.gb32960.model.InfoBlock; import com.lingniu.ingest.protocol.gb32960.model.InfoBlock;
import com.lingniu.ingest.protocol.gb32960.model.InfoBlockType; import com.lingniu.ingest.protocol.gb32960.model.InfoBlockType;
import com.lingniu.ingest.protocol.gb32960.model.ProtocolVersion; import com.lingniu.ingest.protocol.gb32960.model.ProtocolVersion;
@@ -48,13 +50,42 @@ public final class Gb32960BodyParser {
/** 2025 版实时上报尾部签名标识(表 9。 */ /** 2025 版实时上报尾部签名标识(表 9。 */
private static final int SIGNATURE_MARKER = 0xFF; private static final int SIGNATURE_MARKER = 0xFF;
private final InfoBlockParserRegistry registry; private final Gb32960ProfileRegistry profileRegistry;
private final VendorExtensionSelector selector;
/**
* 单 registry 构造(向后兼容旧测试)。等价于 default profile + 无 vendor 扩展。
*/
public Gb32960BodyParser(InfoBlockParserRegistry registry) { public Gb32960BodyParser(InfoBlockParserRegistry registry) {
this.registry = registry; this.profileRegistry = new Gb32960ProfileRegistry(
new java.util.ArrayList<>(), // ignored, defaultRegistry overridden via wrapper below
new com.lingniu.ingest.protocol.gb32960.codec.profile.VendorExtensionCatalog(java.util.Map.of()),
java.util.Set.of()) {
@Override public InfoBlockParserRegistry forProfile(String name) { return registry; }
@Override public InfoBlockParserRegistry defaultRegistry() { return registry; }
};
this.selector = VendorExtensionSelector.NONE;
} }
/**
* 标准构造profile 注册表 + vendor 选择器。运行期由
* {@link Gb32960MessageDecoder} 调用 {@link #parse(Gb32960ParserContext, ByteBuffer)}
* 携带上下文,按 selector 路由到具体 profile 的 registry。
*/
public Gb32960BodyParser(Gb32960ProfileRegistry profileRegistry, VendorExtensionSelector selector) {
this.profileRegistry = profileRegistry;
this.selector = selector;
}
/** 旧入口:无上下文 → 走默认 profile。保留用于不需要 vendor 路由的单测。 */
public Gb32960MessageDecoder.BodyParseResult parse(ProtocolVersion version, ByteBuffer body) { public Gb32960MessageDecoder.BodyParseResult parse(ProtocolVersion version, ByteBuffer body) {
return parse(Gb32960ParserContext.minimal(version), body);
}
/** 主入口:根据上下文按 selector 选 vendor profile然后照旧解析循环。 */
public Gb32960MessageDecoder.BodyParseResult parse(Gb32960ParserContext ctx, ByteBuffer body) {
ProtocolVersion version = ctx.version();
InfoBlockParserRegistry registry = profileRegistry.forProfile(selector.select(ctx));
List<InfoBlock> blocks = new ArrayList<>(8); List<InfoBlock> blocks = new ArrayList<>(8);
byte[] signature = null; byte[] signature = null;
// 捕获 info-block 区间的起止位置,用于未知块出现时打 dump 帮助手动定位漂移点。 // 捕获 info-block 区间的起止位置,用于未知块出现时打 dump 帮助手动定位漂移点。

View File

@@ -0,0 +1,21 @@
package com.lingniu.ingest.protocol.gb32960.codec;
import com.lingniu.ingest.protocol.gb32960.model.ProtocolVersion;
/**
* Body parser 调用上下文:携带选 profile 时需要的三元组。
*
* <p>由 {@link Gb32960MessageDecoder} 在解析帧时构造vin / version 来自帧字节,
* platformAccount 由 handler 通过 channel attribute 传入)。
*
* @param version 协议版本(来自帧起始符)
* @param vin 17 字节 VINtrim 后),平台登入 0x05 等帧 VIN 全 0 时为空串
* @param platformAccount 平台登入用户名;非平台对接场景为 null
*/
public record Gb32960ParserContext(ProtocolVersion version, String vin, String platformAccount) {
/** 占位上下文版本不可缺vin/account 允许为 null便于内部构造和单测。 */
public static Gb32960ParserContext minimal(ProtocolVersion version) {
return new Gb32960ParserContext(version, null, null);
}
}

View File

@@ -0,0 +1,64 @@
package com.lingniu.ingest.protocol.gb32960.codec.profile;
import com.lingniu.ingest.protocol.gb32960.codec.InfoBlockParser;
import com.lingniu.ingest.protocol.gb32960.codec.InfoBlockParserRegistry;
import java.util.ArrayList;
import java.util.HashMap;
import java.util.List;
import java.util.Map;
import java.util.Set;
/**
* 命名 profile → {@link InfoBlockParserRegistry} 映射。
*
* <p>构造时接收:
* <ul>
* <li>{@code defaultParsers} —— 标准 GB/T 32960 parser 集合v2016 + v2025
* <li>{@code catalog} —— vendor 扩展套件目录
* <li>{@code enabledExtensionNames} —— 在配置文件里被引用过的 vendor 套件名集合
* </ul>
*
* 构造内部为 default profile 和每个 enabled 扩展套件分别构建一个 registry
* 套件 registry = default parsers + catalog 里该套件的 parsers。运行期通过
* {@link #forProfile(String)} 按名字查找;查不到或传 null 时返回 default。
*/
public class Gb32960ProfileRegistry {
/** 约定的默认 profile 名(不会出现在配置里,仅作为 forProfile(null) 的 fallback 标识)。 */
public static final String DEFAULT_PROFILE = "__default__";
private final InfoBlockParserRegistry defaultRegistry;
private final Map<String, InfoBlockParserRegistry> registriesByProfile;
public Gb32960ProfileRegistry(List<InfoBlockParser> defaultParsers,
VendorExtensionCatalog catalog,
Set<String> enabledExtensionNames) {
this.defaultRegistry = new InfoBlockParserRegistry(defaultParsers);
Map<String, InfoBlockParserRegistry> map = new HashMap<>();
for (String name : enabledExtensionNames) {
List<InfoBlockParser> vendorParsers = catalog.parsersFor(name);
if (vendorParsers.isEmpty()) {
throw new IllegalStateException(
"vendor extension '" + name + "' referenced in config but not present in catalog. "
+ "Available: " + catalog.names());
}
List<InfoBlockParser> combined = new ArrayList<>(defaultParsers.size() + vendorParsers.size());
combined.addAll(defaultParsers);
combined.addAll(vendorParsers);
map.put(name, new InfoBlockParserRegistry(combined));
}
this.registriesByProfile = Map.copyOf(map);
}
/** 按 profile 名取 registrynull 或未注册的名字返回默认 registry。 */
public InfoBlockParserRegistry forProfile(String profileName) {
if (profileName == null) return defaultRegistry;
InfoBlockParserRegistry r = registriesByProfile.get(profileName);
return r != null ? r : defaultRegistry;
}
public InfoBlockParserRegistry defaultRegistry() {
return defaultRegistry;
}
}

View File

@@ -0,0 +1,117 @@
package com.lingniu.ingest.protocol.gb32960.codec.profile;
import com.lingniu.ingest.protocol.gb32960.codec.Gb32960ParserContext;
import com.lingniu.ingest.protocol.gb32960.config.Gb32960Properties;
import java.util.ArrayList;
import java.util.List;
import java.util.Locale;
import java.util.Map;
import java.util.Optional;
import java.util.Set;
import java.util.concurrent.ConcurrentHashMap;
/**
* 配置驱动的 vendor 扩展选择器。按 {@link Gb32960Properties#getVendorExtensions()} 配置的
* 顺序自上而下首匹first-match-wins命中的 entry 的 {@code name} 即返回值。
*
* <p>命中字段:{@code platformAccount} / {@code vin} / {@code vinPrefix}(任一命中即整条
* entry 命中)。比较为 case-insensitive。
*
* <p>结果按 {@code (account, vin)} 缓存以避免每帧重复扫描;约定 cache key 不含 version
* 因为版本不参与匹配。Cache 用 {@link Optional} 包裹以同时缓存 "no match"。
*/
public final class RuleBasedVendorExtensionSelector implements VendorExtensionSelector {
private final List<CompiledRule> rules;
private final ConcurrentHashMap<CacheKey, Optional<String>> cache = new ConcurrentHashMap<>();
public RuleBasedVendorExtensionSelector(List<Gb32960Properties.VendorExtension> entries,
Set<String> validNames) {
List<CompiledRule> compiled = new ArrayList<>(entries.size());
for (Gb32960Properties.VendorExtension e : entries) {
if (e.getName() == null || e.getName().isBlank()) {
throw new IllegalStateException("vendor extension entry missing 'name'");
}
if (!validNames.contains(e.getName())) {
throw new IllegalStateException(
"vendor extension '" + e.getName() + "' not registered in catalog. "
+ "Available: " + validNames);
}
compiled.add(CompiledRule.from(e));
}
this.rules = List.copyOf(compiled);
}
@Override
public String select(Gb32960ParserContext ctx) {
if (rules.isEmpty()) return null;
CacheKey key = new CacheKey(normalize(ctx.platformAccount()), normalize(ctx.vin()));
Optional<String> cached = cache.get(key);
if (cached != null) return cached.orElse(null);
String hit = scan(key);
cache.put(key, Optional.ofNullable(hit));
return hit;
}
private String scan(CacheKey key) {
for (CompiledRule r : rules) {
if (r.matches(key.account, key.vin)) return r.name;
}
return null;
}
private static String normalize(String s) {
return s == null || s.isEmpty() ? null : s.toUpperCase(Locale.ROOT);
}
/** 暴露给测试和 actuator 端点(如果以后加)。 */
public int cacheSize() { return cache.size(); }
private record CacheKey(String account, String vin) {}
/** 预编译后的单条规则:所有列表已 normalize 成大写 set。 */
private static final class CompiledRule {
final String name;
final Set<String> accounts;
final Set<String> vins;
final List<String> vinPrefixes;
CompiledRule(String name, Set<String> accounts, Set<String> vins, List<String> vinPrefixes) {
this.name = name;
this.accounts = accounts;
this.vins = vins;
this.vinPrefixes = vinPrefixes;
}
static CompiledRule from(Gb32960Properties.VendorExtension e) {
Gb32960Properties.VendorExtension.Match m = e.getMatch();
return new CompiledRule(
e.getName(),
Set.copyOf(toUpper(m.getPlatformAccounts())),
Set.copyOf(toUpper(m.getVins())),
List.copyOf(toUpper(m.getVinPrefixes())));
}
boolean matches(String account, String vin) {
if (account != null && accounts.contains(account)) return true;
if (vin != null && vins.contains(vin)) return true;
if (vin != null) {
for (String prefix : vinPrefixes) {
if (vin.startsWith(prefix)) return true;
}
}
return false;
}
private static List<String> toUpper(List<String> list) {
if (list == null || list.isEmpty()) return List.of();
List<String> out = new ArrayList<>(list.size());
for (String s : list) {
if (s == null || s.isBlank()) continue;
out.add(s.trim().toUpperCase(Locale.ROOT));
}
return out;
}
}
}

View File

@@ -0,0 +1,40 @@
package com.lingniu.ingest.protocol.gb32960.codec.profile;
import com.lingniu.ingest.protocol.gb32960.codec.InfoBlockParser;
import java.util.Collections;
import java.util.List;
import java.util.Map;
/**
* Vendor 扩展套件目录:从套件名映射到该套件包含的 {@link InfoBlockParser} 列表。
*
* <p>Catalog 在启动时由 {@code Gb32960AutoConfiguration} 一次性构建并注入到
* {@link Gb32960ProfileRegistry},运行期不可变。每个 entry 代表一个**已实现的**厂商扩展
* 协议(如广东燃料电池汽车示范规范),其 parser 集合会在选中时**追加**到默认标准 parser
* 集合之上,组成完整的 profile registry。
*
* <p>当前内置 key
* <ul>
* <li>{@code guangdong-fc} —— 广东燃料电池汽车示范规范 0x30~0x34、0x80
* </ul>
*/
public final class VendorExtensionCatalog {
private final Map<String, List<InfoBlockParser>> extensions;
public VendorExtensionCatalog(Map<String, List<InfoBlockParser>> extensions) {
this.extensions = Map.copyOf(extensions);
}
/** 已注册的全部 vendor 扩展名集合。 */
public java.util.Set<String> names() {
return extensions.keySet();
}
/** 取指定 vendor 扩展套件的 parser 列表;不存在返回空列表。 */
public List<InfoBlockParser> parsersFor(String name) {
List<InfoBlockParser> list = extensions.get(name);
return list == null ? Collections.emptyList() : list;
}
}

View File

@@ -0,0 +1,20 @@
package com.lingniu.ingest.protocol.gb32960.codec.profile;
import com.lingniu.ingest.protocol.gb32960.codec.Gb32960ParserContext;
/**
* 给定上下文返回应用的 vendor 扩展套件名({@code null} 表示走默认 profile
*
* <p>实现需要线程安全且 O(1) 期望复杂度(建议内部缓存)。
*/
public interface VendorExtensionSelector {
/**
* @return 命中的扩展套件名(与 {@code Gb32960ProfileRegistry} 注册名一致),
* 未命中返回 {@code null}。
*/
String select(Gb32960ParserContext ctx);
/** 永远返回 null 的实现,用于不启用任何 vendor 扩展的默认场景。 */
VendorExtensionSelector NONE = ctx -> null;
}

View File

@@ -26,6 +26,22 @@ public class Gb32960Properties {
/** TLS 双向认证。 */ /** TLS 双向认证。 */
private Tls tls = new Tls(); private Tls tls = new Tls();
/**
* 厂商扩展协议vendor extensions路由配置。
*
* <p>每个 entry 声明一个**已被实现**的 vendor 扩展套件名({@code name})以及该套件
* 的命中规则({@link VendorExtension.Match})。命中规则由
* {@code RuleBasedVendorExtensionSelector} 在收到帧时按上下文platformAccount /
* vin / vin-prefix逐条按 list 顺序首匹,命中后用对应套件的 parser 集合替换默认的
* 标准解析器集合(仍包含 0x01..0x09 标准块)。
*
* <p>套件名必须与 {@code VendorExtensionCatalog} 里注册的 key 一致;
* 当前内置:{@code guangdong-fc}(广东燃料电池汽车示范规范 0x30~0x34 + 0x80
*
* <p>未匹配任何规则时回退到默认 profile仅保留 GB/T 32960 标准块。
*/
private List<VendorExtension> vendorExtensions = new ArrayList<>();
public boolean isEnabled() { return enabled; } public boolean isEnabled() { return enabled; }
public void setEnabled(boolean enabled) { this.enabled = enabled; } public void setEnabled(boolean enabled) { this.enabled = enabled; }
public int getPort() { return port; } public int getPort() { return port; }
@@ -38,6 +54,10 @@ public class Gb32960Properties {
public void setAuth(Auth auth) { this.auth = auth; } public void setAuth(Auth auth) { this.auth = auth; }
public Tls getTls() { return tls; } public Tls getTls() { return tls; }
public void setTls(Tls tls) { this.tls = tls; } public void setTls(Tls tls) { this.tls = tls; }
public List<VendorExtension> getVendorExtensions() { return vendorExtensions; }
public void setVendorExtensions(List<VendorExtension> vendorExtensions) {
this.vendorExtensions = vendorExtensions;
}
/** /**
* VIN 白名单认证。 * VIN 白名单认证。
@@ -143,4 +163,42 @@ public class Gb32960Properties {
public boolean isRequireClientAuth() { return requireClientAuth; } public boolean isRequireClientAuth() { return requireClientAuth; }
public void setRequireClientAuth(boolean requireClientAuth) { this.requireClientAuth = requireClientAuth; } public void setRequireClientAuth(boolean requireClientAuth) { this.requireClientAuth = requireClientAuth; }
} }
/**
* 单条 vendor 扩展路由配置。{@code name} 必须与
* {@code VendorExtensionCatalog} 注册的 key 一致。
*/
public static class VendorExtension {
/** 扩展套件名,必须在 catalog 中已注册(如 {@code guangdong-fc})。 */
private String name;
/** 命中规则。任一字段命中即视为整条 entry 命中。 */
private Match match = new Match();
public String getName() { return name; }
public void setName(String name) { this.name = name; }
public Match getMatch() { return match; }
public void setMatch(Match match) { this.match = match; }
/**
* 命中规则。所有字段为 OR 关系:任意一个命中即匹配。
* 字段内部为 case-insensitive 比较;字段为空 list 视为不参与匹配(不会主动命中)。
*/
public static class Match {
/** 平台登入用户名精确匹配列表(来自 0x05 PlatformLogin 的 username 字段)。 */
private List<String> platformAccounts = new ArrayList<>();
/** VIN 精确匹配列表。 */
private List<String> vins = new ArrayList<>();
/** VIN 前缀匹配列表startsWith。 */
private List<String> vinPrefixes = new ArrayList<>();
public List<String> getPlatformAccounts() { return platformAccounts; }
public void setPlatformAccounts(List<String> platformAccounts) {
this.platformAccounts = platformAccounts;
}
public List<String> getVins() { return vins; }
public void setVins(List<String> vins) { this.vins = vins; }
public List<String> getVinPrefixes() { return vinPrefixes; }
public void setVinPrefixes(List<String> vinPrefixes) { this.vinPrefixes = vinPrefixes; }
}
}
} }

View File

@@ -0,0 +1,106 @@
package com.lingniu.ingest.protocol.gb32960.codec.profile;
import com.lingniu.ingest.protocol.gb32960.codec.Gb32960ParserContext;
import com.lingniu.ingest.protocol.gb32960.config.Gb32960Properties;
import com.lingniu.ingest.protocol.gb32960.model.ProtocolVersion;
import org.junit.jupiter.api.Test;
import java.util.List;
import java.util.Set;
import static org.assertj.core.api.Assertions.assertThat;
import static org.assertj.core.api.Assertions.assertThatThrownBy;
class RuleBasedVendorExtensionSelectorTest {
@Test
void platformAccountMatchHits() {
var ext = entry("guangdong-fc", List.of("lingniu"), List.of(), List.of());
var selector = new RuleBasedVendorExtensionSelector(List.of(ext), Set.of("guangdong-fc"));
var ctx = new Gb32960ParserContext(ProtocolVersion.V2016, "LNV1234567890ABCD", "lingniu");
assertThat(selector.select(ctx)).isEqualTo("guangdong-fc");
}
@Test
void vinExactMatchHits() {
var ext = entry("guangdong-fc", List.of(), List.of("LNV1234567890ABCD"), List.of());
var selector = new RuleBasedVendorExtensionSelector(List.of(ext), Set.of("guangdong-fc"));
var ctx = new Gb32960ParserContext(ProtocolVersion.V2016, "lnv1234567890abcd", null);
assertThat(selector.select(ctx))
.as("VIN match should be case-insensitive")
.isEqualTo("guangdong-fc");
}
@Test
void vinPrefixMatchHits() {
var ext = entry("guangdong-fc", List.of(), List.of(), List.of("LNVFC", "LZG"));
var selector = new RuleBasedVendorExtensionSelector(List.of(ext), Set.of("guangdong-fc"));
var ctx = new Gb32960ParserContext(ProtocolVersion.V2016, "LNVFC0001", null);
assertThat(selector.select(ctx)).isEqualTo("guangdong-fc");
}
@Test
void noMatchReturnsNull() {
var ext = entry("guangdong-fc", List.of("other"), List.of(), List.of());
var selector = new RuleBasedVendorExtensionSelector(List.of(ext), Set.of("guangdong-fc"));
var ctx = new Gb32960ParserContext(ProtocolVersion.V2016, "ZZZ", "lingniu");
assertThat(selector.select(ctx)).isNull();
}
@Test
void firstMatchWinsTopDown() {
var first = entry("guangdong-fc", List.of("lingniu"), List.of(), List.of());
var second = entry("guangdong-fc", List.of(), List.of(), List.of("LNV"));
var selector = new RuleBasedVendorExtensionSelector(
List.of(first, second), Set.of("guangdong-fc"));
// 即便两条都能命中,应返回第一条匹配 entry 的 name这里凑巧一样
var ctx = new Gb32960ParserContext(ProtocolVersion.V2016, "LNV1", "lingniu");
assertThat(selector.select(ctx)).isEqualTo("guangdong-fc");
}
@Test
void emptyConfigReturnsNullWithoutScanning() {
var selector = new RuleBasedVendorExtensionSelector(List.of(), Set.of("guangdong-fc"));
var ctx = new Gb32960ParserContext(ProtocolVersion.V2016, "ANY", "ANY");
assertThat(selector.select(ctx)).isNull();
}
@Test
void unknownExtensionNameRejectedAtConstruction() {
var ext = entry("not-in-catalog", List.of("x"), List.of(), List.of());
assertThatThrownBy(() ->
new RuleBasedVendorExtensionSelector(List.of(ext), Set.of("guangdong-fc")))
.isInstanceOf(IllegalStateException.class)
.hasMessageContaining("not-in-catalog");
}
@Test
void resultIsCachedPerAccountVinTuple() {
var ext = entry("guangdong-fc", List.of("lingniu"), List.of(), List.of());
var selector = new RuleBasedVendorExtensionSelector(List.of(ext), Set.of("guangdong-fc"));
var ctx1 = new Gb32960ParserContext(ProtocolVersion.V2016, "VIN1", "lingniu");
var ctx2 = new Gb32960ParserContext(ProtocolVersion.V2016, "VIN1", "lingniu");
selector.select(ctx1);
selector.select(ctx2);
// 同一个 (account, vin) 应只产生一个 cache 条目
assertThat(selector.cacheSize()).isEqualTo(1);
var ctx3 = new Gb32960ParserContext(ProtocolVersion.V2016, "VIN2", "lingniu");
selector.select(ctx3);
assertThat(selector.cacheSize()).isEqualTo(2);
}
private static Gb32960Properties.VendorExtension entry(String name,
List<String> accounts,
List<String> vins,
List<String> vinPrefixes) {
var e = new Gb32960Properties.VendorExtension();
e.setName(name);
var m = new Gb32960Properties.VendorExtension.Match();
m.setPlatformAccounts(accounts);
m.setVins(vins);
m.setVinPrefixes(vinPrefixes);
e.setMatch(m);
return e;
}
}