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

资讯详情

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

PHP 8.2 readonly class 与 Doctrine ORM 3.4 如何终结实体类中的 getter/setter

PHP 8.2 readonly class 与 Doctrine ORM 3.4 如何终结实体类中的 getter/setter 做了八年PHP开发我在实体类里老老实实写getter/setter的时间大概比很多同行写代码的总时长都长。说实话以前真没觉得这有什么问题IDE一键生成工具链也顺手顶多就是实体类看起来胖一点。直到这半年借着一次技术债清理我把项目从Doctrine ORM 2.x一路升级到3.4.0顺手用PHP 8.2的readonly class 构造函数属性提升把大部分实体重构了一遍才真正意识到过去八年里大量getter/setter根本不是必需品而是语言和ORM能力不到位时不得不接受的妥协。这篇文章我会按这次重构的完整顺序来写起初为什么觉得非改不可、中间基于什么原则设计方案、具体怎么一步步改、以及落地时踩到的几个重要坑。如果你也正在纠结“实体类到底该不该去掉getter/setter”或者准备把项目升级到Doctrine ORM 3.x这篇应该能帮你省下不少试错时间。1. 八年getter/setter我到底在烦什么1.1 实体类越来越胖一半代码都是无脑传递先给大家看一个非常普通的实体这是这次重构前我在项目里随手打开的一个类#[ORM\Entity] #[ORM\Table(name: audit_logs)] class AuditLog { #[ORM\Id] #[ORM\Column(type: integer)] #[ORM\GeneratedValue] private int $id; #[ORM\Column(type: string, length: 32)] private string $action; #[ORM\Column(type: json)] private array $context; #[ORM\Column(type: datetime_immutable)] private DateTimeImmutable $createdAt; public function getId(): int { return $this-id; } public function getAction(): string { return $this-action; } public function setAction(string $action): void { $this-action $action; } public function getContext(): array { return $this-context; } public function setContext(array $context): void { $this-context $context; } public function getCreatedAt(): DateTimeImmutable { return $this-createdAt; } }一个只有4个字段的实体却有7个访问器方法。如果字段数到了15到20个这个类里一半以上代码都是在做同一件事把属性原封不动搬出去或者把参数原封不动塞进来。写的时候很机械看的时候又很干扰真正想找业务逻辑还得在一大堆getter/setter里翻。可能有人会说封装性好呀以后可以在setter里加校验。但我在真实代码里看到的真相是绝大部分setter从入职写到离职都没有加过任何校验就是一个裸赋值。既然setter本身没有逻辑那它就只是一个语法层面的“闸门”不仅挡不住不合理变更反而给了每个调用方一个名正言顺的写入口。1.2 可变实体是隐蔽的bug温床比样板代码更要命的是可变实体带来的业务规则失控。举个例子订单状态本来应该走状态机流转先是pending支付成功后变成paid发货后变成shipped。但因为实体上有一个setStatus(string $status)谁都可以在任何地方写一行$order-setStatus(done)把状态改掉。这个问题的本质是实体把“写”的能力公开给了所有调用方而业务规则又没法在每个setter里真正落地。结果就是团队的领域逻辑被迫放在了Service层可Service层并不可控新来的同事不看文档根本不知道这里应该调用$order-markAsPaid()而不是setStatus(paid)。集合属性也是重灾区。我以前见过很多实体直接暴露getItems()返回一个可变的Collection调用方随手$order-getItems()-add($item)绕过聚合根上本该有的addItem方法价格计算、库存校验全被跳过。等到出了问题查代码的时候根本找不到是哪一行往集合里塞了数据。1.3 为什么拖了八年才动手不是不知道这些问题是真的没得选。很长一段时间里PHP语言本身没有不可变属性Doctrine ORM也不支持把实体类设计成完全只读的形态。如果硬要自己搞一套“对象创建后不允许修改”的约定只能靠团队纪律而团队纪律这种东西在排期紧张的时候永远是第一个被牺牲的。真正让我下决心的是这两年PHP和Doctrine同时走到了一个成熟节点PHP 8.0的构造函数属性提升、8.1的readonly属性、8.2的readonly class加上Doctrine ORM 3.x对只读实体的支持越来越稳。到了3.4.0这个版本我测试了几个之前会卡住的组合场景发现都跑得挺顺于是才正式启动了这次重构。2. 转变契机PHP 8.2 readonly class与Doctrine ORM 3.4.0的配合2.1 语言层面终于给出了一个像样的答案PHP 8.0开始构造器属性提升能把属性声明、构造函数参数、赋值三件事合成一行class Money { public function __construct( public int $amount, public string $currency, ) {} }PHP 8.1的readonly属性则让属性在初始化之后彻底变成只读一旦在构造函数里赋过值后续任何形式的修改都会直接抛出Error。到了PHP 8.2readonly class进一步把这个约束推向整个类类里所有属性自动变成readonly。这三个特性叠在一起意味着价值对象和不可变实体第一次有了原生语言支持。以前需要手写getter来“保护”的属性现在直接声明成public readonly就行外部能读但不能写以前需要setter来制造一个可变的写入口现在构造器把参数收进来整个对象从一开始就是完整且固定的。2.2 Doctrine ORM 3.x这些年做了什么另一个关键变量是Doctrine ORM本身。3.0版本把PHP版本下限提到了8.1映射方式也全面转向PHP attributesannotation、XML、YAML这些老配置方式逐步退出主流程。这个变化在初期对老项目有点折腾但也倒逼我们把实体定义彻底翻新了一遍。3.2版本开始Doctrine官方对只读实体给出了正式支持方案也就是允许把实体类或实体属性设计成readonly。不过作为一个“写熟了就不想折腾”的人我没有在最早期就冲上去。真正让我判断可以动手的是3.4.0这个时间点只读实体在日常CRUD、查询、结果集映射这几个核心路径上已经很稳定没有那种“demo能跑但一上业务就报错”的感觉和PHP 8.1的枚举、自定义类型、Embeddable值对象组合使用都很顺畅社区里关于readonly实体踩坑的讨论已经足够多哪些能做哪些不能做基本都有明确结论。对一次要交到团队手上的框架升级来说基础设施稳了才是动手的信号。2.3 为什么我不建议再等也有同事问过我要不要等PHP 8.4或者Doctrine ORM 4.0出来再说。我的想法是如果真想用只读实体这套设计现在的PHP 8.2/8.3 Doctrine ORM 3.4.0已经是够用的组合。语言底层的readonly模型从8.1到现在没有大的语义变化Doctrine 3.x的只读实体支持也经历了几个版本的迭代指望下一个大版本突然冒出什么翻天覆地的改进不如先把眼前的样板代码清掉。当然这不等于所有代码都能无脑改成只读具体哪些实体能改、哪些实体千万别改下面这部分才是这次重构里真正的核心。3. 重构设计不是“把所有实体改成readonly”这么简单3.1 先分类哪些适合只读哪些不适合我这次重构的第一步是把项目里的实体分成三类。第一类是完全适合改成readonly class的典型就是日志、流水、审计记录这类创建后就不再变化的实体。它们从诞生到归档都处于“只读”状态唯一的写动作就是创建本身天然和不可变模型完美契合。第二类是部分适合的比如用户、订单这类核心业务实体。它们虽然整体是变动的但其中某些字段是创建后就不会再改的比如用户注册时的用户名、订单创建时的快照价格。这些字段可以单独声明成public readonly可变字段继续保留行为方法。第三类是完全不适合的典型是草稿、配置、高频局部更新的大对象。如果一个实体本质上就是一个长期可变的状态容器每个字段都可能被单独修改那强行改成只读只会让你的代码变成一团用withXxx()方法拼出来的灾难。3.2 主键设计先放弃自增ID这是最容易被忽略的坑这是我这次重构里最想提醒大家的一点。很多实体用的是数据库自增主键这在传统getter/setter时代毫无问题但一旦想把实体类声明为readonly自增主键会立刻变成绊脚石。原因很简单readonly属性一旦在对象创建时被初始化之后就不能再被修改。数据库自增主键的ID是insert之后才生成的Doctrine在flush之后需要把这个生成的主键回填到内存中的实体对象上这个回填动作本质上就是第二次写入一个readonly属性直接触发PHP的Error。解决方案也不复杂把主键改成应用层生成最常见的就是UUID。你在构造函数里先生成好ID这个值在进入数据库之前就已经确定insert之后Doctrine不需要再回填readonly就不再冲突。如果项目已经有很多自增ID实体迁移时要把主键切换作为独立步骤先做掉。3.3 集合关联从实体里移走而不是硬塞进只读类一对多集合是另一块硬骨头。以前我习惯在User里放一个Collection $orders用Doctrine的OneToMany映射自动管理。但在readonly class的语境下这种设计会有两个问题一方面Doctrine加载实体的过程中集合属性往往需要被替换成持久化集合而readonly属性不允许这种替换。另一方面集合本身是可变的调用方一样能拿到集合然后往里塞数据这和不可变的初衷完全相反。我在这次重构里的策略是凡是核心聚合根里的一对多集合能拆就拆拆不掉就改成显式查询。简单说User实体里不再放orders集合而是通过OrderRepository::findByUserId($userId)去拿订单列表。这样做的好处是两层的实体变轻了只读化没有阻碍同时集合的写操作路径也消失了所有订单创建都必须走Order这个聚合根来管理。3.4 审计字段和时间戳怎么处理还有一个常见问题是审计字段。很多项目习惯在实体上放createdAt和updatedAt然后通过Doctrine的lifecycle callback在保存前自动更新updatedAt。但readonly属性意味着你无法在生命周期事件里去修改它因为那也是一个迟到写入。我对这个问题的判断是真正适合改成只读的实体本身就不应该有updatedAt。一个创建后永远不更新的日志流水放一个updatedAt字段纯属自欺欺人。而一个经常要变状态的User实体如果把它整个改成只读那才是给自己挖坑。所以重构方案是createdAt进readonly实体updatedAt留给可变实体。如果一个实体同时需要很强的不可变性和频繁更新那说明这个设计本身还没有想清楚边界先回头把职责拆开再动手改代码。4. 实操实录把一个实体从getter/setter改造成只读实体4.1 改造前一个典型的AuditLog实体我们继续用开头的AuditLog举例。实体本身是审计日志写入后不会修改属于第一类完全适合改造成只读的实体。原始代码有4个字段、7个方法全是机械的getter/setter。4.2 改造主键从自增ID切到UUID在改字段可见性之前先把主键换掉。这里我用的是ramsey/uuid库也可以用symfony/uid或者任何你习惯的UUID实现关键是ID在应用层生成不依赖数据库回填。self::class对应数据库字段长度36。#[ORM\Entity] #[ORM\Table(name: audit_logs)] readonly class AuditLog { #[ORM\Id] #[ORM\Column(type: string, length: 36, unique: true)] public string $id; #[ORM\Column(length: 32)] public string $action; #[ORM\Column(type: json)] public array $context; #[ORM\Column(type: datetime_immutable)] public DateTimeImmutable $createdAt; public function __construct( string $action, array $context [], ?DateTimeImmutable $createdAt null, ) { $this-id Uuid::v4()-toString(); $this-action $action; $this-context $context; $this-createdAt $createdAt ?? new DateTimeImmutable(); } }对比一下改造前后的代码量原来7个方法现在全部消失实体字段直接以public readonly形式暴露。读取日志动作时直接写$log-action不再调用$log-getAction()。4.3 改造后public readonly属性替代getter有同事问把字段设成public是不是就破坏了封装我的看法是public readonly和private getter在“外部可以读取”这个语义上是完全一致的区别在于前者还明确表达了“外部不能写入”这层约束。过去我们写privategetter只在语法层面防止了直接字段访问但通过setter还是可以随便写现在是彻底堵住了写入口只留下一个构造函数。这在审计日志这个场景非常合适因为日志一旦入库就需要保持原样任何后续修改都没有意义。如果未来真的需要修改那也不该是修改这条日志而是写一条新的日志。4.4 过渡方案普通实体里的半只读字段当然不是所有实体都适合一步到位改成readonly class。比如Useremail和status都是经常要改的整体只读不现实。我的做法是保留User作为普通实体但把其中真正不可变的字段比如注册时的id、创建时间声明成public readonlyclass User { #[ORM\Id] #[ORM\Column(type: string, length: 36, unique: true)] public readonly string $id; #[ORM\Column(length: 64)] private string $username; public function __construct(string $username) { $this-id Uuid::v4()-toString(); $this-username $username; } public function getUsername(): string { return $this-username; } public function rename(string $username): void { $this-username $username; } }这样改造后id不再需要getterusername则保留了行为方法而非裸setter。一直觉得setUsername太通用的现在改成rename之后语义反而清楚了。这个过渡方案能让你在一半实体上先见到效果降低整体迁移的风险。4.5 调用点批量调整getXxx()方法怎么换实体改完后所有读取的地方都要跟着改。比如$log-getAction()要变成$log-action$log-getCreatedAt()-format(...)要变成$log-createdAt-format(...)。项目规模不大时可以靠IDE的重构功能和正则一批替换但有几个问题要特别注意如果方法名里有业务语义而不是简单的属性直读比如isActive()、hasPermission()这些不是getter不应该直接替换成属性访问如果调用方原来通过getContext()拿到数组后转成了某种数据结构要注意新代码里的类型一致性替换完之后一定要让静态分析工具跑一遍我当时是用PHPStan设了最高级别来扫很多漏改的地方都是它抓出来的。5. 踩着过的坑与排查方法5.1 自增主键回填导致的“Cannot modify readonly property”这个坑在3.2节已经提过但值得再展开讲讲。如果你没切换主键就直接把实体改成readonly最常见的报错长这样Cannot modify readonly property App\Entity\AuditLog::$id这个错误通常不是发生在查询读取时而是发生在插入新记录后的flush阶段。Doctrine拿到数据库生成的自增ID后想把它同步回实体对象结果发现对象已经初始化过了于是直接抛出异常。排查的时候尤其迷惑因为报错堆栈里看不到业务代码的痕迹。我的建议是在做只读化改造之前先用脚本把涉及的自增主键全部切到UUID并把数据库字段类型一起迁完确认写入和读取都正常之后再动实体字段的readonly声明。两步分开做出问题的时候定位会快很多。5.2 readonly class与懒加载代理不兼容第二个大坑是懒加载。Doctrine在处理实体关联时默认会生成代理对象代理继承实体类并在访问属性时才触发真正的查询加载。但PHP的readonly class在使用上有限制代理机制在属性初始化后无法再注入数据这就导致只读实体和懒加载天然打架。我遇到的典型场景是Order实体上有一个ManyToOne指向UserUser被改成了readonly class结果一查Order就报错或proxy生成失败。解决方案是把这个关联改成EAGER加载让User在查询Order时立即取出#[ORM\ManyToOne(targetEntity: User::class, fetch: EAGER)] #[ORM\JoinColumn(nullable: false)] public User $user;如果实体之间关联层次比较深全是EAGER会导致查询join爆炸再配合懒加载又会有N1问题。所以更实际的做法是只读实体尽量不持有需要懒加载的对象关联宁可像前面说的那样在Repository里显式查询也不要把整张关联网都挂在实体上。5.3 不要在只读实体上使用refresh和merge还有一个我踩过的小坑EntityManager::refresh($entity)方法会强制从数据库重新取一次数据覆盖当前对象的状态。在readonly实体上执行这个操作本质上又是一个迟到写入一样会触发PHP的Error。反过来如果业务里真的需要强制刷新最新数据正确做法是先clear()清掉实体管理器再重新查询一次。这样返回的是一个全新对象所有属性从数据库row初始化完全符合readonly的规则。这个区别在重构后的代码评审里我专门提醒了团队。5.4 生命周期回调里不要试图更新时间戳lifecycle callback里的prePersist、preUpdate事件在flush阶段触发如果你尝试在这一步给readonly属性赋值同样是“初始化后写入”直接报错。更麻烦的是这类报错发生在flush深处业务代码里没有明显线索。所以我前面才强调有updatedAt需求的实体就别改成readonly。如果真的需要一个审计时间我建议单独做成一条日志记录而不是把更新时间塞进原有实体里。保持一个实体只做一种事情的边界比省一张表要重要得多。5.5 查询投影和只读实体是更好的组合只读实体还有一个隐含收益它天然适合做查询投影。以前用ResultSetMapping做自定义查询时经常要写一个满屏getter/setter的DTO现在完全可以直接用readonly class来承接结果集PHP会自动做构造器参数匹配。#[ORM\ColumnResult(name: action)] #[ORM\ColumnResult(name: created_at)] class AuditLogListDTO { public function __construct( public string $action, public DateTimeImmutable $createdAt, ) {} }这让只读实体不止在领域层发光在读模型和写模型分离的时候也更顺手。列表页展示、报表导出这类场景少了整实体的重量代码干净很多也基本不会踩到懒加载的坑。6. 迁移后的真实体感与建议这次重构花了两周左右不是一口气把所有实体全改掉而是先挑了审计日志、流水、快照这类天然不可变的实体试点跑了一个迭代确认没有回归再逐步扩大到其他实体的半只读改造。改完之后最直观的变化是代码量缩水了很多就拿AuditLog来说从原来各种getter/setter堆叠的长类变成一个不到30行、一眼能看完全部字段和构造函数的结构。对我来说更重要的收获是实体不再是一个谁都能随便写的公共数据袋子了业务上该有边界的部分被语言特性强制立了起来。如果你的团队也在考虑做类似的重构我的建议是不要追求所有实体都变成readonly先把典型的日志、流水、价值对象类实体挑出来练手动手前一定要先切UUID主键这是最容易忽略也最伤筋动骨的准备工作把实体里的一对多集合拆出去不要让集合背负只读体的限制静态分析工具一定要开PHPStan或者Psalm在批量替换getter时能帮你兜住大部分低级错误。这次重构的经验也让我彻底改变了写实体的习惯。以后再建新实体我会先问一句这个东西创建之
返回列表