PHP 中的 SOLID SOLID 是什么?🙄 为什么要使用它们?SOLID 代表什么?我们做什么?S - 单一职责原则 (SRP) O - 开闭原则 (OCP) L - 里氏替换原则 (LSP) I - 接口隔离原则 (ISP) D - 依赖倒置原则 (DIP) 结论 Webgraphy

2025-05-24

PHP 中的 SOLID

SOLID 是什么?🙄

我为什么要使用它们?

SOLID 代表什么

我们做什么

S——单一职责原则(SRP)

O——开放封闭原则(OCP)

L——里氏替换原则(LSP)

I - 接口隔离原则(ISP)

D——依赖倒置原则(DIP)

结论

网络摄影

SOLID 是什么?🙄

这是鲍勃大叔编写的一套良好软件设计实践的原则

我为什么要使用它们?

  • 软件设计原则或惯例。
  • 得到业界的广泛认可。
  • 帮助使代码更易于维护并且更能容忍变化。
  • 适用于类设计(微设计),也适用于软件架构层面。

如果你不使用 SOLID,你可能会在不知情的情况下编写出 STUPID¹ 的代码

¹:STUPID 代表:单例、紧耦合、不可测试性、过早优化、不具描述性的命名、重复

SOLID 代表什么

SOLID是以下单词的首字母缩写:

  • 单一职责原则
  • 开放/封闭原则
  • L iskov替代原理
  • 接口隔离原则
  • 依赖倒置原则

我们做什么

S——单一职责原则(SRP)

替代文本

💡 一个类应该只有一个改变的原因,这意味着它应该只有一个责任。

  • 如何实现
    • 小班授课,目标明确
  • 目的或收益:
    • 高内聚力和稳健性
    • 允许班级组合(注入合作者)
    • 避免代码重复

例子

假设我们有一个表示文本文档的类,该文档包含标题和内容。该文档必须能够导出为 HTML 和 PDF。

违反 SRP 👎

⚠️ 代码包含多个职责

class Document
{
    protected $title;
    protected $content;

    public function __construct(string $title, string $content)
    {
        $this->title = $title;
        $this->content= $content;
    }

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

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

        public function exportHtml() {
                echo "DOCUMENT EXPORTED TO HTML".PHP_EOL;
        echo "Title: ".$this->getTitle().PHP_EOL;
        echo "Content: ".$this->getContent().PHP_EOL.PHP_EOL;
        }

        public function exportPdf() {
                echo "DOCUMENT EXPORTED TO PDF".PHP_EOL;
        echo "Title: ".$this->getTitle().PHP_EOL;
        echo "Content: ".$this->getContent().PHP_EOL.PHP_EOL;
        }
}
Enter fullscreen mode Exit fullscreen mode

正如您所看到的,我们作为 API 公开给其他程序员使用的方法或函数包括getTitle()和,getContent()但这些方法在同一个类的行为中使用。

这违反了“说—不问”的原则

💬 “告诉-不要询问”原则可以帮助人们记住,面向对象就是将数据与操作该数据的函数捆绑在一起。它提醒我们,与其向对象请求数据并根据数据采取行动,不如告诉对象该做什么。

最后,我们还看到,必须表示文档的类不仅有责任表示它,而且还有责任以不同的格式导出它。

遵循 SRP 原则

一旦我们确定该类Document除了“文档”的表示之外不应该有任何其他内容,接下来我们要建立的就是我们想要通过其与导出进行通信的 API。

为了导出,我们需要创建一个interface接收文档。

interface ExportableDocumentInterface
{
    public function export(Document $document);
}
Enter fullscreen mode Exit fullscreen mode

接下来我们要做的是提取不属于类的逻辑。

class HtmlExportableDocument implements ExportableDocumentInterface
{
    public function export(Document $document)
    {
        echo "DOCUMENT EXPORTED TO HTML".PHP_EOL;
        echo "Title: ".$document->getTitle().PHP_EOL;
        echo "Content: ".$document->getContent().PHP_EOL.PHP_EOL;
    }
}
Enter fullscreen mode Exit fullscreen mode
class PdfExportableDocument implements ExportableDocumentInterface
{
    public function export(Document $document)
    {
        echo "DOCUMENT EXPORTED TO PDF".PHP_EOL;
        echo "Title: ".$document->getTitle().PHP_EOL;
        echo "Content: ".$document->getContent().PHP_EOL.PHP_EOL;
    }
}
Enter fullscreen mode Exit fullscreen mode

让类实现类似这样

class Document
{
    protected $title;
    protected $content;

    public function __construct(string $title, string $content)
    {
        $this->title = $title;
        $this->content= $content;
    }

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

    public function getContent(): string
    {
        return $this->content;
    }
}
Enter fullscreen mode Exit fullscreen mode

这使得导出和文档类更容易进行更好的测试。

O——开放封闭原则(OCP)

替代文本

💡 对象或实体应该对扩展开放,但对修改关闭。

  • 如何实现
    • 避免依赖特定的实现,使用抽象类或接口。
  • 目的或收益:
    • 可以轻松地向我们的应用程序添加新的用例

示例

假设我们需要实现一个登录系统。首先,为了验证用户身份,我们需要用户名和密码(主要用例),目前为止一切顺利。但是,如果我们自己或者业务部门要求用户通过 Twitter 或 Gmail 进行身份验证,该怎么办呢?

首先,如果出现这种情况,重要的是要明白,我们被要求的是一项新功能,而不是修改现有功能。Twitter 的情况是一个用例,而 Gmail 的情况则完全不同。

第三方 API 登录 - OCP 违规 👎

class LoginService
{
    public function login($user)
    {
        if ($user instanceof User) {
            $this->authenticateUser($user);
        } else if ($user instanceOf ThirdPartyUser) {
            $this->authenticateThirdPartyUser($user);
        }
    }
}
Enter fullscreen mode Exit fullscreen mode

使用第三方 API 登录 - 遵循 OCP 👍

我们应该做的第一件事是创建一个符合我们想要做的事情并且适合特定用例的界面。

interface LoginInterface
{
    public function authenticateUser(UserInterface $user);
}
Enter fullscreen mode Exit fullscreen mode

现在我们应该将已经为用例创建的逻辑分离,并在实现我们接口的类中实现它。

class UserAuthentication implements LoginInterface
{
    public function authenticateUser(UserInterface $user)
    {
        // TODO: Implement authenticateUser() method.
    }
}
Enter fullscreen mode Exit fullscreen mode
class ThirdPartyUserAuthentication implements LoginInterface
{
    public function authenticateUser(UserInterface $user)
    {
        // TODO: Implement authenticateUser() method.
    }
}
Enter fullscreen mode Exit fullscreen mode
class LoginService
{
    public function login(LoginInterface $loginService, UserInterface $user)
    {
        $loginService->authenticateUser($user);
    }
}
Enter fullscreen mode Exit fullscreen mode

正如您所见,该类LoginService与哪种身份验证方法无关(通过网络、通过谷歌或推特等)。

使用 switch 实现的支付 API - OCP 违规 👎

一种非常常见的情况是,我们有一个 switch 语句switch(),其中每个 case 执行不同的操作,并且将来我们可能会继续向 switch 添加更多 case。让我们看下面的例子。

这里我们有一个带有pay()方法的控制器,它负责通过请求接收付款类型,并且根据付款类型,通过 Payment 类中的一种或另一种方法处理付款。

public function pay(Request $request)
{
    $payment = new Payment();

    switch ($request->type) {
        case 'credit':
            $payment->payWithCreditCard();
            break;
        case 'paypal':
            $payment->payWithPaypal();
            break;
        default:
            // Exception
            break;
    }
}
Enter fullscreen mode Exit fullscreen mode
class PaymentRequest
{
    public function payWithCreditCard()
    {
        // Logic to pay with a credit card...
    }

    public function payWithPaypal()
    {
        // Logic to pay with paypal...
    }
}
Enter fullscreen mode Exit fullscreen mode

这段代码有两个大问题:

  • 我们应该为接受的每笔新付款添加一个案例,或者如果我们不接受通过 PayPal 的更多付款,则删除一个案例。
  • 所有处理不同类型付款的方法都包含在一个类中,即 Payment 类。因此,当我们添加或删除新的付款类型时,都应该编辑 Payment 类。正如 所说Open / Closed principle,这并不理想。这也违反了 原则Single Responsibility

这种违规也与代码异味有关,称为Switch 语句异味,如果您想了解有关重构或示例的更多信息,请进入重构大师

使用 switch 实现支付 API - 遵循 OCP 👍

为了尝试遵守 OCP,我们可以做的第一件事就是创建一个带有pay()方法的接口。

interface PayableInterface
{
    public function pay();
}
Enter fullscreen mode Exit fullscreen mode

现在我们将继续创建应该实现这些接口的类。

class CreditCardPayment implements PayableInterface
{
    public function pay()
    {
        // Logic to pay with a credit card...
    }
}
Enter fullscreen mode Exit fullscreen mode
class PaypalPayment implements PayableInterface
{
    public function pay()
    {
        // Logic to pay with paypal...
    }
}
Enter fullscreen mode Exit fullscreen mode

下一步就是重构我们的pay()方法。

public function pay(Request $request)
{
    $paymentFactory = new PaymentFactory();
    $payment = $paymentFactory->initialize($request->type);

    return $payment->pay();
}
Enter fullscreen mode Exit fullscreen mode

👁 如您所见,我们已经用工厂替换了开关。

class PaymentFactory
{
    public function initialize(string $type): PayableInterface
    {
        switch ($type) {
            case 'credit':
                return new CreditCardPayment();
            case 'paypal':
                return new PayPalPayment();
            default:
                throw new \Exception("Payment method not supported");
                break;
        }
    }
}
Enter fullscreen mode Exit fullscreen mode

开放/封闭原则的好处

  • 扩展系统的功能,而无需触及系统的核心。
  • 我们通过添加新功能来防止系统某些部分遭到破坏。
  • 易于测试。
  • 分离不同的逻辑。

🎨 我们发现对 OCP 有用的设计模式

L——里氏替换原则(LSP)

替代文本

这个原则是由美国第一位获得计算机科学博士学位的女性、大师级人物芭芭拉·利斯科夫(Barbara Liskov)提出的。这是一个非常有趣的原则。

根据维基百科,里氏替换原则指出,每个从另一个类继承的类都可以用作其父类,而不必知道它们之间的区别。

  • 概念:
    • 如果S是T的子类型,则T的实例应该可以替换为 S 的实例,而无需改变程序属性,也就是说,通过层次结构意味着我们在父类中建立契约,因此确保在子类中维护此契约将允许我们替换父类,并且应用程序将继续完美运行。
  • 如何实现:
    • 子类的行为必须尊重超类中建立的契约。
    • 保持功能正确性以便能够应用 OCP。

为了不违反里氏原则,我们必须牢记 3 个要点

  • 不强化父类的前置条件,也不削弱父类的后置条件(防御性编程)。
  • 基类中设置的不变量必须在子类中保留。
  • 子类中的方法不能违背基类的行为。这被称为历史约束

例子

运费计算

假设我们有一个运输类,它将根据产品的重量和目的地计算产品的运输成本。

class Shipping
{
    public function calculateShippingCost($weightOfPackageKg, $destiny)
    {
        // Pre-condition:
        if ($weightOfPackageKg <= 0) {
            throw new \Exception('Package weight cannot be less than or equal to zero');
        }

        // We calculate the shipping cost by
        $shippingCost = rand(5, 15);

        // Post-condition
        if ($shippingCost <= 0) {
            throw new \Exception('Shipping price cannot be less than or equal to zero');
        }

        return $shippingCost;
    }
}
Enter fullscreen mode Exit fullscreen mode

运费计算 - 由于子类行为变化导致 LSP 违规 👎

class WorldWideShipping extends Shipping
{
    public function calculateShippingCost($weightOfPackageKg, $destiny)
    {
        // Pre-condition
        if ($weightOfPackageKg <= 0) {
            throw new \Exception('Package weight cannot be less than or equal to zero');
        }

        // We strengthen the pre-conditions
        if (empty($destiny)) {
            throw new \Exception('Destiny cannot be empty');
        }

        // We calculate the shipping cost by
        $shippingCost = rand(5, 15);

        // By changing the post-conditions we allow there to be cases
        // in which the shipping is 0
        if ('Spain' === $destiny) {
            $shippingCost = 0;
        }

        return $shippingCost;
    }
}
Enter fullscreen mode Exit fullscreen mode

问题在于,我们使用与前一个类类似的类来生成,我们为程序员公开了类似的 API,但其实现方式不同。

这个类将成为我们示例的父类,其中计算运费的方法具有前置条件和后置条件(这种使用前置条件和后置条件的编程方式称为防御性编程)。

例如,我们团队的一名程序员确信该类calculateShippingCost()的方法Shipping允许null目的地和运输成本大于零,因此通过使用该类WorldWideShipping,它可能会导致系统崩溃,例如,如果您想在切片中使用结果calculateShippingCost()或赋予它一个null命运。

因此,WorldWideShipping 类违反了里氏替换原则。

运费计算 - 由于子类的不变量变化导致 LSP 违规 👎

变量是父类的值,子类不能修改。

假设我们想要修改Shipping之前的类,并希望将每公斤的重量限制设为 0,但它在一个变量中。

class Shipping
{
    protected $weightGreaterThan = 0;

    public function calculateShippingCost($weightOfPackageKg, $destiny)
    {
        // Pre-condition:
        if ($weightOfPackageKg <= $this->weightGreaterThan) {
            throw new \Exception("Package weight cannot be less than or equal to {$this->weightGreaterThan}");
        }

        // We calculate the shipping cost by
        $shippingCost = rand(5, 15);

        // Post-condition
        if ($shippingCost <= 0) {
            throw new \Exception('Shipping price cannot be less than or equal to zero');
        }

        return $shippingCost;
    }
}
Enter fullscreen mode Exit fullscreen mode
class WorldWideShipping extends Shipping
{
    public function calculateShippingCost($weightOfPackageKg, $destiny)
    {
    // We modify the value of the parent class
        $this->weightGreaterThan = 10;

        // Pre-condition
        if ($weightOfPackageKg <= $this->weightGreaterThan) {
            throw new \Exception("Package weight cannot be less than or equal to {$this->weightGreaterThan}");
        }
    // Previous code...
    }
}
Enter fullscreen mode Exit fullscreen mode

运费计算 - 通过改变子类的不变量来遵循 LSP 👍

避免这种情况的最简单方法是,如果我们的PHP$weightOfPackageKg版本(7.1.0)允许,则只需将变量创建为私有常量,但要创建该私有变量。

历史限制

历史限制表明子类中不能存在违背其父类行为的方法。

也就是说,如果父类中有这个FixedTax()方法,那么ModifyTax()子类中就不能有这个方法。难道他们没教过你不要忤逆父母吗?😆。

子类的方法修改基类的属性值违反了里氏原则,因为类必须只能改变其属性的值(封装)。

避免破坏 LSP 的最简单方法

避免破坏 LSP 的最佳方法是使用接口。而不是从父类扩展我们的子类。

interface CalculabeShippingCost
{
    public function calculateShippingCost($weightOfPackageKg, $destiny);
}
Enter fullscreen mode Exit fullscreen mode
class WorldWideShipping implements CalculabeShippingCost
{
    public function calculateShippingCost($weightOfPackageKg, $destiny)
    {
        // Implementation of logic
    }
}
Enter fullscreen mode Exit fullscreen mode

通过使用接口,您可以实现各种类所共有的方法,但每种方法都有自己的实现、自己的前置条件和后置条件、自己的不变量等。我们并不依赖于父类。

⚠️ 这并不意味着我们要到处都使用接口,尽管它们确实很棒。但有时使用基类更好,有时使用接口更好。这完全取决于具体情况。

接口🆚抽象类

  • 接口优势
    • 不修改层次树
    • 允许实现 N 个接口
  • 抽象类的好处
    • 它允许通过将逻辑推入模型来开发Template Method¹模式。问题:难以追踪参与者是谁以及何时捕获错误
    • 私人获取者(告诉-不问原则

¹. 设计模式模板方法:它指出,在抽象类中,我们将定义一个方法主体来定义要执行的操作,但我们会调用一些定义为抽象的方法(将实现委托给子类)。但要小心!👀 这意味着我们代码的可追溯性会丧失。

接口🆚抽象类的总结

  • 我们何时使用接口?:当我们要解耦各层之间时。

  • 我们何时使用抽象?:在某些情况下,对于领域模型(领域模型不是 ORM 模型,以避免贫血领域模型

🎨 在 LSP 中对我们有用的设计模式

I - 接口隔离原则(ISP)

替代文本

💡 客户应该只知道他们将要使用的方法,而不知道他们将不会使用的方法。

简单来说,这条原则指的是我们不应该创建包含数千个方法的类,因为这样最终会形成一个巨大的文件。因为我们生成的是一个庞大的类,大多数时候我们每次只会使用其中的一些方法。因此,它需要接口,理解这一点对单一职责原则 (SRP)也很重要。

  • 如何实现
    • 根据使用接口的客户端来定义接口契约,而不是根据我们可能拥有的实现(接口属于客户端)。
    • 通过推广角色接口来避免使用标头接口
  • 目的或收益:
    • 高内聚,低结构耦合

标头接口

Martin fowler 在文章HeaderInterface中对此进行了支持。

💬 头接口是一种显式接口,它模仿了类的隐式公共接口。本质上,你获取类的所有公共方法,并在接口中声明它们。然后,你可以为该类提供替代实现。这与 RoleInterface 相反——我将在那里讨论更多细节及其优缺点。

角色接口

Martin fowler 在文章RoleInterface中对此进行了支持。

💬 角色接口是通过查看供应商和消费者之间的特定交互来定义的。供应商组件通常会实现多个角色接口,每个接口对应一种交互模式。这与 HeaderInterface 不同,在 HeaderInterface 中,供应商只有一个接口。

示例

简单示例

我们希望能够通过电子邮件、Slack 或文本文件发送通知。界面应该采用什么签名?📨

  • a)$notifier($content)✔️
  • b)$notifier($slackChannel,$messageTitle,$messageContent,$messageStatus)❌
  • c)$notifier($recieverEmail,$emailSubject,$emailContent)❌
  • d) $notifier($destination, $subject, $content) ❌
  • e)$notifier($filename,$tag,$description)❌

我们可以排除选项 B、C 和 E,因为 Header Interface 将基于实现(分别针对 Slack、电子邮件和文件)。

对于选项 D,我们可以认为它无效,因为类型$destination它没有给我们提供任何特殊性(我们不知道它是电子邮件、频道......)。

最后,在选项 A 中,我们只会发送内容,因此必须在构造函数中给出每种通知类型的特殊性(取决于用例,您不能总是这样做)。

👁 接口属于客户端,而不是实现它们的人。

示例开发人员 | QA | PM - 由于职责过多和抽象性差而违反 ISP 👎

一个简单的例子如下。假设我们有开发人员、QA团队和项目经理,他们需要决定是否进行编程。

假设程序员可以编程和测试,而 QA 只能测试。

interface Workable
{
    public function canCode();
    public function code();
    public function test();
}
Enter fullscreen mode Exit fullscreen mode
class Developer implements Workable
{
    public function canCode()
    {
        return true;
    }
    public function code()
    {
        return 'coding';
    }
    public function test()
    {
        return 'testing in localhost';
    }
}
Enter fullscreen mode Exit fullscreen mode
class Tester implements Workable
{
    public function canCode()
    {
        return false;
    }
    public function code()
    {
        // El QA no puede programar
         throw new Exception('Opps! I can not code');
    }
    public function test()
    {
        return 'testing in test server';
    }
}
Enter fullscreen mode Exit fullscreen mode
class ProjectManagement
{
    public function processCode(Workable $member)
    {
        if ($member->canCode()) {
            $member->code();
        }
    }
}
Enter fullscreen mode Exit fullscreen mode

如果我们注意的话,我们会发现该类Tester有一个与它不对应的方法,因为它没有被调用,如果它被调用,它会给我们一个Exception

因此我们应该进行一点小的重构,以便能够遵守接口隔离的原则。

示例开发人员 | QA | PM - 关注 ISP 👍

首先要确定我们要执行的操作,设计接口并根据用例将这些接口分配给相应的参与者。

interface Codeable
{
    public function code();
}
Enter fullscreen mode Exit fullscreen mode
interface Testable
{
    public function test();
}
Enter fullscreen mode Exit fullscreen mode
class Programmer implements Codeable, Testable
{
    public function code()
    {
        return 'coding';
    }
    public function test()
    {
        return 'testing in localhost';
    }
}
Enter fullscreen mode Exit fullscreen mode
class Tester implements Testable
{
    public function test()
    {
        return 'testing in test server';
    }
}
Enter fullscreen mode Exit fullscreen mode
class ProjectManagement
{
    public function processCode(Codeable $member)
    {
        $member->code();
    }
}
Enter fullscreen mode Exit fullscreen mode

这段代码确实符合接口隔离原则。与之前的原则一样。

🎨 在 ISP 中对我们有用的设计模式

D——依赖倒置原则(DIP)

替代文本

首先我必须明确一点,依赖注入不同于依赖倒置。依赖倒置是一种原则,而依赖注入是一种设计模式。

💡 高级模块不应该依赖于低级模块。两者都应该依赖于抽象

  • 如何实现
    • 注入依赖项(构造函数中接收的参数)。
    • 依赖于这些依赖项的接口(契约),而不是具体的实现。
    • LSP作为前提。
  • 目的或收益:
    • 方便实施的修改和替换。
    • 更好的类可测试性

依赖注入的原则是尽量保持低耦合。

Laravel 控制器示例

假设我们有一个UserController。它的index方法的作用是返回JSON前一天创建的用户列表。

Laravel 控制器 - DIP 违规 👎

public function index()
    {
        $users = new User();
        $users = $users->where('created_at', Carbon::yesterday())->get();

        return response()->json(['users' => $users]);
    }
}
Enter fullscreen mode Exit fullscreen mode

这段代码本身并不算糟糕,因为它显然可以正常工作。但与此同时,它会产生以下问题:

  • 由于我们受限于 Eloquent,因此无法重复使用代码。
  • 测试实例化一个或多个对象(高耦合)的方法很困难,因为很难验证它是否失败。
  • 它破坏了单一责任原则,因为除了方法完成其工作之外,它还必须创建对象才能完成其工作。

Laravel 控制器 - 遵循 DIP 👍

interface UserRepositoryInterface
{
    // 👁 I am returning an array
    // but it should return Domain Models
    public function getUserFromYesterday(DateInterface $date): array;
}
Enter fullscreen mode Exit fullscreen mode
class UserEloquentRepository implements UserRepositoryInterface
{
    public function getUserFromYesterday(DateInterface $date): array
    {
        return User::where('created_at', '>', $date)
            ->get()
            ->toArray();
    }
}
Enter fullscreen mode Exit fullscreen mode
class UserSqlRepository implements UserRepositoryInterface
{
    public function getUserFromYesterday(DateInterface $date): array
    {
        return \DB::table('users')
            ->where('created_at', '>', $date)
            ->get()
            ->toArray();
    }
}
Enter fullscreen mode Exit fullscreen mode
class UserCsvRepository implements UserRepositoryInterface
{
    public function getUserFromYesterday(DateInterface $date): array
    {
        // 👁 I am accessing the infrastructure
        // from the same method maybe not the best
        $fileName = "users_created_{$date}.csv";
        $fileHandle = fopen($fileName, 'r');

        while (($users[] = fgetscsv($fileHandle, 0, ","))  !== false) {
        }

        fclose($fileHandle);

        return $users;
    }
}
Enter fullscreen mode Exit fullscreen mode

我们看到,所有类都实现了该接口。这样一来,我们就可以自由地从、 from或👏😲 文件UserRespositoryInterface获取用户EloquentSQLCSV

这很好并且可以在正常的应用程序中工作,但是我们如何让 Laravel 控制器在其索引方法中接收该存储库?

答案是注册它默认具有的类的接口。

namespace App\Providers;

use Illuminate\Support\ServiceProvider;


class AppServiceProvider extends ServiceProvider
{
    public function register()
    {
        $this->app->bind(
            'App\Repositories\Contracts\UserRepositoryInterface',
            'App\Repositories\UserCsvRepository'
        );
    }
}
Enter fullscreen mode Exit fullscreen mode

在 Symfony 等其他框架中,可以使用PHP DI来完成。

结论

为了编写更易于维护、可重用和可测试的代码,我们应该尝试实践这些原则。例如,我们应该了解在使用这些原则时可能有用的设计模式。

网络摄影

🇪🇸

(🇬🇧/🇺🇸)

文章来源:https://dev.to/evrtrabajo/solid-in-php-d8e
PREV
超过 700 位 Web 开发者请我提供 LinkedIn 个人资料反馈,以下是我的🖐️5 条重要建议。🏃我提供反馈的速度
NEXT
没有电脑的一个月如何改变了我 斋月的准备 摄影 手表 地图和指南针 记事本 想法 结束 结论