
1. 为什么“Repository是设计模式”这句话让人半信半疑第一次听到“PHP的Repository 设计模式”这个问题时我大概正坐在某次技术分享的最后一排。台上的讲师放出一张类图说“这里我们用Repository模式解耦数据访问”底下有个新人举手问“Repository是23种设计模式里的哪一种”讲师愣了一下答“它是……一种数据访问层的模式。”那个“了一种”拖得特别长长到我能感觉到他自己也不太确定。后来我在很多PHP项目里都见过同样的困惑有人说Repository就是设计模式有人说不是还有人写了一堆Repository代码结果除了让Controller多包了一层什么好处都没拿到。今天我想把这件事彻底讲清楚Repository到底算什么模式、它解决什么问题、接口该怎么设计才不白写、哪些项目根本不需要它。先说结论严格意义上Repository不是GoF那23种设计模式中的任何一种它出自Martin Fowler的《企业应用架构模式》属于架构模式/领域模式但在口语化的开发讨论中大家已经习惯把一切可复用的编码套路都叫“设计模式”所以非要把Repository归为设计模式也不能算错。关键不在于名词分类而在于你是否知道它真正解决的是什么问题。这个问题之所以绕是因为要想明白它需要把“模式”的体系和“数据访问”的具体场景都摸清楚。我不是理论洁癖患者但我见过太多人把Repository用成“为了用而用”的反面教材所以打算分几个层面把这件事拆开聊。1.1 Repository的三个“出身”不是每本经典里都有它先交代一个容易混淆的知识背景。程序员口中常说的“23种设计模式”指的是GoFGang 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 CollectionUser */ 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 CollectionOrder */ public function findValidOrdersByUser(int $userId): Collection; }然后写了一个基于Eloquent的实现把五个地方反复出现的判断逻辑统一到一处。这一步做完效果立竿见影——不只是代码瘦了连之前几个状态判断不一致导致的bug都消失了。6.2 过渡期的兼容策略接口先行逐步替换重构时最怕“一刀切”。我当时的做法是定义接口和实现类注册到服务容器。挑一个受影响最小的Controller把它内部的查询改成调用Repository。跑全量回归看业务结果是否一致。确认无问题后再逐批替换其他调用点。替换完所有调用点后删除Controller里裸奔的查询代码。注意替换过程中会有一种“过渡期脏代码”有些方法在Repository里已经存在但Controller里还在用旧的方式查询。这时候宁可让Repository和旧代码共存一段时间也不要急着把旧代码删掉毕竟没人喜欢在周五下午部署一个巨变版本。6.3 重构后的效果以及真正需要注意的阻力重构完成后我做过一次统计涉及订单查询的Controller平均瘦了大约40%经验里的状态判断逻辑只剩一个出处新增一个订单查询入口时不用再前怕狼后怕虎。更重要的是后续写单元测试时我终于可以用一个假的OrderRepository替换真正的数据访问业务层的测试不再需要连数据库。但这个过程中最大的阻力不是技术而是同事的抵触。有人觉得“代码能跑就行为什么要多包一层”直到我拿出diff指出同一个状态判断之前有五个实现、其中一个还漏了条件对方才真正服气。所以如果你想在团队里推Repository最好带上具体的问题证据而不是一句“这是最佳实践”。7. 我的判断习惯什么时候想起Repository写了这么多最后说说我个人的实操习惯。我现在在新项目里不会一上来就搭一堆Repository接口而是先让业务“裸奔”一个迭代。在这期间我会盯三个信号同一段查询条件是否在第二处出现、是否有必须mock数据源的测试需求、是否因为Query Builder把SQL细节带进业务层而导致的改动事故。一旦这三个信号里有任何两个同时出现我就知道该给数据访问层加一条接缝了——这条接缝的名字就是Repository。如果项目前两个月都没触发这些信号那就不加。不加模式不是错误错误是不知道为什么加。等你被“同一查询逻辑改了五处”折磨过一次之后你自然会理解Repository这个抽象的意义而当你被“每个Repository都只有一个透明方法”折磨过一次之后你也会理解抽象不是越多越好。“PHP的Repository 设计模式”这个问题在我这里的答案是它不是一个需要背定义的教条而是一个需要判断力去使用的手术刀。用对了代码边界清晰、业务语义突出用错了它只是另一层没人在乎的封装。希望这篇分享能让你在下次写Repository时多问自己一句“我到底在隔离什么”