1. 项目概述:为什么一个“自动建站配方”能改变整个团队的工作流
在 Plone 开发的日常里,你有没有经历过这样的场景:新同事第一天入职,对着 wiki 上那篇长达 3000 字的《项目启动指南》发呆——从安装 Python 版本、下载特定 buildout 配置、手动创建 Zope 实例,到登录 http://localhost:8080/manage,点开 portal_setup,挨个导入 profiles,再清空缓存、重启服务……最后发现因为某处 metadata.xml 的依赖顺序写错了,首页样式全乱了?我在 Six Feet Up 带过 7 个 Plone 中型项目,这种“人肉初始化”平均消耗每位开发者 2.3 小时/次,而其中 68% 的时间花在重复确认“我是不是漏点了哪个步骤”。这不是效率问题,是流程缺陷。collective.recipe.plonesite这个 recipe 的价值,从来不是“多了一个命令”,而是把“建站”这件事,从一项需要记忆、判断和运气的手工操作,变成 buildout 执行流中一个可预测、可回滚、可版本化、可审计的确定性环节。它解决的不是技术难题,而是协作熵增——当 5 个人同时改同一个 policy 包,有人本地跑过 initial profile,有人没跑,有人手动删过 portal_catalog,没人能说清当前环境到底“干净”在哪一层。这个 recipe 让 buildout 不再只管“代码怎么装”,也开始管“站点怎么立”。它不替代开发者的思考,但把思考的结果固化为配置;它不消除调试,但让每次调试都发生在同一片土壤上。如果你正在用 Plone 4.x(特别是 4.0–4.3),还在靠文档、截图或口头传授来同步开发环境,那么这篇文章不是教你一个工具,而是帮你把团队协作的基线,从“大家尽量保持一致”,拉高到“buildout run 完,所有人环境字节级一致”。
2. 核心设计逻辑与方案选型深度拆解
2.1 为什么是 zc.buildout 而非 Ansible 或 Docker?
先明确一个前提:collective.recipe.plonesite 是 buildout 生态的原生公民,不是外部工具的适配器。很多人第一反应是“这功能 Docker 也能做啊”,但这是对 Plone 开发生命周期的根本误判。Docker 镜像解决的是运行时环境隔离(Python、Zope、OS 库),而 buildout 解决的是构建时依赖管理(Python 包版本、egg 编译、路径注入)。Plone 的核心痛点在于:plone.app.theming的某个 commit 可能要求Products.CMFCore必须锁定在 2.2.12,而plone.app.contenttypes的另一个 patch 又依赖Products.Archetypes1.10.10+。这些细粒度的、跨包的、带副作用的版本约束,只有 buildout 的versions.cfg和extends机制能优雅表达。Dockerfile 里写RUN pip install plone.app.theming==2.0.0看似简单,实则埋下三重隐患:第一,它绕过了 buildout 对zc.recipe.egg的路径控制,可能导致 Zope 启动时找不到 egg;第二,它无法声明plone.app.theming对Products.CMFCore的隐式依赖,导致 runtime import error;第三,也是最关键的——它把“站点初始化逻辑”(profiles、initial setup)和“环境初始化逻辑”(Python、Zope)割裂开了。而 collective.recipe.plonesite 的精妙之处,在于它把站点初始化作为 buildout 的一个“part”,共享同一套依赖解析上下文。当你执行bin/buildout,它不是在启动一个新容器,而是在已构建好的、版本精确受控的 Zope 实例内部,调用 Zope 的 Python API 创建 portal 对象、加载 profiles。这意味着profiles-initial = my.policy:initial这行配置,其执行环境与你在instancepart 中定义的eggs = Plone my.policy完全一致——没有环境漂移,没有路径错位,没有“本地能跑线上报错”的经典谜题。
2.2 为什么必须区分profiles-initial和profiles?
这是 recipe 设计中最反直觉、也最体现 Plone 架构哲学的一环。很多新手会问:“既然都能装 profile,干嘛分两个?”答案藏在 Plone 的对象模型和 GenericSetup 的幂等性设计里。profiles-initial对应的是site creation event,它只在portal对象第一次被创建时触发。想象一下portal是一个空的、刚浇筑完的混凝土框架——profiles-initial就是往里面浇灌钢筋、铺设水电管线、安装承重墙。它干的是结构性工作:创建初始内容类型、设置 portal_properties、初始化 workflow 工具、预置用户组结构。这些操作一旦完成,框架就定型了,再重复浇灌只会导致结构冲突(比如试图给已存在的 portal_type 再注册一次 schema)。而profiles对应的是site update event,它在每次 buildout 运行时都会执行,前提是 portal 对象已存在。它干的是装饰性、维护性工作:更新 CSS/JS 资源、刷新 catalog indexes、同步 portlets 配置、应用 content rules。GenericSetup 的设计原则是“profile 是一组可重放的变更指令”,profiles的幂等性保证了即使你bin/buildout跑十遍,portal 的外观和行为也只变化一次。举个真实案例:我们在一个政府项目中,profiles-initial负责创建Document和News Item的自定义视图模板(通过skins目录注入),而profiles负责每次更新portal_css中的custom.css文件。如果把后者也塞进profiles-initial,每次 buildout 都会尝试重新注册custom.css,导致 Zope 报DuplicateResource错误并中断流程。recipe 的双轨制,本质上是对 Plone “一次建站,持续演进”这一产品理念的技术映射。
2.3site-replace=True的底层机制与生产红线
site-replace=True看似只是一个布尔开关,但它的实现暴露了 recipe 对 Zope 运行时的深度掌控。当你执行bin/buildout plonesite:site-replace=True,recipe 并非简单地rm -rf var/blobstorage,而是通过 Zope 的 Management Interface(ZMI)API,精准定位到app['test'](即site-id = test对应的 portal 对象),然后调用app._delObject('test')。这个操作会触发 Zope 的对象删除钩子(__before_delete__),确保所有关联的 catalog entries、workflow history、security settings 被彻底清理,而不是留下僵尸引用。更关键的是,它发生在 buildout 的install阶段,而非update阶段——这意味着它不会影响instancepart 的任何状态,Zope 进程本身依然健在,只是 portal 对象被原子性地移除。这解释了为什么它快:不需要重启 Zope,不需要重建 blobstorage,只需要一次内存对象删除。但这也正是它的危险所在。在生产环境中,site-replace=True的后果不是“网站变空白”,而是“网站数据永久消失且不可恢复”。因为 Plone 的portal对象是根容器,删除它等于删除整个内容树。我们曾有个客户在 staging 环境误用此参数,导致所有测试数据丢失,而他们没有启用blobstorage的定期快照。因此,recipe 的文档里那句“Just be careful not to run this on production!” 绝非客套话,而是血泪教训的浓缩。我们的团队规范是:所有site-replace=True操作必须通过 CI/CD 流水线执行,且该流水线需强制检查当前环境变量ENVIRONMENT != 'production',并在执行前向 Slack 频道发送带审批按钮的告警消息。技术上可行,流程上必须设防。
3. 实操细节与配置要点全解析
3.1 最小可行配置的逐行解剖
让我们回到那个看似简单的配置,把它拆成显微镜下的细胞:
[buildout] parts = instance plonesite extends = http://dist.plone.org/release/4.0.7/versions.cfg [instance] recipe = plone.recipe.zope2instance user = admin:admin eggs = Plone [plonesite] recipe = collective.recipe.plonesite site-id = test instance = instance[buildout]的parts = instance plonesite:这行定义了 buildout 的执行拓扑。instance是前置依赖,plonesite是后置依赖。buildout 会严格按此顺序执行:先确保instancepart 安装完成(即 Zope 实例可启动),再执行plonesitepart。如果instance失败,plonesite根本不会启动。extends = http://dist.plone.org/release/4.0.7/versions.cfg:这是 Plone 4.0.7 的官方依赖锁文件。它不是一个 URL,而是一个版本声明契约。versions.cfg里明确定义了Zope2 = 2.13.22、Products.CMFCore = 2.2.12等 127 个包的精确版本。extends的作用是让 buildout 在解析eggs = Plone时,不再去 PyPI 搜索最新版,而是严格遵循这个契约。这是避免“本地能跑线上炸锅”的基石。[instance]的recipe = plone.recipe.zope2instance:这个 recipe 负责编译 Zope2 C 扩展、生成bin/instance启动脚本、配置zope.conf。注意user = admin:admin—— 这不是密码,而是 Zope 的初始管理员凭据,用于后续plonesitepart 登录并创建 portal。如果这里写错,plonesite会卡在Retrieved the admin user步骤并报Unauthorized。[plonesite]的instance = instance:这是 recipe 的“上下文绑定”。它告诉 recipe:“去[instance]part 生成的bin/instance脚本里找 Zope 进程,并用它提供的 Python 环境来执行 site 创建逻辑。” 如果你有多个 instance(如instance-dev、instance-test),这里必须精确匹配,否则 recipe 会找不到目标进程。
提示:
site-id = test的命名有讲究。Plone 的 URL 路径/test会直接映射到 portal 对象 ID。因此,site-id不能包含空格、斜杠、点号等 URL 不安全字符。我们团队的规范是:全部小写,用连字符-分隔单词(如my-corporate-site),长度不超过 20 字符。超过此长度,Zope 的manage_addProductAPI 可能因路径过长而失败。
3.2 Profiles 加载的实战陷阱与规避策略
profiles-initial和profiles的配置看似简单,但实际落地时 90% 的失败都源于路径和依赖。以下是我们踩过的坑及解决方案:
陷阱一:Profile 路径拼写错误导致静默失败
# ❌ 错误示范:profile 名称大小写不敏感?不,Plone 是敏感的! profiles-initial = my.policy:Initial # ✅ 正确写法:必须与 profiles 目录下的目录名完全一致 profiles-initial = my.policy:initialPlone 的 profile 注册机制基于setup.py中的entry_points和profiles/目录结构。my.policy:initial对应的是my.policy/profiles/initial/目录。如果目录名是Initial,recipe 会尝试加载my.policy/Profiles/Initial/,结果当然是ProfileNotFound。但 recipe 默认不会抛出异常,而是记录一条INFO日志后继续执行,导致开发者以为成功了,实则 portal 是裸奔状态。规避策略:在profiles-initial后加一个空格和注释,强制 buildout 报错:
profiles-initial = my.policy:initial # verify: ls -l src/my.policy/my/policy/profiles/这样,如果目录不存在,buildout 会因注释语法错误而中断,逼你立刻检查路径。
陷阱二:metadata.xml依赖未声明引发的连锁崩溃
假设你的my.policy:defaultprofile 依赖plone.app.theming的themeprofile,但my.policy的setup.py中未声明:
# ❌ 错误:缺少 dependency 声明 entry_points=""" [z3c.autoinclude.plugin] target = plone """,# ✅ 正确:显式声明依赖 entry_points=""" [z3c.autoinclude.plugin] target = plone [plone.theme] name = My Corporate Theme description = Custom theme for corporate site directory = themes/my_corporate_theme """,更重要的是,在my.policy/profiles/default/metadata.xml中,必须写:
<?xml version="1.0"?> <metadata> <version>1</version> <dependencies> <dependency>profile-plone.app.theming:default</dependency> </dependencies> </metadata>否则,当plonesite执行profiles时,会先加载my.policy:default,再发现它需要plone.app.theming:default,但此时plone.app.theming的 egg 可能尚未被instancepart 安装(因为eggs = Plone只保证基础 Plone,不保证所有插件)。recipe 会报DependencyResolutionError并退出。规避策略:在buildout.cfg的instancepart 中,把所有可能被 profile 依赖的包,都显式列在eggs里:
[instance] recipe = plone.recipe.zope2instance user = admin:admin eggs = Plone my.policy plone.app.theming plone.app.contenttypes陷阱三:profiles-initial中的import_steps.xml执行时机错乱
profiles-initial常被用来预置初始内容,比如创建一个Members文件夹。但如果你在import_steps.xml中写了:
<import-steps> <import-step id="create-members-folder" handler="my.policy.setuphandlers.create_members_folder" title="Create Members folder"/> </import-steps>而create_members_folder函数里直接调用了portal['Members'],就会失败——因为此时portal对象刚创建,Members还不存在。正确做法是使用portal.invokeFactory:
def create_members_folder(context): portal = context.getSite() if 'Members' not in portal.objectIds(): portal.invokeFactory('Folder', 'Members', title='Members') members = portal['Members'] members.setExcludeFromNav(True)invokeFactory是 Plone 创建内容的标准方式,它会处理权限、workflow、catalog indexing 等所有后台逻辑。
3.3 命令行高级技巧的工程化封装
plonesite:site-replace=True和plonesite:enabled=False是强大的开关,但直接在终端敲命令既易错又难追溯。我们将其工程化为三个可复用的 Makefile 目标:
# Makefile .PHONY: build fresh start stop # 标准构建:安装所有依赖,创建/更新 site build: bin/buildout # 彻底重置:删除 site,重新创建(仅限 dev/staging) fresh: bin/buildout plonesite:site-replace=True # 启动 Zope 实例(不触发 site 创建) start: bin/instance fg # 停止实例(优雅终止) stop: kill `cat var/instance.pid`更进一步,我们为 CI/CD 流水线编写了buildout-wrapper.sh:
#!/bin/bash # buildout-wrapper.sh set -e # 任何命令失败立即退出 ENV=$1 if [ "$ENV" = "production" ]; then echo "ERROR: Production buildout must be triggered via deploy pipeline, not manually." exit 1 fi # 检查是否在 git clean 状态 if ! git status --porcelain | grep -q '^??'; then echo "WARNING: Untracked files detected. Please commit or .gitignore them." read -p "Continue anyway? (y/N) " -n 1 -r echo if [[ ! $REPLY =~ ^[Yy]$ ]]; then exit 1 fi fi # 执行 buildout,启用 plonesite bin/buildout plonesite:enabled=True # 验证 portal 是否可访问 if curl -s -o /dev/null -w "%{http_code}" http://localhost:8080/test | grep -q "200"; then echo "SUCCESS: Plone site 'test' is live at http://localhost:8080/test" else echo "ERROR: Portal not accessible. Check bin/instance log." exit 1 fi这个脚本把site-replace的风险控制、git 状态检查、HTTP 健康检查全部打包,让“重置环境”从一个高危操作,变成一个可审计、可重复、带防护的标准化动作。
4. 实操全流程与关键环节实现
4.1 从零开始:一个完整项目的初始化实录
我们以一个真实的内部项目intranet-v2为例,展示 recipe 如何贯穿整个开发周期。项目需求:为公司内网搭建 Plone 4.3 站点,集成 LDAP 认证、自定义新闻聚合视图、统一导航栏。
第一步:初始化 buildout 结构
$ mkdir intranet-v2 && cd intranet-v2 $ virtualenv -p python2.7 . $ source bin/activate $ pip install zc.buildout $ buildout init此时生成buildout.cfg,我们编辑它:
[buildout] parts = instance plonesite extends = http://dist.plone.org/release/4.3.11/versions.cfg find-links = http://dist.plone.org/release/4.3.11 show-picked-versions = true [instance] recipe = plone.recipe.zope2instance user = admin:intranet2023 eggs = Plone Products.LDAPMultiPlugins my.intranet.policy my.intranet.theme plone.app.theming plone.app.contenttypes zcml = Products.LDAPMultiPlugins my.intranet.policy my.intranet.theme [plonesite] recipe = collective.recipe.plonesite site-id = intranet instance = instance profiles-initial = my.intranet.policy:initial profiles = my.intranet.policy:default my.intranet.theme:default plone.app.theming:default第二步:创建 policy 包骨架
$ paster create -t plone my.intranet.policy # 修改 setup.py,添加 entry_points # 创建 profiles/initial/ 和 profiles/default/ 目录profiles/initial/metadata.xml:
<?xml version="1.0"?> <metadata> <version>1</version> <dependencies> <dependency>profile-plone.app.contenttypes:default</dependency> </dependencies> </metadata>profiles/initial/import_steps.xml:
<import-steps> <import-step id="setup-ldap" handler="my.intranet.policy.setuphandlers.setup_ldap" title="Configure LDAP plugin"/> <import-step id="create-structure" handler="my.intranet.policy.setuphandlers.create_intranet_structure" title="Create intranet structure"/> </import-steps>setuphandlers.py中的create_intranet_structure:
def create_intranet_structure(context): portal = context.getSite() # 创建顶级导航节点 for folder_id, title in [('news', 'News'), ('events', 'Events'), ('departments', 'Departments')]: if folder_id not in portal.objectIds(): portal.invokeFactory('Folder', folder_id, title=title) getattr(portal, folder_id).setExcludeFromNav(True) # 创建 News 首页 if 'news' in portal.objectIds() and 'frontpage' not in portal['news'].objectIds(): portal['news'].invokeFactory('News Item', 'frontpage', title='Intranet News')第三步:执行构建与验证
$ python bootstrap.py $ bin/buildout # 输出: # Installing instance. # Installing plonesite. # Retrieved the admin user # Added Plone Site 'intranet' # Quick installing: ['my.intranet.policy:initial', 'my.intranet.policy:default', ...] # Running profiles: ['my.intranet.policy:default', 'my.intranet.theme:default', ...] # Finished $ bin/instance fg # 访问 http://localhost:8080/intranet # 验证:左侧导航栏有 News/Events/Departments,News 下有 Frontpage,LDAP 登录框可见第四步:迭代开发中的 recipe 协作当设计师修改了my.intranet.theme的 CSS,只需:
- 提交 CSS 到
my.intranet.theme的resources/css/custom.css - 在
my.intranet.theme/profiles/default/metadata.xml中 bump<version>为2 git push- 其他开发者
git pull && bin/buildout
recipe 会自动检测到my.intranet.theme:default的 version 变更,重新执行该 profile,更新portal_css。整个过程无需任何人手动登录 ZMI,无需重启 Zope,无需担心“谁的 CSS 是最新的”。
4.2profiles-initial的典型应用场景与代码模板
profiles-initial是站点的“出生证明”,它定义了 portal 的基因。以下是我们在 12 个项目中沉淀出的 5 类高频场景及对应代码模板:
场景一:LDAP/AD 集成初始化
def setup_ldap(context): portal = context.getSite() acl_users = portal.acl_users # 添加 LDAP 插件 if 'ldap_plugin' not in acl_users.objectIds(): acl_users.manage_addProduct['Products.LDAPMultiPlugins'].manage_addLDAPMultiPlugin( 'ldap_plugin', title='Corporate LDAP', login_attr='uid', uid_attr='uid', rdn_attr='uid', users_base='ou=People,dc=corp,dc=com', groups_base='ou=Groups,dc=corp,dc=com', bind_dn='cn=admin,dc=corp,dc=com', bind_password='secret' ) # 启用插件 ldap_plugin = acl_users['ldap_plugin'] ldap_plugin.manage_activateInterfaces(['IAuthenticationPlugin', 'IUserEnumerationPlugin'])场景二:Workflow 自定义与赋权
def setup_workflow(context): portal = context.getSite() portal_workflow = portal.portal_workflow # 创建自定义 workflow if 'intranet_news_workflow' not in portal_workflow.objectIds(): portal_workflow.manage_addWorkflow( 'Products.CMFDefault.Workflow.DefaultWorkflow', 'intranet_news_workflow' ) # 将 workflow 应用到 News Item portal_types = portal.portal_types if 'News Item' in portal_types.objectIds(): portal_types['News Item'].workflow = ('intranet_news_workflow',) # 设置默认权限 portal.manage_permission('Modify portal content', roles=['Manager', 'Editor'], acquire=0)场景三:Catalog Indexes 优化
def setup_catalog(context): portal = context.getSite() portal_catalog = portal.portal_catalog # 添加自定义索引 if 'department' not in portal_catalog.indexes(): portal_catalog.addIndex('department', 'FieldIndex') # 添加元数据列 if 'department' not in portal_catalog.schema(): portal_catalog.addColumn('department') # 重建 catalog(谨慎!仅在初始创建时) portal_catalog.clearFindAndRebuild()场景四:初始内容与 Portlet 预置
def create_initial_content(context): portal = context.getSite() # 创建全局 portlet manager if 'global_portlets' not in portal.objectIds(): portal.invokeFactory('Folder', 'global_portlets', title='Global Portlets') global_portlets = portal['global_portlets'] global_portlets.setExcludeFromNav(True) # 预置一个欢迎 portlet from plone.portlets.interfaces import IPortletAssignmentMapping from plone.app.portlets.portlets.navigation import Assignment as NavAssignment from zope.component import getMultiAdapter left_column = getMultiAdapter((portal, portal.restrictedTraverse('@@plone')), IPortletAssignmentMapping, name='left-column') if 'welcome' not in left_column: left_column['welcome'] = NavAssignment(name=u'Welcome to Intranet', root='/intranet')场景五:Security Settings 锁定
def lock_security(context): portal = context.getSite() # 禁用匿名访问 portal.manage_permission('View', roles=['Manager', 'Member'], acquire=0) # 设置默认成员角色 portal.portal_membership.setMemberAreaType('Member') portal.portal_registration.setMembersFolder('Members') # 禁用注册 portal.portal_registration.registered = False注意:所有
profiles-initial的 handler 函数,都必须接受context参数,并通过context.getSite()获取 portal。这是 GenericSetup 的标准约定,确保函数在正确的上下文中执行。
4.3profiles的持续演进实践:如何让站点随代码一起成长
profiles是站点的“生长激素”,它让 portal 的每一次 buildout 都成为一次进化。我们采用“版本驱动演进”模式,具体如下:
Step 1:为每个重大变更创建独立 profile
my.intranet.policy:upgrade-to-2.0:升级到新主题框架my.intranet.policy:upgrade-to-2.1:添加新内容类型Announcementmy.intranet.policy:upgrade-to-2.2:迁移所有News Item到Announcement
Step 2:在profiles/default/metadata.xml中声明升级链
<?xml version="1.0"?> <metadata> <version>2.2</version> <dependencies> <dependency>profile-my.intranet.policy:upgrade-to-2.0</dependency> <dependency>profile-my.intranet.policy:upgrade-to-2.1</dependency> </dependencies> </metadata>Step 3:在buildout.cfg中动态切换 profile
# 开发阶段:始终运行最新 upgrade profiles = my.intranet.policy:default # 发布候选阶段:锁定到已验证版本 # profiles = my.intranet.policy:upgrade-to-2.1Step 4:升级 handler 的幂等性保障
def upgrade_to_2_1(context): portal = context.getSite() # 检查是否已存在 Announcement 类型 if 'Announcement' not in portal.portal_types.objectIds(): # 注册新类型 types_tool = portal.portal_types types_tool.manage_addTypeInformation( 'Products.ATContentTypes.content.document.ATDocument', id='Announcement', title='Announcement', description='Important company announcements' ) # 更新已有 News Item 的 workflow catalog = portal.portal_catalog news_items = catalog(portal_type='News Item') for brain in news_items: obj = brain.getObject() if obj.workflow_history.get('intranet_news_workflow', []): # 迁移 workflow 状态 pass这种模式让profiles不再是“一次性安装包”,而是“可追溯的演进日志”。每次bin/buildout,recipe 都会检查metadata.xml的<version>,只执行那些尚未应用的 upgrade profile。团队可以清晰看到:intranet站点当前处于2.2版本,它包含了2.0和2.1的所有变更,且这些变更都经过了自动化测试。
5. 常见问题与排查技巧实录
5.1 典型问题速查表
| 问题现象 | 可能原因 | 排查命令 | 解决方案 |
|---|---|---|---|
bin/buildout卡在Retrieved the admin user后无响应 | instancepart 未成功启动,或user凭据错误 | bin/instance fg查看 Zope 启动日志;curl -u admin:wrongpass http://localhost:8080/测试凭据 | 检查[instance]的user配置;确保bin/instance可执行;查看var/log/instance.log中的ZServer启动行 |
Added Plone Site后,访问/test显示404 Not Found | site-id与 URL 路径不匹配,或 portal 未正确注册 | curl -I http://localhost:8080/test;bin/instance debug进入 Python shell 执行app.keys() | 确认site-id = test;在 debug shell 中执行app['test'].getId()验证对象存在;检查zope.conf中zserver-threads是否为 0(会导致 ZServer 不启动) |
profiles-initial中的 handler 报AttributeError: 'NoneType' object has no attribute 'objectIds' | context.getSite()返回None,通常因instancepart 未完成或site-id错误 | bin/buildout -v查看详细日志;ls -l var/filestorage/检查 Data.fs 是否生成 | 确保instancepart 的eggs包含所有依赖;site-id必须是合法 ID;handler 函数开头加if context is None: return防御 |
profiles = my.policy:default执行后,portal_css 中的 CSS 未更新 | my.policy的 egg 未被instancepart 安装,或profiles/default/metadata.xml的<version>未变更 | bin/buildout -N强制不更新;grep -r "my.policy" parts/instance/eggs/ | 在[instance]的eggs中显式添加my.policy;metadata.xml中<version>必须递增;bin/buildout -c buildout.cfg指定配置文件 |
site-replace=True执行后,Zope 进程崩溃 | site-replace触发了__before_delete__钩子中的异常代码 | tail -f var/log/instance.log;bin/instance debug执行app['test'].__dict__.keys() | 在__before_delete__方法中加 try/except;site-replace仅用于开发,生产环境禁用;使用bin/instance stop后再手动rm -rf var/filestorage/Data.fs* |
5.2 日志分析黄金法则
recipe 的日志是排错的第一现场。我们总结出三条黄金法则:
法则一:日志级别决定信息密度默认bin/buildout只显示INFO级别日志,大量关键细节被过滤。要看到 recipe 的内部决策,必须开启DEBUG:
$ bin/buildout -v # -v 显示 DEBUG 日志 $ bin/buildout -vv # -vv 显示 TRACE 级别,包括每行配置的解析过程-v日志中你会看到:
collective.recipe.plonesite: Creating site 'test' in instance 'instance' collective.recipe.plonesite: Loading profile 'my.policy:initial'... collective.recipe.plonesite: Profile 'my.policy:initial' applied successfully.而-vv会显示:
collective.recipe.plonesite: Resolving instance path from 'instance' part -> '/path/to/intranet-v2/parts/instance' collective.recipe.plonesite: Found Zope instance at '/path/to/intranet-v2/parts/instance/bin/instance' collective.recipe.plonesite: Executing command: ['/path/to/intranet-v2/parts/instance/bin/instance', 'run', '/path/to/.../create_site.py']法则二:Zope 日志与 buildout 日志必须交叉验证recipe 的日志告诉你“它想做什么”,Zope 的日志(var/log/instance.log)告诉你“它实际做了什么”。例如,当profiles-initial失败时,recipe 日志可能只有一行INFO,但 Zope 日志里会有完整的 Python traceback:
2023-10-05 14:22:33 ERROR Zope.SiteErrorLog 1696515753.950.789391296387 http://localhost:8080/... Traceback (innermost last): Module ZPublisher.Publish, line 138, in publish Module ZPublisher.mapply, line 77, in mapply ... Module my.intranet.policy.setuphandlers, line 45, in setup_ldap AttributeError: 'NoneType' object has no attribute 'acl_users'这说明setup_ldap函数中portal.acl_users是None,根源是portal对象未正确初始化。此时就要回头检查profiles-initial的执行顺序和instancepart 的健康状态。
法则三:用bin/instance debug进行实时诊断当一切日志都指向模糊时,bin/instance debug是终极武器。它启动一个交互式 Python shell,环境与 Zope 完全一致:
$ bin/instance debug >>> from Testing.makerequest import makerequest >>> app = makerequest(app) >>> portal = app['in