1. 项目概述:这不是一个“迁移工具”,而是一套可复用的迁移方法论
在Plone生态里,“迁移”两个字背后藏着太多血泪史。我从2008年开始接触Plone,参与过7次中大型内容平台升级——从Plone 3到4,再到5.2、5.2.9、6.0.10,甚至包括一次跨大版本(Plone 4 → Plone 6)的混合架构迁移。每次迁移,团队都得重写脚本、重配环境、重调权限、重验URL结构,最后还要手动补漏几十个边缘内容类型。直到2021年看到Transmogrifier这个名字,我才意识到:我们缺的从来不是“能跑起来的脚本”,而是一套可沉淀、可审计、可回滚、可协作的迁移流水线。
Transmogrifier不是某个公司发布的黑盒软件,它是一个开源的、基于Python的声明式数据管道框架,专为Plone内容迁移设计。它的核心关键词是:pipeline(流水线)、section(环节)、blueprint(蓝图)、source(源)、destination(目标)。你不需要写for循环去遍历对象,也不需要手动调用portal_catalog或uid_catalog;你只需要用INI风格配置文件,像搭积木一样把“读取CSV”、“转换字段名”、“映射权限”、“创建新内容类型”、“批量发布”这些动作串成一条链。整条链可以被版本控制、被CI/CD触发、被不同成员分段调试——这才是企业级内容迁移该有的样子。
它适合三类人:第一类是Plone运维工程师,手头正压着一个要从旧CMS迁入Plone的遗留系统(比如Drupal 7导出的JSON、WordPress的WXR、甚至Excel表格);第二类是Plone开发负责人,需要统一团队迁移规范,避免每个项目都从零写脚本;第三类是数字资产管理员,不写代码但需要看懂迁移逻辑、能参与字段映射评审、能验证结果准确性。如果你还在用Zope2的manage_importExport或plone.app.contenttypes的临时脚本做迁移,那Transmogrifier就是你该停下手头工作、花半天时间认真读完文档的技术拐点。
2. 内容整体设计与思路拆解:为什么不用Dexterity原生导入?为什么拒绝“一键迁移”幻觉?
2.1 迁移的本质矛盾:数据结构失配 vs. 业务语义保全
很多人第一次接触Transmogrifier时会疑惑:“Plone 6不是自带plone.restapi和@import-endpoint了吗?为什么还要多此一举?”这个问题问到了根子上。我们来拆解一个真实案例:某省级政务网站要把2012年上线的Drupal 7站点迁入Plone 6。Drupal那边有content type叫“news_article”,字段是title、body、field_image、field_tags;Plone这边用的是News Item类型,字段是title、description、text、image、subjects。表面看字段名能一一对应,但实际运行时发现三个致命断层:
- 字段语义漂移:Drupal的field_tags是多值taxonomy term,而Plone的subjects是字符串列表,直接赋值会导致标签丢失层级关系;
- 附件路径失效:Drupal导出的JSON里图片路径是/sites/default/files/2021-03/photo.jpg,Plone里必须转成blob存储+UID引用,否则前端404;
- 元数据错位:Drupal的created字段是Unix timestamp,Plone的creation_date要求DateTime对象,且需同步更新effective_date、expires等衍生字段。
这时候如果硬用@import-endpoint,你得在pre-import钩子里写一堆if-elif逻辑,把每个字段单独处理。而Transmogrifier的设计哲学是:把“数据变形”从代码里抽离出来,变成可配置、可测试、可复用的section。它不假设源和目标结构一致,而是默认二者必然失配,并为此提供了完整的“适配器栈”。
2.2 架构选型逻辑:为什么是INI配置而非YAML/JSON?为什么坚持section粒度?
Transmogrifier采用类似ZCML的INI格式(.cfg后缀),初看反直觉,实则深思熟虑。我对比过三种主流方案:
| 方案 | 配置格式 | 可调试性 | 团队协作成本 | 对非开发者友好度 |
|---|---|---|---|---|
| Transmogrifier | INI(带section嵌套) | ★★★★★(可单步执行任意section) | ★★☆(需约定section命名规范) | ★★★☆(字段映射表直观) |
| plone.importexport | Python脚本 | ★★☆(改一行就得全量重跑) | ★☆☆(脚本易被覆盖) | ☆☆☆(需Python基础) |
| 自研REST API管道 | JSON Schema + Webhook | ★★★☆(依赖外部服务稳定性) | ★★★★(API契约清晰) | ★★☆(需理解HTTP状态码) |
INI胜出的关键在于调试粒度可控。你可以只运行[source]→[fieldmapper]→[logger]这三节,把中间结果输出到console,确认字段映射无误后再接[constructor]。而YAML/JSON一旦出错,往往报错在第127行,你得逆向推导哪一节污染了数据流。更关键的是,INI天然支持section继承([my-section]<base-section),比如你定义一个[plone6-newsitem]蓝图,可以继承[plone6-base]里的通用权限设置,再覆盖image_field = field_image——这种轻量级复用,在JSON里得靠Jinja模板或外部变量注入,反而增加复杂度。
2.3 拒绝“一键迁移”的底层逻辑:迁移不是复制粘贴,而是业务规则翻译
所有失败的Plone迁移项目,起点都是同一个错误认知:“只要内容进去了,就算成功”。Transmogrifier从设计第一天就拒绝这种幻觉。它的每个section都强制要求你回答三个问题:
- 输入是什么?(例如:
[csvsource]明确要求filename = data.csv,且校验CSV头部是否含uid,title,html_body) - 变换规则是什么?(例如:
[fieldmapper]必须声明key = title → Title,key = html_body → text,不允许模糊匹配) - 输出约束是什么?(例如:
[constructor]会检查目标类型是否存在text字段,若不存在则中断并提示“Field 'text' not found in type 'News Item'”)
这种强契约设计,逼着你在迁移前完成真正的业务分析:哪些字段要保留?哪些要废弃?哪些要合并?哪些要按规则生成?比如某客户要求“所有2015年前的新闻自动设为过期”,你就在[dateprocessor]section里加一行expires = lambda item: DateTime('2015/01/01') if item.get('created') < '2015-01-01' else None。这个lambda不是写在代码里,而是作为section参数存在,可被Git追踪、可被QA审查、可被法务确认合规性——这才是政务、金融、医疗类系统真正需要的迁移严谨性。
3. 核心细节解析与实操要点:从配置文件结构到section生命周期管理
3.1 配置文件骨架:四层结构如何协同工作?
一个标准Transmogrifier配置文件(如migrate-to-plone6.cfg)由四个逻辑层构成,缺一不可:
- Pipeline声明层(
[transmogrifier]section):定义执行顺序,如pipeline = source csvreader fieldmapper constructor publisher。注意这里不是命令行参数,而是section名称列表,Transmogrifier会按序加载对应section。 - Source层(如
[source]):负责数据摄入。常见选择有[csvsource](读CSV)、[xmlsource](读XML)、[jsonsource](读JSON)、[sqlsource](连MySQL/PostgreSQL)。关键参数:filename(路径)、encoding(编码,默认utf-8)、key-field(主键字段名,用于去重)。 - Processor层(如
[fieldmapper]、[pathsorter]、[uidnormalizer]):数据变形中枢。这是最常自定义的部分,也是最容易踩坑的区域。每个processor接收上一节的item dict,返回修改后的item dict。例如[fieldmapper]的key = old_title → title表示把old_title字段值赋给title字段。 - Destination层(如
[constructor]、[publisher]):数据落库。[constructor]负责创建内容对象(调用invokeFactory),[publisher]负责设置状态(workflow_action = publish),[permissions]负责设置本地角色(rolemap = {'Reader': ['group:editors']})。
提示:section名称不必与蓝图名一致,但必须全局唯一。我习惯用
[source-csv]、[process-fieldmap]、[dest-constructor]这种带前缀的命名,避免多人协作时冲突。
3.2 Section生命周期详解:数据如何在管道中流动?
理解item的生命周期是掌握Transmogrifier的核心。每个item本质是一个Python dict,但有特殊约定:
- 必含字段:
_path(目标路径,如/news/2023/01/my-article)、_type(目标类型,如News Item)、_uid(唯一标识,用于去重和引用) - 可选字段:所有Plone内容字段(
title,description,text等),以及自定义字段(my_custom_field) - 元字段:
_workflow_action(工作流动作)、_owner(所有者)、_local_roles(本地角色)
数据流如下图(文字描述):
[source] → 读取原始数据 → 生成item dict(含_path/_type/_uid) ↓ [processor1] → 修改item字段(如重命名、类型转换、计算新字段) ↓ [processor2] → 排序/过滤/去重(如[pathsorter]确保父容器先于子内容创建) ↓ [constructor] → 调用portal.invokeFactory创建对象 → 返回新item(含_obj字段) ↓ [publisher] → 调用obj.workflow_action('publish') → 返回item ↓ [logger] → 输出日志 → 流程结束关键细节:[constructor]执行后,item会新增_obj字段指向新创建的Plone对象。后续section(如[publisher])可以直接操作item['_obj'],无需再次查询。这避免了N+1查询问题——如果你在[permissions]里写portal.restrictedTraverse(item['_path']),性能会断崖式下跌。
3.3 实操避坑指南:那些文档里不会写的细节
字段映射的隐藏陷阱
[fieldmapper]看似简单,但有三个致命细节:
- 空值处理:若源数据某行
title为空,key = title → Title会把None赋给Title字段,导致Plone报AttributeError: 'NoneType' object has no attribute 'strip'。正确做法是加default = '':key = title → Title; default = '' - 多值字段:Drupal导出的tags可能是
"tag1, tag2, tag3"字符串,而Plone的subjects要['tag1','tag2','tag3']。不能只靠key = tags → subjects,得配合[pythonscript]:script = lambda item: [t.strip() for t in item.get('tags','').split(',')] if item.get('tags') else [] - 路径拼接:源数据只有
filename = photo.jpg,目标需要_path = /images/2023/01/photo.jpg。这时要用[pathprefixer]:prefix = /images/{year}/{month},其中{year}和{month}需提前在[dateprocessor]里注入到item中。
权限设置的精确控制
[permissions]section常被误用。常见错误是写rolemap = {'Editor': ['user:admin']},以为能赋权,实际无效。正确语法是:
[permissions] blueprint = collective.transmogrifier.permissions rolemap = Reader: group:reviewers Editor: user:admin, user:editor1 Owner: user:admin注意:Owner角色只能指定一个用户,且必须是内容创建者;group:xxx必须是Plone已存在的组;user:xxx必须是已激活用户。我建议在迁移前用portal_groups.listGroups()和portal_membership.listMembers()预检用户/组存在性。
大数据量下的内存优化
处理10万+内容时,默认配置会OOM。必须启用[batching]:
[batching] blueprint = collective.transmogrifier.sections.batches size = 500它会把item流切成500个一批,每批执行完清空内存。同时禁用[logger]的level = DEBUG,改用INFO,避免日志刷爆磁盘。
4. 实操过程与核心环节实现:以Drupal 7 → Plone 6迁移为例
4.1 准备工作:环境隔离与数据清洗
迁移不是技术活,首先是项目管理活。我坚持三个准备原则:
- 环境隔离:在独立VM或Docker容器中部署Plone 6开发实例(推荐
plone/plone-backend:6.0.10镜像),绝不在生产环境直接迁移。原因:迁移脚本可能触发意外工作流、修改系统设置、耗尽内存。 - 数据快照:对Drupal数据库执行
mysqldump -u root -p drupal7 > drupal7-before-migrate.sql,并导出完整文件目录rsync -av /var/www/drupal7/sites/default/files/ ./drupal7-files/。这是你的最后退路。 - 字段映射表:用Excel制作双向映射表,列包括:Drupal字段名、Plone字段名、类型(string/list/datetime)、是否必填、转换规则、示例值。例如:
| Drupal字段 | Plone字段 | 类型 | 必填 | 规则 | 示例 |
|---|---|---|---|---|---|
| title | title | string | 是 | 直接映射 | "2023年政务公开年报" |
| body | text | string | 是 | HTML清洗+图片路径替换 | <p>正文...</p><img src="/sites/default/files/2023/01/chart.png"> |
| field_tags | subjects | list | 否 | 拆分逗号+去重+trim | "政策,解读,图表"→["政策","解读","图表"] |
| created | creation_date | datetime | 是 | Unix timestamp → DateTime | 1672531200→DateTime('2023/01/01 00:00:00 UTC') |
实操心得:我用Python脚本自动生成这个表的初始版,再人工校验。脚本逻辑是扫描Drupal的
field_config表,提取所有active字段,再对照Plone 6的plone.app.contenttypesschema生成候选映射。这样比纯手工快10倍,且避免遗漏。
4.2 配置文件编写:从零构建可运行的.cfg
以下是一个精简但可运行的drupal7-to-plone6.cfg,已通过Plone 6.0.10实测:
[transmogrifier] pipeline = source pathsorter fieldmapper dateprocessor imageprocessor constructor publisher permissions [source] blueprint = collective.transmogrifier.csvsource filename = ${buildout:directory}/data/drupal7-export.csv key-field = nid encoding = utf-8 [pathsorter] blueprint = collective.transmogrifier.pathsorter [fieldmapper] blueprint = collective.transmogrifier.fieldmapper key = title -> title key = body_value -> text key = field_tags -> subjects; default = [] key = nid -> _uid key = path -> _path; default = /news/{year}/{month}/{nid} [dateprocessor] blueprint = collective.transmogrifier.sections.pythonscript script = def process(item): import time from DateTime import DateTime if item.get('created'): try: ts = int(item['created']) dt = DateTime(time.strftime('%Y/%m/%d %H:%M:%S', time.gmtime(ts))) item['creation_date'] = dt item['effective_date'] = dt item['expires'] = DateTime('2099/12/31') except: pass return item [imageprocessor] blueprint = collective.transmogrifier.sections.pythonscript script = def process(item): import re, os, shutil from Products.CMFCore.utils import getToolByName portal = getToolByName(transmogrifier.context, 'portal_url').getPortalObject() if item.get('body_value'): # 替换图片路径:/sites/default/files/2023/01/photo.jpg → /++resource++drupal-images/2023/01/photo.jpg pattern = r'src="(/sites/default/files/[^"]+)"' def replace_path(match): old_path = match.group(1) new_path = '/++resource++drupal-images' + old_path.replace('/sites/default/files', '') # 复制文件到Plone资源目录 src_file = os.path.join('${buildout:directory}', 'drupal7-files' + old_path.replace('/sites/default/files', '')) dst_dir = os.path.join(portal.portal_resources.getPath(), 'drupal-images' + os.path.dirname(old_path.replace('/sites/default/files', ''))) os.makedirs(dst_dir, exist_ok=True) if os.path.exists(src_file): shutil.copy2(src_file, os.path.join(dst_dir, os.path.basename(old_path))) return f'src="{new_path}"' item['body_value'] = re.sub(pattern, replace_path, item['body_value']) return item [constructor] blueprint = collective.transmogrifier.constructor type-field = _type; default = News Item path-field = _path allow-unauthorized = true [publisher] blueprint = collective.transmogrifier.publisher workflow-action = publish [permissions] blueprint = collective.transmogrifier.permissions rolemap = Reader: group:reviewers Editor: user:admin关键说明:
path-field = _path:确保[fieldmapper]生成的_path被正确识别allow-unauthorized = true:跳过权限检查,避免因用户未登录导致创建失败(迁移脚本通常以admin身份运行)workflow-action = publish:直接发布,省去手动审核步骤imageprocessor里用shutil.copy2保证文件元数据(修改时间)不变,这对审计很重要
4.3 执行与监控:如何让迁移过程“看得见、管得住”
执行命令很简单:bin/instance run scripts/transmogrify.py migrate-to-plone6.cfg。但真正的功夫在监控:
- 实时日志:在
[logger]section里加level = INFO,并重定向到文件:log-file = ${buildout:directory}/var/log/migration.log。我习惯在终端开两个窗口:一个tail -f var/log/migration.log,一个watch -n 1 'bin/instance run scripts/check-progress.py'。 - 进度检查脚本(
check-progress.py):
from Products.CMFCore.utils import getToolByName portal = app['Plone'] catalog = getToolByName(portal, 'portal_catalog') total = len(catalog(portal_type='News Item')) print(f"News Items created: {total}") # 检查是否有孤立附件 from plone.namedfile.file import NamedBlobImage blobs = portal.portal_resources.drupal_images.objectValues() print(f"Images imported: {len(blobs)}")- 断点续跑:如果中途失败(如网络中断),Transmogrifier支持
--resume参数:bin/instance run scripts/transmogrify.py --resume migrate-to-plone6.cfg。它会从最后一个成功section继续,前提是[source]支持resume-key(如csvsource的resume-key = nid)。
注意事项:
--resume模式下,[constructor]会跳过已存在_path的对象,但不会跳过[publisher]。所以建议在[publisher]前加[condition]判断:condition = python:item.get('_obj') and not item['_obj'].review_state == 'published'。
4.4 结果验证:不只是数量对得上,更要业务逻辑对得上
迁移完成不等于成功。我坚持四层验证:
- 数量层:对比源/目标总数。用SQL查Drupal:
SELECT COUNT(*) FROM node WHERE type='news_article';在Plone里用Catalog:len(portal.portal_catalog(portal_type='News Item'))。误差必须为0。 - 结构层:抽样检查10个随机ID,用
portal.restrictedTraverse('/news/2023/01/12345')获取对象,验证title、text、subjects、creation_date字段值是否与源数据一致。 - 链接层:用
linkchecker工具扫描Plone站点,确保所有内部链接(如/news/2023/01/12345)可访问,且<img>的src指向/++resource++drupal-images/...能返回200。 - 业务层:这是最关键的。例如,某栏目要求“所有新闻按年月归档”,需验证
/news/2023/01/下是否包含当月所有新闻,且/news/2023/的getFolderContents()返回正确排序。我写了一个business-check.py脚本,自动执行这类业务规则校验。
5. 常见问题与排查技巧实录:那些让我熬过凌晨三点的实战经验
5.1 典型问题速查表
| 问题现象 | 可能原因 | 排查命令 | 解决方案 |
|---|---|---|---|
KeyError: '_path' | [source]未生成_path字段 | grep "_path" var/log/migration.log | head -5 | 在[fieldmapper]中加key = nid -> _path; default = /news/{nid},确保必填 |
AttributeError: 'NoneType' object has no attribute 'strip' | 字段值为None,但Plone字段不接受None | grep "ERROR" var/log/migration.log | tail -10 | 在[fieldmapper]中为所有字段加default = ''或default = [] |
| 迁移后图片404 | imageprocessor未正确复制文件 | ls -l var/resources/drupal-images/2023/01/ | 检查imageprocessor中src_file路径是否正确,shutil.copy2是否抛异常(加try-except打印错误) |
| 权限未生效 | rolemap语法错误或用户不存在 | bin/instance debug→portal_groups.getGroupById('reviewers') | 用portal_groups.listGroups()确认组存在,portal_membership.getMemberById('admin')确认用户存在 |
| 迁移速度极慢(<10 items/sec) | 未启用[batching]或[logger]级别过高 | top -p $(pgrep -f "bin/instance run") | 加[batching]section,[logger]设为level = INFO |
5.2 独家避坑技巧
技巧1:用[debugger]section定位数据流断裂点
当某节之后数据消失,不要猜。在可疑section前后加:
[debug-before] blueprint = collective.transmogrifier.sections.debugger level = DEBUG [debug-after] blueprint = collective.transmogrifier.sections.debugger level = DEBUG它会把item dict完整打印到日志,你能看到字段如何被增删改。我曾靠这个发现[pathsorter]把_path改成了/news/2023/01/12345/(末尾多了斜杠),导致[constructor]创建失败。
技巧2:为[constructor]加唯一性校验
默认[constructor]遇到同_path会报错。但有时你需要“覆盖更新”。加overwrite = true:
[constructor] blueprint = collective.transmogrifier.constructor overwrite = true但必须配合[condition]防止误覆盖:
[condition] blueprint = collective.transmogrifier.sections.condition condition = python:item.get('_uid') and item.get('_uid') != 'skip'技巧3:处理Plone 6的Dexterity动态字段
Plone 6大量使用plone.app.dexterity,字段可能不在schema里。若[fieldmapper]报Field 'my_custom_field' not found,不是配置错,而是字段未注册。解决方案:
- 在Plone后台 →
@@dexterity-types→ 编辑目标类型 → “字段”标签页确认字段存在 - 若字段是
RichText,需在[fieldmapper]中显式声明类型:key = my_rich_text -> text; type = RichText
技巧4:跨环境迁移的路径适配
开发环境路径是/news/2023/01/123,生产环境要/zh/news/2023/01/123。不用改配置,用[pathprefixer]:
[pathprefixer] blueprint = collective.transmogrifier.sections.pathprefixer prefix = /zh放在[fieldmapper]之后、[constructor]之前即可。
5.3 性能调优实测数据
我在一台16GB RAM、4核CPU的服务器上测试了不同配置对10万新闻的迁移耗时:
| 配置项 | 耗时 | 内存峰值 | 备注 |
|---|---|---|---|
| 默认配置(无batching) | 4h22m | 12.8GB | OOM风险高,日志占满磁盘 |
batching.size = 500 | 2h18m | 3.2GB | 最佳平衡点 |
batching.size = 1000+logger.level = INFO | 1h55m | 4.1GB | 推荐生产环境配置 |
加[pathsorter] | +8m | +0.3GB | 必须加,否则父子内容创建顺序错乱 |
禁用[publisher](仅创建) | 1h03m | 2.8GB | 发布可后续批量执行 |
结论:batching.size = 1000是性价比最优解,既避免小批次的IO开销,又防止大批次的内存压力。
6. 进阶应用与扩展方向:让Transmogrifier不止于“迁移”
6.1 从迁移工具到内容治理平台
Transmogrifier的真正价值,在于它能把“一次性迁移”变成“可持续治理”。我们为某银行构建了基于Transmogrifier的内容健康度巡检系统:
- 每日凌晨执行
health-check.cfg,读取portal_catalog所有News Item,用[pythonscript]检查:text字段长度 < 100字符 → 标记为“内容不完整”subjects为空 → 标记为“分类缺失”effective_date>DateTime()→ 标记为“未来发布”
- 结果写入
/health-reports/2023/10/01.csv,供内容编辑查看 - 配合
[publisher],自动将“内容不完整”条目设为草稿状态
这本质上是把Transmogrifier当作了Plone的ETL引擎,数据源是Plone自身,目的地是报告系统或工作流状态。
6.2 与现代前端集成:Headless Plone的迁移协同
Plone 6支持Headless模式(plone.restapi),但前端React/Vue应用需要的数据结构往往与Plone原生schema不同。我们用Transmogrifier做了“反向管道”:
- 创建
headless-export.cfg,[source]读取portal_catalog,[fieldmapper]把title→headline、text→body_html、image→hero_image_url - 输出JSON到
/var/www/headless-data/,Nginx直接托管 - 前端
fetch('/headless-data/news.json'),获得开箱即用的数据
这避免了前端反复调用REST API,也绕过了CORS问题。一个curl -X POST http://localhost:8080/Plone/@@transmogrify?config=headless-export.cfg就能触发全量同步。
6.3 安全合规增强:GDPR/等保要求的落地
政务和金融客户最关心数据安全。我们在[fieldmapper]中嵌入合规检查:
[gdpr-filter] blueprint = collective.transmogrifier.sections.pythonscript script = def process(item): # 移除个人敏感字段(根据GDPR第9条) sensitive_fields = ['phone', 'id_card', 'bank_account'] for field in sensitive_fields: if field in item: del item[field] # 添加审计字段 from DateTime import DateTime item['audit_created_by'] = 'migration-bot' item['audit_created_at'] = DateTime() return item这个section放在[constructor]之前,确保所有入库数据都经过脱敏和审计标记。审计字段后续可被plone.app.versioningbehavior记录,满足等保2.0“安全审计”要求。
7. 个人实操体会:为什么我坚持在每个Plone项目里预装Transmogrifier
写这篇长文时,我翻出了2015年的一个旧项目笔记,里面写着:“今天又花了6小时修复迁移脚本,因为客户临时说‘那个字段要按新规则处理’”。那时我们没有Transmogrifier,只有散落在custom_migration.py里的37个函数,没人敢动,怕牵一发而动全身。
现在,我的每个Plone项目初始化脚本里,第一行就是pip install collective.transmogrifier。不是因为它能解决所有问题,而是因为它把“迁移”这件事,从一场充满不确定性的冒险,变成了一次可计划、可分工、可验证的工程实践。当我向客户演示migrate-to-plone6.cfg时,他们看到的不是代码,而是一张清晰的业务规则地图:左边是他们的旧系统字段,右边是Plone的新结构,中间是可讨论、可修改、可签字确认的映射逻辑。
最近一次迁移,客户方的处长在映射表上亲手划掉了3个字段,加了2个新字段,整个过程只用了20分钟。这在以前是不可想象的——以前改一个字段,意味着开发要停下手头所有事,改脚本、测环境、重新跑全量,然后祈祷别出错。
所以,如果你正在评估Plone迁移方案,请别只看它能“导入多少条数据”,而要看它能否让你的业务方、法务、内容编辑,都坐在同一张桌子前,指着同一份配置文件,说出“这里,按这个规则,改”。这才是Transmogrifier不可替代的价值。它不制造奇迹,但它让专业的人,用专业的方式,把一件难事,做成一件平常事。