如何用PHP项目实现用户自定义多租户?

wen java案例 2

本文目录导读:

如何用PHP项目实现用户自定义多租户?

  1. 核心设计原则
  2. 方案一:行级隔离 + EAV/Json字段(适合小型项目,灵活但查询复杂)
  3. 方案二:Schema/数据库隔离 + 动态Schema变更(适合中型项目,强隔离性)
  4. 方案三:纯JSON/NoSQL模式(适合极高灵活性,但需牺牲数据库强约束)
  5. 前端配合:动态表单生成
  6. 最佳实践与注意事项
  7. 总结建议

这是一个关于多租户架构的复杂问题,在PHP项目中实现“用户自定义多租户”(通常意味着租户可以自定义功能、字段或视图),需要从数据隔离Schema扩展配置管理三个核心维度来设计。

下面我提供一个从基础到进阶的完整实现方案,重点在于让租户能灵活定制自身行为

核心设计原则

  1. 租户识别:通过域名、子域名或请求头参数(X-Tenant-ID)识别。
  2. 数据隔离:选择数据库(Database)隔离、Schema 隔离或行级(Row-Level/tenant_id字段)隔离。
  3. 自定义能力:通过“元数据驱动”而非硬编码字段来实现自定义。
  4. 配置缓存:针对每个租户的自定义配置进行高性能缓存。

行级隔离 + EAV/Json字段(适合小型项目,灵活但查询复杂)

这种模式下,所有租户共享同一张表,通过 tenant_id 行级字段隔离,自定义字段通过 EAV 或 JSON 实现。

数据库结构 (MySQL为例)

-- 核心租户表
CREATE TABLE tenants (
    id BIGINT AUTO_INCREMENT PRIMARY KEY,
    domain VARCHAR(255) UNIQUE NOT NULL,
    name VARCHAR(255),
    settings JSON COMMENT '租户自定义全局设置(如主题色、Logo)',
    status ENUM('active', 'suspended') DEFAULT 'active',
    created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP
);
-- 用户表(行级隔离)
CREATE TABLE users (
    id BIGINT AUTO_INCREMENT PRIMARY KEY,
    tenant_id BIGINT NOT NULL,
    name VARCHAR(255),
    email VARCHAR(255),
    -- 标准字段
    created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP,
    FOREIGN KEY (tenant_id) REFERENCES tenants(id)
);
-- 方案A: 自定义字段表 (EAV模式 - 适用于字段极少且查询简单)
CREATE TABLE user_custom_fields (
    id BIGINT AUTO_INCREMENT PRIMARY KEY,
    tenant_id BIGINT NOT NULL,
    user_id BIGINT NOT NULL,
    field_key VARCHAR(100),  -- 如: 'department', 'employee_number'
    field_value TEXT,
    FOREIGN KEY (tenant_id) REFERENCES tenants(id),
    FOREIGN KEY (user_id) REFERENCES users(id),
    UNIQUE KEY unique_field (tenant_id, user_id, field_key)
);
-- 方案B: 使用JSON列(推荐,现代MySQL/MariaDB支持)
ALTER TABLE users ADD COLUMN custom_fields JSON AFTER email;
-- 示例JSON: {"department": "IT", "employee_number": "EMP001"}
-- 租户自定义字段定义表(定义哪些字段可用)
CREATE TABLE tenant_custom_field_definitions (
    id BIGINT AUTO_INCREMENT PRIMARY KEY,
    tenant_id BIGINT NOT NULL,
    field_key VARCHAR(100) NOT NULL,
    field_label VARCHAR(255),
    field_type ENUM('text', 'number', 'date', 'select', 'multiselect'),
    field_options JSON COMMENT 'select/multiselect的选项',
    is_required BOOLEAN DEFAULT FALSE,
    sort_order INT DEFAULT 0,
    FOREIGN KEY (tenant_id) REFERENCES tenants(id)
);

PHP后端关键代码 (封装在TenantService中)

<?php
class TenantService {
    private $currentTenantId;
    public function initialize() {
        // 根据域名或Header获取当前租户
        $domain = $_SERVER['HTTP_HOST'] ?? '';
        $tenant = $this->getTenantByDomain($domain);
        if (!$tenant || $tenant['status'] !== 'active') {
            throw new \Exception('Tenant not found or inactive');
        }
        $this->currentTenantId = $tenant['id'];
        // 将租户设置注入到应用上下文
        app()->instance('tenant', $tenant);
        app()->instance('tenantSettings', json_decode($tenant['settings'], true));
    }
    // 核心:获取租户自定义字段定义
    public function getCustomFieldDefinitions() {
        $cacheKey = "tenant:{$this->currentTenantId}:custom_fields";
        return cache()->remember($cacheKey, 3600, function() {
            return DB::table('tenant_custom_field_definitions')
                ->where('tenant_id', $this->currentTenantId)
                ->orderBy('sort_order')
                ->get()
                ->toArray();
        });
    }
    // 查询用户时,自动处理JSON字段
    public function getUserWithCustomFields($userId) {
        $user = DB::table('users')
            ->where('tenant_id', $this->currentTenantId)
            ->where('id', $userId)
            ->first();
        if ($user && $user->custom_fields) {
            $customFields = json_decode($user->custom_fields, true);
            $definitions = $this->getCustomFieldDefinitions();
            // 将字段定义和实际值合并,返回给前端渲染
            foreach ($definitions as $def) {
                $user->custom_fields_display[] = [
                    'key' => $def->field_key,
                    'label' => $def->field_label,
                    'type' => $def->field_type,
                    'value' => $customFields[$def->field_key] ?? null,
                    'options' => $def->field_options ? json_decode($def->field_options, true) : null,
                ];
            }
        }
        return $user;
    }
    private function getTenantByDomain($domain) {
        return DB::table('tenants')->where('domain', $domain)->first();
    }
    public function getCurrentTenantId() {
        return $this->currentTenantId;
    }
}

中间件自动注入租户ID

<?php
// Laravel风格中间件
namespace App\Http\Middleware;
use Closure;
use App\Services\TenantService;
class TenantMiddleware {
    public function handle($request, Closure $next) {
        try {
            $tenantService = app(TenantService::class);
            $tenantService->initialize();
            // 自动绑定到查询作用域(使用Laravel的Global Scope)
            // 或手动在每次模型查询时加 where('tenant_id', $tenantId)
            $request->attributes->set('current_tenant_id', $tenantService->getCurrentTenantId());
        } catch (\Exception $e) {
            return response('Tenant error: ' . $e->getMessage(), 403);
        }
        return $next($request);
    }
}

Schema/数据库隔离 + 动态Schema变更(适合中型项目,强隔离性)

每个租户拥有独立的数据库或Schema,自定义字段通过动态建表实现。

架构设计

  • 路由层:中间件根据租户ID动态切换数据库连接。
  • 元数据表system_db.tenant_customizations 记录每个租户的自定义表结构变化。
  • DDL执行器:安全的SQL生成器,根据租户请求动态添加列。

动态数据库连接切换

<?php
// config/database.php 配置
return [
    'default' => env('DB_CONNECTION', 'mysql'),
    'connections' => [
        'tenant' => [], // 占位符,实际动态生成
        'system' => [
            'driver' => 'mysql',
            'host' => env('DB_HOST', '127.0.0.1'),
            'database' => 'system_db', // 存放租户元数据的库
            'username' => env('DB_USERNAME'),
            'password' => env('DB_PASSWORD'),
        ],
    ],
];
// PHP动态切换
<?php
class DynamicDatabase {
    public static function switchToTenant($tenantId) {
        $tenant = DB::connection('system')
            ->table('tenants')
            ->where('id', $tenantId)
            ->first();
        $connectionName = "tenant_{$tenantId}";
        config(["database.connections.{$connectionName}" => [
            'driver' => 'mysql',
            'host' => $tenant->db_host,
            'port' => $tenant->db_port ?? 3306,
            'database' => "tenant_{$tenantId}", // 每个租户一个数据库
            'username' => $tenant->db_username,
            'password' => decrypt($tenant->db_password),
            'charset' => 'utf8mb4',
            'prefix' => '',
        ]]);
        DB::purge($connectionName);
        DB::setDefaultConnection($connectionName);
    }
}

动态字段管理系统

<?php
class TenantSchemaManager {
    /**
     * 为租户添加自定义字段(动态改表结构)
     */
    public function addCustomField($tenantId, $tableName, $fieldKey, $fieldType, $fieldLength = null) {
        // 1. 检查表是否存在
        $hasTable = DB::statement("SELECT 1 FROM information_schema.tables 
            WHERE table_schema = ? AND table_name = ?", ["tenant_{$tenantId}", $tableName]);
        if (!$hasTable) {
            throw new \Exception("Table {$tableName} does not exist");
        }
        // 2. 使用安全的表名和字段名(防止注入)
        $safeTable = preg_replace('/[^a-zA-Z0-9_]/', '', $tableName);
        $safeField = 'custom_' . preg_replace('/[^a-zA-Z0-9_]/', '', $fieldKey);
        // 3. 决定SQL类型
        $sqlType = match ($fieldType) {
            'text' => 'VARCHAR(255)',
            'longtext' => 'TEXT',
            'number' => 'INT',
            'decimal' => 'DECIMAL(10,2)',
            'date' => 'DATE',
            'boolean' => 'TINYINT(1)',
            default => 'VARCHAR(255)'
        };
        if ($fieldLength && $fieldType === 'text') {
            $sqlType = "VARCHAR({$fieldLength})";
        }
        // 4. 执行DDL(使用参数化绑定无法保护表名/字段名,但已做了清理)
        DB::statement("ALTER TABLE `tenant_{$tenantId}`.`{$safeTable}` 
            ADD COLUMN `{$safeField}` {$sqlType} NULL");
        // 5. 记录元数据
        DB::connection('system')->table('custom_field_metadata')->insert([
            'tenant_id' => $tenantId,
            'table_name' => $tableName,
            'field_key' => $fieldKey,
            'field_type' => $fieldType,
            'column_name' => $safeField,
            'created_at' => now(),
        ]);
    }
    /**
     * 动态查询:根据请求的字段列表构建SELECT
     */
    public function queryWithCustomFields($tenantId, $tableName, array $customFieldsToSelect) {
        // 获取该租户在此表上注册的自定义字段列名映射
        $columns = DB::connection('system')
            ->table('custom_field_metadata')
            ->where('tenant_id', $tenantId)
            ->where('table_name', $tableName)
            ->whereIn('field_key', $customFieldsToSelect)
            ->pluck('column_name', 'field_key');
        // 构建SELECT语句
        $selectFields = ['id', 'name']; // 基础字段
        foreach ($columns as $fieldKey => $columnName) {
            $selectFields[] = "{$columnName} as custom_{$fieldKey}";
        }
        return DB::table("tenant_{$tenantId}.{$tableName}")
            ->select($selectFields);
    }
}

纯JSON/NoSQL模式(适合极高灵活性,但需牺牲数据库强约束)

几乎所有数据都以JSON存入单个或少量表,利用MongoDB或PostgreSQL的JSONB。

-- PostgreSQL示例
CREATE TABLE entity_data (
    id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
    tenant_id BIGINT NOT NULL,
    entity_type VARCHAR(50) NOT NULL,  -- 'user', 'product', 'order'
    data JSONB NOT NULL,               -- 完全灵活的租户自定义数据
    created_at TIMESTAMP DEFAULT NOW(),
    updated_at TIMESTAMP DEFAULT NOW(),
    FOREIGN KEY (tenant_id) REFERENCES tenants(id)
);
-- 常用查询需要建立GIN索引
CREATE INDEX idx_entity_data_gin ON entity_data USING gin (data jsonb_path_ops);
CREATE INDEX idx_tenant_entity ON entity_data (tenant_id, entity_type);
-- 查询示例: 查找所有 custom_field.department = 'IT' 的用户
SELECT * FROM entity_data 
WHERE tenant_id = 1 
  AND entity_type = 'user' 
  AND data @> '{"department": "IT"}';

前端配合:动态表单生成

实现“用户自定义”时,前端需要根据后端返回的字段定义渲染表单。

// 基于字段定义渲染动态表单
function renderCustomForm(fieldDefinitions) {
    const form = document.getElementById('custom-fields-form');
    form.innerHTML = '';
    fieldDefinitions.forEach(field => {
        const wrapper = document.createElement('div');
        wrapper.className = 'form-group';
        const label = document.createElement('label');
        label.textContent = field.label;
        wrapper.appendChild(label);
        let input;
        switch (field.type) {
            case 'text':
                input = `<input type="text" name="custom_${field.key}" value="${field.value || ''}" ${field.is_required ? 'required' : ''}>`;
                break;
            case 'select':
                const options = field.options.map(opt => 
                    `<option value="${opt.value}" ${opt.value === field.value ? 'selected' : ''}>${opt.label}</option>`
                ).join('');
                input = `<select name="custom_${field.key}">${options}</select>`;
                break;
            case 'date':
                input = `<input type="date" name="custom_${field.key}" value="${field.value || ''}">`;
                break;
        }
        wrapper.innerHTML += input;
        form.appendChild(wrapper);
    });
}

最佳实践与注意事项

  1. 缓存策略

    • 租户的自定义字段定义(field_definitions)应缓存在Redis/Memcached中。
    • 缓存Key示例:tenant:{id}:schema:{entity_type}
    • 当租户更新自定义字段时,主动清除缓存。
  2. 搜索与排序

    • 方案一(JSON):使用MySQL的 JSON_EXTRACT->> 运算符,但索引复杂。
    • 方案二(独立列):可以直接在动态添加的列上建立索引,性能最好。
    • 对于大数据量,考虑将常用自定义字段提升为真实列,其余保留为JSON。
  3. 审计日志

    记录每个租户对元数据(字段定义)的修改,防止误操作。

  4. 成本控制

    • 方案二(Schema/DB隔离) 当租户数量很大(>1000)时,数据库连接池和运维成本激增。
    • 方案一(行级) 更经济,但需要谨慎设计索引,避免跨租户的慢查询。
  5. 安全底线

    • 所有用户输入的表名、字段名,必须经过白名单验证或正则清洗(preg_replace('/[^a-zA-Z0-9_]/', '', $input))。
    • 动态生成的SQL(如 ALTER TABLE 中的列名)绝对不能直接拼接用户输入。

总结建议

  • 小型/快速验证:推荐 方案一JSON列 + 字段定义表 组合,配合Laravel的 JSON casts 使用起来非常流畅。
  • 中型/性能要求高/强隔离:推荐 方案二数据库隔离 + 动态DDL,并增加一个“元数据管理后台”让租户管理员操作。
  • 大型/SaaS平台:通常采用 混合模式:基础表用行级隔离,自定义字段用动态列(方案二),高频查询字段物化为索引列,低频查询留在JSON中。

实现用户自定义多租户的关键不在于技术难度,而在于“定义好自定义的边界”——即:租户可以改什么,不可以改什么?这通常需要和产品经理共同制定规则。

抱歉,评论功能暂时关闭!