前阵子做一个办公系统的国产化适配,客户用的编辑器就是 XHEDITOR,业务里塞了一堆动态公式。从老系统往国产数据库迁的时候,历史数据里的公式全乱了:有的变成一长串看不懂的 HTML 标签,有的直接成了问号,还有的反斜杠丢了一半,公式在编辑器里彻底没法二次编辑。
排查到最后我发现,根子不在编辑器本身,而在一开始就没想清楚“动态公式”到底该以什么形态落库。很多人以为公式就是一段文本,随便找个字段存进去完事,结果被字符集、大字段、转义、回显这些问题挨个教做人。今天把这段踩坑经验整理出来,从 XHEDITOR 公式内容的真实形态,到国产化数据库选型、表结构设计、读写链路,再到实战排查,一次性讲透。
1. 先搞明白:XHEDITOR 里的“动态公式”到底是怎么产生的
1.1 编辑器输出内容的三种形态
XHEDITOR 本身是一个轻量级开源富文本编辑器,核心能力是 HTML 编辑,它并不自带公式引擎。所谓动态公式,通常是在 XHEDITOR 基础上挂接 MathJax、KaTeX 这类公式库插件,用户点击工具栏插入公式,公式库把输入的 LaTeX 或 MathML 源码实时渲染成浏览器里能看的数学公式。
以 MathJax 为例,用户在弹窗里输入\int_a^b f(x)dx,编辑器可视区会显示渲染好的积分号,但底层 DOM 里其实是一整套 MathJax 生成的 HTML 结构,包括<math>、<mrow>、<mo>、<mi>这些语义标签。如果你把 XHEDITOR 切到源码模式,会看到两种情况:一种是整段 MathJax 输出的 HTML,长到令人窒息;另一种是<span class="math-tex">\(...\)</span>这种带占位标记的结构,LaTeX 源码还藏在里面。
这两种情况对存储的影响完全不同。XHEDITOR 的同步机制决定了它最终回写到 textarea 的内容来自源码模式下的 HTML 字符串,而不是可视化层看到的渲染图。如果公式插件只输出 MathJax 的 HTML,那你落库的就是一堆<math>标签;如果插件输出的是带 class 标记的源码包裹结构,落库的是“源码 + 标记”的混合体。后者才是真正可二次编辑的数据。
1.2 “动态公式”和静态图片公式的本质区别
传统 Word 里插入的公式本质是一张图片,保存和展示都不依赖任何公式库,你把它当成普通图片处理就行。XHEDITOR 里的动态公式不一样,它是源码和渲染态的组合体:源码是一段 LaTeX 或 MathML,渲染态是公式库即时生成的 HTML 或 SVG。用户在编辑界面改一个字符,前端重新渲染一次,但如果数据库只存了渲染态 HTML,下一次打开编辑器想改公式,就没有源码可恢复了。
我把动态公式的核心特征总结成三点:
- 可编辑性:必须保存 LaTeX 或 MathML 源码,否则无法二次编辑;
- 可重渲染性:页面加载时公式库需要重新排版,存的数据必须能被公式库识别;
- 可定位性:公式在正文里的位置不能因为换行、转义而丢失,标记必须稳定。
这三点直接决定了字段类型怎么选、内容怎么清洗。你可以把动态公式理解成“照片 + 底片”:图片公式只存照片,动态公式必须把底片也留下,底片就是 LaTeX 源码和必要的标记信息。数据库里存什么、怎么存,本质上是在回答一个问题:下次打开这篇文档时,能不能把底片重新洗成照片。
2. 国产化数据库选型:迁移前必须想清楚的兼容性问题
2.1 常见国产化数据库的分类与兼容模式
国产化替代项目里出现的数据库不少,我接触比较多的是达梦 DM8、人大金仓 KingbaseES、GBase 8s,还有部分项目用到的 GaussDB、TDSQL 这类分布式形态。它们对开源生态的兼容方式不完全一样。
如果原系统后端是 Oracle,达梦和人大金仓的 Oracle 兼容模式做得比较成熟,很多 SQL 语法、PL/SQL、数据类型能平滑迁移。如果原系统是 MySQL,金仓有 MySQL 兼容模式,GaussDB、TDSQL 更偏 MySQL 协议兼容。但“兼容”不等于“零改动”,尤其是 CLOB 字段写入、JDBC 驱动版本、字符集设置这些底层差异,最容易在公式内容存储上暴露。
我见过一个项目,原系统是 MySQL,文章内容字段是longtext,切到达梦后开发直接照搬建了个text字段,结果 SQL 里用了concat、ifnull这些函数,达梦默认的 Oracle 兼容模式不认同名函数,报错报了一整屏。其实达梦可以初始化成 MySQL 兼容模式,但实例参数在初始化时就要定好,后面再改成本很高。选型阶段一定要先确认你用的是“兼容 Oracle 语法”还是“兼容 MySQL 语法”的实例,不是装完达梦就自动通吃。
2.2 存储公式内容对数据库的两个硬性要求
第一个硬性要求是 CLOB/TEXT 大字段支持要到位数。动态公式内容通常以整篇 HTML 保存,一篇带几十个公式的文章,HTML 很容易超过 8KB、16KB,甚至更大。如果字段类型还是VARCHAR(4000),数据会被直接截断,公式断在半截,页面回显直接崩。国产库对 LOB 的实现细节也各不相同:达梦的 CLOB 走 LOB 存储,金仓的 TEXT 底层是变长字符串,写入方式、最大长度、能否参与索引都有差异。我的建议是建表时统一用 CLOB/TEXT,不要去赌 VARCHAR 够用。
第二个硬性要求是字符集必须完整支持 Unicode。数学公式里大量出现希腊字母(α、β、γ)、特殊符号(≤、≥、∑、∏)、花体字母(𝔽、ℝ),如果数据库字符集是 GBK,很多字符存进去就变成“?”。这里建议建库时直接选 UTF-8,JDBC 连接串明确指定characterEncoding=UTF-8,同时确认服务端字符集确实生效。金仓默认 UTF8,达梦默认可能是 GBK,这一步没配好,后续乱码问题能缠你两周。
另外,排序规则和全文检索能力也要提前评估。公式源码里有大量反斜杠、花括号、$,普通分词器会把它们切得七零八落,全文检索命中率很低。所以我不建议对整段公式 HTML 建全文索引,而是单独抽一个纯文本摘要字段用来检索,公式本体保持原样。
3. 字段设计与建表:给动态公式一个真正能落地的结构
3.1 主表字段类型:CLOB 还是 TEXT
先给一套经过实测的建表方案,达梦和人大金仓的 Oracle 兼容模式下可以直接用:
CREATE TABLE t_article ( id VARCHAR(64) NOT NULL, title VARCHAR(500) NOT NULL, content_html CLOB NOT NULL, content_text CLOB, formula_ver VARCHAR(16) DEFAULT '2' NOT NULL, create_time TIMESTAMP DEFAULT CURRENT_TIMESTAMP, update_time TIMESTAMP, PRIMARY KEY (id) );content_html存 XHEDITOR 回传的完整 HTML,content_text存纯文本摘要,用于列表页展示和搜索。formula_ver是公式插件的版本标识,切换 MathJax 版本或 LaTeX 宏包时,这个字段能帮你判断哪些历史内容需要重新渲染。
为什么不把content_html建得更小一点?原因很简单:XHEDITOR 的 HTML 不是普通正文,它可能包含 MathML 标签、内联样式、编辑器自动生成的临时样式,任何截断都会破坏页面结构。CLOB 能撑到几个 GB,完全覆盖单篇文章的量级。实测下来,一篇带 20 个公式的技术文档,HTML 大约 15KB 到 40KB,CLOB 存储毫无压力。
3.2 公式明细表:要不要拆?怎么拆?
我的做法是“一主干、一辅助”:主表t_article保存完整 HTML,辅助表t_formula_item单独记录每个公式的源码和位置。公式明细表的作用不是替代主表的 HTML,而是给统计、质检、批量迁移提供抓手。
CREATE TABLE t_formula_item ( id VARCHAR(64) NOT NULL, article_id VARCHAR(64) NOT NULL, formula_type VARCHAR(20) NOT NULL, formula_src TEXT NOT NULL, position_seq INT NOT NULL, create_time TIMESTAMP DEFAULT CURRENT_TIMESTAMP );formula_type标记 LaTeX 还是 MathML,formula_src存源码,position_seq表示第几个公式。有了这张表,你可以快速统计全库公式数量、扫描 LaTeX 语法错误,甚至把历史数据从图片公式批量迁移成源码公式。但要注意,不要为了“规范化”把正文拆成一条条分段记录存储。XHEDITOR 的 HTML 是整体结构,强行拆分会导致编辑器回显顺序错乱。主数据永远是那一条完整的content_html,明细表只是辅助索引。
如果业务需要保留编辑历史,再加一张版本表:
CREATE TABLE t_article_version ( id VARCHAR(64) NOT NULL, article_id VARCHAR(64) NOT NULL, version_no INT NOT NULL, content_html CLOB NOT NULL, content_md5 VARCHAR(64), create_by VARCHAR(64), create_time TIMESTAMP DEFAULT CURRENT_TIMESTAMP, PRIMARY KEY (id), UNIQUE (article_id, version_no) );保存前在应用层计算content_html的 MD5,如果跟上一版本不一致就插入新记录。这样历史公式不会被覆盖,出问题还能回滚。
4. 动态公式写入和读取的完整链路
4.1 前端提交前必须做同步
XHEDITOR 有个经典坑:编辑器内容在 iframe 里,textarea 不会自动更新。如果你直接读$('#contentTextarea').val(),拿到的往往是页面初始化时的空字符串。提交前一定要先同步:
if (editor && typeof editor.sync === 'function') { editor.sync(); } var html = $('#contentTextarea').val(); console.log('准备提交的HTML长度:', html.length);别小看这一行sync(),我见过不止一次因为漏了它,保存后整篇文章变空。提交前打印一眼 HTML 长度,至少能确认公式标签还在。如果走 AJAX,建议用 FormData 或encodeURIComponent包裹,避免 LaTeX 源码里的&、$被浏览器编码后截断。
4.2 后端清洗:既要防 XSS,又不能误杀公式标签
动态公式 HTML 里的标签非常多,MathJax 输出的<math>系列标签不在常规富文本白名单里。如果后端用 Jsoup 做严格白名单过滤,很容易把公式标签全干掉。实操上我建议分两层处理:
第一层,删除危险标签和事件属性,Java 里可以这样处理基础清洗:
String cleaned = html.replaceAll("(?is)<script.*?</script>", "") .replaceAll("(?is)<iframe.*?</iframe>", "") .replaceAll("(?i)\\son\\w+\\s*=\\s*(\"[^\"]*\"|'[^']*'|[^\\s>]+)", "");第二层,放行公式相关标签和 class,比如<math>、<mrow>、<mo>、<mi>、<mn>、<mfrac>、<msqrt>、<msub>、<msup>,以及 class 里包含math、katex、formula的节点。如果是<span class="math-tex">这种包裹 LaTeX 源码的结构,要保留\(、\[和源码文本,同时过滤源码里可能被塞进来的危险标签。
这里要特别提醒:LaTeX 源码本身是可以包含“看起来像 HTML”的内容的,比如\text{<script>}。所以清洗不能只看外层 HTML,公式源码里的尖括号也要做转义或过滤。我的习惯是清洗公式源码时把<和>先替换成<、>,渲染时公式库会还原。
4.3 JDBC 写入大字段的正确姿势
不要在 SQL 里拼字符串写公式 HTML。公式内容里有大量单引号、双引号、反斜杠,拼接必然出错,而且 SQL 注入风险极高。正确的做法是 PreparedStatement,CLOB 用流式写入:
PreparedStatement ps = conn.prepareStatement( "INSERT INTO t_article(id, title, content_html) VALUES (?, ?, ?)"); ps.setString(1, id); ps.setString(2, title); ps.setCharacterStream(3, new StringReader(html), html.length()); ps.executeUpdate();达梦 JDBC 对超长setString可能报“仅可以绑定 LONG 值”或者“字符串截断”,换成setCharacterStream更稳。金仓的 TEXT 字段用setString通常没问题,但统一走流式写法最省心,一套代码在所有国产库上都能跑。
4.4 读取与回显:让公式重新“动”起来
从数据库读出来的 HTML 要回填给 XHEDITOR。Java 读取 CLOB:
Clob clob = rs.getClob("content_html"); String html = clob.getSubString(1, (int) clob.length());如果存的是<span class="math-tex">包裹的 LaTeX 源码,页面加载后需要调用 MathJax 重新渲染:
<script> MathJax = { tex: { inlineMath: [['\\(', '\\)'], ['$', '$']] } }; </script> <script src="https://cdn.example.com/mathjax/tex-mml-chtml.js"></script>MathJax 加载后会自动扫描math-tex标记并排版。如果用的是 KaTeX,则要调用renderMathInElement。这里有一个细节:XHEDITOR 插入公式时如果用了临时随机 ID,保存前最好清掉,只保留 class 和源码标记。否则回显时这些 ID 可能和新页面里其他元素冲突,公式渲染位置错乱。
5. 实战中踩过的坑和排查实录
5.1 大字段写入报“字符串截断”或“仅可绑定 LONG 值”
这个问题我在达梦上遇到过不止一次。insert 语句里content_html用setString直接写一个 30KB 的 HTML,运行时报java.sql.SQLException: 仅可以绑定 LONG 值。原因不是数据大小超了,而是驱动把超长 String 绑定到 CLOB 列时走了错误的转换路径。解决办法就是前面说的改成setCharacterStream,或者在参数映射里显式声明java.sql.Types.CLOB。金仓的 Oracle 兼容模式下出现过 TEXT 列被识别为 VARCHAR、长度超限,统一流式写入后问题消失。
5.2 GBK 字符集导致公式符号变成问号
一次现场问题:公式里的≤、∑在数据库客户端里显示为?。排查时先查数据库字符集,发现达梦实例是 GBK,JDBC 连接串也没指定 UTF-8,三层全错。把实例字符集改成 UTF-8 后,历史乱码已经恢复不了,只能从原系统重新同步。所以迁移方案里必须有字符集检查这一项:前端页面 UTF-8、后端代码 UTF-8、数据库实例 UTF-8、JDBC 连接串 UTF-8,四层缺一不可。
5.3 反斜杠被吞:LaTeX 源码保存后变成废码
LaTeX 源码到处都是\frac{1}{2}。如果内容经过 JSON 序列化、Java 字符串转义、数据库驱动转义好几层,很容易从\frac变成rac。排查时先直接从数据库客户端看 CLOB 原文,确认反斜杠是不是真的在库里;如果库里是好的,那问题出在前端或接口层;如果库里就丢了,重点查接收端有没有把\当转义字符。我在工程里习惯保存前打印一段原始串到日志,对比反斜杠数量,十次里有八次能定位到是哪一层吞的。
5.4 保存整篇 HTML 后,打开编辑器变成源码模式
有用户反馈,保存后再编辑,看到的是满屏<math>标签而不是渲染后的公式。原因是回填内容时直接把 HTML 塞进了 XHEDITOR 的源码模式,没有切到设计模式。正确流程是:用editor.setSource(html)设置内容,然后切到 WYSIWYG 模式,再调用一次editor.reload()。有些 XHEDITOR 版本还需要手动触发MathJax.typesetPromise(),否则公式不出图。
5.5 公式太多导致页面卡顿
一篇包含大量公式的文档 HTML 可能几百 KB,回显时前端同时渲染几百个公式,MathJax 会非常吃力。实测下来的做法是:列表页不渲染公式,用content_text里的纯文本摘要;详情页和编辑页再加载 MathJax。历史版本对比时优先对比纯文本,不要一次性渲染全部公式。
| 常见问题 | 排查入口 | 解决方案 |
|---|---|---|
| 写入超长报错 | JDBC 驱动日志 | 改用setCharacterStream绑定 CLOB |
| 特殊符号乱码 | 数据库字符集、JDBC 连接串 | 统一 UTF-8,重建实例或迁移 |
| 反斜杠丢失 | 数据库原文比对 | 检查后端转义链路,保存前打印日志 |
| 回显源码模式 | 前端设置内容方式 | 先setSource再切设计模式并 reload |
| 页面卡顿 | 渲染公式数量 | 列表页不渲染,详情页按需加载 |
6. 回顾与我的实操建议
6.1 一套适合多数业务的最小落地组合
如果你不想踩我踩过的坑,可以直接抄下面这套组合:
- 主内容:整段 HTML 存 CLOB/TEXT,公式以
<span class="math-tex">LaTeX 源码</span>形式嵌在 HTML 里; - 辅助表:单独建公式明细表,存 LaTeX/MathML 源码,用于统计和质检;
- 渲染:只读页面用 MathJax,编辑页面从 HTML 恢复到 XHEDITOR;
- 数据库:选 UTF-8 字符集的国产库版本,CLOB 大字段一律流式写入;
- 安全:后端保留公式标签白名单,同时过滤公式源码里的危险片段。
这套方案兼顾了编辑体验、存储安全和后续迁移成本,在我经手的几个项目里运行都比较稳。
6.2 历史数据迁移时的一个额外提醒
如果老系统里公式是图片格式,迁移时先别急着删图。可以写脚本识别<img>形式的公式图,用公式识别服务转成 LaTeX 源码,生成新的<span class="math-tex">结构,新旧内容并存一段时间,验证渲染效果后再下线图片。直接删图后公式会丢,这个坑我见得太多了。迁移完成后,随机抽 10 篇含公式的文章,做“写入 → 读取 → 回显 → 修改 → 再保存”的全链路回归,确认公式能改、能存、能出图再收工。
我个人整个项目跑下来最深的感受是:动态公式能不能在国产化数据库里稳定落地,90% 的功夫在编辑器侧和代码侧的细节上,数据库本身反而相对简单。把公式的源码形态想清楚,把 CLOB 的写入姿势写对,把字符集和转义链路查一遍,这个功能基本就稳了。希望这篇整理能帮你少走一段弯路。