拓十年匠心定制 · 商业建站与技术教学双线并行 咨询热线:400-886-1026 service@lmnt.cn
ARTICLE DETAIL

资讯详情

深耕网站建设与运营推广的一线实战洞察。

PHP中Repository到底算不算设计模式?一文厘清概念与实战

PHP中Repository到底算不算设计模式?一文厘清概念与实战

1. 为什么“Repository是设计模式”这句话让人半信半疑

第一次听到“PHP的Repository = 设计模式?”这个问题时,我大概正坐在某次技术分享的最后一排。台上的讲师放出一张类图,说“这里我们用Repository模式解耦数据访问”,底下有个新人举手问:“Repository是23种设计模式里的哪一种?”讲师愣了一下,答:“它是……一种数据访问层的模式。”那个“了一种”拖得特别长,长到我能感觉到他自己也不太确定。

后来我在很多PHP项目里都见过同样的困惑:有人说Repository就是设计模式,有人说不是,还有人写了一堆Repository代码,结果除了让Controller多包了一层,什么好处都没拿到。今天我想把这件事彻底讲清楚:Repository到底算什么模式、它解决什么问题、接口该怎么设计才不白写、哪些项目根本不需要它。

先说结论:严格意义上,Repository不是GoF那23种设计模式中的任何一种,它出自Martin Fowler的《企业应用架构模式》,属于架构模式/领域模式;但在口语化的开发讨论中,大家已经习惯把一切可复用的编码套路都叫“设计模式”,所以非要把Repository归为设计模式,也不能算错。关键不在于名词分类,而在于你是否知道它真正解决的是什么问题。

这个问题之所以绕,是因为要想明白它,需要把“模式”的体系和“数据访问”的具体场景都摸清楚。我不是理论洁癖患者,但我见过太多人把Repository用成“为了用而用”的反面教材,所以打算分几个层面把这件事拆开聊。

1.1 Repository的三个“出身”:不是每本经典里都有它

先交代一个容易混淆的知识背景。程序员口中常说的“23种设计模式”,指的是GoF(Gang of Four)在《Design Patterns》里总结的创建型、结构型、行为型三类模式,比如单例、工厂、观察者、策略。这里没有Repository的位置。

Repository最早被系统化地讨论,是在Martin Fowler的《Patterns of Enterprise Application Architecture》(大致可以叫它企业应用架构模式)里。Fowler把它定义为“在领域模型和数据映射层之间做中介,让你像操作内存中的集合一样操作数据源”的模式。后来Eric Evans的《领域驱动设计》又把Repository作为领域模型仓库的设施概念强化了一遍。

所以严格来说,Repository是架构模式,解决的是代码分层之间“依赖怎么走”的问题;而GoF设计模式解决的是“对象怎么创建、怎么组合、怎么协作”的问题,二者不在一个粒度上。

1.2 一个名词,两个圈子:为什么大家都叫它“设计模式”

但现实是,当你打开搜索引擎输入“Repository 设计模式”,会看到大量文章直接把Repository称为设计模式。这种叫法是广义的“设计模式”:只要是被反复验证的、解决特定问题的一套代码组织方案,都可以叫模式。在这个口径下,Repository当然算。

我的建议是:如果面试官问“Repository是设计模式吗”,你不需要纠结对错,而是直接回答它在体系中的位置,再补一句它解决的具体问题。一个漂亮回答是:“它不属于GoF的23种模式,而是企业应用架构模式中的架构模式,核心目的是让业务层不感知数据访问细节。你一定要把它叫设计模式,我也没意见,但它的应用层次比单例、工厂要高。”

1.3 Repository和门面、工厂的“长相区别”

很多人分不清Repository和门面模式(Facade)、工厂模式(Factory),因为它们在外观上都是“包装一层”。把三者并排看一下,区别就很清晰了:

概念解决的核心问题典型的动作一句话类比
门面模式给复杂子系统提供统一入口封装一堆子系统调用前台接待,不用你挨个部门跑
工厂模式对象创建逻辑复用new 什么、怎么 new生产线,按配置生产实例
Repository数据访问细节与业务逻辑隔离查询、持久化领域对象采购清单,你说要什么,他搞定货源

门面和工厂内部装的是什么,外人不关心;但Repository的要求更严格——它对外暴露的语义必须贴近业务,而不是贴近数据库。明确了这一点,后面的接口设计才有评价标准。

2. Repository真正解决的问题:把“怎么查”变回“要什么”

聊完理论,回到PHP项目里最真实的场景:一个Controller到底会写成什么样。

2.1 没有Repository之前,Controller里能塞多少东西

假设你维护一个用户管理系统,最简单的列表页,代码可能是这样:

class UserController extends Controller { public function index(Request $request) { $users = User::query() ->where('status', User::STATUS_ACTIVE) ->when($request->filled('keyword'), function ($q) use ($request) { $q->where(function ($query) use ($request) { $query->where('name', 'like', '%' . $request->keyword . '%') ->orWhere('email', 'like', '%' . $request->keyword . '%'); }); }) ->orderByDesc('created_at') ->paginate(20); return view('users.index', ['users' => $users]); } }

这段代码的问题不在链式操作本身,而在于业务规则散落在了查询表达式里。“只查活跃用户”“按关键字在姓名和邮箱里模糊搜索”“按创建时间倒序”——这些本该是业务语义的东西,被拆成了一个个where和orderBy,每次使用就要重新拼一遍。同一个“活跃用户搜索”逻辑,可能在API接口、后台管理、报表脚本里各出现一版,而且每版的写法还略有差异。

一旦用户表从MySQL挪到别的数据源,比如临时接了个Elasticsearch或者写了个模拟服务,这些Controller全部要改。这就是“怎么查”和“要什么”没有分清带来的后果。

2.2 Repository介入后,调用方看到的是什么

引入Repository后,Controller变成这样:

class UserController extends Controller { public function __construct(private UserRepository $users) { } public function index(Request $request) { $users = $this->users->searchActiveUsers( keyword: $request->keyword, page: $request->get('page', 1) ); return view('users.index', ['users' => $users]); } }

注意变化的是什么:Controller不再关心“这个查询是操作MySQL还是什么”,不再关心表字段,不再关心分页查询的细节写法。它只说了一句话:“我要活跃用户,可能带个关键字,默认第一页。”

Repository把“怎么查”的内部细节接过去了,对外只留下“要什么”的语义接口。这是它解决的最大问题,没有之一。

如果你想知道为什么需要一个中间层,而不是直接把逻辑放进Model的scope或Query Builder扩展里,我的看法是:Model层本身已经绑定了具体的数据源,而Repository的价值是在“业务代码”和“数据源实现”之间再插一道可替换的接缝。这个接缝就是未来换数据源、写单元测试、做多数据源并存时的底气。

2.3 为什么接口不能直接返回ORM模型

这是Repository实践里一个非常微妙的点。从维护角度讲,如果Repository的方法返回的是Eloquent模型实例,那调用方拿到模型后,依然可以接着做->where(...)、->orderBy(...),等于说“隔离”只停留在入口,数据访问的尾巴还是漏进了业务代码。

理想情况下,Repository应该返回领域对象、DTO,或者至少是结构的只读形态。但PHP项目的现实是:完全和ORM模型切断关系非常痛,因为Eloquent模型本身承担了关系加载、序列化、事件等一堆职责,硬要做成纯POJO反而增加无谓的转换开销。

在实践中我有一个折中建议:Repository方法签名上明确返回类型,但允许返回模型;只是不要在模型上继续做查询拼接,所有查询能力都只通过Repository暴露。这是“契约层面”的隔离,而不是“物理层面”的隔离。只要团队达成共识,在Code Review时盯住有没有人拿到模型后还在Controller里写->where(),效果基本足够。

3. Repository接口怎么设计才算合格

看到这里你大概已经明白,Repository这个抽象的价值,很大程度上取决于接口长什么样。我见过最糟糕的Repository,是把User::where('a', 1)->get()原封不动搬进去,方法名还叫getUsersByFieldA,这种接口我称它为“SQL的翻译器”,毫无意义。

3.1 读接口按业务命名,写接口统一动词

好的Repository,读方法应该贴近业务语言。比如:

interface UserRepository { public function findById(int $id): ?User; public function findByEmail(string $email): ?User; public function searchActiveUsers(?string $keyword = null, int $page = 1): Paginator; /** * @param int[] $ids * @return Collection<User> */ public function findByIds(array $ids): Collection; }

写操作则用统一的保存/删除语义:

public function save(User $user): void; public function delete(User $user): void;

有人问:为什么不暴露一个通用的findAll()?我的经验是:通用查找方法往往是偷懒的第一步。一旦你提供了findAll(),调用方就会传各种条件数组进来,里头塞着排序、分组、关联预载入,Repository的约束瞬间瓦解。宁可让接口列表长一点,也要保证每个方法都有明确的业务含义。

3.2 查询方法爆炸怎么办:用过滤条件对象收敛

另一个常见问题:业务复杂之后,searchActiveUsers、searchPendingUsers、searchVipUsers……接口会多到爆炸。这时候可以引入一个简单的过滤器对象,或者直接传数组统一入口:

public function searchUsers(UserSearchCriteria $criteria): Paginator;

UserSearchCriteria里放的是查询参数:

final class UserSearchCriteria { public function __construct( public readonly ?string $keyword = null, public readonly ?int $status = null, public readonly ?string $orderBy = 'created_at', public readonly string $sort = 'desc', public readonly int $page = 1, public readonly int $perPage = 20, ) { } }

这样接口只有一个,语义仍然集中在searchUsers上,查询条件的扩展由参数去承载。Controller和Service不需要知道这些条件最终变成了什么SQL,只负责组装Criteria。

3.3 分页、排序、过滤应该独立出来还是塞进Repository

分页这件事容易踩坑。很多人的Repository方法签名长这样:

public function paginateActiveUsers(int $page, int $perPage, ?string $keyword, ?string $orderBy, string $sort): Paginator;

参数一多,连调用的人自己都记不住顺序,改起来也痛苦。把分页和过滤参数收敛进一个Criteria对象,是Java世界里很常见但PHP项目里很少用的做法。我强烈建议PHP项目也这么做,因为PHP的命名参数虽然能缓解长签名问题,但无法根治“参数含义不明确”的毛病。

3.4 方法数量反映领域能力,而不是表字段数量

最后,记住一个评价Repository设计好坏的隐性标准:一个好的Repository,方法数量大致等于这个聚合根对外提供的“用例能力”数量,而不是数据库表字段就能映射出来的各种组合。比如用户表有20个字段,如果Repository方法数接近20,多半是掉进了“按字段查询”的陷阱。真正的领域能力可能是“按邮箱找用户”“搜索活跃用户”“找到某批ID的用户”,撑死了也就三五个方法。

我自己在Review同事的Repository时,第一个看的就是方法列表。如果列表清清瘦瘦、每个名字都读得出业务含义,那这个层大概率是健康的;如果列表长得像Excel筛选器,就该坐下来聊聊设计问题了。

4. Laravel项目里最容易踩的五个Repository坑

理论说再多,不如动手试一轮。我在几个Laravel项目里用过Repository,也接手过别人写坏的Repository,下面这五个坑算是高频中的高频,每一个我都见过、改过。

4.1 透明转发器:把ORM的查询原样搬进来

这种Repository长这样:

class UserRepository { public function getActiveUsers() { return User::where('status', 1)->get(); } public function getUsersByName($name) { return User::where('name', 'like', "%{$name}%")->get(); } }

它看起来是抽象了一层,实际没有任何抽象价值,只是把用户查询从Controller挪到了另一个地方。判断是不是透明转发器,就看你把Controller里的查询贴到Repository里,是否一行都没改。

修正思路:透明转发器不一定是错,如果只是为了统一入口、方便以后替换,这种简单封装也有意义;但如果指望它解耦,那就是自欺欺人。至少要让Repository的方法具备“业务语义”而不是“字段访问语义”。

4.2 在Repository方法里泄露Query Builder

还有一种做法让人头疼:Repository的方法返回了Query Builder对象,让调用方继续拼接条件:

public function queryActiveUsers(): Builder { return User::query()->where('status', 1); }

调用方拿回来后继续->where(...)、->orderBy(...),等于把SQL拼装的权限重新交给了业务层。你辛辛苦苦建立的隔离,一夜回到解放前。

修正思路:Repository方法对外只能返回结果,不能返回半成品构造器。如果调用方有更多查询条件,把它收进Criteria对象,让Repository内部处理。

4.3 缓存逻辑全塞进Repository,业务层被旧数据“绑架”

我曾经在一个项目里见到Repository里直接加了Redis缓存,每个方法都先查缓存,没有再查库。第一眼觉得又省事又高效,等到业务层发现数据延迟、需要强制刷新时,问题就来了:业务把你整个Repository的缓存行为当成“透明”的,可你实际上放了一层缓存,读到的数据可能是几十分钟前的旧数据。

修正思路:缓存本身不是错,但不应该无条件地内嵌在每个读方法里。要么通过显式的refresh()方法控制失效,要么把缓存提升为独立的装饰器方案,要么至少在方法文档里写清楚“此方法有缓存”。比起让所有人猜,明确说透更重要。

4.4 为了模式而模式:每个Controller都套一层Repository

很多项目上Repository是“设计评审”里的硬性要求,于是每个Domian都建Repository,每个Repository里只有一个方法,方法体就是三行查询。代码量翻了一倍,收益为零。

修正思路:Repository这个抽象的收益来源于“数据访问复杂度”和“领域复用度”。如果一个实体只有单表增删改查、没有任何复用,那么直接让Controller或Service操作Model,都比强行加一层强。模式要服务于项目,不是项目服务于模式。

4.5 Service和Repository边界混乱:业务逻辑溜进数据层

这是最隐蔽的坑。看下这个Repository方法:

public function findEligibleUsersForCoupon(float $threshold): Collection { return User::query() ->where('status', 1) ->whereRaw('total_spent >= ?', [$threshold]) ->get(); }

total_spent的统计口径、用户是否符合优惠券资格,这是业务判断,不是数据访问逻辑。把这种筛选放进Repository,短期内很舒服,长期会让业务规则越来越难找——数据层里藏着一堆业务规则,业务层反而成了空壳。

修正思路:Repository里保留原子性的查询能力,比如findByIds、findActiveUsers;具体的资格判断放到Service层,先查出候选数据,再在Service里做领域校验。表面上多写几行,边界却清清楚楚。

5. 哪些项目根本不需要Repository

说了这么多Repository的好处,你可能会觉得所有PHP项目都该上。我的真实看法是:至少有一半的项目完全不需要Repository。判断标准不是“够不够酷”,而是“有没有那个接缝需求”。

5.1 一个判断清单:满足两条以上才考虑引入

我常用的判断信号有这些:

  • 同一个查询逻辑,在Controller、命令行脚本、队列消费者里出现了至少三份。
  • 项目有明确的计划,未来要把数据从MySQL迁移到别的存储,或者同时使用多种数据源。
  • 业务代码需要脱离数据库做单元测试,而当前测试总是被数据库连接拖累。
  • 团队规模超过三个人,需要明确规定“数据访问只能从这里走”,才能避免人人都直接写Model查询。
  • 领域模型复杂,数据表结构和业务对象结构明显不一致,需要数据映射。

如果一条都不占,那用Eloquent的scope直接写在Model里,完全够用。不要太迷信“架构前瞻性”——过度设计也是债。

5.2 不引入Repository时,怎么保持代码整洁

有人担心不引入Repository代码就会失控。其实PHP有更轻量的替代方案:把查询封装成Model的局部作用域(scope),既能复用条件,又不出Controller那层:

class User extends Model { public function scopeActive($query) { return $query->where('status', self::STATUS_ACTIVE); } public function scopeSearch($query, ?string $keyword) { if ($keyword === null || $keyword === '') { return $query; } return $query->where(function ($q) use ($keyword) { $q->where('name', 'like', '%' . $keyword . '%') ->orWhere('email', 'like', '%' . $keyword . '%'); }); } }

Controller里就可以写成:

$users = User::query()->active()->search($request->keyword)->paginate();

这种写法的优点是省了一层,缺点是所有查询还是绑死在Eloquent上。它适合小项目,但请记住:scope不属于数据访问隔离,它只是查询条件复用。两者解决的问题不同,不要混为一谈。

5.3 项目长大以后还能补吗:怎么无痛引入Repository

更让团队焦虑的是另一个问题:“我现在不加,以后项目长大了怎么补?”我的回答是:能补,而且没那么痛。前提是业务代码里的查询没有散得到处都是,Controller里调用查询时没有直接把User::where()写死在一堆逻辑中间。只要查询条件本身已经收敛在Service或Model scope里,后期抽出Repository只是一个“搬代码”的操作,接口设计反而比一开始拍脑袋做得更准。

6. 给老系统引入Repository的重构实录:一次真实的操作过程

最后分享一次真实的重构经验,项目是一个用了三年、没人愿意动它的老订单系统。当时的情况是:Controller里散落着大量直接操作Order::where()的代码,status字段的取值判断逻辑重复了无数遍,任何一次需求改动都要小心翼翼。

6.1 为什么先从最疼的用户查询接口下手

我没有一次性把所有Model都包进Repository,而是先挑了一个最疼的点:订单状态查询。业务上频繁用到的“查询某个用户的有效订单”,在代码里至少有五个地方有近似实现,但细节各有不同,有的漏了deleted_at判断,有的忘了关联商品数据。

我先定义了一个最小接口:

interface OrderRepository { /** * @return Collection<Order> */ public function findValidOrdersByUser(int $userId): Collection; }

然后写了一个基于Eloquent的实现,把五个地方反复出现的判断逻辑统一到一处。这一步做完,效果立竿见影——不只是代码瘦了,连之前几个状态判断不一致导致的bug都消失了。

6.2 过渡期的兼容策略:接口先行,逐步替换

重构时最怕“一刀切”。我当时的做法是:

  1. 定义接口和实现类,注册到服务容器。
  2. 挑一个受影响最小的Controller,把它内部的查询改成调用Repository。
  3. 跑全量回归,看业务结果是否一致。
  4. 确认无问题后,再逐批替换其他调用点。
  5. 替换完所有调用点后,删除Controller里裸奔的查询代码。

注意,替换过程中会有一种“过渡期脏代码”:有些方法在Repository里已经存在,但Controller里还在用旧的方式查询。这时候宁可让Repository和旧代码共存一段时间,也不要急着把旧代码删掉,毕竟没人喜欢在周五下午部署一个巨变版本。

6.3 重构后的效果,以及真正需要注意的阻力

重构完成后我做过一次统计:涉及订单查询的Controller平均瘦了大约40%,经验里的状态判断逻辑只剩一个出处,新增一个订单查询入口时不用再前怕狼后怕虎。更重要的是,后续写单元测试时,我终于可以用一个假的OrderRepository替换真正的数据访问,业务层的测试不再需要连数据库。

但这个过程中最大的阻力不是技术,而是同事的抵触。有人觉得“代码能跑就行,为什么要多包一层”,直到我拿出diff,指出同一个状态判断之前有五个实现、其中一个还漏了条件,对方才真正服气。所以如果你想在团队里推Repository,最好带上具体的问题证据,而不是一句“这是最佳实践”。

7. 我的判断习惯:什么时候想起Repository

写了这么多,最后说说我个人的实操习惯。

我现在在新项目里不会一上来就搭一堆Repository接口,而是先让业务“裸奔”一个迭代。在这期间我会盯三个信号:同一段查询条件是否在第二处出现、是否有必须mock数据源的测试需求、是否因为Query Builder把SQL细节带进业务层而导致的改动事故。一旦这三个信号里有任何两个同时出现,我就知道该给数据访问层加一条接缝了——这条接缝的名字就是Repository。

如果项目前两个月都没触发这些信号,那就不加。不加模式不是错误,错误是不知道为什么加。等你被“同一查询逻辑改了五处”折磨过一次之后,你自然会理解Repository这个抽象的意义;而当你被“每个Repository都只有一个透明方法”折磨过一次之后,你也会理解抽象不是越多越好。

“PHP的Repository = 设计模式?”这个问题,在我这里的答案是:它不是一个需要背定义的教条,而是一个需要判断力去使用的手术刀。用对了,代码边界清晰、业务语义突出;用错了,它只是另一层没人在乎的封装。希望这篇分享能让你在下次写Repository时,多问自己一句:“我到底在隔离什么?”

返回列表