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

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

PHP 让你能快速交付功能。

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

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

你开始看到这样的函数:

php 复制代码
function registerUser(
    string $email,
    string $password,
    string $firstName,
    string $lastName,
    string $countryCode
) {
    // ...
}

或者这样的 service 方法:

php 复制代码
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 代码库中原始类型偏执的典型症状。

函数参数都是原始类型

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 对象,或者至少是个受约束的值
  • 完全看不出哪些格式是合法的

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

用关联数组充当对象

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

$this->processOrder($order);

processOrder 里面:

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

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

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

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

验证逻辑到处重复

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

php 复制代码
// 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 就冒出来了。

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

原始类型偏执的危害

领域规则分散

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

最后你会:

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

意图不清晰

对比:

php 复制代码
public function send(string $from, string $to, string $body): void

和:

php 复制代码
public function send(EmailAddress $from, EmailAddress $to, EmailBody $body): void

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

静态分析失效

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

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

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

测试变得啰嗦和重复

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

php 复制代码
$service->createDiscount('WELCOME10', 10.0, 'USD', 'percentage', time() + 3600);

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

解决方案:值对象

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

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

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

不是:

php 复制代码
string $email
string $currency
float  $amount
string $couponCode

而是:

php 复制代码
EmailAddress $email
Currency $currency
Money $amount
CouponCode $couponCode

下面构建几个具体例子。

EmailAddress 值对象

重构前

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

其他地方:

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

重构后

php 复制代码
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;
    }
}

使用:

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

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

好处:

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

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

Money 和 Currency:告别浮点数陷阱

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

重构前

php 复制代码
$total = 19.99;
$discount = 0.1; // 10%
$final = $total - ($total * $discount);

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

php 复制代码
var_dump(19.99 * 100); // 1998.9999999999...

加上货币就变成:

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

重构后

更好的做法:

  • 用最小单位(分)的整数表示金额
  • 金额始终带着货币
php 复制代码
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
            ));
        }
    }
}

使用:

php 复制代码
$price = Money::fromFloat(19.99, 'USD');
$discount = Money::fromFloat(5.00, 'USD');
$final = $price->subtract($discount);

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

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

php 复制代码
public function applyCoupon(Money $price, CouponCode $coupon): Money
{
    // ...
}

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

类型化 ID

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

重构前

php 复制代码
public function findUserById(int $id): User
{
    // ...
}

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

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

php 复制代码
$service->assignUserToSegment($segmentId, $userId); // 搞反了...

重构后

php 复制代码
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;
    }
}

现在:

php 复制代码
public function findUserById(UserId $id): User
{
    // ...
}

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

如果你想搞混参数:

php 复制代码
$service->assignUserToSegment($segmentId, $userId);

你会立即得到反馈:

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

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

用领域对象替代魔法数组

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

不要这样:

php 复制代码
$segmentRule = [
    'field'    => 'last_login_at',
    'operator' => '>=',
    'value'    => '2025-11-01',
];

可以创建一个小对象:

php 复制代码
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;
    }
}

使用:

php 复制代码
$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 里把输入包装成值对象:

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

Eloquent Casting

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

php 复制代码
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;
    }
}

在模型里:

php 复制代码
protected $casts = [
    'email' => EmailAddressCast::class,
];

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

Symfony

使用 Translation/Validator 组件和 Doctrine:

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

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

值对象让测试更简单

值对象通常:

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

EmailAddress 的测试示例(用 PHPUnit):

php 复制代码
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、数据库等)转换回原始类型

允许两种方式并存

如果担心大规模重构:

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

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

用静态分析工具辅助

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

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

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

总结

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

php 复制代码
string $email
float  $price
string $currency
int    $id
array  $order

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

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

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

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

我们讲了:

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

下次你写这样的方法时:

php 复制代码
public function applyDiscount(string $couponCode, float $amount, string $currency): float

停一下,问问自己:

"这真的只是字符串和 float......还是 CouponCode 和 Money?"

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

相关推荐
爱勇宝39 分钟前
深扒 Anthropic 1680 位工程师简历:应届生几乎没机会,AI 公司最缺的不是博士
前端·后端·程序员
AskHarries1 小时前
工具失败时怎么办:重试、回滚、人工确认和风险提示
后端·程序员
苏三说技术3 小时前
Claude Code从失控到起飞,只用了这些技巧
后端
长栎3 小时前
写 for 循环写了十年,你却从没用过迭代器模式最狠的那一面
后端
LiaCode3 小时前
Redis 在生产项目的使用
前端·后端
用户559822481224 小时前
Docker Compose Down 导致容器数据误删——ext4 日志恢复全记录
后端
LiaCode4 小时前
一天学完 redis 的爽翻版核心知识总结
前端·后端
大刚测试开发实战4 小时前
如何内网穿透访问本地私有化部署的TestHub
前端·后端·github
xiaodaoluanzha4 小时前
迄今為止,最簡單的編程語言 Nolang
前端·后端
Csvn4 小时前
Docker 容器管理入门 — 从镜像到容器编排
后端