如何为PHP项目编写升级脚本?

wen PHP项目 3

本文目录导读:

如何为PHP项目编写升级脚本?

  1. 核心升级类型
  2. 升级脚本设计模式
  3. 数据库迁移详解
  4. 配置文件升级
  5. 依赖更新脚本
  6. 安全与最佳实践
  7. 典型升级执行命令

为PHP项目编写升级脚本是一个需要谨慎处理的任务,旨在将数据库结构、配置文件、代码逻辑等从旧版本平滑过渡到新版本。

以下是编写PHP项目升级脚本的完整指南,包括策略、步骤、代码示例和最佳实践。


核心升级类型

通常升级脚本需要处理以下几类变更:

  1. 数据库变更:新增/修改/删除表、字段、索引、外键。
  2. 配置文件变更config.php.env 中新增/废弃/修改配置项。
  3. 逻辑及数据迁移:需要批量转换旧数据格式(如将用户头像从本地路径改为云端URL)。
  4. 依赖更新composer.jsonpackage.json 中的依赖更新。
  5. 文件系统变更:移动/删除/新增目录或文件。

升级脚本设计模式

基于“里程碑”版本号(Version-Based)

每个版本对应一个独立的PHP脚本文件,按照版本号顺序执行。

文件结构示例:

migrations/
├── V1_0_1__add_email_column.php
├── V1_0_2__rename_status_field.php
├── V1_1_0__init_user_logs_table.php
└── V1_2_0__update_config_format.php

核心执行逻辑:

// upgrade.php (主入口)
class UpgradeRunner {
    private $db;
    private $currentVersion;
    private $migrationDir;
    private $versionTable = 'schema_versions';
    public function __construct() {
        $this->db = new PDO('mysql:host=localhost;dbname=myapp', 'user', 'pass');
        $this->migrationDir = __DIR__ . '/migrations/';
        $this->ensureVersionTable();
        $this->currentVersion = $this->getCurrentVersion();
    }
    private function ensureVersionTable() {
        $sql = "CREATE TABLE IF NOT EXISTS {$this->versionTable} (
            version VARCHAR(50) PRIMARY KEY,
            applied_at DATETIME DEFAULT CURRENT_TIMESTAMP,
            checksum VARCHAR(64)
        )";
        $this->db->exec($sql);
    }
    private function getCurrentVersion() {
        $stmt = $this->db->query("SELECT version FROM {$this->versionTable} ORDER BY version DESC LIMIT 1");
        $row = $stmt->fetch(PDO::FETCH_ASSOC);
        return $row ? $row['version'] : '0.0.0';
    }
    private function getMigrationFiles() {
        $files = glob($this->migrationDir . 'V*.php');
        usort($files, 'version_compare'); // 按版本号自然排序
        return $files;
    }
    public function run() {
        $files = $this->getMigrationFiles();
        foreach ($files as $file) {
            // 提取版本号:V1_0_1__xxx.php -> 1.0.1
            preg_match('/V(\d+_\d+_\d+)__/', basename($file), $matches);
            $fileVersion = str_replace('_', '.', $matches[1]);
            // 检查是否已执行
            if (version_compare($fileVersion, $this->currentVersion, '<=')) {
                continue;
            }
            echo "Applying migration: " . basename($file) . "\n";
            // 执行迁移脚本(注意:脚本应包含一个可调用的函数或类)
            $migration = require $file; // 假设返回一个闭包或对象
            if (is_callable($migration)) {
                $migration($this->db);
            }
            // 记录版本信息(计算文件哈希,防止篡改)
            $checksum = hash_file('sha256', $file);
            $stmt = $this->db->prepare("INSERT INTO {$this->versionTable} (version, checksum) VALUES (?, ?)");
            $stmt->execute([$fileVersion, $checksum]);
            $this->currentVersion = $fileVersion; // 更新内部状态
        }
        echo "All migrations applied. Current version: " . $this->currentVersion . "\n";
    }
}
// 使用
$runner = new UpgradeRunner();
$runner->run();

直接“增量执行”(SQL文件方式)

将升级脚本以 .sql 文件存放,搭配批量执行工具。

简单执行器:

class SqlMigrator {
    private $pdo;
    private $tableName = 'migrations';
    public function executeSqlFile($filePath) {
        $sql = file_get_contents($filePath);
        // 去除注释和空行
        $sql = preg_replace('/--.*\n/', '', $sql);
        $sql = preg_replace('/#.*\n/', '', $sql);
        $sql = preg_replace('/\/\*.*?\*\//s', '', $sql);
        $statements = explode(';', $sql);
        foreach ($statements as $stmt) {
            $stmt = trim($stmt);
            if (!empty($stmt)) {
                $this->pdo->exec($stmt);
            }
        }
    }
}

缺点:对于复杂的逻辑迁移(如逐行遍历旧数据并插入新表)能力不足。


数据库迁移详解

安全的DDL操作

// V1_0_1__add_email_column.php
return function (PDO $db) {
    // 使用 IF NOT EXISTS / ALTER 之前检查
    try {
        $db->exec("ALTER TABLE users ADD COLUMN email VARCHAR(255) NULL AFTER username");
        echo "✓ Added 'email' column to users table\n";
    } catch (PDOException $e) {
        // 如果列已存在,直接忽略
        if (strpos($e->getMessage(), 'Duplicate column') !== false) {
            echo "~ Column 'email' already exists, skipping\n";
        } else {
            throw $e;
        }
    }
};

复杂数据迁移(带事务和批量处理)

// V1_2_0__encrypt_sensitive_data.php
return function (PDO $db) {
    $db->beginTransaction();
    try {
        // 1. 创建临时字段
        $db->exec("ALTER TABLE users ADD COLUMN email_encrypted VARCHAR(512) NULL");
        // 2. 分批迁移旧数据
        $offset = 0;
        $limit = 1000;
        while (true) {
            $stmt = $db->prepare("SELECT id, email FROM users WHERE email IS NOT NULL AND email != '' LIMIT :limit OFFSET :offset");
            $stmt->execute(['limit' => $limit, 'offset' => $offset]);
            $rows = $stmt->fetchAll(PDO::FETCH_ASSOC);
            if (empty($rows)) break;
            foreach ($rows as $row) {
                $encrypted = openssl_encrypt($row['email'], 'AES-128-CBC', 'your-secure-key', 0, 'iv-16bytes-long');
                $updateStmt = $db->prepare("UPDATE users SET email_encrypted = :encrypted WHERE id = :id");
                $updateStmt->execute(['encrypted' => $encrypted, 'id' => $row['id']]);
            }
            $offset += $limit;
            echo "  Processed {$offset} rows...\n";
        }
        // 3. 删除旧字段,重命名新字段
        $db->exec("ALTER TABLE users DROP COLUMN email");
        $db->exec("ALTER TABLE users CHANGE COLUMN email_encrypted email VARCHAR(512) NULL");
        $db->commit();
        echo "✓ Encrypted all existing email data\n";
    } catch (Exception $e) {
        $db->rollBack();
        throw $e;
    }
};

回滚策略

编写 down() 方法(可选,但强烈建议):

// 升级脚本结构
return [
    'up' => function (PDO $db) {
        $db->exec("ALTER TABLE users ADD COLUMN avatar_url VARCHAR(500) NULL");
    },
    'down' => function (PDO $db) {
        $db->exec("ALTER TABLE users DROP COLUMN avatar_url");
    }
];

主执行器增加 --down 参数支持回滚。


配置文件升级

通用配置合并逻辑

// V1_3_0__add_cache_config.php
return function () {
    $configFile = __DIR__ . '/../config.php';
    $backupFile = $configFile . '.bak';
    // 1. 备份旧配置(关键步骤!)
    copy($configFile, $backupFile);
    // 2. 读取旧配置(假设是返回数组的PHP文件)
    $oldConfig = include $configFile;
    // 3. 合并新配置默认值
    $newConfig = array_merge($oldConfig, [
        'cache' => [
            'driver' => 'file',
            'prefix' => 'myapp_',
            'ttl' => 3600
        ],
        'app' => array_merge($oldConfig['app'] ?? [], [
            'debug' => strtolower(getenv('APP_DEBUG') ?: 'false')
        ])
    ]);
    // 4. 写回(保留用户原有值,新增默认值)
    $content = '<?php' . PHP_EOL . 'return ' . var_export($newConfig, true) . ';' . PHP_EOL;
    file_put_contents($configFile, $content);
    echo "✓ Configuration updated with cache settings\n";
};

环境变量文件升级

// V1_4_0__add_env_vars.php
return function () {
    $envFile = __DIR__ . '/../.env';
    $backupFile = $envFile . '.backup';
    // 1. 备份
    copy($envFile, $backupFile);
    // 2. 读取现有内容
    $existingLines = file($envFile, FILE_IGNORE_NEW_LINES | FILE_SKIP_EMPTY_LINES);
    $existingKeys = [];
    foreach ($existingLines as $line) {
        if (strpos($line, '=') !== false) {
            list($key) = explode('=', $line, 2);
            $existingKeys[trim($key)] = true;
        }
    }
    // 3. 新变量定义
    $newVars = [
        'CACHE_DRIVER' => 'file',
        'CACHE_PREFIX' => 'myapp_',
        'REDIS_HOST' => '127.0.0.1'
    ];
    // 4. 追加不存在的变量
    $fp = fopen($envFile, 'a');
    foreach ($newVars as $key => $defaultValue) {
        if (!isset($existingKeys[$key])) {
            fwrite($fp, PHP_EOL . "$key=$defaultValue");
        }
    }
    fclose($fp);
    echo "✓ .env variables appended (existing values preserved)\n";
};

依赖更新脚本

// V1_5_0__update_composer_and_cache.php
return function () {
    // 1. 更新 composer.json (可通过新增或修改 require)
    $composerFile = __DIR__ . '/../composer.json';
    $composer = json_decode(file_get_contents($composerFile), true);
    $composer['require']['monolog/monolog'] = '^2.0';
    $composer['require']['guzzlehttp/guzzle'] = '^7.0';
    file_put_contents($composerFile, json_encode($composer, JSON_PRETTY_PRINT | JSON_UNESCAPED_SLASHES));
    // 2. 执行 composer update
    $output = [];
    exec('cd ' . escapeshellarg(dirname($composerFile)) . ' && composer update --no-dev 2>&1', $output, $returnCode);
    if ($returnCode !== 0) {
        throw new RuntimeException("Composer update failed: " . implode("\n", $output));
    }
    // 3. 清除旧缓存目录(可选)
    $cacheDir = __DIR__ . '/../storage/cache/';
    if (is_dir($cacheDir)) {
        array_map('unlink', glob($cacheDir . '*'));
        echo "✓ Old cache cleared\n";
    }
    echo "✓ Dependencies updated to new versions\n";
};

安全与最佳实践

实践 说明
原子性 每个升级脚本应包含在事务中(如 BEGIN / COMMIT),失败时自动回滚
幂等性 升级脚本可重复执行而不产生副作用(使用 IF NOT EXISTSchecksum 校验)
备份 修改配置文件或数据库前,自动备份到 .backupmigrations/backups/
进度反馈 长任务(如数据迁移上千条)应输出进度,避免用户误以为卡死
失败容忍 非关键失败(如已存在的列)应视为警告而非错误,允许继续
版本锁 schema_versions 表中增加 checksum 字段,防止版本被手动篡改
测试模式 增加 --dry-run 参数,仅输出将执行的操作但不实际操作

典型升级执行命令

# 执行所有待处理的升级
php upgrade.php
# 仅运行到特定版本
php upgrade.php --to=1.5.0
# 回滚上一个版本
php upgrade.php --rollback=1
# 测试模式(不实际执行)
php upgrade.php --dry-run

一个健壮的PHP项目升级脚本应具备:

  • 版本追踪:记录已执行的每个数据库/文件变更。
  • 顺序执行:按版本号自然排序,逐级升级。
  • 错误处理:严格的事务控制,关键操作可回滚。
  • 用户反馈:清晰的进度、成功/失败信息。
  • 容错性:允许重复执行、忽略已存在的变更。

通过以上模式,你可以安全地将任何PHP项目的数据库、配置和逻辑从任意旧版本升级到最新版本。

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