只是预先说明:我写这篇文章是为了思考,而不是作为绝对的真理来源。我很乐意听到人们不同意并告诉我原因,所以请不要犹豫,随心所欲地回复。


您之前可能使用过策略模式:一种能够在运行时选择算法的行为模式

让我们考虑一个经典的例子:用户以 XML、JSON 或数组的形式提供一些输入;我们希望将该输入解析为漂亮的 JSON 字符串。

所以所有这些输入:

'{"title":"test"}'
'<title>test</title>'
['title' => 'test']

将转换为:

{
    "title": "test"
}

哦,还有一个要求:我们需要这些策略是可扩展的。应该允许开发人员添加自己的策略来处理其他类型的输入:YAML、接口、可迭代对象,无论他们需要什么。

让我们看一下经典解决方案及其问题。


通常,我们首先引入某种所有策略都应该实现的接口:

interface ParserInterface
{
    public function canParse(mixed $input): bool;
    
    public function parse(mixed $input): mixed;
}

每个策略都必须定义它是否可以在给定的输入上运行,并提供其实际实现。

接下来我们可以提供该接口的几个实现:

final class ArrayParser implements ParserInterface
{
    public function canParse(mixed $input): bool
    {
        return is_array($input);
    }
    
    public function parse(mixed $input): mixed
    {
        return json_encode($input, JSON_PRETTY_PRINT);
    }
}

final class JsonParser implements ParserInterface
{
    public function canParse(mixed $input): bool
    {
        return 
            is_string($input) 
            && str_starts_with(trim($input), '{') 
            && str_ends_with(trim($input), '}');
    }
    
    public function parse(mixed $input): mixed
    {
        return json_encode(
            json_decode($input), 
            JSON_PRETTY_PRINT
        );
    }
}

final class XmlParser implements ParserInterface
{
    public function canParse(mixed $input): bool
    {
        return
            is_string($input) 
            && str_starts_with(trim($input), '<') 
            && str_ends_with(trim($input), '>');
    }
    
    public function parse(mixed $input): mixed
    {
        return json_encode(
            simplexml_load_string(
                $input, 
                "SimpleXMLElement", 
                LIBXML_NOCDATA
            ), 
            JSON_PRETTY_PRINT
        );
    }
}

完全披露:这些都是非常幼稚的实现。该canParse方法中的策略检测只是查看输入字符串的第一个和最后一个字符,可能不是万无一失的。另外:XML 解码不能正常工作;但是对于这个例子来说已经足够了。

下一步是提供一个类,开发人员可以将其用作公共 API,这将在下面使用我们不同的策略。它是通过添加一组策略实现来配置的,parse并向外部公开一个方法:

final class Parser
{
    /** @var ParserInterface[] */
    private array $parsers = [];
    
    public function __construct() {
        $this
            ->addParser(new ArrayParser)
            ->addParser(new JsonParser)
            ->addParser(new XmlParser);
    }
    
    public function addParser(ParserInterface $parser): self
    {
        $this->parsers[] = $parser;
        
        return $this;
    }
    
    public function parse(mixed $input): mixed
    {
        foreach ($this->parsers as $parser) {
            if ($parser->canParse($input)) {
                return $parser->parse($input);
            }
        }
        
        throw new Exception("Could not parse given input");
    }
}

我们已经完成了,对吧?用户现在可以Parser像这样使用我们的:

$parser = new Parser();

$parser->parse('{"title":"test"}');
$parser->parse('<title>test</title>');
$parser->parse(['title' => 'test']);

并且输出将始终是一个漂亮的 JSON 字符串。

好吧……让我们从另一个角度来看一下:想要使用自己的功能扩展现有解析器的开发人员:将Request类转换为 JSON 字符串的实现。正是出于这个原因,我们用策略模式设计了我们的解析器;所以,很容易:

final class RequestParser implements ParserInterface
{
    public function canParse(mixed $input): bool
    {
        return $input instanceof Request;
    }
    
    public function parse(mixed $input): mixed
    {
        return json_encode([
            'method' => $input->method,
            'headers' => $input->headers,
            'body' => $input->body,
        ], JSON_PRETTY_PRINT);
    }
}

假设我们的解析器注册在 IoC 容器的某个地方,我们可以像这样添加它:

Container::singleton(
    Parser::class,
    fn () => (new Parser)->addParser(new RequestParser);
);

我们完成了!

除了……你发现了一个问题吗?如果您以前以这种方式使用过策略模式(许多开源软件包都应用它),您可能已经有了一个想法。

在我们的方法中:RequestParser::parse

public function parse(mixed $input): mixed
{
    return json_encode([
        'method' => $input->method,
        'headers' => $input->headers,
        'body' => $input->body,
    ], JSON_PRETTY_PRINT);
}

这里的问题是我们不知道 $input 的实际类型。因为 canParse 中的检查,我们知道它应该是一个 Request 对象,但我们的 IDE 当然不知道。所以我们需要提供一个文档注释来帮助它:

/**
 * @var mixed|Request $input 
 */
public function parse(mixed $input): mixed
{
    return json_encode([
        'method' => $input->method,
        'headers' => $input->headers,
        'body' => $input->body,
    ], JSON_PRETTY_PRINT);
}

或者通过instanceof再次检查:

public function parse(mixed $input): mixed
{
    if (! $input instanceof Request) {
        // error?
    }
    
    return json_encode([
        'method' => $input->method,
        'headers' => $input->headers,
        'body' => $input->body,
    ], JSON_PRETTY_PRINT);
}

因此,由于我们的设计方式ParserInterface,想要实现它的开发人员将不得不做双重工作:

final class RequestParser implements ParserInterface
{
    public function canParse(mixed $input): bool
    {
        return $input instanceof Request;
    }
    
    public function parse(mixed $input): mixed
    {
        if (! $input instanceof Request) {
            // error?
        }
        
        // …
    }
}

这种代码重复并不是世界末日,最多只是一个小不便。大多数开发人员甚至不会眨眼。

但是我愿意。作为包维护者,我希望我的公共 API 尽可能直观且无摩擦。对我来说,这意味着静态洞察是开发人员体验的关键部分,我不希望我的代码用户因为我设计这个解析器的方式而受到阻碍。

因此,让我们讨论几种解决此问题的方法。

我正在YouTube上创建一个由四部分组成的关于 PHP 中的泛型的迷你系列:它们是什么,我们今天如何使用它们,以及将来有什么可能。看一看,别忘了点赞哦!

PHP 泛型的案例 深入泛型

#不再重复

如果重复的问题是因为我们拆分了我们的canParseparse方法,也许最简单的解决方案是简单地……不拆分它们?

如果我们以这样一种方式设计我们的策略类,如果它们无法解析它就会抛出异常,而不是使用显式条件呢?

interface ParserInterface
{
    /**
     * @throws CannotParse 
     *         When this parser can't parse 
     *         the given input. 
     */
    public function parse(mixed $input): mixed;
}

final class RequestParser implements ParserInterface
{
    public function parse(mixed $input): mixed
    {
        if (! $input instanceof Request) {
            throw new CannotParse;
        }
        
        // …
    }
}

我们的通用解析器类会改变如下:

final class Parser
{
    // …
    
    public function parse(mixed $input): mixed
    {
        foreach ($this->parsers as $parser) {
            try {
                return $parser->parse($input);
            } catch (ParseException) {
                continue;
            }
        }
        
        throw new Exception("Could not parse given input");
    }
}

当然,现在我们正在打开“什么是异常”以及是否允许我们以这种方式使用异常来控制我们的程序流的兔子洞。我个人的看法是“是的,肯定的”;因为将字符串传递给只能与Request对象一起使用的方法实际上是规则的例外。至少,这是我的定义。

有些人可能会选择返回null而不是抛出异常,尽管这对我来说感觉更不对:null没有传达这个特定方法无法处理输入。事实上,null这很可能是这个解析器的有效结果,具体取决于它的要求。所以不,不null适合我。

但是,我同意可能有几个人在阅读本文时的观点:返回null或抛出异常并不是最干净的解决方案。如果我们踏上这段旅程的唯一目的是修复一个只有少数开发人员可能会担心的细节,我们也可能会探索其他选项,并深入研究兔子洞。

#类型

我们编写这个手动检查是为了防止无效输入:$input instanceof Request;但是您知道 PHP 有一种自动的方式来进行这些检查吗?它的内置类型系统!为什么要手动重写 PHP 可以在幕后为我们做的事情呢?为什么不简单地输入提示 Request

final class RequestParser implements ParserInterface
{
    public function parse(Request $input): mixed
    {
        // …
    }
}

好吧,我们不能,因为两个问题:

  • 根据 Liskov 替换原则,我们不允许将参数类型从mixed改为Request,这是由PHP 和 ParserInterface 强制执行的;而且
  • 并非每个输入都可以表示为专用类型:XML 和 JSON 都是字符串,存在一些歧义。

那么,故事结束了吗?嗯……我们已经深入兔子洞了,我们不妨试一试。

让我们先想象一下上面提到的两个问题都不是问题:我们实际上是否可以设计我们的解析器,使其能够检测每个策略的接受输入,并根据该信息选择合适的策略?

我们当然可以!最简单的解决方案是遍历所有策略,尝试向它们传递一些输入,如果它们无法处理则继续;让 PHP 的类型系统处理其余的:

final class Parser
{
    public function handle(mixed $input): mixed
    {
        foreach ($this->parsers as $parser) {
            try {
                return $parser->parse($input);
            } catch (TypeError) {
                continue;
            }
        }
        
        throw new Exception("Could not parse given input");
    }
}

实际上,我更喜欢这种方法,而不是试图确定哪个方法可以接受哪个输入的任何类型的运行时反射。我们不要试图在运行时重新创建 PHP 的类型检查器。这种方法工作的唯一真正要求是您的策略方法不会有任何副作用,并且它们将始终正确地键入提示他们的输入。这是我在编程时的个人基石之一,因此我在编写假定这一原则的代码时没有问题。

好的,因此可以根据其方法签名将任何给定的输入与其正确的策略相匹配。但是我们仍然需要处理我们最初的两个问题。

第一个是我们不允许这样写:

final class RequestParser implements ParserInterface
{
    public function parse(Request $input): mixed
    {
        // …
    }
}

因为我们parse在我们的ParserInterfacelike中定义了签名:

interface ParserInterface
{
    public function parse(mixed $input): mixed;
}

我们不能缩小参数类型,我们只能扩大它们;这就是所谓的逆变

因此,一方面我们有一个接口,它表明我们的策略可以接受任何类型的输入(mixed);但另一方面,我们的策略类告诉我们它们只能使用特定类型的操作输入。

如果我们想进一步深入兔子洞,那么除了我们的界面实际上并没有说实话之外没有其他结论可以得出:我们没有制定适用于任何类型输入的策略,因此它没有有一个界面告诉我们我们这样做是有意义的。这个界面本质上是在撒谎,没有理由保留它。

好吧,实际上:有这个接口有原因的:它指导开发人员了解如何添加自己的策略,而不必依赖文档。当开发人员看到此方法签名时:

final class Parser
{
    // …
    
    public function addParser(ParserInterface $parser): self
    {
        $this->parsers[] = $parser;
        
        return $this;
    }
}

他们很清楚,他们需要实施ParserInterface他们的自定义策略才能发挥作用。所以我想说摆脱这个接口可能弊大于利,因为没有它,开发人员将在黑暗中运作。

我能想到的一种解决方案可以解决这个问题:接受可调用对象。

public function addParser(callable $parser): self
{
    $this->parsers[] = $parser;
    
    return $this;
}

callable是 PHP 中的一种特殊类型,因为它不仅包括函数和闭包,还包括可调用对象。这里唯一缺少的是我们无法确定——从我们的代码中——我们的可调用对象应该是什么样子。

我们已经建立了一条规则,说它应该接受它可以使用的任何类型的输入,但是如果不提供额外的文档块,我们无法告诉开发人员扩展我们的代码。这绝对是这种方法的一个缺点,并且可能足以让您有理由不使用它。

我个人不介意,我认为我们一开始的代码重复和手动类型验证比阅读文档块更让我烦恼:

/**
 * @param callable $parser A callable accepting one typed parameter.
 *                         This parameter's type is used to match 
 *                         the input given to the parser to the
 *                         correct parser implementation.
 */
public function addParser(callable $parser): self
{
    $this->parsers[] = $parser;
    
    return $this;
}

然后是我们的第二个问题:不是所有的东西都可以用一个类型来表示。例如:JSON 和 XML 解析器都应该匹配stringJSON 或 XML 中的一个,我们不能输入提示。我可以想到两种解决方案。

  • parse在方法中对这些边缘情况进行一些手动检查,并TypeError在它们不匹配时抛出;要么
  • 引入JsonStringXmlString作为自定义类,并让工厂首先将这些原始字符串转换为适当的类型。

第一个选项如下所示:

final class JsonParser
{
    public function __invoke(string $input): string
    {
        if (
            ! str_starts_with(trim($input), '{') 
            || ! str_ends_with(trim($input), '}')
        ) {
            throw new TypeError("Not a valid JSON string");   
        }
        
        return json_encode(
            json_decode($input), 
            JSON_PRETTY_PRINT
        );
    }
}

final class XmlParser 
{
    public function __invoke(string $input): string
    {
        if (
            ! str_starts_with(trim($input), '<') 
            || ! str_ends_with(trim($input), '>')
        ) {
            throw new TypeError("Not a valid XML string");
        }
        
        return json_encode(
            simplexml_load_string(
                $input, 
                "SimpleXMLElement", 
                LIBXML_NOCDATA
            ), 
            JSON_PRETTY_PRINT
        );
    }
}

第二个,有一个自定义类JsonStringand XmlString,看起来像这样:

final class JsonParser
{
    public function __invoke(JsonString $input): string
    {
        return json_encode(
            json_decode($input), 
            JSON_PRETTY_PRINT
        );
    }
}

final class XmlParser 
{
    public function __invoke(XmlString $input): string
    {
        return json_encode(
            simplexml_load_string(
                $input, 
                "SimpleXMLElement", 
                LIBXML_NOCDATA
            ), 
            JSON_PRETTY_PRINT
        );
    }
}

但是不要忘记,我们还需要引入一个工厂来将字符串转换为正确的类型,这意味着相当多的开销。

最后一点,callable还有另一个优点:用户不必使用可调用的类。根据他们的需求和测试方式,他们可以简单地添加闭包:

Container::singleton(
    Parser::class,
    fn () => (new Parser)->addParser(
        fn (Request $request) => json_encode([
            'method' => $request->method,
            'headers' => $request->headers,
            'body' => $request->body,
        ], JSON_PRETTY_PRINT)
    );
);

这种方法有缺点吗?确实。就像我们有很多代码重复的原始解决方案有缺点一样。我个人认为,从开发人员经验的角度来看;值得考虑替代我们如何实施动态策略的原始方式;我可以想象一些项目会从中受益。

你怎么看?通过Twitter电子邮件告诉我;如果您这么认为,请不要犹豫说我会慢慢发疯!