您现在的位置: 365建站网 > 365文章 > PHP 如何设计自定义 CMS?内容模型、分层架构与缓存失效方案

PHP 如何设计自定义 CMS?内容模型、分层架构与缓存失效方案

文章来源:365jz.com 点击数:10 更新时间:2024-06-24 19:28

自己写 CMS 的人不少,但很多版本写到中后期都会遇到同样的困境:加一个内容类型要改七八个文件,模板里塞满查询逻辑,改字段就升级失败。问题不在代码量,而在架构分层从一开始就没定。本文给出一套能长期演进的 PHP 内容管理系统架构设计。

PHP内容管理系统架构设计

一、先定清 CMS 的边界

一个能维护的 CMS 至少要拆成六层,每层只依赖它下面一层:

入口 index.php
  └─ Router(URI → Handler)
      └─ Controller / Action(参数校验、编排)
          ├─ Service(业务规则:发布、审核、版本)
          │    └─ Repository(数据存取,唯一接触 SQL 的层)
          ├─ Template Engine(渲染,只做展示)
          └─ Event / Hook(扩展点)
  └─ 公共设施:Config、DBAL、Cache、Logger、Auth、Queue

三条纪律:模板不查库、Service 不知道 HTTP、SQL 只出现在 Repository。守住这三条,后期换框架、换数据库、做 API 化都不用重写。

二、数据模型:内容类型必须可扩展

CMS 的核心矛盾是“栏目千差万别,但代码要统一”。两种主流建模:

方案 A:主表 + EAV(扩展属性表)

content(id, typeid, title, slug, status, pub_at, author_id, ...)
content_field(field_id, content_id, name, value)   -- 竖表
field_def(id, typeid, name, type, required, sort)

优点:任意字段无需改表;缺点:查询/排序/统计困难,一次列表页要 JOIN 多个字段。适合字段少变的场景。

方案 B:JSON 列 + 生成的搜索列(现代推荐)

CREATE TABLE content (
  id       BIGINT UNSIGNED AUTO_INCREMENT PRIMARY KEY,
  typeid   INT UNSIGNED NOT NULL,
  title    VARCHAR(255) NOT NULL,
  slug     VARCHAR(190) NOT NULL,
  status   TINYINT NOT NULL DEFAULT 0,       -- 0草稿 1已发布 2待审 3归档
  body     LONGTEXT,
  meta     JSON NOT NULL,                    -- 扩展字段全部进这里
  price    DECIMAL(10,2) GENERATED ALWAYS AS (JSON_UNQUOTE(JSON_EXTRACT(meta,'$.price'))) STORED,
  pub_at   DATETIME NULL,
  FULLTEXT KEY ft_title_body (title, body) WITH PARSER ngram,
  UNIQUE KEY uk_slug (slug),
  KEY idx_type_status_pub (typeid, status, pub_at)
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4;
-- MySQL 8.0.17+ 可直接建索引:CREATE INDEX idx_price ON content ((CAST(meta->>'$.price' AS DECIMAL(10,2))));

这套兼顾灵活与查询能力,也是当下多数新 CMS 的做法。注意 ngram 全文索引需要设 ngram_token_size=2 才适合中文分词。

另外三张必备表:category(树形,用 parent_id + left_id/right_id 或闭包表 category_closure)、user / role / permission、revision(内容版本,字段同 content 但加 content_id, version)。

三、路由与渲染:URL 规则要能配

CMS 的 URL 是产品的一部分。设计成可配置的规则表而不是硬编码:

// route_rule 表:typeid, pattern, handler, cache_ttl
'/{category}/{slug}'            → content@show
'/blog/{year:\d{4}}/{month:\d{2}}' → archive@month
'/tag/{slug}'                   → taxonomy@show

渲染分两条路径:HTML 走模板引擎(Twig/Blade),数据走 API(同一套 Repository,Controller 只是序列化差异)。这样前端换 Vue/小程序时零成本。

模板体系要支持继承 + 主题覆盖:查找顺序为 主题A/blog/show.html → 基础主题/blog/show.html → 默认。实现方式是模板查找路径数组 + 首个命中,这也是 DedeCMS/Discuz 的做法。

四、缓存:三层,且必须能精确失效

  • L1 页面缓存:整页 HTML,按 URL + 语言 + 是否登录 作键,存 Redis 或文件,TTL 5~30 分钟。收益最大,风险是脏页。
  • L2 对象缓存:栏目树、字段定义、菜单——这些几乎不变,应常驻直到显式失效。
  • L3 查询缓存:MySQL 自身 query cache 已废弃,别指望;靠 L1/L2 即可。

关键机制是标签失效(tag invalidation):每个缓存项记下依赖的 content:123、category:7,发布/修改时只删相关标签的缓存。Redis 用 SET 存 tag → keys 映射,或用 Cache::tags()(Laravel 自带,其它框架可自实现约 60 行)。

// 发布文章后
$event->dispatch('content.published', ['id' => 123, 'typeid' => 7]);
// 监听器
$cache->invalidateTags(['content:123', 'category:7', 'list:typeid:7']);
$queue->push('rebuild', ['typeid' => 7]);   // 需要静态化的场景

五、扩展点:Hook 而不是改源码

老 CMS 的通病是二次开发靠改核心文件,升级即失效。解决方式是事件/钩子:

final class Hook {
    private static array $map = [];
    public static function on(string $name, callable $fn, int $priority = 10): void {
        self::$map[$name][$priority][] = $fn;
    }
    public static function fire(string $name, array $ctx = []): array {
        $list = self::$map[$name] ?? [];
        ksort($list);
        foreach ($list as $fns) { foreach ($fns as $fn) { $ctx = $fn($ctx) ?? $ctx; } }
        return $ctx;
    }
}
// 插件只需注册
Hook::on('content.before_save', function ($ctx) {
    $ctx['data']['slug'] ??= slugify($ctx['data']['title']);
    return $ctx;
});

约定好稳定的钩子名集合(content.before_save / after_publish / template.render / menu.build / search.index),插件就只依赖这些名字,不依赖实现。这就是 WordPress 的 add_action/apply_filters 之所以长命的核心原因。

六、权限与审核流

CMS 的权限不能只有“能不能进后台”。推荐 RBAC + 内容级作用域:

  • 权限点形如 content.edit、content.publish、content.edit.own、category.manage:7;
  • 角色 → 权限点,用户 → 角色;再加上“范围”维度(全站 / 指定栏目 / 仅自己的稿件),因为编辑往往只能管自己频道;
  • 审核流用状态机:draft → submitted → reviewing → published → archived,非法跳转直接拒绝;每次转换写 audit_log(who/when/from/to/ip)。

七、可维护性清单

  • 安装与升级:迁移文件按序号(0001_init.sql、0002_add_meta.sql)+ migration_version 表记录已执行项;绝不手写“顺便改下数据库”。
  • 配置分层:config/*.php 默认值 + .env 覆盖 + 数据库可改项(站点名、开关)三层,别把密钥写进代码库。
  • 后台与前台分离:入口不同(/admin 走独立中间件与 IP/二次验证),Session 隔离。
  • 搜索:数据量 > 百万级就接 Meilisearch/Elasticsearch,用队列异步建索引,MySQL 全文只做兜底。
  • 多语言 / 多站点:一开始就决定是加 locale 字段还是分库(多租户);后期改造极贵。字段设计里预留 site_id。

八、常见问题 FAQ

问:为什么不直接用 WordPress/Discuz?
答:能用就用,自研 CMS 的合理性通常只出现在“数据模型差异极大 + 强合规要求 + 需深度嵌入自有业务”这三种情况。否则你会长期维护一个别人已经做好的轮子。

问:静态化还有必要吗?
答:高并发读、少改动的门户/文章站仍然有效(把 HTML 推到 CDN)。做法是发布事件触发重建任务,配合 try_files 优先返回已存在的静态文件。

问:Service 和 Repository 是不是过度设计?
答:项目只有 5 张表时确实显得重。但只要出现“同一个业务动作要被后台、API、定时任务三处调用”,分层立刻就有价值。

总结架构骨架就一句话:内容类型可扩展、模板不含逻辑、缓存可精确失效、扩展靠钩子、变更靠迁移。这五条守住,CMS 能被不同人接手十年。

生产内容,同时打通搜索引擎(SEO)与AI搜索引擎(GEO)流量入口的软件! 点击进入365建站器 »