001/** 002 * Copyright (c) 2025-2026, Michael Yang 杨福海 (fuhai999@gmail.com). 003 * <p> 004 * Licensed under the GNU Lesser General Public License (LGPL) ,Version 3.0 (the "License"); 005 * you may not use this file except in compliance with the License. 006 * You may obtain a copy of the License at 007 * <p> 008 * http://www.gnu.org/licenses/lgpl-3.0.txt 009 * <p> 010 * Unless required by applicable law or agreed to in writing, software 011 * distributed under the License is distributed on an "AS IS" BASIS, 012 * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. 013 * See the License for the specific language governing permissions and 014 * limitations under the License. 015 */ 016package dev.tinyflow.core.util; 017 018import com.alibaba.fastjson.JSON; 019import com.alibaba.fastjson.JSONPath; 020import com.alibaba.fastjson.serializer.SerializerFeature; 021 022import java.util.*; 023import java.util.concurrent.ConcurrentHashMap; 024import java.util.regex.Matcher; 025import java.util.regex.Pattern; 026 027/** 028 * 文本模板引擎,用于将包含 {{xxx}} 占位符的字符串模板,动态渲染为最终文本。 029 * 支持 JSONPath 取值语法与 “??” 空值兜底逻辑。 030 * <p> 031 * 例如: 032 * 模板: "Hello {{ user.name ?? 'Unknown' }}!" 033 * 数据: { "user": { "name": "Alice" } } 034 * 输出: "Hello Alice!" 035 * <p> 036 * 支持缓存模板与 JSONPath 编译结果,提升性能。 037 */ 038public class TextTemplate { 039 040 /** 041 * 匹配 {{ expression }} 的正则表达式 042 */ 043 private static final Pattern PLACEHOLDER_PATTERN = 044 Pattern.compile("\\{\\{\\s*([^{}]+?)\\s*}}"); 045 046 /** 047 * 模板缓存(按原始模板字符串) 048 */ 049 private static final Map<String, TextTemplate> TEMPLATE_CACHE = new ConcurrentHashMap<>(); 050 051 /** 052 * JSONPath 编译缓存,避免重复编译 053 */ 054 private static final Map<String, JSONPath> JSONPATH_CACHE = new ConcurrentHashMap<>(); 055 056 /** 057 * 原始模板字符串 058 */ 059 private final String originalTemplate; 060 061 /** 062 * 模板中拆分出的静态与动态 token 列表 063 */ 064 private final List<TemplateToken> tokens; 065 066 public TextTemplate(String template) { 067 this.originalTemplate = template != null ? template : ""; 068 this.tokens = Collections.unmodifiableList(parseTemplate(this.originalTemplate)); 069 } 070 071 /** 072 * 从缓存中获取或新建模板实例 073 */ 074 public static TextTemplate of(String template) { 075 String finalTemplate = template != null ? template : ""; 076 return MapUtil.computeIfAbsent(TEMPLATE_CACHE, finalTemplate, k -> new TextTemplate(finalTemplate)); 077 } 078 079 /** 080 * 清空模板与 JSONPath 缓存 081 */ 082 public static void clearCache() { 083 TEMPLATE_CACHE.clear(); 084 JSONPATH_CACHE.clear(); 085 } 086 087 088 public String formatToString(List<Map<String, Object>> rootMaps) { 089 Map<String, Object> rootMap = new HashMap<>(); 090 for (Map<String, Object> m : rootMaps) { 091 if (m != null) { 092 rootMap.putAll(m); 093 } 094 } 095 return formatToString(rootMap, false); 096 } 097 098 /** 099 * 将模板格式化为字符串 100 */ 101 public String formatToString(Map<String, Object> rootMap) { 102 return formatToString(rootMap, false); 103 } 104 105 /** 106 * 将模板格式化为字符串,可选择是否对结果进行 JSON 转义 107 * 108 * @param rootMap 数据上下文 109 * @param escapeForJsonOutput 是否对结果进行 JSON 字符串转义 110 */ 111 public String formatToString(Map<String, Object> rootMap, boolean escapeForJsonOutput) { 112 if (tokens.isEmpty()) return originalTemplate; 113 if (rootMap == null) rootMap = Collections.emptyMap(); 114 115 StringBuilder sb = new StringBuilder(originalTemplate.length() + 64); 116 117 for (TemplateToken token : tokens) { 118 if (token.isStatic) { 119 // 静态文本,直接拼接 120 sb.append(token.content); 121 continue; 122 } 123 124 // 动态表达式求值 125 String value = evaluate(token.parseResult, rootMap, escapeForJsonOutput); 126 127 // 没有兜底且值为空时抛出异常 128 if (!token.explicitEmptyFallback && value.isEmpty()) { 129 throw new IllegalArgumentException(String.format( 130 "Missing value for expression: \"%s\"%nTemplate: %s%nProvided parameters:%n%s", 131 token.rawExpression, 132 originalTemplate, 133 JSON.toJSONString(rootMap, SerializerFeature.PrettyFormat) 134 )); 135 } 136 sb.append(value); 137 } 138 139 return sb.toString(); 140 } 141 142 /** 143 * 解析模板字符串,将其拆解为静态文本与动态占位符片段 144 */ 145 private List<TemplateToken> parseTemplate(String template) { 146 List<TemplateToken> result = new ArrayList<>(template.length() / 8); 147 if (template == null || template.isEmpty()) return result; 148 149 Matcher matcher = PLACEHOLDER_PATTERN.matcher(template); 150 int lastEnd = 0; 151 152 while (matcher.find()) { 153 int start = matcher.start(); 154 int end = matcher.end(); 155 156 // 处理 {{ 前的静态文本 157 if (start > lastEnd) { 158 result.add(TemplateToken.staticText(template.substring(lastEnd, start))); 159 } 160 161 // 处理 {{ ... }} 动态部分 162 String rawExpr = matcher.group(1); 163 TemplateParseResult parsed = parseTemplateExpression(rawExpr); 164 result.add(TemplateToken.dynamic(parsed.parseResult, rawExpr, parsed.explicitEmptyFallback)); 165 166 lastEnd = end; 167 } 168 169 // 末尾剩余静态文本 170 if (lastEnd < template.length()) { 171 result.add(TemplateToken.staticText(template.substring(lastEnd))); 172 } 173 174 return result; 175 } 176 177 /** 178 * 解析单个表达式内容,处理 ?? 空值兜底逻辑。 179 * 例如: user.name ?? user.nick ?? "未知" 180 */ 181 private TemplateParseResult parseTemplateExpression(String expr) { 182 // 无 ?? 表示该值必填 183 if (!expr.contains("??")) { 184 return new TemplateParseResult(new ParseResult(expr.trim(), null), false); 185 } 186 187 // 按 ?? 分割,支持链式兜底 188 String[] parts = expr.split("\\s*\\?\\?\\s*", -1); 189 boolean explicitEmptyFallback = parts[parts.length - 1].trim().isEmpty(); 190 191 // 从右往左构建兜底链 192 ParseResult result = null; 193 for (int i = parts.length - 1; i >= 0; i--) { 194 String p = parts[i].trim(); 195 if (p.isEmpty()) p = "\"\""; // 空串转为 "" 字面量 196 result = new ParseResult(p, result); 197 } 198 199 return new TemplateParseResult(result, explicitEmptyFallback); 200 } 201 202 /** 203 * 递归求值表达式(支持多级兜底) 204 */ 205 private String evaluate(ParseResult pr, Map<String, Object> root, boolean escapeForJsonOutput) { 206 if (pr == null) return ""; 207 208 // 字面量直接返回 209 if (pr.isLiteral) { 210 String literal = pr.getUnquotedLiteral(); 211 return escapeForJsonOutput ? escapeJsonString(literal) : literal; 212 } 213 214 // 尝试从 JSONPath 取值 215 Object value = getValueByJsonPath(root, pr.expression, escapeForJsonOutput); 216 if (value instanceof CharSequence || 217 value instanceof Number || 218 value instanceof Boolean || 219 value instanceof Character) { 220 return value.toString(); 221 } else if (value != null) { 222 return com.alibaba.fastjson2.JSON.toJSONString(value); 223 } 224 225 // 若未取到,则尝试 fallback 226 return evaluate(pr.defaultResult, root, escapeForJsonOutput); 227 } 228 229 /** 230 * 根据 JSONPath 获取对象值 231 */ 232 private Object getValueByJsonPath(Map<String, Object> root, String path, boolean escapeForJsonOutput) { 233 try { 234 String fullPath = path.startsWith("$") ? path : "$." + path; 235 JSONPath compiled = MapUtil.computeIfAbsent(JSONPATH_CACHE, fullPath, JSONPath::compile); 236 Object value = compiled.eval(root); 237 if (escapeForJsonOutput && value instanceof String) { 238 return escapeJsonString((String) value); 239 } 240 return value; 241 } catch (Exception ignored) { 242 return null; 243 } 244 } 245 246 /** 247 * 将字符串进行 JSON 安全转义 248 */ 249 private static String escapeJsonString(String input) { 250 if (input == null || input.isEmpty()) return input; 251 return input 252 .replace("\\", "\\\\") 253 .replace("\"", "\\\"") 254 .replace("\b", "\\b") 255 .replace("\f", "\\f") 256 .replace("\n", "\\n") 257 .replace("\r", "\\r") 258 .replace("\t", "\\t"); 259 } 260 261 /** 262 * 去掉字符串两端的引号 263 */ 264 private static String unquote(String str) { 265 if (str == null || str.length() < 2) return str; 266 char first = str.charAt(0); 267 char last = str.charAt(str.length() - 1); 268 if ((first == '\'' && last == '\'') || (first == '"' && last == '"')) { 269 return str.substring(1, str.length() - 1); 270 } 271 return str; 272 } 273 274 275 /** 276 * 模板片段对象。 277 * 每个模板字符串会被解析为若干个 TemplateToken: 278 * - 静态文本(isStatic = true) 279 * - 动态表达式(isStatic = false) 280 */ 281 private static class TemplateToken { 282 final boolean isStatic; // 是否为静态文本 283 final String content; // 静态文本内容 284 final ParseResult parseResult; // 动态解析结果(表达式树) 285 final String rawExpression; // 原始表达式字符串 286 final boolean explicitEmptyFallback; // 是否显式声明空兜底(以 ?? 结尾) 287 288 private TemplateToken(boolean isStatic, String content, 289 ParseResult parseResult, String rawExpression, 290 boolean explicitEmptyFallback) { 291 this.isStatic = isStatic; 292 this.content = content; 293 this.parseResult = parseResult; 294 this.rawExpression = rawExpression; 295 this.explicitEmptyFallback = explicitEmptyFallback; 296 } 297 298 /** 299 * 创建静态文本 token 300 */ 301 static TemplateToken staticText(String text) { 302 return new TemplateToken(true, text, null, null, false); 303 } 304 305 /** 306 * 创建动态表达式 token 307 */ 308 static TemplateToken dynamic(ParseResult parseResult, String rawExpression, boolean explicitEmptyFallback) { 309 return new TemplateToken(false, null, parseResult, rawExpression, explicitEmptyFallback); 310 } 311 } 312 313 /** 314 * 表达式解析结果。 315 * 支持嵌套的默认值链,如:user.name ?? user.nick ?? "匿名" 316 */ 317 private static class ParseResult { 318 final String expression; // 当前表达式内容(可能是 JSONPath 或字符串字面量) 319 final ParseResult defaultResult; // 默认值链的下一个节点 320 final boolean isLiteral; // 是否为字面量字符串('xxx' 或 "xxx") 321 322 ParseResult(String expression, ParseResult defaultResult) { 323 this.expression = expression; 324 this.defaultResult = defaultResult; 325 this.isLiteral = isLiteralExpression(expression); 326 } 327 328 /** 329 * 判断是否是字符串字面量 330 */ 331 private static boolean isLiteralExpression(String expr) { 332 if (expr == null || expr.length() < 2) return false; 333 char first = expr.charAt(0); 334 char last = expr.charAt(expr.length() - 1); 335 return (first == '\'' && last == '\'') || (first == '"' && last == '"'); 336 } 337 338 /** 339 * 返回去除引号后的字符串字面量值 340 */ 341 String getUnquotedLiteral() { 342 if (!isLiteral) throw new IllegalStateException("Not a literal: " + expression); 343 return unquote(expression); 344 } 345 } 346 347 /** 348 * 模板解析的最终结果,包含: 349 * - 解析后的表达式树(ParseResult) 350 * - 是否显式声明空兜底 351 */ 352 private static class TemplateParseResult { 353 final ParseResult parseResult; 354 final boolean explicitEmptyFallback; 355 356 TemplateParseResult(ParseResult parseResult, boolean explicitEmptyFallback) { 357 this.parseResult = parseResult; 358 this.explicitEmptyFallback = explicitEmptyFallback; 359 } 360 } 361}