解决文件结构之争:Type-Based vs Context-Based
主要导航
__2025年5月2日 _ Design _ _
解决文件结构之争:Settling the File Structure Debate
每个人都对如何组织文件有自己的看法:有些人坚持按类型分组,另一些人则按领域分组。今天,我们跳过无休止的争论,直接进入一个真实世界的例子,展示为什么结构很重要,以及如何为长期发展选择正确的结构。
这不是理论,而是构建持久项目的生存技能。
免责声明:不同的语言和生态系统,如 .NET 解决方案或 Java 包,通常出于技术原因使用项目结构——例如将代码拆分为可独立部署的单元(例如,将核心逻辑与 API 端点或消息消费者分离)。本文仅关注项目结构的可维护性和清晰性,而不是部署或编译问题。
免责声明 2:所示示例以 PHP 编写,因为这是我在日常工作中使用的主要语言。也就是说,本文讨论的原则和结构方法与语言无关,同样适用于任何编程语言。
文件结构的期望
在我们深入研究之前,让我们先解决一个重要问题:什么才是好的或坏的文件结构? 答案可能会让你失望:这是主观的。
“但是……那我们为什么在这里?”你问。 因为即使在主观选择的世界中,一些原则也会悄然超越噪音,就像你可以依赖的老朋友一样。今天,我们将认识其中的一些。
首先,一个残酷的事实:你的文件结构永远不会反映你的应用程序在运行时的真实行为。 为什么? 因为文件树是扁平的,而你的应用程序的对象图是一个动态的、活生生的关系网络。试图在文件夹中完美地反映运行时行为? 这就像试图用罐子抓住风。
相反,我们根据其他因素来判断结构:它们帮助我们的能力。 主要地,它们使项目长期可维护的能力。
是什么使事物具有可维护性? 我很高兴你问了。
易于更改
如果软件开发中有一个不可动摇的预言,那就是:你的代码 将会 改变。
也许明天。 也许在六个月内。 但变化来得比你早上喝的咖啡带来的下一次咖啡因崩溃还要快。 聪明的开发人员会预先接受这一点,并设置他们的项目来拥抱它,而不是与之抗争。
在实践中? 你的文件结构应该让你_轻松_移动东西、调整行为并添加新功能,而无需开始玩 3D 叠叠乐游戏。
鲜明的架构
想象一下房子的蓝图。 当你瞥一眼它时,你看到了什么?
如果你发现自己说“两扇门、三扇窗、四面墙”,你就错过了更大的图景。 你应该想到“这是一个舒适的客厅,但厨房感觉很拥挤。”
你的架构应该_鲜明地_表达其目的。 不是技术实现细节,而是应用程序的实际意图和故事。
在代码中,这意味着你应该立即理解业务规则在哪里、核心操作在哪里发生,以及系统的哪些部分扮演什么角色,而无需像沮丧的 Inspector Gadget 那样寻找线索。
清晰的知识边界
每个人都听说过 DRY —— “Don't Repeat Yourself”。 大多数开发人员都被教导认为它是关于_节省击键次数_。
这是一个小的启示:它不是。
真正的 DRY 是关于集中知识。 如果你的系统需要回答一个问题,那么应该只有一个组件负责知道答案。 没有争论。 没有歧义。
例子:
- “John 是否有权运行此操作?” → Identity/Access 边界。
- “John 的首选语言是什么?” → Localization Preferences 边界。
- “John 想要删除他的帐户。” → User-Initiated Account Deletion 边界。
换句话说:每个问题或命令都属于系统的精确一部分。 这可以使你的系统保持敏锐、理智,并免受可怕的“谁知道这个?”综合症的影响。
设置场景
采用以下真实的文件结构:
.
├──database
│ ├──factories
│ │ ├──AdminFactory.php
│ │ ├──EmailVerificationFactory.php
│ │ └──UserFactory.php
│ ├──migrations
│ │ ├──2024_06_10_154108_create_admins_table.php
│ │ ├──2024_06_10_154108_create_users_table.php
│ │ ├──2024_12_23_135704_create_password_reset_tokens_table.php
│ │ └──2025_01_29_171753_create_email_verifications_table.php
│ └──seeders
│ └──DatabaseSeeder.php
└──src
├──Admin.php
├──AdminId.php
├──AllAdmins.php
├──AllAdminsUsingEloquent.php
├──AllEmailVerifications.php
├──AllEmailVerificationsUsingEloquent.php
├──AllUsers.php
├──AllUsersUsingEloquent.php
├──ChangePasswordHandler.php
├──CouldNotChangePassword.php
├──CouldNotFindEmailVerification.php
├──CouldNotFindUser.php
├──CouldNotRegisterAdmin.php
├──CouldNotRegisterUser.php
├──CouldNotSendEmailVerificationNotification.php
├──CouldNotStartEmailVerification.php
├──CouldNotVerifyEmail.php
├──Email.php
├──EmailVerification.php
├──ExpireEmailVerificationHandler.php
├──FirstName.php
├──FixedTokenGenerator.php
├──GetUserUsingDatabase.php
├──LastName.php
├──Password.php
├──RandomizedTokenGenerator.php
├──RecordsEvents.php
├──RegisterAdminHandler.php
├──RegisterUserHandler.php
├──ResetPasswordHandler.php
├──ResetPasswordNotification.php
├──Role.php
├──SendEmailVerificationNotificationHandler.php
├──StartEmailVerificationHandler.php
├──Token.php
├──TokenGenerator.php
├──User.php
├──UserId.php
├──VerifyEmailHandler.php
└──VerifyEmailNotification.php
仅仅通过浏览它,你可能已经猜到它属于哪个领域:身份和访问管理 (IAM)。 这正是我选择它的原因:它很熟悉、直观且难以误解。 你会毫不费力地发现电子邮件验证、管理员注册和密码重置等概念。 但这也是一个很好的例子,说明事物如何快速变得_模糊_。 EmailVerification.php
很明显。 RegisterAdminHandler.php
? 也很清楚。 但是 AllUsers.php
? 嗯。 这一个让我们停下来。
如果可以更快地找到并理解事物,那不是更好吗?
按类型分组
让我们使用历史悠久的约定重新排列结构:按类类型分组。
.
├──Concern
├──Enum
├──Exception
├──Handler
├──Interface
├──Model
├──Notification
├──Repository
├──Service
└──ValueObject
感觉很干净,对吧? 它减少了噪音。 如果你知道你要寻找模型,你去 Model
。 一个 repository? 前往 Repository
。
.
├──Model
│ ├──Admin.php
│ ├──EmailVerification.php
│ └──User.php
├──Repository
│ ├──AllAdminsUsingEloquent.php
│ ├──AllEmailVerificationsUsingEloquent.php
│ └──AllUsersUsingEloquent.php
很棒! 现在我知道 AllAdminsUsingEloquent
是一个 repository。 有用。
但是...
在我诚实的意见中,这就是好处的终点。
按类型分组仅在最简单的情况下有所帮助:“我需要找到一个模型。” 但除此之外,这种结构并没有告诉我们代码实际上试图实现什么。 你失去了上下文。 一切都是从编程语言的角度组织的——而不是从领域组织的。 这就像我们按颜色而不是功能对车库中的所有工具进行排序(!)。
当非开发人员参与进来时,它变得更加无用。 当项目经理说,
嘿,Muhammed,用户在电子邮件验证期间收到无效的 token——你能调查一下吗?
...你精心彩色编码、基于类型的文件夹不提供任何捷径。 你只能寻找相关性。
所以,我想提出第二种做事方式。
按上下文/过程分组
好的,让我们切入正题。 这是一个上下文驱动的结构可能是什么样子(我现在将跳过内部文件):
.
├──Admin
│ └──Registration
└──User
├──EmailVerification
│ ├──Tokens
├──PasswordChange
├──PasswordReset
├──RBAC
└──Registration
现在_这_才是我的语言。
仅仅通过浏览它,我已经可以比使用“按类型”结构更多地了解系统。 我可以清楚地看到 IAM 被分解为两个主要参与者:User
和 Admin
。 我还可以看到 Admin 只有 Registration
过程,而 User 有几个——确切地说,有五个。
让我们回到我们的项目经理所说的话:
嘿,Muhammed,用户在电子邮件验证期间收到无效的 token——你能调查一下吗?
听起来很模糊,对吧? 但是现在,我几乎可以将_那个句子直接投射_到我们的文件夹结构上。 让我们从根目录开始。
第 1 步:“users”
.
├──Admin
└──User
太棒了,让我们展开 User
。
.
└──User
├──EmailVerification
├──PasswordChange
├──PasswordReset
├──RBAC
└──Registration
第 2 步:“tokens during email verification”
好的,我没有立即看到与 token 相关的文件夹,但是 EmailVerification
立即跳了出来。 让我们深入一层。
├──EmailVerification
│ ├──AllEmailVerifications.php
│ ├──AllEmailVerificationsUsingEloquent.php
│ ├──CouldNotFindEmailVerification.php
│ ├──CouldNotSendEmailVerificationNotification.php
│ ├──CouldNotStartEmailVerification.php
│ ├──CouldNotVerifyEmail.php
│ ├──EmailVerification.php
│ ├──ExpireEmailVerificationHandler.php
│ ├──SendEmailVerificationNotificationHandler.php
│ ├──StartEmailVerificationHandler.php
│ ├──Tokens
│ ├──VerifyEmailHandler.php
│ └──VerifyEmailNotification.php
├──PasswordChange
├──PasswordReset
├──RBAC
└──Registration
哇。 好的。 看起来这个过程非常复杂。 但是没有必要惊慌:我们还有那个句子中的一个关键词:“tokens”。
让我们打开 Tokens
目录。
├──Tokens
│ ├──FixedTokenGenerator.php
│ ├──RandomizedTokenGenerator.php
│ ├──Token.php
│ └──TokenGenerator.php
轰。 我们进来了。
这种结构只是通过追踪项目经理的句子,一步一步地引导我直接进入我需要查看的代码区域。 没有猜测。 没有 grep。 没有 Cmd+Shift+F。 只有上下文。
最好的部分是什么? 因为这个边界是明确界定的,所以我知道 token 生成逻辑就位于_这里_,而不是位于某些随机共享的 Service
或 Shared
目录中。 我不需要在其他地方查找,现在可以完全专注于解决手头的问题。
这就是上下文优先设计的回报。
这是完整的图片:
.
├──Admin
│ ├──Admin.php
│ ├──AdminId.php
│ ├──AllAdmins.php
│ ├──AllAdminsUsingEloquent.php
│ └──Registration
│ ├──CouldNotRegisterAdmin.php
│ └──RegisterAdminHandler.php
├──Contract
│ ├──Command
│ │ ├──ChangePassword.php
│ │ ├──ExpireEmailVerification.php
│ │ ├──RegisterAdmin.php
│ │ ├──RegisterUser.php
│ │ ├──ResetPassword.php
│ │ ├──SendEmailVerificationNotification.php
│ │ ├──StartEmailVerification.php
│ │ └──VerifyEmail.php
│ ├──Event
│ │ └──EmailVerified.php
│ ├──IdentityAccessManagementException.php
│ ├──Query
│ │ ├──GetUser.php
│ │ └──User.php
│ └──ServiceProvider.php
├──Email.php
├──FirstName.php
├──LastName.php
├──Password.php
├──RecordsEvents.php
└──User
├──AllUsers.php
├──AllUsersUsingEloquent.php
├──CouldNotFindUser.php
├──EmailVerification
│ ├──AllEmailVerifications.php
│ ├──AllEmailVerificationsUsingEloquent.php
│ ├──CouldNotFindEmailVerification.php
│ ├──CouldNotSendEmailVerificationNotification.php
│ ├──CouldNotStartEmailVerification.php
│ ├──CouldNotVerifyEmail.php
│ ├──EmailVerification.php
│ ├──ExpireEmailVerificationHandler.php
│ ├──SendEmailVerificationNotificationHandler.php
│ ├──StartEmailVerificationHandler.php
│ ├──Tokens
│ │ ├──FixedTokenGenerator.php
│ │ ├──RandomizedTokenGenerator.php
│ │ ├──Token.php
│ │ └──TokenGenerator.php
│ ├──VerifyEmailHandler.php
│ └──VerifyEmailNotification.php
├──GetUserUsingDatabase.php
├──PasswordChange
│ ├──ChangePasswordHandler.php
│ └──CouldNotChangePassword.php
├──PasswordReset
│ ├──ResetPasswordHandler.php
│ └──ResetPasswordNotification.php
├──RBAC
│ └──Role.php
└──Registration
├──CouldNotRegisterUser.php
└──RegisterUserHandler.php
├──User.php
└──UserId.php
你可能已经注意到一个 Contract
目录。 这不是类型 Interface
的同义词。 它代表给定模块/边界的_公共契约_。 如果有任何 JavaScript 开发人员阅读这篇博文,这或多或少与 index.ts
barrel 文件中的 module.exports
相同。 对于“设计模式”鉴赏家,我可以将其与 Facade
模式(真正的 facade,而不是 Laravel 借用的那个)进行比较。
作为局外人,你只能依赖此目录中的内容。 其余的被认为是内部的。
额外示例:Deep Linking
注意:Deep Linking 指的是使用标准 Web 链接启动移动应用程序的能力。 如果设备上安装了相应的应用程序,则该链接将打开该应用程序。 否则,它会回退到在浏览器中打开等效页面。
以下文件结构属于一个名为 Deep Linking 的模块:
.
├──config
│ └──linking.php
└──src
├──Android
│ ├──AssetLinksDotJsonController.php
│ ├──DigitalAssetLinks
│ │ ├──CertificateFingerprint.php
│ │ ├──PackageName.php
│ │ └──StatementList.php
│ └──RouteRegistrar.php
├──Contract
│ ├──RouteRegistrar.php
│ └──ServiceProvider.php
└──iOS
├──AppleAppSiteAssociationController.php
├──RouteRegistrar.php
└──UniversalLinks
├──ApplicationId.php
└──Association.php
正如你所看到的,两个广泛的子域立即显现出来:Android 和 iOS。 每个都有自己的内部结构,反映了 deep linking 的特定于供应商的实现。 虽然 Android 使用 Digital Asset Links,但 Apple 采用 Universal Links。 两者都旨在解决相同的问题,因此它们理所当然地位于相同的有界上下文中:Deep Linking。
在这种特殊情况下,很难想象“按类型分组”结构如何提供任何有意义的清晰度或导航优势。 按流程和平台组织使领域模型_不言自明_。
想知道那些 RouteRegistrar
文件是什么吗? 它们实现了 Laravel 核心路由 API 中缺少的部分,即模块化 RouteRegistrar
的概念。 你可以在这里阅读更多相关信息。
哪一个更好?
在真空中,答案都不是。 什么使结构“好”或“坏”完全是上下文相关的——受你的团队的目标、规模和你所做的工作类型的影响。 也就是说,在这篇文章的开头,我们建立了一些我们希望从文件结构中获得的期望:清晰性、可导航性(鲜明的架构)、与业务问题的对齐(边界)以及对长期可维护性的支持(易于更改)。
考虑到这些,你可能会得出自己的结论。
并排比较
特性 / 关注点 | 按类型分组 | 按上下文/流程分组 ---|---|--- 按文件类型查找 | ✅ 易于查找特定类型的所有文件 | ⚠️ 类型分散在上下文中 按技术关注点更改影响 | ✅ 非常适合对类似类进行全面更改(例如 repos) | ⚠️ 需要接触多个上下文 架构的高级视图 | ⚠️ 模糊领域行为,强调技术堆栈 | ✅ 立即显示领域结构和有界上下文 从业务语言映射 | ⚠️ 差——需要从业务术语进行心理翻译 | ✅ 强——直接反映利益相关者的语言 入职友好性 | ⚠️ 需要指导; 领域流程如何不明显 | ✅ 更容易——结构匹配现实世界的功能/流程 上下文隔离 | ⚠️ 类型在应用程序中松散地作用域 | ✅ 流程的代码放在一起 随着团队规模扩展 | ⚠️ 需要跨共享类型进行更多协调 | ✅ 团队可以独立拥有和发展上下文 功能工作/调试期间的导航 | ⚠️ 需要在目录之间跳转 | ✅ 大多数相关文件都位于同一位置 战术重构(按类型) | ✅ 简单——系统范围内更新一种类 | ⚠️ 更加分散——重构一种类型可能跨越上下文 促进领域理解 | ⚠️ 弱——领域知识埋藏在技术层下 | ✅ 强——结构反映了业务领域
总结:
- 基于类型的分组非常适合以技术为中心的任务、一致的命名和大型的全面更改。
- 基于上下文/流程的分组擅长于领域清晰度、团队所有权、调试以及将业务问题直接映射到代码。
再说一次,我并不是说一个比另一个更好。 自己判断,并在你自己的约束条件下尽可能高效!
在 X (以前的 Twitter) 上加入讨论! 我很想知道你对这篇博文的看法。
感谢阅读! _ _© 2025 Muhammed Sarı _ _ _ _ ___ Navigation _