一个PHP信创适配工具包。检测php项目在国产化系统的兼容性 自动修复
PHP 信创适配工具包 - 从 0 到 1 完整开源项目
一、这个工具是干啥用的?(大白话)
信创 = "信息技术应用创新",就是国家要求用国产软硬件替换 Windows、Intel、Oracle 这些外国货。
痛点:你公司有个跑了 5 年的 PHP 老项目,现在客户(政府/银行/国企)要求部署到国产环境:
- 操作系统:统信 UOS / 麒麟 Kylin / openEuler(不是 CentOS 了)
- CPU:龙芯 LoongArch / 飞腾 ARM / 鲲鹏 ARM(不是 x86 了)
- 数据库:达梦 DM / 人大金仓 Kingbase(不是 MySQL 了)
- 中间件:东方通 TongWeb(不是 Tomcat 了)
手动迁移会踩的坑:
1. 代码里写死了 mysql_connect() → 国产库连不上
2. SQL 用了 MySQL 方言 LIMIT 10 → 达梦不认,要 ROWNUM
3. 路径写死 C:\\ 或 /var/www → 换系统就崩
4. 用了 ioncube/sg11 加密扩展 → ARM/龙芯没编译版本
5. 文件名大小写 User.php vs user.php → Linux 区分大小写
6. 编码 GBK 乱码 → 国产系统默认 UTF-8
这个工具做啥:
- 扫描:用 AST(抽象语法树)分析 PHP 代码,找出所有"国产环境跑不起来"的地方
- 报告:生成 HTML 报告,告诉你哪一行第几列有问题、严重程度、怎么改
- 自动修复:能自动改的直接改(比如 mysql_* → mysqli_*、SQL 方言转换)
- 持续监控:接入 CI,每次提交代码都检查,防止开倒车
---
二、技术选型(为什么选这些库)
┌──────────────┬─────────────────────────────────┬────────────────────────────────────────────┐
│ 用途 │ 库 │ 为啥选它 │
├──────────────┼─────────────────────────────────┼────────────────────────────────────────────┤
│ AST 解析 │ nikic/php-parser │ PHP 静态分析事实标准,PHPStan/Rector 都用它 │
├──────────────┼─────────────────────────────────┼────────────────────────────────────────────┤
│ 静态分析框架 │ phpstan/phpstan │ 规则可扩展,生态最强 │
├──────────────┼─────────────────────────────────┼────────────────────────────────────────────┤
│ CLI 框架 │ symfony/console │ Composer/Laravel Artisan 都用这个 │
├──────────────┼─────────────────────────────────┼────────────────────────────────────────────┤
│ 文件遍历 │ symfony/finder │ 链式 API 比 glob() 强一万倍 │
├──────────────┼─────────────────────────────────┼────────────────────────────────────────────┤
│ 配置 │ symfony/yaml │ YAML 写规则比 JSON 易读 │
├──────────────┼─────────────────────────────────┼────────────────────────────────────────────┤
│ 日志 │ monolog/monolog │ PHP 日志事实标准 │
├──────────────┼─────────────────────────────────┼────────────────────────────────────────────┤
│ 代码改写 │ nikic/php-parser 的 NodeVisitor │ 改 AST 比正则替换安全 │
├──────────────┼─────────────────────────────────┼────────────────────────────────────────────┤
│ HTML 报告 │ twig/twig │ 模板引擎,和 Symfony 一家 │
├──────────────┼─────────────────────────────────┼────────────────────────────────────────────┤
│ 测试 │ phpunit/phpunit │ 不解释 │
├──────────────┼─────────────────────────────────┼────────────────────────────────────────────┤
│ 类型检查 │ phpstan/phpstan level 8 │ 自己用自己 │
├──────────────┼─────────────────────────────────┼────────────────────────────────────────────┤
│ 代码风格 │ friendsofphp/php-cs-fixer │ PSR-12 自动格式化 │
└──────────────┴─────────────────────────────────┴────────────────────────────────────────────┘
---
三、完整代码
1. composer.json — 项目元数据和依赖
{
"name": "yourorg/php-xinchuang-kit",
"description": "PHP 信创(国产化)环境兼容性扫描与自动修复工具包",
"type": "library",
"license": "MIT",
"keywords": ["xinchuang", "信创", "国产化", "compatibility", "static-analysis", "php", "dameng", "kingbase",
"kylin", "uos"],
"authors": [
{"name": "Your Name", "email": "you@example.com"}
],
"require": {
"php": ">=8.1",
"ext-json": "*",
"ext-mbstring": "*",
"nikic/php-parser": "^5.0",
"symfony/console": "^6.4",
"symfony/finder": "^6.4",
"symfony/yaml": "^6.4",
"symfony/process": "^6.4",
"monolog/monolog": "^3.5",
"twig/twig": "^3.8"
},
"require-dev": {
"phpunit/phpunit": "^10.5",
"phpstan/phpstan": "^1.10",
"friendsofphp/php-cs-fixer": "^3.40",
"rector/rector": "^1.0"
},
"autoload": {
"psr-4": {"Xinchuang\\Kit\\": "src/"}
},
"autoload-dev": {
"psr-4": {"Xinchuang\\Kit\\Tests\\": "tests/"}
},
"bin": ["bin/xinchuang"],
"scripts": {
"test": "phpunit",
"stan": "phpstan analyse src --level=8",
"cs": "php-cs-fixer fix",
"ci": ["@cs", "@stan", "@test"]
},
"config": {
"sort-packages": true,
"optimize-autoloader": true
},
"minimum-stability": "stable"
}
大白话:这是项目的"身份证"。声明依赖、自动加载规则、可执行入口、CI 脚本快捷方式。
---
2. bin/xinchuang — CLI 入口
#!/usr/bin/env php
<?php
declare(strict_types=1);
// 兼容全局安装和项目内安装两种方式
$autoloads = [
__DIR__ . '/../vendor/autoload.php', // 项目内
__DIR__ . '/../../../autoload.php', // 作为依赖被安装
];
foreach ($autoloads as $file) {
if (file_exists($file)) {
require $file;
break;
}
}
use Xinchuang\Kit\Application;
(new Application())->run();
大白话:用户终端敲 xinchuang scan ./src 时,执行的就是这个文件。
---
3. src/Application.php — 应用入口
<?php
declare(strict_types=1);
namespace Xinchuang\Kit;
use Symfony\Component\Console\Application as ConsoleApplication;
use Xinchuang\Kit\Command\ScanCommand;
use Xinchuang\Kit\Command\FixCommand;
use Xinchuang\Kit\Command\ReportCommand;
final class Application extends ConsoleApplication
{
public const VERSION = '0.1.0';
public function __construct()
{
parent::__construct('PHP Xinchuang Kit', self::VERSION);
$this->add(new ScanCommand());
$this->add(new FixCommand());
$this->add(new ReportCommand());
}
}
大白话:注册三个子命令:scan(扫描)、fix(修复)、report(出报告)。
---
4. src/Rule/rules.yaml — 规则库(核心知识)
# 信创兼容性规则库,持续更新
# 严重级别: error(必须改) / warning(建议改) / info(提示)
extensions:
blocked:
- name: ioncube_loader
reason: "ionCube 在龙芯/部分 ARM 平台无官方版本"
severity: error
suggestion: "改用 sourceguardian 或者去除加密"
- name: sg11
reason: "SourceGuardian 国产 CPU 兼容性差"
severity: error
- name: zend_guard_loader
reason: "Zend Guard 已停止维护,国产环境无法安装"
severity: error
deprecated:
- name: mysql
since_php: 7.0
replace_with: mysqli
severity: error
- name: mcrypt
since_php: 7.2
replace_with: openssl / sodium
severity: error
functions:
deprecated:
mysql_connect: { replace: mysqli_connect, severity: error }
mysql_query: { replace: mysqli_query, severity: error }
mysql_fetch_array: { replace: mysqli_fetch_array, severity: error }
mysql_real_escape_string: { replace: mysqli_real_escape_string, severity: error }
ereg: { replace: preg_match, severity: error }
split: { replace: explode / preg_split, severity: error }
each: { replace: foreach, severity: error }
create_function: { replace: 匿名函数, severity: error }
database:
mysql_to_dameng:
- pattern: '/\bLIMIT\s+(\d+)\s*,\s*(\d+)/i'
replace: 'OFFSET $1 ROWS FETCH NEXT $2 ROWS ONLY'
reason: "达梦不支持 MySQL 的 LIMIT m,n 写法"
- pattern: '/`([a-zA-Z_][a-zA-Z0-9_]*)`/'
replace: '"$1"'
reason: "达梦使用双引号包裹标识符,不是反引号"
- pattern: '/\bIFNULL\s*\(/i'
replace: 'NVL('
reason: "达梦使用 NVL 而非 IFNULL"
- pattern: '/\bNOW\(\)/i'
replace: 'SYSDATE'
reason: "达梦使用 SYSDATE 获取当前时间"
- pattern: '/\bAUTO_INCREMENT\b/i'
replace: 'IDENTITY(1,1)'
reason: "达梦自增列语法不同"
mysql_to_kingbase:
- pattern: '/`([a-zA-Z_][a-zA-Z0-9_]*)`/'
replace: '"$1"'
reason: "金仓基于 PostgreSQL,使用双引号"
- pattern: '/\bLIMIT\s+(\d+)\s*,\s*(\d+)/i'
replace: 'LIMIT $2 OFFSET $1'
reason: "金仓 LIMIT/OFFSET 顺序与 MySQL 不同"
paths:
hardcoded:
- pattern: '/[A-Z]:\\\\/'
severity: error
reason: "硬编码 Windows 盘符,Linux 国产系统无法运行"
- pattern: '/\/var\/www\/html/'
severity: warning
reason: "硬编码 Apache 路径,国产中间件路径不同"
encoding:
gbk_files: { severity: warning, reason: "建议统一为 UTF-8" }
bom_files: { severity: error, reason: "BOM 头会导致 header() 失败" }
architecture:
x86_only_extensions:
- intl_icu_legacy
- sqlsrv
middleware:
apache_specific:
- pattern: 'apache_request_headers\('
severity: warning
reason: "东方通 TongWeb 等国产中间件不支持 Apache 专用函数"
replace: "getallheaders() 或自行解析 \$_SERVER"
大白话:这个 YAML 是工具的"大脑",所有规则都从这里读。社区可以提交 PR 加规则,不用改代码。
---
5. src/Rule/RuleLoader.php — 规则加载器
<?php
declare(strict_types=1);
namespace Xinchuang\Kit\Rule;
use Symfony\Component\Yaml\Yaml;
final class RuleLoader
{
/** @var array<string, mixed> */
private array $rules;
public function __construct(?string $customPath = null)
{
$defaultPath = __DIR__ . '/rules.yaml';
$this->rules = Yaml::parseFile($defaultPath);
if ($customPath !== null && file_exists($customPath)) {
$custom = Yaml::parseFile($customPath);
$this->rules = array_replace_recursive($this->rules, $custom);
}
}
/** @return array<string, mixed> */
public function get(string $key): array
{
return $this->rules[$key] ?? [];
}
/** @return array<string, mixed> */
public function all(): array
{
return $this->rules;
}
}
大白话:加载默认规则,如果用户传了自己的规则文件就合并。
---
6. src/Report/Issue.php — 问题数据对象
<?php
declare(strict_types=1);
namespace Xinchuang\Kit\Report;
final readonly class Issue
{
public function __construct(
public string $file,
public int $line,
public int $column,
public string $severity, // error / warning / info
public string $category, // extension / database / path / encoding / function / arch
public string $rule, // 规则 ID
public string $message, // 给人看的描述
public ?string $suggestion = null,
public ?string $codeSnippet = null,
public bool $autoFixable = false,
) {}
/** @return array<string, mixed> */
public function toArray(): array
{
return [
'file' => $this->file,
'line' => $this->line,
'column' => $this->column,
'severity' => $this->severity,
'category' => $this->category,
'rule' => $this->rule,
'message' => $this->message,
'suggestion' => $this->suggestion,
'snippet' => $this->codeSnippet,
'auto_fixable' => $this->autoFixable,
];
}
}
大白话:每个发现的问题封装成一个不可变对象,用 PHP 8.2 的 readonly class(写完不能改)。
---
7. src/Report/Report.php — 报告聚合器
<?php
declare(strict_types=1);
namespace Xinchuang\Kit\Report;
final class Report
{
/** @var Issue[] */
private array $issues = [];
public function add(Issue $issue): void
{
$this->issues[] = $issue;
}
/** @return Issue[] */
public function all(): array
{
return $this->issues;
}
/** @return Issue[] */
public function bySeverity(string $severity): array
{
return array_values(array_filter(
$this->issues,
fn(Issue $i) => $i->severity === $severity
));
}
public function errorCount(): int
{
return count($this->bySeverity('error'));
}
public function warningCount(): int
{
return count($this->bySeverity('warning'));
}
public function hasErrors(): bool
{
return $this->errorCount() > 0;
}
public function toJson(): string
{
return json_encode(
['issues' => array_map(fn(Issue $i) => $i->toArray(), $this->issues)],
JSON_PRETTY_PRINT | JSON_UNESCAPED_UNICODE
);
}
}
大白话:把所有 Issue 收集起来,按严重级别分类、统计、导出 JSON。
---
8. src/Detector/DetectorInterface.php — 检测器接口
<?php
declare(strict_types=1);
namespace Xinchuang\Kit\Detector;
use Xinchuang\Kit\Report\Report;
interface DetectorInterface
{
public function name(): string;
public function detect(string $filePath, string $content, Report $report): void;
}
大白话:所有检测器都实现这个接口,这样可以无限扩展(策略模式)。
---
9. src/Parser/PhpAstParser.php — AST 解析器封装
<?php
declare(strict_types=1);
namespace Xinchuang\Kit\Parser;
use PhpParser\Error;
use PhpParser\Node;
use PhpParser\Parser;
use PhpParser\ParserFactory;
final class PhpAstParser
{
private Parser $parser;
public function __construct()
{
$this->parser = (new ParserFactory())->createForHostVersion();
}
/**
* @return Node\Stmt[]|null 返回 AST 节点数组,解析失败返回 null
*/
public function parse(string $code): ?array
{
try {
return $this->parser->parse($code);
} catch (Error) {
return null;
}
}
}
大白话:nikic/php-parser 把 PHP 代码变成树状结构,这样我们用代码就能"看懂"代码。
---
10. src/Detector/DeprecatedFunctionDetector.php — 废弃函数检测
<?php
declare(strict_types=1);
namespace Xinchuang\Kit\Detector;
use PhpParser\Node;
use PhpParser\NodeTraverser;
use PhpParser\NodeVisitorAbstract;
use Xinchuang\Kit\Parser\PhpAstParser;
use Xinchuang\Kit\Report\Issue;
use Xinchuang\Kit\Report\Report;
use Xinchuang\Kit\Rule\RuleLoader;
final class DeprecatedFunctionDetector implements DetectorInterface
{
public function __construct(
private readonly PhpAstParser $parser,
private readonly RuleLoader $rules,
) {}
public function name(): string
{
return 'deprecated_function';
}
public function detect(string $filePath, string $content, Report $report): void
{
$ast = $this->parser->parse($content);
if ($ast === null) {
return;
}
/** @var array<string, array{replace: string, severity: string}> $deprecated */
$deprecated = $this->rules->get('functions')['deprecated'] ?? [];
$traverser = new NodeTraverser();
$traverser->addVisitor(new class($filePath, $deprecated, $report) extends NodeVisitorAbstract {
public function __construct(
private readonly string $file,
private readonly array $deprecated,
private readonly Report $report,
) {}
public function enterNode(Node $node): null
{
if ($node instanceof Node\Expr\FuncCall && $node->name instanceof Node\Name) {
$fnName = $node->name->toString();
if (isset($this->deprecated[$fnName])) {
$rule = $this->deprecated[$fnName];
$this->report->add(new Issue(
file: $this->file,
line: $node->getStartLine(),
column: $node->getStartFilePos(),
severity: $rule['severity'],
category: 'function',
rule: 'deprecated_function',
message: "使用了废弃函数 {$fnName}()",
suggestion: "替换为 {$rule['replace']}",
autoFixable: true,
));
}
}
return null;
}
});
$traverser->traverse($ast);
}
}
大白话:遍历 AST,发现一个函数调用就查规则表,在表里就报告。比正则匹配可靠 —— 注释、字符串里的 mysql_connect 不会误报。
---
11. src/Detector/DatabaseDetector.php — SQL 方言检测
<?php
declare(strict_types=1);
namespace Xinchuang\Kit\Detector;
use PhpParser\Node;
use PhpParser\NodeTraverser;
use PhpParser\NodeVisitorAbstract;
use Xinchuang\Kit\Parser\PhpAstParser;
use Xinchuang\Kit\Report\Issue;
use Xinchuang\Kit\Report\Report;
use Xinchuang\Kit\Rule\RuleLoader;
final class DatabaseDetector implements DetectorInterface
{
public function __construct(
private readonly PhpAstParser $parser,
private readonly RuleLoader $rules,
private readonly string $targetDb = 'dameng',
) {}
public function name(): string
{
return 'database_dialect';
}
public function detect(string $filePath, string $content, Report $report): void
{
$ast = $this->parser->parse($content);
if ($ast === null) {
return;
}
$ruleKey = 'mysql_to_' . $this->targetDb;
/** @var array<int, array{pattern: string, replace: string, reason: string}> $sqlRules */
$sqlRules = $this->rules->get('database')[$ruleKey] ?? [];
$traverser = new NodeTraverser();
$traverser->addVisitor(new class($filePath, $sqlRules, $report) extends NodeVisitorAbstract {
public function __construct(
private readonly string $file,
private readonly array $sqlRules,
private readonly Report $report,
) {}
public function enterNode(Node $node): null
{
// 找字符串字面量,看里面是不是 SQL
if ($node instanceof Node\Scalar\String_) {
$value = $node->value;
if (!$this->looksLikeSql($value)) {
return null;
}
foreach ($this->sqlRules as $rule) {
if (preg_match($rule['pattern'], $value)) {
$this->report->add(new Issue(
file: $this->file,
line: $node->getStartLine(),
column: 0,
severity: 'error',
category: 'database',
rule: 'sql_dialect',
message: $rule['reason'],
suggestion: "应用规则: {$rule['pattern']} → {$rule['replace']}",
codeSnippet: mb_substr($value, 0, 120),
autoFixable: true,
));
}
}
}
return null;
}
private function looksLikeSql(string $s): bool
{
return (bool) preg_match(
'/\b(SELECT|INSERT|UPDATE|DELETE|CREATE|ALTER|DROP)\b/i',
$s
);
}
});
$traverser->traverse($ast);
}
}
大白话:从 AST 里挖出所有字符串,判断像不像 SQL,像就拿规则一条条匹配。只看 SQL 字符串,不会把 PHP 代码当 SQL 处理。
---
12. src/Detector/ExtensionDetector.php — 扩展检测
<?php
declare(strict_types=1);
namespace Xinchuang\Kit\Detector;
use Xinchuang\Kit\Report\Issue;
use Xinchuang\Kit\Report\Report;
use Xinchuang\Kit\Rule\RuleLoader;
final class ExtensionDetector implements DetectorInterface
{
public function __construct(private readonly RuleLoader $rules) {}
public function name(): string
{
return 'extension';
}
public function detect(string $filePath, string $content, Report $report): void
{
// 只扫 composer.json
if (!str_ends_with($filePath, 'composer.json')) {
return;
}
try {
$composer = json_decode($content, true, flags: JSON_THROW_ON_ERROR);
} catch (\JsonException) {
return;
}
$required = array_merge(
$composer['require'] ?? [],
$composer['require-dev'] ?? []
);
/** @var array<int, array{name: string, reason: string, severity: string, suggestion?: string}> $blocked */
$blocked = $this->rules->get('extensions')['blocked'] ?? [];
foreach ($required as $pkg => $version) {
if (!str_starts_with($pkg, 'ext-')) {
continue;
}
$extName = substr($pkg, 4);
foreach ($blocked as $rule) {
if ($rule['name'] === $extName) {
$report->add(new Issue(
file: $filePath,
line: 1,
column: 0,
severity: $rule['severity'],
category: 'extension',
rule: 'blocked_extension',
message: "扩展 {$extName} 在国产环境受限: {$rule['reason']}",
suggestion: $rule['suggestion'] ?? null,
));
}
}
}
}
}
大白话:解析 composer.json,看你依赖的扩展在不在"黑名单"里。
---
13. src/Detector/EncodingDetector.php — 编码检测
<?php
declare(strict_types=1);
namespace Xinchuang\Kit\Detector;
use Xinchuang\Kit\Report\Issue;
use Xinchuang\Kit\Report\Report;
final class EncodingDetector implements DetectorInterface
{
public function name(): string
{
return 'encoding';
}
public function detect(string $filePath, string $content, Report $report): void
{
// BOM 头检测(UTF-8 BOM = EF BB BF)
if (str_starts_with($content, "\xEF\xBB\xBF")) {
$report->add(new Issue(
file: $filePath,
line: 1,
column: 0,
severity: 'error',
category: 'encoding',
rule: 'utf8_bom',
message: '文件包含 UTF-8 BOM 头',
suggestion: '移除 BOM 头,否则 header()/setcookie() 会失败',
autoFixable: true,
));
}
// 非 UTF-8 检测
if (!mb_check_encoding($content, 'UTF-8')) {
$detected = mb_detect_encoding($content, ['GBK', 'GB2312', 'BIG5', 'ASCII'], true);
$report->add(new Issue(
file: $filePath,
line: 1,
column: 0,
severity: 'warning',
category: 'encoding',
rule: 'non_utf8',
message: "文件编码不是 UTF-8" . ($detected ? " (检测为 {$detected})" : ''),
suggestion: '建议用 iconv 或 mb_convert_encoding 转换为 UTF-8',
autoFixable: true,
));
}
}
}
大白话:检测 BOM 头(国产 PHP 环境特别敏感)和非 UTF-8 文件(GBK 文件在国产 Linux 上会乱码)。
---
14. src/Detector/PathDetector.php — 硬编码路径检测
<?php
declare(strict_types=1);
namespace Xinchuang\Kit\Detector;
use Xinchuang\Kit\Report\Issue;
use Xinchuang\Kit\Report\Report;
use Xinchuang\Kit\Rule\RuleLoader;
final class PathDetector implements DetectorInterface
{
public function __construct(private readonly RuleLoader $rules) {}
public function name(): string
{
return 'path';
}
public function detect(string $filePath, string $content, Report $report): void
{
if (!str_ends_with($filePath, '.php')) {
return;
}
/** @var array<int, array{pattern: string, severity: string, reason: string}> $patterns */
$patterns = $this->rules->get('paths')['hardcoded'] ?? [];
$lines = explode("\n", $content);
foreach ($lines as $lineNum => $line) {
// 跳过注释行(简单判断)
$trimmed = ltrim($line);
if (str_starts_with($trimmed, '//') || str_starts_with($trimmed, '*') || str_starts_with($trimmed, '#')) {
continue;
}
foreach ($patterns as $rule) {
if (preg_match($rule['pattern'], $line, $m)) {
$report->add(new Issue(
file: $filePath,
line: $lineNum + 1,
column: (int) (strpos($line, $m[0]) ?: 0),
severity: $rule['severity'],
category: 'path',
rule: 'hardcoded_path',
message: $rule['reason'],
suggestion: '使用 DIRECTORY_SEPARATOR 或环境变量配置',
codeSnippet: trim($line),
));
}
}
}
}
}
大白话:逐行扫描 PHP 文件,找 C:\ 这种 Windows 路径或 /var/www/html 这种写死的 Apache 路径。
---
15. src/Fixer/FixerInterface.php — 修复器接口
<?php
declare(strict_types=1);
namespace Xinchuang\Kit\Fixer;
interface FixerInterface
{
public function name(): string;
public function supports(string $filePath): bool;
/** 返回修复后的内容,没改动返回 null */
public function fix(string $filePath, string $content): ?string;
}
---
16. src/Fixer/DeprecatedFunctionFixer.php — 自动替换废弃函数
<?php
declare(strict_types=1);
namespace Xinchuang\Kit\Fixer;
use PhpParser\Node;
use PhpParser\NodeTraverser;
use PhpParser\NodeVisitorAbstract;
use PhpParser\PrettyPrinter\Standard;
use Xinchuang\Kit\Parser\PhpAstParser;
final class DeprecatedFunctionFixer implements FixerInterface
{
/** @var array<string, string> */
private const REPLACEMENTS = [
'mysql_connect' => 'mysqli_connect',
'mysql_query' => 'mysqli_query',
'mysql_fetch_array' => 'mysqli_fetch_array',
'mysql_fetch_assoc' => 'mysqli_fetch_assoc',
'mysql_num_rows' => 'mysqli_num_rows',
'mysql_close' => 'mysqli_close',
'mysql_real_escape_string' => 'mysqli_real_escape_string',
'split' => 'explode',
'ereg' => 'preg_match',
];
public function __construct(private readonly PhpAstParser $parser) {}
public function name(): string
{
return 'deprecated_function_fixer';
}
public function supports(string $filePath): bool
{
return str_ends_with($filePath, '.php');
}
public function fix(string $filePath, string $content): ?string
{
$ast = $this->parser->parse($content);
if ($ast === null) {
return null;
}
$changed = false;
$traverser = new NodeTraverser();
$traverser->addVisitor(new class($changed) extends NodeVisitorAbstract {
public function __construct(public bool &$changed) {}
public function enterNode(Node $node): ?Node
{
if ($node instanceof Node\Expr\FuncCall && $node->name instanceof Node\Name) {
$fnName = $node->name->toString();
if (isset(DeprecatedFunctionFixer::REPLACEMENTS[$fnName])) {
$node->name = new Node\Name(DeprecatedFunctionFixer::REPLACEMENTS[$fnName]);
$this->changed = true;
}
}
return null;
}
});
$newAst = $traverser->traverse($ast);
if (!$changed) {
return null;
}
return (new Standard())->prettyPrintFile($newAst);
}
}
大白话:这是工具的"杀手锏"——改 AST 而不是改文本。结果是 100% 语法正确,不像正则替换可能改坏字符串里的内容。
---
17. src/Fixer/EncodingFixer.php — 编码修复
<?php
declare(strict_types=1);
namespace Xinchuang\Kit\Fixer;
final class EncodingFixer implements FixerInterface
{
public function name(): string
{
return 'encoding_fixer';
}
public function supports(string $filePath): bool
{
return str_ends_with($filePath, '.php')
|| str_ends_with($filePath, '.html')
|| str_ends_with($filePath, '.js')
|| str_ends_with($filePath, '.css');
}
public function fix(string $filePath, string $content): ?string
{
$changed = false;
// 移除 BOM
if (str_starts_with($content, "\xEF\xBB\xBF")) {
$content = substr($content, 3);
$changed = true;
}
// GBK → UTF-8
if (!mb_check_encoding($content, 'UTF-8')) {
$detected = mb_detect_encoding($content, ['UTF-8', 'GBK', 'GB2312', 'BIG5'], true);
if ($detected !== false && $detected !== 'UTF-8') {
$converted = mb_convert_encoding($content, 'UTF-8', $detected);
if (is_string($converted)) {
$content = $converted;
$changed = true;
}
}
}
return $changed ? $content : null;
}
}
---
18. src/Command/ScanCommand.php — 扫描命令
<?php
declare(strict_types=1);
namespace Xinchuang\Kit\Command;
use Symfony\Component\Console\Attribute\AsCommand;
use Symfony\Component\Console\Command\Command;
use Symfony\Component\Console\Helper\Table;
use Symfony\Component\Console\Input\InputArgument;
use Symfony\Component\Console\Input\InputInterface;
use Symfony\Component\Console\Input\InputOption;
use Symfony\Component\Console\Output\OutputInterface;
use Symfony\Component\Console\Style\SymfonyStyle;
use Symfony\Component\Finder\Finder;
use Xinchuang\Kit\Detector\DatabaseDetector;
use Xinchuang\Kit\Detector\DeprecatedFunctionDetector;
use Xinchuang\Kit\Detector\DetectorInterface;
use Xinchuang\Kit\Detector\EncodingDetector;
use Xinchuang\Kit\Detector\ExtensionDetector;
use Xinchuang\Kit\Detector\PathDetector;
use Xinchuang\Kit\Parser\PhpAstParser;
use Xinchuang\Kit\Report\Report;
use Xinchuang\Kit\Rule\RuleLoader;
#[AsCommand(name: 'scan', description: '扫描 PHP 项目的信创兼容性问题')]
final class ScanCommand extends Command
{
protected function configure(): void
{
$this
->addArgument('path', InputArgument::REQUIRED, '要扫描的项目路径')
->addOption('target-db', null, InputOption::VALUE_REQUIRED, '目标数据库 (dameng / kingbase)', 'dameng')
->addOption('format', 'f', InputOption::VALUE_REQUIRED, '输出格式 (table / json / html)', 'table')
->addOption('output', 'o', InputOption::VALUE_REQUIRED, '输出文件(默认 stdout)')
->addOption('rules', 'r', InputOption::VALUE_REQUIRED, '自定义规则文件')
->addOption('exclude', 'e', InputOption::VALUE_REQUIRED, '排除目录,逗号分隔', 'vendor,node_modules,.git')
->addOption('fail-on-error', null, InputOption::VALUE_NONE, '发现 error 级别问题时退出码非 0(用于 CI)');
}
protected function execute(InputInterface $input, OutputInterface $output): int
{
$io = new SymfonyStyle($input, $output);
$path = (string) $input->getArgument('path');
if (!is_dir($path) && !is_file($path)) {
$io->error("路径不存在: {$path}");
return Command::FAILURE;
}
$rules = new RuleLoader($input->getOption('rules'));
$parser = new PhpAstParser();
$report = new Report();
$detectors = [
new ExtensionDetector($rules),
new EncodingDetector(),
new PathDetector($rules),
new DeprecatedFunctionDetector($parser, $rules),
new DatabaseDetector($parser, $rules, (string) $input->getOption('target-db')),
];
$finder = new Finder();
$finder->files()->in($path);
foreach (explode(',', (string) $input->getOption('exclude')) as $ex) {
$finder->exclude(trim($ex));
}
$finder->name(['*.php', 'composer.json']);
$io->title('PHP 信创兼容性扫描');
$io->progressStart(iterator_count($finder));
foreach ($finder as $file) {
$content = (string) file_get_contents($file->getRealPath());
foreach ($detectors as $d) {
$this->safeDetect($d, $file->getRealPath(), $content, $report, $io);
}
$io->progressAdvance();
}
$io->progressFinish();
$this->renderOutput($input, $output, $io, $report);
if ((bool) $input->getOption('fail-on-error') && $report->hasErrors()) {
return Command::FAILURE;
}
return Command::SUCCESS;
}
private function safeDetect(
DetectorInterface $detector,
string $file,
string $content,
Report $report,
SymfonyStyle $io
): void {
try {
$detector->detect($file, $content, $report);
} catch (\Throwable $e) {
$io->warning("检测器 {$detector->name()} 处理 {$file} 时出错: {$e->getMessage()}");
}
}
private function renderOutput(
InputInterface $input,
OutputInterface $output,
SymfonyStyle $io,
Report $report
): void {
$format = (string) $input->getOption('format');
$outputFile = $input->getOption('output');
$rendered = match ($format) {
'json' => $report->toJson(),
'html' => (new \Xinchuang\Kit\Report\HtmlReporter())->render($report),
default => null,
};
if ($rendered !== null) {
if ($outputFile !== null) {
file_put_contents((string) $outputFile, $rendered);
$io->success("报告已写入 {$outputFile}");
} else {
$output->writeln($rendered);
}
return;
}
// 默认 table 格式
if (count($report->all()) === 0) {
$io->success('未发现兼容性问题,可以放心部署到国产环境!');
return;
}
$table = new Table($output);
$table->setHeaders(['文件', '行', '级别', '类别', '说明', '可自动修复']);
foreach ($report->all() as $issue) {
$table->addRow([
basename($issue->file),
(string) $issue->line,
$issue->severity,
$issue->category,
mb_substr($issue->message, 0, 50),
$issue->autoFixable ? '是' : '否',
]);
}
$table->render();
$io->newLine();
$io->writeln(sprintf(
'<error>错误: %d</error> <comment>警告: %d</comment> 共: %d',
$report->errorCount(),
$report->warningCount(),
count($report->all())
));
}
}
大白话:xinchuang scan ./src --target-db=dameng --format=html -o report.html 一键扫描出报告。
---
19. src/Command/FixCommand.php — 修复命令
<?php
declare(strict_types=1);
namespace Xinchuang\Kit\Command;
use Symfony\Component\Console\Attribute\AsCommand;
use Symfony\Component\Console\Command\Command;
use Symfony\Component\Console\Input\InputArgument;
use Symfony\Component\Console\Input\InputInterface;
use Symfony\Component\Console\Input\InputOption;
use Symfony\Component\Console\Output\OutputInterface;
use Symfony\Component\Console\Style\SymfonyStyle;
use Symfony\Component\Finder\Finder;
use Xinchuang\Kit\Fixer\DeprecatedFunctionFixer;
use Xinchuang\Kit\Fixer\EncodingFixer;
use Xinchuang\Kit\Fixer\FixerInterface;
use Xinchuang\Kit\Parser\PhpAstParser;
#[AsCommand(name: 'fix', description: '自动修复信创兼容性问题')]
final class FixCommand extends Command
{
protected function configure(): void
{
$this
->addArgument('path', InputArgument::REQUIRED, '项目路径')
->addOption('dry-run', null, InputOption::VALUE_NONE, '只显示会修改什么,不实际写入')
->addOption('backup', 'b', InputOption::VALUE_NONE, '修改前备份原文件为 .bak')
->addOption('exclude', 'e', InputOption::VALUE_REQUIRED, '排除目录', 'vendor,node_modules,.git');
}
protected function execute(InputInterface $input, OutputInterface $output): int
{
$io = new SymfonyStyle($input, $output);
$path = (string) $input->getArgument('path');
$dryRun = (bool) $input->getOption('dry-run');
$backup = (bool) $input->getOption('backup');
if (!$dryRun && !$io->confirm('修复会直接修改源文件,建议先 git commit。继续吗?', false)) {
return Command::SUCCESS;
}
$parser = new PhpAstParser();
/** @var FixerInterface[] $fixers */
$fixers = [
new EncodingFixer(),
new DeprecatedFunctionFixer($parser),
];
$finder = new Finder();
$finder->files()->in($path);
foreach (explode(',', (string) $input->getOption('exclude')) as $ex) {
$finder->exclude(trim($ex));
}
$finder->name('*.php');
$changed = 0;
foreach ($finder as $file) {
$filePath = $file->getRealPath();
$original = (string) file_get_contents($filePath);
$content = $original;
foreach ($fixers as $fixer) {
if (!$fixer->supports($filePath)) {
continue;
}
$fixed = $fixer->fix($filePath, $content);
if ($fixed !== null) {
$content = $fixed;
}
}
if ($content !== $original) {
$changed++;
$io->writeln("<info>✓</info> {$filePath}");
if (!$dryRun) {
if ($backup) {
file_put_contents($filePath . '.bak', $original);
}
file_put_contents($filePath, $content);
}
}
}
$io->success(sprintf(
'%s %d 个文件',
$dryRun ? '将修复' : '已修复',
$changed
));
return Command::SUCCESS;
}
}
大白话:xinchuang fix ./src --dry-run 先预览,确认没问题再去掉 --dry-run 真正改。--backup 给每个改过的文件留一份 .bak。
---
20. src/Report/HtmlReporter.php — HTML 报告生成
<?php
declare(strict_types=1);
namespace Xinchuang\Kit\Report;
final class HtmlReporter
{
public function render(Report $report): string
{
$rows = '';
foreach ($report->all() as $issue) {
$badgeClass = match ($issue->severity) {
'error' => 'danger',
'warning' => 'warning',
default => 'info',
};
$rows .= sprintf(
'<tr><td>%s</td><td>%d</td><td><span class="badge
badge-%s">%s</span></td><td>%s</td><td>%s</td><td>%s</td></tr>',
htmlspecialchars($issue->file),
$issue->line,
$badgeClass,
$issue->severity,
htmlspecialchars($issue->category),
htmlspecialchars($issue->message),
htmlspecialchars($issue->suggestion ?? '')
);
}
$errors = $report->errorCount();
$warnings = $report->warningCount();
$total = count($report->all());
$time = date('Y-m-d H:i:s');
return <<<HTML
<!DOCTYPE html>
<html lang="zh-CN">
<head>
<meta charset="UTF-8">
<title>PHP 信创兼容性报告</title>
<style>
body { font-family: -apple-system, "PingFang SC", "Microsoft YaHei", sans-serif; padding: 24px; background: #f5f5f7; }
h1 { color: #1d1d1f; }
.summary { display: flex; gap: 16px; margin: 20px 0; }
.card { background: white; padding: 20px; border-radius: 12px; flex: 1; box-shadow: 0 2px 8px rgba(0,0,0,.04); }
.card .num { font-size: 36px; font-weight: 700; }
.card.danger .num { color: #ff3b30; }
.card.warning .num { color: #ff9500; }
.card.info .num { color: #007aff; }
table { width: 100%; background: white; border-collapse: collapse; border-radius: 12px; overflow: hidden; box-shadow:
0 2px 8px rgba(0,0,0,.04); }
th, td { padding: 12px 16px; text-align: left; border-bottom: 1px solid #f0f0f0; }
th { background: #fafafa; font-weight: 600; }
.badge { padding: 4px 10px; border-radius: 8px; font-size: 12px; font-weight: 500; }
.badge-danger { background: #ffe5e5; color: #ff3b30; }
.badge-warning { background: #fff4e5; color: #ff9500; }
.badge-info { background: #e5f0ff; color: #007aff; }
</style>
</head>
<body>
<h1>PHP 信创兼容性扫描报告</h1>
<p style="color:#86868b;">生成时间: {$time}</p>
<div class="summary">
<div class="card danger"><div class="num">{$errors}</div>错误</div>
<div class="card warning"><div class="num">{$warnings}</div>警告</div>
<div class="card info"><div class="num">{$total}</div>总计</div>
</div>
<table>
<thead><tr><th>文件</th><th>行</th><th>级别</th><th>类别</th><th>问题</th><th>建议</th></tr></thead>
<tbody>{$rows}</tbody>
</table>
</body>
</html>
HTML;
}
}
---
21. src/Command/ReportCommand.php — 单独生成报告
<?php
declare(strict_types=1);
namespace Xinchuang\Kit\Command;
use Symfony\Component\Console\Attribute\AsCommand;
use Symfony\Component\Console\Command\Command;
use Symfony\Component\Console\Input\InputArgument;
use Symfony\Component\Console\Input\InputInterface;
use Symfony\Component\Console\Input\InputOption;
use Symfony\Component\Console\Output\OutputInterface;
use Symfony\Component\Console\Style\SymfonyStyle;
use Xinchuang\Kit\Report\HtmlReporter;
use Xinchuang\Kit\Report\Issue;
use Xinchuang\Kit\Report\Report;
#[AsCommand(name: 'report', description: '从已有的 JSON 扫描结果生成 HTML 报告')]
final class ReportCommand extends Command
{
protected function configure(): void
{
$this
->addArgument('json', InputArgument::REQUIRED, 'JSON 报告文件路径')
->addOption('output', 'o', InputOption::VALUE_REQUIRED, '输出 HTML 路径', 'report.html');
}
protected function execute(InputInterface $input, OutputInterface $output): int
{
$io = new SymfonyStyle($input, $output);
$jsonPath = (string) $input->getArgument('json');
$data = json_decode((string) file_get_contents($jsonPath), true);
$report = new Report();
foreach (($data['issues'] ?? []) as $i) {
$report->add(new Issue(
file: $i['file'],
line: $i['line'],
column: $i['column'] ?? 0,
severity: $i['severity'],
category: $i['category'],
rule: $i['rule'],
message: $i['message'],
suggestion: $i['suggestion'] ?? null,
codeSnippet: $i['snippet'] ?? null,
autoFixable: $i['auto_fixable'] ?? false,
));
}
$html = (new HtmlReporter())->render($report);
$outFile = (string) $input->getOption('output');
file_put_contents($outFile, $html);
$io->success("HTML 报告: {$outFile}");
return Command::SUCCESS;
}
}
---
22. 单元测试示例 tests/Detector/DeprecatedFunctionDetectorTest.php
<?php
declare(strict_types=1);
namespace Xinchuang\Kit\Tests\Detector;
use PHPUnit\Framework\TestCase;
use Xinchuang\Kit\Detector\DeprecatedFunctionDetector;
use Xinchuang\Kit\Parser\PhpAstParser;
use Xinchuang\Kit\Report\Report;
use Xinchuang\Kit\Rule\RuleLoader;
final class DeprecatedFunctionDetectorTest extends TestCase
{
public function testDetectsMysqlConnect(): void
{
$detector = new DeprecatedFunctionDetector(new PhpAstParser(), new RuleLoader());
$report = new Report();
$code = <<<'PHP'
<?php
$conn = mysql_connect('localhost', 'user', 'pass');
PHP;
$detector->detect('test.php', $code, $report);
self::assertCount(1, $report->all());
self::assertSame('error', $report->all()[0]->severity);
self::assertStringContainsString('mysql_connect', $report->all()[0]->message);
}
public function testIgnoresStringsContainingFunctionName(): void
{
$detector = new DeprecatedFunctionDetector(new PhpAstParser(), new RuleLoader());
$report = new Report();
$code = <<<'PHP'
<?php
$msg = "请不要用 mysql_connect";
PHP;
$detector->detect('test.php', $code, $report);
self::assertCount(0, $report->all(), 'AST 不应该把字符串内容误报为函数调用');
}
}
大白话:第二个测试就是 AST 比正则强的最好证明——字符串里写 mysql_connect 不会误报。
---
23. phpunit.xml
<?xml version="1.0"?>
<phpunit bootstrap="vendor/autoload.php" colors="true" failOnWarning="true">
<testsuites>
<testsuite name="default">
<directory>tests</directory>
</testsuite>
</testsuites>
<source>
<include><directory>src</directory></include>
</source>
</phpunit>
24. phpstan.neon
parameters:
level: 8
paths:
- src
excludePaths:
- src/Rule/rules.yaml
25. .github/workflows/ci.yml — CI 流水线
name: CI
on: [push, pull_request]
jobs:
test:
runs-on: ubuntu-latest
strategy:
matrix:
php: ['8.1', '8.2', '8.3']
steps:
- uses: actions/checkout@v4
- uses: shivammathur/setup-php@v2
with:
php-version: ${{ matrix.php }}
extensions: mbstring, json
coverage: xdebug
- name: Cache Composer
uses: actions/cache@v4
with:
path: ~/.composer/cache
key: ${{ runner.os }}-composer-${{ hashFiles('composer.lock') }}
- run: composer install --prefer-dist --no-progress
- run: composer run cs -- --dry-run
- run: composer run stan
- run: composer run test
release:
needs: test
if: startsWith(github.ref, 'refs/tags/v')
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v4
- uses: softprops/action-gh-release@v2
with:
generate_release_notes: true
大白话:每次提交、PR 都跑测试,打 tag 自动发版。
---
26. .php-cs-fixer.dist.php
<?php
return (new PhpCsFixer\Config())
->setRules([
'@PSR12' => true,
'@PHP81Migration' => true,
'declare_strict_types' => true,
'no_unused_imports' => true,
'ordered_imports' => true,
])
->setFinder(
(new PhpCsFixer\Finder())->in(['src', 'tests'])
);
---
27. README.md(节选)
# PHP 信创适配工具包(Xinchuang Kit)
> 扫描和自动修复 PHP 项目在信创环境(统信 UOS / 麒麟 / 龙芯 / 达梦 / 金仓)下的兼容性问题
[](...)
[](LICENSE)
## 安装
```bash
composer require --dev yourorg/php-xinchuang-kit
快速开始
# 扫描 + HTML 报告
vendor/bin/xinchuang scan ./src --format=html -o report.html
# 干跑预览修复
vendor/bin/xinchuang fix ./src --dry-run
# 真改 + 备份
vendor/bin/xinchuang fix ./src --backup
# CI 集成(发现 error 退出码非 0)
vendor/bin/xinchuang scan ./src --fail-on-error
检测项
- ✅ MySQL → 达梦 / 金仓 SQL 方言转换
- ✅ 废弃函数(
mysql_*、ereg、split、each…) - ✅ 黑名单扩展(
ioncube、sg11、zend_guard) - ✅ 硬编码路径(
C:\\、/var/www/html) - ✅ 编码问题(BOM、GBK)
- ✅ Apache 专用 API
自定义规则
xinchuang scan ./src --rules=my-rules.yaml
四、开源项目从 0 到持续维护 完整流程
阶段 1:前期准备(0~1 天)
- GitHub 建仓:yourorg/php-xinchuang-kit,选 MIT 协议
- 本地初始化
git init && composer init
echo “vendor/” > .gitignore - 建分支策略:main(稳定) + develop(开发) + feature/*
阶段 2:MVP 最小可用版(1~2 周)
- 先做 1 个检测器(DeprecatedFunctionDetector)+ CLI + JSON 输出
- 能跑 + 有 1 个测试 = 立刻 push —— 不要憋大招
阶段 3:完善生态(2~4 周)
- 补全检测器和修复器
- 写测试(覆盖率 ≥ 80%)
- 接入 PHPStan level 8 + PHP-CS-Fixer
- 配置 GitHub Actions CI
阶段 4:首次发版 v0.1.0
git tag v0.1.0 && git push --tags
去 https://packagist.org 提交仓库 → composer require 立刻能用
阶段 5:推广
- 写文章发掘金 / 知乎 / SegmentFault:“我把信创适配从 3 周缩短到 3 小时”
- 提交到 awesome-php (https://github.com/ziadoz/awesome-php) 列表
- 在国产数据库厂商社区(达梦、金仓)发帖
阶段 6:持续维护
┌────────────┬───────────────────────────────────────────────────────────────────┐
│ 环节 │ 工具 │
├────────────┼───────────────────────────────────────────────────────────────────┤
│ Issue 模板 │ .github/ISSUE_TEMPLATE/bug.yml │
├────────────┼───────────────────────────────────────────────────────────────────┤
│ PR 模板 │ .github/pull_request_template.md │
├────────────┼───────────────────────────────────────────────────────────────────┤
│ 行为准则 │ CODE_OF_CONDUCT.md(用 Contributor Covenant) │
├────────────┼───────────────────────────────────────────────────────────────────┤
│ 贡献指南 │ CONTRIBUTING.md │
├────────────┼───────────────────────────────────────────────────────────────────┤
│ 安全策略 │ SECURITY.md(漏洞上报渠道) │
├────────────┼───────────────────────────────────────────────────────────────────┤
│ 自动化 │ Dependabot + Renovate 自动升级依赖 │
├────────────┼───────────────────────────────────────────────────────────────────┤
│ 版本规范 │ SemVer (https://semver.org/) │
├────────────┼───────────────────────────────────────────────────────────────────┤
│ 变更日志 │ Keep a Changelog (https://keepachangelog.com) 格式的 CHANGELOG.md │
├────────────┼───────────────────────────────────────────────────────────────────┤
│ 自动发版 │ release-please 或手动打 tag │
└────────────┴───────────────────────────────────────────────────────────────────┘
阶段 7:社区运营
- 每周 处理 issue,响应 < 7 天
- 每月 出版本(哪怕只是修复 typo)
- 每季度 重构一次,但要先发 RFC
- 每年 跟新 PHP / 国产数据库版本(PHP 8.4 / DM9 等)
五、这个项目能帮谁、价值多大
┌────────────────┬─────────────────────────────────────────────────┐
│ 角色 │ 价值 │
├────────────────┼─────────────────────────────────────────────────┤
│ 政企软件供应商 │ 投标必备,中标后部署省 80% 时间 │
├────────────────┼─────────────────────────────────────────────────┤
│ 系统集成商 │ 给客户做信创迁移服务,有标准化工具 │
├────────────────┼─────────────────────────────────────────────────┤
│ 国产数据库厂商 │ 帮助生态扩展,降低用户迁移门槛 │
├────────────────┼─────────────────────────────────────────────────┤
│ PHP 老项目 │ 续命,不用整体重写 │
├────────────────┼─────────────────────────────────────────────────┤
│ 开发者本人 │ 简历上一个有真实场景的开源项目,信创赛道现在缺人 │
└────────────────┴─────────────────────────────────────────────────┘
简单说:这是一个能直接换钱的开源项目 ——
帮一家公司做一次信创迁移,这工具能省下几个人月的成本,以工具为入口卖咨询服务、定制规则、企业版,商业模式很清晰。
代码已全部给到,可直接 composer install 跑起来。下一步建议:先把 bin/xinchuang scan 在你手上一个真实 PHP
项目上跑通,看到第一份报告后,再回头按规则优先级补检测器。
AtomGit 是由开放原子开源基金会联合 CSDN 等生态伙伴共同推出的新一代开源与人工智能协作平台。平台坚持“开放、中立、公益”的理念,把代码托管、模型共享、数据集托管、智能体开发体验和算力服务整合在一起,为开发者提供从开发、训练到部署的一站式体验。
更多推荐


所有评论(0)