万物皆字符串 PHP 中的原始类型偏执

AI摘要
本文讨论了PHP开发中常见的“原始类型偏执”问题,即过度使用字符串、整数等基础类型表示领域概念(如邮箱、金额),导致代码意图模糊、验证逻辑分散且易出错。核心解决方案是引入“值对象”(如EmailAddress、Money),将数据和验证逻辑封装在特定类型中,从而提升代码清晰度、类型安全性和可维护性。文章强调这是一种务实的重构手段,并提供了识别问题、构建值对象以及与主流框架集成的具体方法。

万物皆字符串 PHP 中的原始类型偏执

PHP 让你能快速交付功能。

需要邮箱?用字符串。
需要价格?float 凑合用。
需要用户数据?随便往数组里塞。

感觉很快——直到出问题。

你开始看到这样的函数:

function registerUser(
    string $email,
    string $password,
    string $firstName,
    string $lastName,
    string $countryCode
) {
    // ...
}

或者这样的 service 方法:

public function createDiscount(
    string $code,
    float $amount,
    string $currency,
    string $type,
    int $expiresAtTimestamp
): void {
    // ...
}

全是原始类型:string、int、float、array。
你的领域概念不知不觉变成了”只是数据”。

这就是原始类型偏执(Primitive Obsession):

用原始类型(string、int、array、float)来表示那些本该有自己类型的领域概念。

本文讨论:

  • 原始类型偏执在 PHP 中的表现(尤其是数组和弱类型值)
  • 为什么它让代码更难改、更容易出问题
  • 如何一步步重构到值对象
  • 具体例子:EmailAddress、Money、CouponCode、类型化 ID、集合
  • 如何与 Laravel 和 Symfony 等现代 PHP 框架配合

避免原始类型偏执不是为了当”OO 纯粹主义者”,而是让领域概念清晰明确。这样你和队友就不用猜 string $x 到底是什么东西了。

原文链接 万物皆字符串 PHP 中的原始类型偏执

识别原始类型偏执

以下是 PHP 代码库中原始类型偏执的典型症状。

函数参数都是原始类型

public function scheduleEmailCampaign(
    string $subject,
    string $body,
    string $sendAt,     // ISO 格式?Y-m-d?时间戳?不知道
    string $segmentId,  // UUID?数字?内部编码?
    string $timezone    // IANA?偏移量?"local"?谁知道
): void {
    // ...
}

都是合法的 PHP。但隐藏了很多东西:

  • sendAt 大概是个日期时间
  • segmentId 应该是个领域 ID
  • timezone 可能应该是个 Timezone 对象,或者至少是个受约束的值
  • 完全看不出哪些格式是合法的

你把大量领域规则编码在注释和约定里,而不是类型里。

用关联数组充当对象

$order = [
    'id'         => 123,
    'total'      => 199.99,
    'currency'   => 'USD',
    'created_at' => '2025-11-27T10:00:00Z',
    'status'     => 'paid'
];

$this->processOrder($order);

processOrder 里面:

public function processOrder(array $order): void
{
    if ($order['status'] === 'paid') {
        // ...
    }
    if ($order['total'] > 100) {
        // ...
    }
    // ...
}

$order 本质上就是个简陋的对象:

  • 没有类型安全
  • 没有保证(键可能缺失或类型错误)
  • 没有封装的不变量

一个拼写错误($order['totla'])就会得到运行时 bug。

验证逻辑到处重复

当所有东西都是标量时,规则往往被重复:

// Controller
if (!filter_var($email, FILTER_VALIDATE_EMAIL)) {
    throw new InvalidArgumentException('Invalid email');
}

// Service
if (!filter_var($email, FILTER_VALIDATE_EMAIL)) {
    throw new InvalidArgumentException('Invalid email');
}

// Another class
if (!filter_var($email, FILTER_VALIDATE_EMAIL)) {
    // ...
}

如果邮箱验证规则改了(比如要支持 IDN 域名),你得记得在所有地方更新——否则 bug 就冒出来了。

原始类型偏执不只是风格问题。它有很实际的影响。

原始类型偏执的危害

领域规则分散

如果”有效邮箱”只是个字符串,任何字符串都能混进来。

最后你会:

  • 到处”以防万一”地检查格式
  • 有些地方忘了检查
  • 不同层有略微不同的验证逻辑

意图不清晰

对比:

public function send(string $from, string $to, string $body): void

和:

public function send(EmailAddress $from, EmailAddress $to, EmailBody $body): void

第二个版本告诉你这些值是什么,而不只是它们的底层类型。

静态分析失效

PHP 8 的类型系统有帮助,但是:

  • string 没说它是邮箱还是产品编码
  • int 没说它是分为单位的价格还是用户 ID

值对象给静态分析器(Psalm、PHPStan)和 IDE 更多结构信息。

测试变得啰嗦和重复

如果每个测试都要小心地设置字符串和整数:

$service->createDiscount('WELCOME10', 10.0, 'USD', 'percentage', time() + 3600);

同样的假设和格式一遍又一遍。用值对象就能把这些假设封装好。

解决方案:值对象

在领域驱动设计中,值对象是这样的小对象:

  • 表示一个领域概念(Email、Money、Percentage、CouponCode、UserId……)
  • 不可变(创建后不会改变)
  • 按值比较,而不是身份

把它们想成”带行为的原始类型”。

不是:

string $email
string $currency
float  $amount
string $couponCode

而是:

EmailAddress $email
Currency $currency
Money $amount
CouponCode $couponCode

下面构建几个具体例子。

EmailAddress 值对象

重构前

public function registerUser(string $email, string $password): void
{
    if (!filter_var($email, FILTER_VALIDATE_EMAIL)) {
        throw new InvalidArgumentException('Invalid email address.');
    }
    // Save user
}

其他地方:

public function sendWelcomeEmail(string $email): void
{
    if (!filter_var($email, FILTER_VALIDATE_EMAIL)) {
        // ...
    }
    // Send...
}

重构后

final class EmailAddress
{
    private string $value;

    private function __construct(string $email)
    {
        $email = trim($email);
        if (!filter_var($email, FILTER_VALIDATE_EMAIL)) {
            throw new InvalidArgumentException(sprintf('Invalid email: "%s"', $email));
        }
        $this->value = strtolower($email);
    }

    public static function fromString(string $email): self
    {
        return new self($email);
    }

    public function value(): string
    {
        return $this->value;
    }

    public function equals(self $other): bool
    {
        return $this->value === $other->value;
    }

    public function __toString(): string
    {
        return $this->value;
    }
}

使用:

public function registerUser(EmailAddress $email, string $password): void
{
    // 这里不需要再验证邮箱:
    // 如果你拿到的是 EmailAddress,它已经是有效的了
}

public function sendWelcomeEmail(EmailAddress $email): void
{
    // 直接用
    $this->mailer->send((string)$email, 'Welcome!', '...');
}

好处:

  • 验证集中且一致
  • 意图清晰:这是邮箱,不是随便什么字符串
  • 之后可以加更多便利方法:
public function domain(): string
public function localPart(): string
public function isFromFreemailProvider(): bool

controller 和 service 不用关心验证细节,直接用就行。

Money 和 Currency:告别浮点数陷阱

用 float 处理金额是经典的原始类型偏执罪行之一。

重构前

$total = 19.99;
$discount = 0.1; // 10%
$final = $total - ($total * $discount);

看起来没问题,但 float 会引入舍入误差:

var_dump(19.99 * 100); // 1998.9999999999...

加上货币就变成:

public function applyDiscount(float $amount, float $discount, string $currency): float
{
    // 缺失:检查货币一致性、舍入策略等
}

重构后

更好的做法:

  • 用最小单位(分)的整数表示金额
  • 金额始终带着货币
final class Money
{
    private int $amount; // 最小单位(如分)
    private string $currency; // ISO 4217 代码如 "USD", "CNY"

    private function __construct(int $amount, string $currency)
    {
        if ($amount < 0) {
            throw new InvalidArgumentException('Money amount cannot be negative.');
        }

        // 可选:验证货币是否在白名单内
        if (!preg_match('/^[A-Z]{3}$/', $currency)) {
            throw new InvalidArgumentException('Invalid currency code: ' . $currency);
        }

        $this->amount = $amount;
        $this->currency = $currency;
    }

    public static function fromFloat(float $amount, string $currency): self
    {
        // 例子:存为分
        $minor = (int) round($amount * 100);
        return new self($minor, $currency);
    }

    public static function fromInt(int $amount, string $currency): self
    {
        return new self($amount, $currency);
    }

    public function amount(): int
    {
        return $this->amount;
    }

    public function currency(): string
    {
        return $this->currency;
    }

    public function add(self $other): self
    {
        $this->assertSameCurrency($other);
        return new self($this->amount + $other->amount, $this->currency);
    }

    public function subtract(self $other): self
    {
        $this->assertSameCurrency($other);

        if ($other->amount > $this->amount) {
            throw new RuntimeException('Cannot subtract more than available.');
        }

        return new self($this->amount - $other->amount, $this->currency);
    }

    public function multiply(float $factor): self
    {
        $newAmount = (int) round($this->amount * $factor);
        return new self($newAmount, $this->currency);
    }

    public function equals(self $other): bool
    {
        return $this->currency === $other->currency && $this->amount === $other->amount;
    }

    public function format(): string
    {
        // 简单格式化(可以用 NumberFormatter 做本地化)
        return sprintf('%s %.2f', $this->currency, $this->amount / 100);
    }

    private function assertSameCurrency(self $other): void
    {
        if ($this->currency !== $other->currency) {
            throw new RuntimeException(sprintf(
                'Currency mismatch: %s vs %s',
                $this->currency,
                $other->currency
            ));
        }
    }
}

使用:

$price = Money::fromFloat(19.99, 'USD');
$discount = Money::fromFloat(5.00, 'USD');
$final = $price->subtract($discount);

echo $final->format(); // USD 14.99

现在你的函数可以这样设计:

public function applyCoupon(Money $price, CouponCode $coupon): Money
{
    // ...
}

再也不用纠结”传的是 float 还是分”了,类型本身就说明一切。

类型化 ID

到处用裸的 int/string ID 是另一个隐蔽的原始类型习惯。

重构前

public function findUserById(int $id): User
{
    // ...
}

public function assignUserToSegment(int $userId, int $segmentId): void
{
    // ...
}

没有什么能阻止你意外地搞混它们:

$service->assignUserToSegment($segmentId, $userId); // 搞反了…

重构后

final class UserId
{
    public function __construct(private int $value)
    {
        if ($this->value <= 0) {
            throw new InvalidArgumentException('UserId must be positive.');
        }
    }

    public function value(): int
    {
        return $this->value;
    }

    public function equals(self $other): bool
    {
        return $this->value === $other->value;
    }

    public function __toString(): string
    {
        return (string) $this->value;
    }
}

final class SegmentId
{
    public function __construct(private int $value)
    {
        if ($this->value <= 0) {
            throw new InvalidArgumentException('SegmentId must be positive.');
        }
    }

    public function value(): int
    {
        return $this->value;
    }

    public function equals(self $other): bool
    {
        return $this->value === $other->value;
    }

    public function __toString(): string
    {
        return (string) $this->value;
    }
}

现在:

public function findUserById(UserId $id): User
{
    // ...
}

public function assignUserToSegment(UserId $userId, SegmentId $segmentId): void
{
    // ...
}

如果你想搞混参数:

$service->assignUserToSegment($segmentId, $userId);

你会立即得到反馈:

  • 来自 IDE
  • 来自静态分析(PHPStan/Psalm)
  • 可能来自 PHP 本身(因为类型不匹配)

原始类型偏执让错误静默发生,值对象则让错误无处遁形。

用领域对象替代魔法数组

关联数组超级方便……也很危险。

不要这样:

$segmentRule = [
    'field'    => 'last_login_at',
    'operator' => '>=',
    'value'    => '2025-11-01',
];

可以创建一个小对象:

final class SegmentRule
{
    public function __construct(
        private string $field,
        private string $operator,
        private string $value
    ) {
        $this->validate();
    }

    private function validate(): void
    {
        if (!in_array($this->operator, ['=', '!=', '>=', '<=', '>', '<'], true)) {
            throw new InvalidArgumentException('Invalid operator: ' . $this->operator);
        }

        if ($this->field === '') {
            throw new InvalidArgumentException('Field name cannot be empty.');
        }
    }

    public function field(): string
    {
        return $this->field;
    }

    public function operator(): string
    {
        return $this->operator;
    }

    public function value(): string
    {
        return $this->value;
    }
}

使用:

$rule = new SegmentRule('last_login_at', '>=', '2025-11-01');

现在你可以逐步加行为:

  • public function appliesTo(User $user): bool
  • 根据 $field 类型转换 $value
  • 关于哪些 field/operator 组合是允许的复杂逻辑

这些都不用散落在代码库各处。

把握好度

到这里,你可能在想:

“所以我该把所有东西都包成值对象?BooleanFlag、PageNumber、Limit、Offset……?”

不用。

避免原始类型偏执不是要消灭原始类型。而是:

不要在原始类型会模糊领域含义或导致规则重复的地方使用它们。

适合做值对象的:

  • 任何有验证规则的(email、URL、电话号码、优惠码)
  • 任何单位/格式重要的(金额、百分比、日期范围、时长)
  • 任何是核心领域语言的(UserId、ProductId、SegmentId、DiscountRule 等)
  • 总是一起出现的值的复杂组合(价格+货币、纬度+经度、开始+结束日期)

用原始类型通常没问题的:

  • 简单计数器(int $retryCount
  • 小的标志和开关(bool $notifyUser
  • 不会泄漏到领域边界的实现细节

拿不准的时候,问自己:

  • “我们是不是到处都在为这个写验证逻辑?”
  • “大家是不是老问’这个 string/int 应该是什么?’”
  • “把行为和这个概念绑定在一起有好处吗?”

如果答案是肯定的,那就是值对象可能值得做的信号。

与 Laravel 和 Symfony 集成

你不需要和框架对着干才能用值对象。

Laravel

Form Request / Controller

可以在 controller 或 form request 里把输入包装成值对象:

public function store(RegisterUserRequest $request)
{
    $email = EmailAddress::fromString($request->input('email'));
    $this->service->registerUser($email, $request->input('password'));
}

Eloquent Casting

可以写自定义 cast 把数据库列映射到值对象(Laravel 7+):

use Illuminate\Contracts\Database\Eloquent\CastsAttributes;

class EmailAddressCast implements CastsAttributes
{
    public function get($model, string $key, $value, array $attributes)
    {
        return EmailAddress::fromString($value);
    }

    public function set($model, string $key, $value, array $attributes)
    {
        if ($value instanceof EmailAddress) {
            return $value->value();
        }

        return $value;
    }
}

在模型里:

protected $casts = [
    'email' => EmailAddressCast::class,
];

现在 $user->email 是个 EmailAddress,不只是字符串。

Symfony

使用 Translation/Validator 组件和 Doctrine:

  • 验证规则可以移到值对象里或通过 Symfony Validator 共享
  • Doctrine 可以把 embeddable 或自定义 DBAL 类型映射到你的值对象:
    • Money 作为 embeddable
    • EmailAddress 作为自定义类型

你的 controller 和 service 就能用领域类型而不是原始类型了。

值对象让测试更简单

值对象通常:

  • 纯粹
  • 无状态(不可变)
  • 容易测试

EmailAddress 的测试示例(用 PHPUnit):

public function testItRejectsInvalidEmail(): void
{
    $this->expectException(InvalidArgumentException::class);
    EmailAddress::fromString('not-an-email');
}

public function testItNormalizesCase(): void
{
    $email = EmailAddress::fromString('TeSt@Example.COM');
    $this->assertSame('test@example.com', $email->value());
}

public function testEquality(): void
{
    $a = EmailAddress::fromString('test@example.com');
    $b = EmailAddress::fromString('TEST@example.com');
    $this->assertTrue($a->equals($b));
}

有了这些测试,你就能确信系统中所有邮箱处理都是一致的——因为都走同一个对象。

渐进式迁移现有代码库

你不需要重写所有东西。可以逐步演进。

从边界入手

好的起点:

  • HTTP controller / 路由
  • CLI 命令
  • 消息消费者(队列 worker)
  • 外部 API 客户端

在这些边界:

  • 尽早把裸的原始类型解析成值对象
  • 在领域/应用服务内部使用值对象
  • 只在需要序列化时(JSON、数据库等)转换回原始类型

允许两种方式并存

如果担心大规模重构:

public function registerUser(EmailAddress|string $email, string $password): void
{
    if (is_string($email)) {
        $email = EmailAddress::fromString($email);
    }
    // 方法剩余部分现在总是处理 EmailAddress
}

你可以逐步更新调用方,让它们传 EmailAddress 而不是字符串。

用静态分析工具辅助

像 PHPStan 或 Psalm 这样的工具可以:

  • 强制类型(EmailAddress vs string)
  • 尽早捕获不匹配
  • 帮你找到所有还在为重要概念使用裸原始类型的地方

慢慢地,你的核心领域代码会越来越强类型、越来越有表达力。

总结

原始类型偏执很隐蔽。它看起来像”简单代码”:

string $email
float  $price
string $currency
int    $id
array  $order

但在规模变大后,它让你的代码:

  • 更难理解(”这个字符串代表什么?”)
  • 更容易出问题(”我们在这里验证过邮箱吗?”)
  • 重构起来很痛苦(”我们改了货币处理,现在到处着火”)

避免原始类型偏执不是学术追求,而是为了:

  • 给你的领域概念命名和结构
  • 集中验证和不变量
  • 在类型层面让意图明显
  • 减少分散的逻辑和重复的检查

我们讲了:

  • 在参数列表、数组和无类型值中识别原始类型偏执
  • 构建和使用 EmailAddress、Money、UserId、SegmentId 和小型领域对象
  • 平衡务实:包装重要的,而不是所有东西
  • 与 Laravel 和 Symfony 集成而不是对抗它们
  • 从边界开始逐步迁移现有代码库

下次你写这样的方法时:

public function applyDiscount(string $couponCode, float $amount, string $currency): float

停一下,问问自己:

“这真的只是字符串和 float……还是 CouponCode 和 Money?”

这个决定日积月累,最终会形成一个稳固、有表达力的代码库——对所有维护者都更友好,尤其是未来的你自己。

本作品采用《CC 协议》,转载必须注明作者和本文链接
讨论数量: 3

如果需要实现这么严谨的类型,至少语言层需要提供 alias type类型别名,和类型功能扩展才行

9小时前 评论

好是好,工作量直接爆炸~

4小时前 评论

如果转换称类的操作,需要考虑数据量的大小和内存的占用情况。

2小时前 评论

讨论应以学习和精进为目的。请勿发布不友善或者负能量的内容,与人为善,比聪明更重要!
开发 @ 家里蹲开发公司
文章
160
粉丝
87
喜欢
507
收藏
345
排名:18
访问:29.5 万
私信
所有博文
社区赞助商