后端与架构
为 Ionic 电商应用构建 Laravel 后端:迁移、数据填充与 RESTful 控制器
阅读需 10 分钟
为 Ionic 电商应用构建 Laravel 后端:迁移、数据填充与 RESTful 控制器
我的硕士毕业设计是一个跨平台电商应用:面向顾客的 Ionic/Angular 客户端,以及一个 Laravel 后端——它既提供应用所需的数据,也承载用于管理商品、分类、客户和订单的后台。这个项目获得了最高荣誉(Matrícula de Honor),但我想在这里记录的不是结果,而是 Laravel 这一侧的一些决定:哪些我会原样再做一次,哪些我会换一种做法。
写代码之前先拆分工作
我做的第一件有用的事,是把待办清单(backlog)拆成四个板块,而不是一条长长的列表:
- 客户端:顾客在 Ionic 应用里看到的一切——搜索栏、购物车、收藏、商品详情、个人资料、订单历史。
- 后台:Laravel 管理面板——带筛选的表格、新增和编辑分类与客户的表单、数据导出。
- 连接:前后端之间的约定。
- 测试。
回头再看这份清单,客户端和后台两个板块的任务几乎都打了勾,而"连接"和"测试"几乎是空的。这是本文最诚实的一条经验:客户端与服务器之间的集成,以及保护这种集成的测试,从第一天起就应该有自己的任务。如果它们只是一个标题,最后往往会被隐式地、很晚地完成。
迁移:顺序本身就是设计的一部分
在搭建数据库时,我用大写字母记下了一句话:建表的顺序很重要。Laravel 按文件名顺序执行迁移,而带外键的表只有在它所引用的表存在之后才能创建。在电商场景里,这条依赖链很长:用户、分类、商品、订单、订单明细、评论、收藏。
声明外键有两种写法,我最终更喜欢简短的那种:
Schema::create('orders', function (Blueprint $table) {
$table->id();
$table->foreignId('user_id')
->constrained()
->cascadeOnDelete()
->cascadeOnUpdate();
$table->timestamps();
});
constrained() 会根据列名推断被引用的表(user_id → users),前提是你遵循 Laravel 的命名约定。这也是我很早就记下的另一条规则:表名使用小写复数形式,因为 Eloquent 会自动把 Product 模型对应到 products 表。和这条约定对着干,付出的代价远大于收益。
down() 方法同样需要注意:先删除外键,再删除它们所依赖的列或表,否则 migrate:rollback 会在中途失败。
我还很早就把数据库设计搬到了 dbdiagram.io 上。把表之间的关系画出来之后,哪个迁移应该先执行就一目了然了。
一个记录下来的数据建模问题
在某个冲刺中我记下了一个疑问:既然订单总价可以通过订单明细计算出来,orders 表还需要 price 列吗?我最后的结论是:严格来说并不需要,但为了在列出大量订单时提高效率,保留它是一个合理的折中——前提是每次订单明细变化时都同步更新它。这只是个小决定,但把它写下来,让我在每次修改订单页面时都不必重新争论一遍。
数据填充(Seeder):从第一周起就有贴近真实的数据
Seeder 让后台在还没有任何真实数据时就可以测试。模式很简单:一个记录数组、一个负责插入的私有方法,再在 run() 中调用它:
class DatabaseSeeder extends Seeder
{
private array $users = [
['name' => 'Demo Admin', 'email' => 'admin@example.com', 'password' => 'secret'],
['name' => 'Demo Customer', 'email' => 'customer@example.com', 'password' => 'secret'],
];
public function run(): void
{
$this->seedUsers();
$this->command->info('Users table seeded.');
}
private function seedUsers(): void
{
foreach ($this->users as $data) {
User::create([...$data, 'password' => bcrypt($data['password'])]);
}
}
}
在开发过程中,php artisan migrate:refresh --seed 成了我的"重置按钮":重建数据库结构、重新载入数据,所有页面都回到一个已知状态。即使是测试数据,密码也要经过 bcrypt() 处理——这是一个最好从一开始就养成的习惯。
Eloquent 还是查询构造器?
Laravel 在数据库之上提供了两层抽象。查询构造器(Query Builder)是一种以链式方式编写 SQL 操作的方法;Eloquent 是建立在其上的 ORM,每一行数据都以模型实例的形式返回。对于一个满是增删改查页面的后台,Eloquent 自然成了默认选择:
$product = Product::findOrFail($id); // 返回 404,而不是手动判断 null
$recent = Order::where('price', '>', 100)->latest()->take(10)->get();
$count = Customer::count();
$average = Order::avg('price');
findOrFail() 让控制器里一整类"试图读取 null 的属性"的错误消失了。聚合方法(count、max、min、avg、sum)覆盖了后台概览所需的统计数字,无需手写 SQL。我只在少数只需要对一组数据做简单操作、完全用不到模型的地方使用查询构造器(DB::table(...)),比如在 Seeder 中清空一张表。
RESTful 控制器
对于后台和应用需要的每一种资源——商品、分类、客户、订单——我都使用了资源控制器:
php artisan make:controller ProductController --resource
Route::resource('products', ProductController::class);
一条命令就能生成七个标准动作(index、create、store、show、edit、update、destroy),一行代码就能注册它们的全部路由,用 php artisan route:list 一眼就能核对。对于由移动客户端而不是 Blade 表单调用的接口,Route::apiResource() 会注册同样的路由,但不包含只对 HTML 表单有意义的 create 和 edit。
它的价值不在于少敲几行代码,而在于可预测性。每个资源的结构都一样,所以无论是 Ionic 那一侧还是后台那一侧,都始终清楚该用哪个 URL 和哪个 HTTP 方法。
后台:Laravel 默认功能不够用的地方
我最先在后台表格上尝试的是 Laravel 自带的分页。它能用,但不包含实时搜索和导出按钮。于是我把这些表格换成了 DataTables,在客户端实时筛选,并为商品、客户和订单加上了 PDF 和 Excel 导出。DataTables 也有自身的局限;如果面对真实规模的商品目录,我会改为在服务器端筛选,但对于毕业设计来说已经足够。
商品图片:用存储层,而不是 public 文件夹
商品图片和头像都通过 Laravel 的存储层处理。保存到 public 磁盘的文件位于 storage/app/public,执行 php artisan storage:link 后会通过 /storage 路径对外提供访问。使用 $request->file('image')->store('products', 'public') 时,文件还会以自动生成的哈希名保存,而不是用户上传时的原始文件名,从而避免文件名中出现引号、HTML 或其他奇怪的字符。
唯一真正的障碍是文件大小:超过 PHP 默认限制(upload_max_filesize = 2M、post_max_size = 8M)的上传会失败。这两个值都要在 php.ini 中调大,并且 post_max_size 至少要和上传限制一样大,然后重启服务器。当时我是通过在 public/ 中放一个包含 phpinfo() 的文件来找到正在生效的 php.ini 的——如今我把这种捷径视为反模式:在终端执行 php --ini 就能得到同样的路径,而遗留在公开目录里的 phpinfo() 页面,会把整台服务器的配置暴露给任何发现它的人。我还在服务器端对图片做了缩放,避免应用为了显示缩略图而下载原尺寸照片。
哪些会保留,哪些会改变
我会保留这些约定——小写复数的表名、foreignId()->constrained()、资源控制器、把 Seeder 当作重置按钮——因为它们让我能快速推进,而不用操心底层的"管道"。我会改变两件事:从第一个冲刺开始就为前后端约定和测试单独建立任务;从一开始就把后台表格设计成在服务器端筛选。这两点都不会改变最终成绩,但都会让代码库更容易继续扩展。