消息模板解析填充方案
需求描述
在涉及到消息推送相关的需求时,我们经常需要对数据按照模板中配置的字段解析,并填充模板。
例如我们的模板为:
{startTime}监控到{alertName}报警,报警详情如下:{detail.data},请尽快处理!
我们需要从报警数据中,解析{}
中配置的字段,获取对应的值并填充到模板最终生成我们要发送的消息内容。
思考:这个需求怎么感觉似曾相识经常遇到呢?
恍然大悟,我们平常用
MyBatis
的时候基本上每个SQL
都会用到#{}
这种形式的参数啊。那我们这个需求是不是可以复用MyBatis源码中的某个类呢?
方案设计
既然我们要封装一个工具,那么我们就要让它能适用于多种场景。
Java中最常见的数据无非就是对象和JSON,所以我们要提供这两种类型的处理方案。不论数据源是对象还是JSON都能通过模板中的字段解析出来。而且需要支持嵌套类型的解析。
我们之前起早贪黑的学习源码,现在用它的时候不就来了吗?我们先看看MyBatis中是怎么解析的:GenericTokenParser#parse(String text)
。我们可以发现,我们只需要对不同的业务实现不同的handler即可。(即使你的项目里没有用MyBatis,那你完全可以把这个类copy下来以实现我们解析的需求)。
public class GenericTokenParser {
private final String openToken; // 定义开始标记符
private final String closeToken; // 定义结束标记符
private final TokenHandler handler; // 处理标记的接口
public GenericTokenParser(String openToken, String closeToken, TokenHandler handler) {
this.openToken = openToken;
this.closeToken = closeToken;
this.handler = handler;
}
public String parse(String text) {
if (text == null || text.isEmpty()) {
return "";
}
// search open token 查找开始标记符
int start = text.indexOf(openToken);
if (start == -1) {
return text;
}
char[] src = text.toCharArray(); // 将待解析的文本转换为字符数组
int offset = 0; // 偏移量,标记已解析的文本长度
final StringBuilder builder = new StringBuilder(); // 用于构建结果字符串的可变字符串
StringBuilder expression = null; // 用于构建注释的可变字符串
while (start > -1) { // 循环直到找不到开始标记符
if (start > 0 && src[start - 1] == '\') {
// this open token is escaped. remove the backslash and continue.
builder.append(src, offset, start - offset - 1).append(openToken);
offset = start + openToken.length();
} else {
// found open token. let's search close token.
if (expression == null) {
expression = new StringBuilder();
} else {
expression.setLength(0);
}
builder.append(src, offset, start - offset);
offset = start + openToken.length();
int end = text.indexOf(closeToken, offset); // 查找结束标记符
while (end > -1) { // 循环直到找不到结束标记符
if (end > offset && src[end - 1] == '\') {
// this close token is escaped. remove the backslash and continue.
expression.append(src, offset, end - offset - 1).append(closeToken);
offset = end + closeToken.length();
end = text.indexOf(closeToken, offset); // 继续查找结束标记符
} else {
expression.append(src, offset, end - offset);
break;
}
}
if (end == -1) {
// close token was not found.
builder.append(src, start, src.length - start);
offset = src.length;
} else {
builder.append(handler.handleToken(expression.toString())); // 处理注释部分
offset = end + closeToken.length();
}
}
start = text.indexOf(openToken, offset); // 继续查找开始标记符
}
if (offset < src.length) {
builder.append(src, offset, src.length - offset); // 将剩余文本添加到结果字符串中
}
return builder.toString(); // 返回解析后的结果字符串
}
}
既然我们已经找到了方向,那就来看看类该怎么设计吧。
方案实现
TemplateParser
我们之前看了MyBatis的源码,我们再来看看MyBatis中是如何使用的:
public SqlSource parse(String originalSql, Class<?> parameterType, Map<String, Object> additionalParameters) {
//handler
ParameterMappingTokenHandler handler = new ParameterMappingTokenHandler(configuration, parameterType, additionalParameters);
//构造方法
GenericTokenParser parser = new GenericTokenParser("#{", "}", handler);
String sql = parser.parse(originalSql);
return new StaticSqlSource(configuration, sql, handler.getParameterMappings());
}
所以我们可以照葫芦画瓢,我们定义一个TemplateParser
类:
public class TemplateParser {
private final GenericTokenParser tokenParser;
private final TokenHandler tokenHandler;
public TemplateParser(TokenHandler tokenHandler) {
this.tokenHandler = tokenHandler;
this.tokenParser = new GenericTokenParser("{", "}", this::handleToken);
}
public String parseTemplate(String template) {
return tokenParser.parse(template);
}
private String handleToken(String content) {
return tokenHandler.handleToken(content);
}
}
这个类定义好后,我们的整体思路很明确,通过实现TokenHandler
接口来实现不同的业务功能。
工具类的定义
下面,我们定义工具类,也就是此功能的入口:
public class MessageBuildUtils {
/**
* 告警消息,根据json填充模板
* @param json json字符串
* @param template 消息模板
* @return 构造完成的消息
*/
public static String buildMsgByJson(String json,String template){
// 创建 TokenHandler 处理器
TokenHandler handler = new JsonTokenHandler(json);
// 创建 TemplateParser 解析器
TemplateParser parser = new TemplateParser(handler);
// 解析并替换占位符为实际值
return parser.parseTemplate(template);
}
/**
* 根据对象填充模板
* @param obj 对象
* @param template 消息模板
* @return 构造完成的消息
*/
public static String buildMsgByObj(Object obj,String template){
// 创建 TokenHandler 处理器
TokenHandler handler = new ObjectTokenHandler(obj);
// 创建 TemplateParser 解析器
TemplateParser parser = new TemplateParser(handler);
// 解析并替换占位符为实际值
return parser.parseTemplate(template);
}
}
定义好了入口,我们就去写JSON和obj两种类型的实现类。
TokenHandler
JSON处理器
public class JsonTokenHandler implements TokenHandler {
private final ObjectMapper objectMapper;
private JsonNode jsonNode;
/**
* 构造函数,接收一个 JSON 字符串作为参数,并初始化 ObjectMapper 和 JsonNode
* @param json json
*/
public JsonTokenHandler(String json) {
objectMapper = new ObjectMapper();
try {
// 将 JSON 字符串解析为 JsonNode 对象
jsonNode = objectMapper.readTree(json);
} catch (Exception e) {
e.printStackTrace();
}
}
@Override
public String handleToken(String content) {
//初始值,也就是如果没匹配到模板中要填充什么?
String value = "'-'";
if (jsonNode != null) {
String[] fieldPath = content.split("\.");
JsonNode currentNode = jsonNode;
for (String fieldName : fieldPath) {
if (currentNode.isObject()) {
currentNode = currentNode.get(fieldName);
} else {
// 如果当前节点不是对象,则尝试解析为字符串形式的 JSON
try {
JsonNode jsonValue = objectMapper.readTree(currentNode.asText());
if (jsonValue != null && jsonValue.isObject()) {
currentNode = jsonValue.get(fieldName);
} else {
break;
}
} catch (Exception e) {
break;
}
}
if (currentNode == null) {
break;
}
}
//将json中解析到的数据做特殊处理
if(currentNode != null){
value = currentNode.asText();
}
}
return "null".equals(value) ? "'-'" : value;
}
}
Obj处理器
@Slf4j
public class ObjectTokenHandler implements TokenHandler {
private final Object obj;
public ObjectTokenHandler(Object obj) {
this.obj = obj;
}
@Override
public String handleToken(String content) {
// 根据占位符的内容从对象中获取对应的值
String value = "'-'";
try {
value = getObjectValue(obj, content);
} catch (Exception e) {
// 处理异常情况,例如字段不存在等
e.printStackTrace();
}
return value;
}
/**
* 获取对象中指定字段的值
*/
private String getObjectValue(Object obj, String fieldPath) throws Exception {
//将字段路径按点号拆分为多个字段名
String[] fields = fieldPath.split("\.");
// 从 obj 对象开始,逐级获取字段值
Object fieldValue = obj;
for (String field : fields) {
// 获取当前字段名对应的字段值
fieldValue = getFieldValue(fieldValue, field);
if (fieldValue == null) {
// 如果字段值为空,则退出循环
break;
}
}
// 将字段值转换为字符串并返回
return fieldValue != null ? fieldValue.toString() : "'-'";
}
/**
* 获取对象中指定字段的值
*/
private Object getFieldValue(Object obj, String fieldName) throws Exception {
// 获取字段对象
Field field = obj.getClass().getDeclaredField(fieldName);
// 设置字段可访问
field.setAccessible(true);
// 获取字段值
return field.get(obj);
}
}
到这里,我们就可以愉快的写个测试类,调用一下工具类中的方法了。
拓展与优化
思考:会不会有这种情况呢?
我JSON中的数据一些状态值是0、1这种,但是我们消息中需要时具体的状态说明;
又或者JSON中的数据中对于时间的格式不对,我们需要年月日可以看的很清楚的表述,数据却是时间戳;
又或者模板中的某一个字段,我们需要有兜底策略,这个字段必须有值…
为了实现上述的功能,而不影响我们之前封装的Handler。我做了如下设计:
增加SpFieldHandler
接口,用于定义某个业务中对特定特殊字段的处理逻辑。
public interface SpFieldHandler<T> {
/**
* 解析特殊字段,配合TokenHandler使用
* @param filedName 字段名
* @param node 值 可以是JsonNode 也可也是Object
* @return 处理完的字符串
*/
String parseSpFieldValue(String filedName, T node);
}
举个例子,例如我们处理报警业务中,有一些特殊字段需要处理。数据来源是通过Kafka推送过来的,所以我们发送消息需要对其中的一些字段二次处理。
public class AlarmJsonSpHandler implements SpFieldHandler<JsonNode>{
//特殊字段
List<String> spFieldList = Arrays.asList("startTime", "endTime", "state", "alarmSource");
@Override
public String parseSpFieldValue(String filedName, JsonNode node) {
if(spFieldList.contains(filedName)){
//如果是特殊字段
switch (filedName){
case "startTime":
case "endTime": {
return DateUtil.format(new Date(node.asLong()), "yyyy.MM.dd HH:mm:ss");
}
case "state":{
return AlertStatusEnum.getValueByCode(node.asInt());
}
case "alarmSource":{
return AlertOriginEnum.getValueByCode(node.asInt());
}
default: return node.asText();
}
}else {
//非特殊字段
return node != null ? node.asText() : "null";
}
}
}
我们再对JsonTokenHandler
进行优化:
public class JsonTokenHandler implements TokenHandler {
private final ObjectMapper objectMapper;
private JsonNode jsonNode;
private final SpFieldHandler<JsonNode> spFieldHandler;
/**
* 构造函数,接收一个 JSON 字符串作为参数,并初始化 ObjectMapper 和 JsonNode
* @param json json
*/
public JsonTokenHandler(String json,SpFieldHandler<JsonNode> spFieldHandler) {
this.spFieldHandler = spFieldHandler;
objectMapper = new ObjectMapper();
try {
// 将 JSON 字符串解析为 JsonNode 对象
jsonNode = objectMapper.readTree(json);
} catch (Exception e) {
e.printStackTrace();
}
}
@Override
public String handleToken(String content) {
String value = "'-'";
if (jsonNode != null) {
String[] fieldPath = content.split("\.");
JsonNode currentNode = jsonNode;
for (String fieldName : fieldPath) {
if (currentNode.isObject()) {
currentNode = currentNode.get(fieldName);
} else {
// 如果当前节点不是对象,则尝试解析为字符串形式的 JSON
try {
JsonNode jsonValue = objectMapper.readTree(currentNode.asText());
if (jsonValue != null && jsonValue.isObject()) {
currentNode = jsonValue.get(fieldName);
} else {
break;
}
} catch (Exception e) {
break;
}
}
if (currentNode == null) {
break;
}
}
//将json中解析到的数据做特殊处理
value = spFieldHandler.parseSpFieldValue(content,currentNode);
}
return "null".equals(value) ? "'-'" : value;
}
}
构造方法中添加了SpFieldHandler
的入参,并且在最后将节点交给SpFieldHandler
进行处理。
我们的Utils中则需要将对应的SpFieldHandler
作为参数传入TokenHandler
。
public static String buildAlarmMsgByJson(String json,String template){
// 创建 TokenHandler 处理器
TokenHandler handler = new JsonTokenHandler(json,new AlarmJsonSpHandler());
// 创建 TemplateParser 解析器
TemplateParser parser = new TemplateParser(handler);
// 解析并替换占位符为实际值
return parser.parseTemplate(template);
}
对于此方法的性能呢,我大致的写了一个测试类,一秒内在我的电脑上(一台破win)可以执行1w次左右。
此方案特色是应用了MyBatis中的现有方法,让我们感觉源码真没白学。如果大佬们有其它优雅的方案,欢迎交流指点。