史蒂夫与马特 - 两位开发者如何解决相同的问题
发表于 作者: 史蒂夫·麦克杜格尔
看到两位程序员以不同的方式编写相同的代码非常常见。但很少见的是,这两位程序员能够达成共识并保持友谊。幸运的是,我们有机会看到两位朋友如何以不同的方式解决相同的编码挑战,以及每位朋友对对方代码和方法的看法。
最近,马特在推特上分享了一个观点:"PHP 代码中的大多数接口都是完全无用的。"一位回复者标记了史蒂夫,因为他对接口的爱,史蒂夫和马特决定写一篇文章来介绍我们各自的编码方式。
背景
作为我们的主题,我们选择了史蒂夫最新的教程,创建密码生成器。史蒂夫将展示他文章中的代码并解释他的理由,然后马特将对史蒂夫的编码风格进行回应;然后马特将使用相同的规范以他的方式编写代码,解释他的理由,史蒂夫将给出他的笔记。
我们希望这将是一个很好的机会进行交流和学习!
规范
在史蒂夫的文章中,他创建了一个名为 Generator
的类,其中包含两个方法:generate
和 generateSecure
。它们(可选地,在 Laravel 中)从一个名词和形容词列表中获取,这两个列表都定义在配置文件中。它们都生成 形容词-名词-形容词-名词
类型的密码,但 generateSecure
将所有 a 替换为 4、e 替换为 3、i 替换为 1、o 替换为 0。
史蒂夫是如何编写的
我认为大多数人都会同意,我喜欢接口,有时甚至有点过头了。我这么做的主要原因之一是,和大多数事情一样 - 定义一个契约会让生活更容易。接口的时间投入与因为需要帮助弄清楚应该调用什么方法而陷入循环相比,微不足道。但关于接口的话题就说到这里,我同意我倾向于“有时”过早地使用它们...
正如我在我的另一篇文章中所写,我不会再用相同的代码讲解来烦你们了,而是将其总结成易于理解的部分,以便我的合著者马特能够做出诚实的见解。
我首先要做的是为我想列出的内容创建一个接口。在本教程中,我将此限制为仅“形容词”和“名词”。但是,我想添加这个接口,因为它允许我在提供有趣上下文的应用程序中扩展此行为。接口如下所示
interface ListContract{ public function random(): string; public function secure(): string;}
现在想象一下,如果这是在钓鱼应用程序中使用(我知道这是一个随机的例子)。你可以实现自己的 FishList
或 EquipmentList
,它们允许你生成一个有趣的密码,比如 trout-rod-bass-lure
,我相信你会同意,对于钓鱼爱好者来说,这个密码会更容易记住!然而,在另一个教程中遗漏的是,这更针对一次性密码,而不是生产密码。然而,这不是本文的重点。
一旦我们有了接口,我们就可以构建一个实现 List 契约本身的列表类,从而构建我们的第一个实现。在本教程中,我将略微偏离原始文章,并添加一些趣味性。我们将创建一个 LaravelList
来使用与 Laravel 相关的随机单词。
final class LaravelList implements ListContract{ use HasWords;}
使用教程的其他部分,我可以像这样在我的配置中添加一个部分
return [ // nouns and adjectives generated already 'laravel' => [ 'taylor', 'james', 'nuno', 'tim', 'jess', 'dries', 'vapor', 'sanctum', 'passport', 'eloquent', ]];
使用我在原始教程中所做的确切实现,我将展示服务提供者如何实现它。
final class PackageServiceProvider extends ServiceProvider{ public function register(): void { $this->app->singleton( abstract: GeneratorContract::class, concrete: fn (): GeneratorContract => new PasswordGenerator( nouns: new NounList( words: (array) config('password-generator.nouns'), ), adjectives: new LaravelList( words: (array) config('password-generator.laravel'), ), ), ); } // boot method would be below.}
我完全不确定如何生成密码/密码 - 我认为我最初的解决方案有一些改进的空间
trait HasWords{ /** * @param array $words */ public function __construct( private readonly array $words, ) { } public function random(): string { return $this->words[array_rand($this->words)]; } public function secure(): string { $word = $this->random(); $asArray = str_split($word); $secureArray = array_map( callback: fn (string $item): string => $this->convertToNumerical($item), array: $asArray, ); return implode('', $secureArray); } public function convertToNumerical(string $item): string { return match ($item) { 'a' => '4', 'e' => '3', 'i' => '1', 'o' => '0', default => $item, }; }}
我绝对确定要使用匹配语句(你会在下面看到马特使用了 str_replace
而不是它),因为它非常可扩展且一目了然。使用它,我可以直接理解每个潜在字母的输出是什么 - 扩展它只需要添加另一个 case。随着你的增长,减少默认返回值意味着你确切地了解从该方法返回什么。是的,这在某种程度上可以被认为是过度工程。但是,正如我实施了一个契约,它说我所要做的就是实现 random
和 secure
,这给了我很多自由来决定我的类。特别是,我可以改进它 - 而不影响应用程序的其余部分或任何已经集成的部分。
最后,关于生成器类本身,我设计它是为了能够使用 Laravel 容器绑定实例 - 允许你在用户端覆盖它的部分,如果你需要的情况与我不同。这正是容器的设计目的。我可以从容器中解析特定实例,或者使用外观静态地与实现交互。我在 build 方法中传递可变数量的“部分”,这样如果你想生成更长的内容或更改顺序,类或契约本身并不关心。它只关心它将返回一个字符串。
final class PasswordGenerator implements GeneratorContract{ public function __construct( private readonly ListContract $nouns, private readonly ListContract $adjectives, ) { } public function generate(): string { return $this->build( $this->nouns->random(), $this->adjectives->random(), $this->nouns->random(), $this->adjectives->random(), ); } public function generateSecure(): string { return $this->build( $this->nouns->secure(), $this->adjectives->secure(), $this->nouns->secure(), $this->adjectives->secure(), ); } private function build(string ...$parts): string { return implode('-', $parts); }}
总的来说,这个解决方案允许用户端进行大量的扩展和定制,或者在需要时快速适应。尽管它是过度工程的!
马特对史蒂夫代码的代码审查
我认为你已经做了很多自我审查,我的朋友!😆
史蒂夫的警告在这里已经提到了我的很多想法:对可能的未来过度使用接口和特征,但最终导致我们创建了一个更复杂的系统,如果未来变成了我们想象的可能发生的以外的事情,它将无法适应。
我认为match
语句比你在我的示例中使用的 str_replace
更令人愉快。str_replace
在一开始更清晰;更多人熟悉它,它需要的代码行更少,并且不需要额外的 array_map
。但是,正如史蒂夫所指出的,该语法在同一行中清晰地显示了初始字母和替换字母,这绝对很好。无论哪种方式,看到使用 match
的所有不同方法都很有趣。
我还对 NounList
和 LaravelList
类有一个注释:如果我们不使用接口类型提示,而是有一个名为 WordList
的单一类,它将单词列表传递到它的构造函数中,会怎样?这样,我们仍然可以抽象 random()
方法,但我们避免了为我们将来可能想要使用的每个单词列表创建一个微小的接口和一个新的类。
马特是如何编写的
让我们谈谈规范,以及我的大脑如何一步一步地构建它。
通用 API
该项目的规格是创建一个名为 Generator
的类,它包含两个方法:generate
和 generateSecure
。这就是我们整个公共 API。为了更清晰,我将它命名为 PasswordGenerator
。
在我的脑海里,这实际上是一个方法,以及对该方法的一种装饰。我设想我们将运行 generate()
的输出,通过某种安全方法。所以,我设想这是我们的一般类结构
class PasswordGenerator{ public function generate(): string { // generate a password } public function generateSecure(): string { return $this->makeStringSecure($this->generate()); } public function makeStringSecure(string $string): string { // replace some characters in the password with numbers }}
使字符串安全
由于 makeStringSecure
是我们尚未实现的两个方法中比较简单的,因此让我们构建它。实际上,我们正在将一些元音(a、e、i 和 o)的实例替换为看起来相似的数字(4、3、1 和 0)。str_replace
帮得上忙!
class PasswordGenerator{ public function generate(): string { // generate a password } public function generateSecure(): string { return $this->makeStringSecure($this->generate()); } public function makeStringSecure(string $string): string { return str_replace( ['a', 'e', 'i', 'o'], ['4', '3', '1', '0'], $string ); }}
写完这个方法后,我意识到我想快速检查一下我是否写得正确。“如果有一个测试来检查这个方法,而不是每次都手动测试,那该多好”,我心想。因此,让我们快速建立一个测试来证明 makeStringSecure
方法按我们期望的方式工作。
class PasswordGeneratorTest extends TestCase{ /** @test */ public function it_converts_some_vowels_to_numbers() { $generator = new PasswordGenerator(); $this->assertEquals( 'fly1ng-f1sh-sw1mm1ng-l1z4rd', $generator->makeStringSecure('flying-fish-swimming-lizard') ); }}
有了这个测试,我相信我处理了史蒂夫在他的文章中给出的示例。然而,这个特定的字符串没有包含字母“e”或“o”,因此我将稍微改变一下,使测试更强大
class PasswordGeneratorTest extends TestCase{ /** @test */ public function it_converts_some_vowels_to_numbers() { $generator = new PasswordGenerator(); $this->assertEquals( 'fly1ng-g04t-3l0p1ng-l1z4rd', $generator->makeStringSecure('flying-goat-eloping-lizard') ); }}
生成密码
最后,我们需要构建 generate()
的方法。规格是什么?
引入词语列表
密码将通过连接来自两个列表的单词来创建,我们希望将这两个列表传递给构造函数,并且(在 Laravel 应用程序中)存储在配置中,名为 password-generator.nouns
和 password-generator.adjectives
。
因此,首先,让我们为密码生成器提供名词和形容词列表,然后构建 generate()
方法。首先,是列表
class PasswordGenerator{ public function __construct( public readonly array $adjectives, public readonly array $nouns ) { }
我们可以每次实例化生成器时传递名词和形容词
$generator = new PasswordGenerator( config('password-generator.adjectives'), config('password-generator.nouns'));
或者,如果我们使用 Laravel,我们可以将其绑定到服务提供者
class AppServiceProvider(){ public function register(): void { $this->app->singleton( PasswordGenerator::class, fn () => new PasswordGenerator( config('password-generator.adjectives'), config('password-generator.nouns') ) ); }}
如果我们将其绑定到服务提供者,那么我们就可以从 Laravel 容器中提取一个实例,而无需显式地传递我们的词语列表。我们可以在许多不同的位置(路由定义、命令的构造函数等)中为类添加类型提示,或者自己提取它
$generator = app(PasswordGenerator::class);
从词语列表生成密码
现在,我们已经可以访问实例上的名词和形容词列表作为属性了,让我们构建 generate()
方法来创建一个密码。
规格指出密码应该是 形容词-名词-形容词-名词
。这意味着我们需要能够轻松地获取一个随机形容词和一个随机名词,然后将它们连接起来。
class PasswordGenerator{ public function generate(): string { // adjective-noun-adjective-noun return implode('-', [ $this->adjectives[array_rand($this->adjectives)], $this->nouns[array_rand($this->nouns)], $this->adjectives[array_rand($this->adjectives)], $this->nouns[array_rand($this->nouns)], ]); }
如果我们知道以后会使用这个工具,也许是为了生成具有不同结构的密码,那么创建获取随机形容词的方法和获取随机名词的方法可能值得。我们还可以考虑将密码的“结构”存储在某个地方。但是规格没有要求这样做,因此 YAGNI(你不会需要它)。我们将在需要时构建我们需要的功能。
最终输出
以下是我们最终产品的样子
class PasswordGenerator{ public function __construct( public readonly array $adjectives, public readonly array $nouns ) { } public function generate(): string { // adjective-noun-adjective-noun return implode('-', [ $this->adjectives[array_rand($this->adjectives)], $this->nouns[array_rand($this->nouns)], $this->adjectives[array_rand($this->adjectives)], $this->nouns[array_rand($this->nouns)], ]); } public function generateSecure(): string { return $this->makeStringSecure($this->generate()); } public function makeStringSecure(string $string): string { return str_replace( ['a', 'e', 'i', 'o'], ['4', '3', '1', '0'], $string ); }}
以下是一个更新后的测试,修改为传递空词语列表。我们可以为它如何从列表中提取单词编写更多测试,但目前,它更新为不会出现错误。
class PasswordGeneratorTest extends TestCase{ /** @test */ public function it_converts_some_vowels_to_numbers() { $generator = new PasswordGenerator(adjectives: [], nouns: []); $this->assertEquals( 'fly1ng-g04t-3l0p1ng-l1z4rd', $generator->makeStringSecure('flying-goat-eloping-lizard') ); }}
为什么我这样写
当我编程时,我的目标是编写灵活、易懂、可更改和可删除的代码。我们无法预测未来,但我们可以编写将来易于更改的代码,因此我选择简单、清晰、简洁的代码。
在我的思维方式中,你总可以在以后添加工具或结构,但后来删除它们要困难得多。我非常坚持 YAGNI(你不会需要它)的理念,如果你好奇了解更多关于我的想法,可以查看我的 Laracon Online 演讲 过早抽象。
当我看出对接口的具体、有形的需求时,我就会构建接口。当我看出某个痛点最适合通过抽象来解决时,我才会构建抽象,绝不会更早。
史蒂夫的代码审查
我非常喜欢马特的“generateSecure”仅仅是装饰器这个想法——对于我的实现来说,这将成为重构的一个很好的关注点。
字符串替换对我来说,虽然更直接——如果我运行任何代码样式,可能会失去简化的目的。当它们彼此对齐时,它是有意义的,并且易于阅读。然而,随着这个列表的增长,管理它需要更多工作。正如我所说——代码样式格式化程序很容易破坏这种易读性,而对于匹配语句来说,这种情况不太可能发生。
将一个数组传递给密码生成器的构造函数,以控制形容词和名词,提供了我的解决方案绝对没有的灵活性。你需要付出更多努力才能实现我所做的,但是对于像这样相对简单的功能来说——这可能是花在过度工程上的时间,而一个数组就足够了。
这种更直接方法的好处在你查看密码生成器上的 generate
方法时就变得很明显了。随着列表的增长,这无疑会更简单,更容易管理,并且内存使用更少。如果要扩展,它确实会失去一点上下文,但为了扩展,这只是一个很小的代价。
总的来说,马特对我的方法进行了明显的改进,这些改进旨在追求简洁而不是扩展——对于像这样的功能来说,这正是我们想要的方法。
结论
非常感谢你查看这篇内容!我们希望看到两位程序员以不同的优先级和思维方式处理同一个问题,但相互尊重可以带来一场非常愉快的对话,让我们都能从中学习!