数据库 Schema 生成 CRUD 接口
解读
国内一线/二线互联网公司 PHP 面试中,这道题通常出现在“代码实战”或“系统设计”环节,面试官真正想考察的是:
- 能否把“数据库表结构”快速映射成“RESTful 接口”,并保证字段、类型、约束、索引、关联关系不丢失;
- 是否熟悉主流 PHP 生态工具(Doctrine、Laravel Migration、Eloquent、Symfony Maker、ThinkPHP6 模型生成器、Hyperf 代码生成器等),以及能否在 5~10 分钟内给出可运行、可单元测试、可部署的代码;
- 对性能、安全、事务、幂等、分页、软删除、乐观锁、字段脱敏等工程细节是否有落地经验;
- 是否具备“可配置”“可扩展”思维:表字段变化后,接口能否零成本重新生成,而不把手工改代码作为常态。
因此,回答时必须给出“一条命令生成全部代码”的完整思路,并现场解释生成规则、目录约定、命名规范、测试策略,而不是只写几个 SQL 和路由。
知识点
- Schema 元数据读取:information_schema、SHOW FULL COLUMNS、PDO::getAttribute、doctrine/dbal SchemaManager。
- 代码生成器模式:Stub 模板 + AST 改写 + PSR-4 自动加载,避免复制粘贴。
- RESTful 规范:URI 复数、HTTP 动词、201/204/422/404 语义、Location 头、分页头 X-Total-Count。
- 字段类型映射:MySQL datetime → string date-format,JSON → array,decimal → string 防精度丢失。
- 验证规则自动推导:NOT NULL → required,UNIQUE → unique:表名,字段名,ENUM → in:列表。
- 关联关系推断:外键名后缀 _id → BelongsTo/HasMany,中间表 → BelongsToMany。
- 安全加固:批量赋值白名单 hidden、频率限制中间件。
- 性能:预加载 with()、索引提示、游标分页(Cursor Pagination)、OPcache 预热。
- 事务与幂等:DB::transaction、分布式幂等令牌(Idempotency-Key)。
- 测试策略:Factory 生成假数据、PHPUnit + SQLite in-memory、Postman 集合自动化、GitHub Action 持续集成。
答案
以下示例基于 Laravel 10 + PHP 8.2,假设 MySQL 8.0 已有 shop 库,表结构如下:
CREATE TABLE product (
id bigint unsigned NOT NULL AUTO_INCREMENT,
sn varchar(32) NOT NULL UNIQUE,
title varchar(120) NOT NULL,
price decimal(10,2) NOT NULL,
stock int NOT NULL DEFAULT 0,
props json DEFAULT NULL,
created_at timestamp NULL DEFAULT CURRENT_TIMESTAMP,
updated_at timestamp NULL DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP,
PRIMARY KEY (id),
KEY idx_title (title)
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci;
步骤一:创建自定义 Generator 命令(文件位置 app/Console/Commands/GenCrudCommand.php)
<?php
namespace App\Console\Commands;
use Illuminate\Console\Command;
use Illuminate\Support\Facades\DB;
use Illuminate\Support\Facades\File;
use Illuminate\Support\Str;
class GenCrudCommand extends Command
{
protected $signature = 'crud:generate {table} {--module=}';
protected $description = '根据表结构一键生成 Model+Request+Controller+Route+Test';
public function handle()
{
$table = $this->argument('table');
$module = $this->option('module') ?: '';
$namespace = $module ? 'App\\Http\\Controllers\\' . $module : 'App\\Http\\Controllers';
$columns = $this->getColumns($table);
$modelName = Str::studly(Str::singular($table));
$varName = Str::camel($modelName);
// 1. 生成 Model
$this->call('make:model', ['name' => $modelName]);
$this->fillModel($modelName, $table, $columns);
// 2. 生成 FormRequest
$requestClass = $modelName . 'Request';
$this->call('make:request', ['name' => $requestClass]);
$this->fillRequest($requestClass, $columns);
// 3. 生成 Controller
$controller = $modelName . 'Controller';
$this->call('make:controller', ['name' => $controller, '--api' => true]);
$this->fillController($controller, $modelName, $requestClass, $namespace);
// 4. 生成 routes/api.php 片段
$this->appendRoute($table, $controller);
// 5. 生成 PHPUnit 测试
$test = $modelName . 'Test';
$this->call('make:test', ['name' => $test]);
$this->fillTest($test, $table, $varName);
$this->info("✅ {$table} CRUD 已生成,请执行 php artisan migrate:fresh --seed 测试");
}
private function getColumns($table)
{
return DB::select("SHOW FULL COLUMNS FROM `{$table}`");
}
private function fillModel($model, $table, $cols)
{
$file = app_path("Models/{$model}.php");
$fill = [];
$hidden = [];
$casts = [];
foreach ($cols as $c) {
$field = $c->Field;
if (in_array($field, ['id', 'created_at', 'updated_at'])) continue;
$fill[] = "'{$field}'";
if ($c->Key === 'UNI' || strpos($field, 'password') !== false) $hidden[] = "'{$field}'";
if (strpos($c->Type, 'json') !== false) $casts[] = "'{$field}' => 'array'";
if (strpos($c->Type, 'decimal') !== false) $casts[] = "'{$field}' => 'string'";
}
$content = file_get_contents($file);
$arrayToString = fn($a) => '[' . implode(', ', $a) . ']';
$replace = [
'use HasFactory;' => "use HasFactory;\n\n protected \\$table = '{$table}';\n\n protected \\$fillable = " . $arrayToString($fill) . ";\n\n protected \\$hidden = " . $arrayToString($hidden) . ";\n\n protected \\$casts = " . $arrayToString($casts) . ";"
];
file_put_contents($file, str_replace(array_keys($replace), array_values($replace), $content));
}
private function fillRequest($class, $cols)
{
$rules = [];
foreach ($cols as $c) {
$field = $c->Field;
if ($field === 'id') continue;
$rule = [];
if (strpos($c->Type, 'varchar') !== false) {
preg_match('/varchar\\((\\d+)\\)/', $c->Type, $m);
$rule[] = 'max:' . $m[1];
}
if ($c->Null === 'NO' && $c->Default === null) $rule[] = 'required';
if ($c->Key === 'UNI') $rule[] = "unique:{$c->Table},{$field}," . ($c->Field === 'sn' ? '' : ',NULL,id');
if (strpos($c->Type, 'decimal') !== false) $rule[] = 'numeric|min:0';
if (strpos($c->Type, 'int') !== false) $rule[] = 'integer|min:0';
$rules[$field] = implode('|', $rule);
}
$stub = <<<STUB
<?php
namespace App\\Http\\Requests;
use Illuminate\\Foundation\\Http\\FormRequest;
class {$class} extends FormRequest
{
public function authorize(){ return true; }
public function rules(){ return %s; }
}
STUB;
file_put_contents(app_path("Http/Requests/{$class}.php"), sprintf($stub, var_export($rules, true)));
}
private function fillController($controller, $model, $request, $ns)
{
$stub = <<<STUB
<?php
namespace {$ns};
use App\\Http\\Requests\\{$request};
use App\\Models\\{$model};
use Illuminate\\Http\\Request;
use Illuminate\\Http\\JsonResponse;
class {$controller} extends Controller
{
public function index(Request \\$request): JsonResponse
{
\\$q = {$model}::query();
if (\\$kw = \\$request->input('keyword')) {
\\$q->where('title', 'like', "%{\\$kw}%");
}
\\$list = \\$q->orderBy('id', 'desc')
->paginate(\\$request->input('page_size', 15));
return response()->json(\\$list);
}
public function store({$request} \\$request): JsonResponse
{
\\$data = \\$request->validated();
\\$item = {$model}::create(\\$data);
return response()->json(\\$item, 201);
}
public function show(\\$id): JsonResponse
{
return response()->json({$model}::findOrFail(\\$id));
}
public function update({$request} \\$request, \\$id): JsonResponse
{
\\$item = {$model}::findOrFail(\\$id);
\\$item->update(\\$request->validated());
return response()->json(\\$item);
}
public function destroy(\\$id): JsonResponse
{
{$model}::destroy(\\$id);
return response()->json(null, 204);
}
}
STUB;
$path = app_path("Http/Controllers/{$controller}.php");
if (!is_dir(dirname($path))) mkdir(dirname($path), 0777, true);
file_put_contents($path, $stub);
}
private function appendRoute($table, $controller)
{
$api = <<<API
// Auto generated by crud:generate {$table}
Route::apiResource('{$table}', \\App\\Http\\Controllers\\{$controller}::class);
API;
File::append(base_path('routes/api.php'), $api);
}
private function fillTest($test, $table, $var)
{
$model = Str::studly(Str::singular($table));
$stub = <<<STUB
<?php
namespace Tests\\Feature;
use App\\Models\\{$model};
use Illuminate\\Foundation\\Testing\\RefreshDatabase;
use Tests\\TestCase;
class {$test} extends TestCase
{
use RefreshDatabase;
public function test_paginate()
{
{$model}::factory()->count(30)->create();
\\$res = \\$this->getJson('/api/{$table}?page_size=10');
\\$res->assertOk()->assertJsonCount(10, 'data');
}
public function test_store()
{
\\$payload = [
'sn' => uniqid(),
'title' => 'iPhone',
'price' => 6999.00,
'stock' => 100,
'props' => ['color' => 'red'],
];
\\$res = \\$this->postJson("/api/{$table}", \\$payload);
\\$res->assertCreated();
\\$this->assertDatabaseHas('{$table}', ['title' => 'iPhone']);
}
public function test_update()
{
\\$item = {$model}::factory()->create();
\\$res = \\$this->putJson("/api/{$table}/{\\$item->id}", ['price' => 5999]);
\\$res->assertOk();
\\$this->assertEquals(5999, \\$item->fresh()->price);
}
public function test_destroy()
{
\\$item = {$model}::factory()->create();
\\$res = \\$this->deleteJson("/api/{$table}/{\\$item->id}");
\\$res->assertNoContent();
\\$this->assertDatabaseMissing('{$table}', ['id' => \\$item->id]);
}
}
STUB;
file_put_contents(base_path("tests/Feature/{$test}.php"), $stub);
}
}
步骤二:注册命令(已在 app/Console/Kernel.php 的 $commands 数组中自动发现)
步骤三:一键生成
php artisan crud:generate product
步骤四:补 Factory(用于测试)
php artisan make:factory ProductFactory
在 factory 中定义生成规则即可。
步骤五:运行测试
php artisan test
全部绿灯即表示 CRUD 接口符合预期。
拓展思考
- 如果字段超过 100 个或包含数十张关联表,如何增量生成?可引入 doctine/migrations-diff + AST 比对,只覆盖变更文件,并自动写入 Git 变更记录。
- 多租户 SaaS 场景下,表前缀带 tenant_id,生成器需要自动在 Model 全局作用域加
where('tenant_id', auth()->user()->tenant_id),并在 Migration 加复合索引。 - 高并发库存扣减,上述生成代码的 update 存在“超卖”风险,需自动识别 stock 字段并在生成器层面加乐观锁(
WHERE stock >= ? AND updated_at = ?)或接入 Redis 原子扣减脚本。 - 若公司采用前后端分离且前端用 TypeScript,可同步输出 OpenAPI 3.0 文档及 TypeScript Interface,利用 zircote/swagger-php + swagger-ui 自动托管,减少联调成本。
- 对于金融/政府项目,字段级脱敏、接口级审计、操作日志、数据权限(行级、列级)必须内置,生成器应提供钩子(Hook)文件,让安全部门插入合规代码,而不是事后人工修补。