用于发布和消费API的可扩展和持久的数据导入。

资助包维护!
Bilge
Patreon

7.0.3 2023-08-03 09:28 UTC

README

Version image Downloads image Build image Quickstart image Quickstart Symfony image Coverage image Mutation score image

用于大规模消费数据和发布可测试SDK的持久性和异步数据导入

Porter是一个通用的PHP数据导入器。它可以从API、网页抓取或任何地方获取数据,并以可迭代的记录集合的形式提供服务,鼓励一次处理一条记录而不是将整个数据集加载到内存中。默认情况下,持久性功能提供自动、透明的从间歇性网络错误中恢复的功能。

Porter的接口三合一包括提供者资源连接器,使我们能够发布可测试的SDK,并且很好地映射到API和HTTP端点。例如,一个典型的API如GitHub将提供者定义为GitHubProvider,资源为GetUserListRepositories,连接器可以是HttpConnector

Porter支持通过fibers(PHP 8.1)进行异步导入,允许并发启动、暂停和恢复多个导入。异步操作使我们能够尽可能快地导入数据,将应用程序从网络绑定(慢)转换为CPU绑定(最优)。节流支持确保我们不会超过对等连接或吞吐量限制。

Porter网络快速链接

目录

  1. 优势
  2. 快速入门
  3. 关于本手册
  4. 使用方法
  5. Porter的API
  6. 概述
  7. 导入规范
  8. 记录集合
  9. 异步
  10. 转换器
  11. 过滤
  12. 持久性
  13. 缓存
  14. 架构
  15. 提供者
  16. 资源
  17. 连接器
  18. 限制
  19. 测试
  20. 贡献
  21. 许可

优势

  • 定义了一个易于测试的接口三合一,用于数据导入:提供者代表一个或多个资源,从连接器获取数据。这些接口使我们可以非常容易地使用行业标准工具测试和模拟导入生命周期的特定部分,无论我们是在连接器级别模拟并输入原始响应,还是在资源级别模拟并供应已填充的对象。
  • 提供内存高效的数据处理接口,通过迭代器逐条处理大型数据集,可以使用延迟执行生成器实现。
  • 异步导入提供了高度高效的CPU绑定数据处理,用于并发多个连接的大规模导入,消除网络延迟性能瓶颈。可以通过节流来限制并发。
  • 通过具有透明性和自动重试失败的请求数据的耐久性功能,保护免受间歇性网络故障的影响。
  • 提供后导入的转换,例如过滤映射,以将第三方数据转换为我们的应用程序的有用数据。
  • 在连接器级别支持PSR-6 缓存,针对每个获取操作。
  • 自动使用子导入将两个或更多相关联的数据集合并在一起。

快速入门

要快速开始,使用现有的Porter提供者,请尝试我们的快速入门指南之一

继续阅读以获得更全面的介绍。

关于本手册

使用Porter提供者的人员为他们的应用程序创建一个Porter实例,并为每个他们希望执行的数据导入创建一个Import实例。发布提供者的人员必须实现ProviderProviderResource

本手册的前半部分涵盖了Porter用于消费数据服务的核心API。后半部分涵盖了发布数据服务的架构、接口和实现细节。中间有一个间歇,这样你就会知道分隔在哪里!

标记为inline code的文本表示直接代码,就像它在PHP文件中显示的那样。例如,Porter特指该库中同名类的类,而Porter则指整个项目。

使用方法

创建容器

创建一个新的Porter实例——我们通常每个应用程序只需要一个。Porter的构造函数需要一个作为提供者存储库的PSR-11兼容的ContainerInterface

当将Porter集成到典型的MVC框架应用程序中时,我们通常已经有了实现此接口的服务定位器或DI容器。我们可以简单地将其整个容器注入到Porter中,尽管最佳实践是只为Porter的提供者创建一个单独的容器。在Symfony中正确执行此操作的示例,请参阅Symfony快速入门

在没有框架的情况下,选择任何PSR-11兼容库,并注入其实例的容器类。我们甚至可以编写自己的容器,因为接口易于实现,但使用现有库是有益的,特别是由于大多数支持服务的懒加载。如果您不确定使用哪个,Joomla DI似乎相当简单且轻量。

注册提供者

通过注册一个或多个Porter 提供者来配置容器。在这个例子中,我们将添加欧洲央行提供者以获取外汇汇率。大多数提供者库只导出一个提供者类;在这种情况下是EuropeanCentralBankProvider。我们可以通过写入类似$container->set(EuropeanCentralBankProvider::class, new EuropeanCentralBankProvider)的内容来向容器添加提供者,但请查阅特定容器实现的文档以获取确切的语法。

建议使用提供者的类名作为容器服务名,如前一段示例所示。Porter默认将按提供者的类名检索服务,这可以减少开始时的摩擦。如果我们使用不同的服务名,则需要在Import中通过调用setProviderName()进行配置。

导入数据

Porter的import方法接受一个Import,它描述了要导入哪些数据以及数据应该如何转换。要导入不应用任何转换的DailyForexRates,可以编写如下代码。

$records = $porter->import(new Import(new DailyForexRates));

调用import()返回一个PorterRecordsCountablePorterRecords的实例,这两个实例都实现了Iterator,允许使用foreach枚举集合中的每个记录,如下例所示。

foreach ($records as $record) {
    var_dump($record);
}

Porter的API

Porter的简单API包含数据导入方法,必须始终使用这些方法开始导入,而不是直接在提供者或资源上调用方法,以便正确利用Porter的功能。

Porter仅提供两个用于导入数据的方法。这些是您应该最熟悉的方法,数据导入操作的生命周期从这里开始。

  • import(Import): PorterRecords|CountablePorterRecords – 从指定的导入规范中包含的资源导入一条或多条记录。如果已知集合的总大小,则记录集合可能实现Countable,否则返回PorterRecords
  • importOne(Import): ?array – 从指定的导入规范中包含的资源导入一条记录。如果导入多条记录,将抛出ImportException。当提供者实现SingleRecordResource并仅返回一条记录时,请使用此方法。

概述

以下数据流图提供了Porter主要接口的概览以及导入数据时它们之间的数据流。请注意,我们使用术语资源以简明扼要,但实际上接口被称为ProviderResource,因为资源是PHP中的保留词。

Data flow diagram

我们的应用程序使用Porter::import()与一个Import一起调用,并返回一个PorterRecords。其他所有事情都发生在内部,因此我们不需要担心,除非编写自定义提供者、资源或连接器。

导入规范

导入规范指定了要导入的内容如何转换以及是否使用缓存。创建一个新的Import实例,并传递一个指定我们想要导入的资源ProviderResource

可以使用以下方法配置选项。

  • setProviderName(string) – 设置提供者服务名。
  • addTransformer(Transformer) – 将转换器添加到转换队列的末尾。
  • addTransformers(Transformer[]) – 将一个或多个转换器添加到转换队列的末尾。
  • setContext(mixed) – 指定要传递给转换器的用户定义数据。
  • enableCache() – 启用缓存。需要CachingConnector
  • setMaxFetchAttempts(int) – 设置在将失败视为永久之前每个连接的获取尝试的最大次数。
  • setFetchExceptionHandler(FetchExceptionHandler) – 设置每次获取尝试失败时调用的异常处理器。
  • setThrottle(Throttle) – 设置连接节流,每次连接器获取数据时调用。

记录集合

记录集合是 Iterator,保证了可以使用 foreach 对导入的数据进行枚举。集合中的每个 记录 都是熟悉的灵活的 array 类型,允许我们以数组的形式呈现结构化或扁平化数据,如JSON、XML或CSV。

详细信息

记录集合可能是 Countable,这取决于导入的数据是否可计数,以及导入后是否执行了任何破坏性操作。过滤是一个破坏性操作,因为它可能会删除记录,因此由 ProviderResource 报告的计数将不再准确。资源有责任通过返回实现 Countable 的迭代器(如 ArrayIterator 或更常见的 CountableProviderRecords)来提供其集合中的记录总数。当使用可计数的迭代器时,如果未执行任何破坏性操作,Porter将返回 CountablePorterRecords

Porter 使用装饰器模式组成记录集合。如果提供者数据未修改,PorterRecords 将装饰从 ProviderResource 返回的 ProviderRecords。也就是说,PorterRecords 有一个指向先前集合的指针,可以表示为:PorterRecordsProviderRecords。如果应用了 filter,集合堆栈将变为 PorterRecordsFilteredRecordsProviderRecords。通常这是一个不重要的细节,但有时对于调试很有用。

记录集合类型的堆栈告诉我们集合经历了哪些转换,并且每种类型都包含指向参与转换的相关对象的指针。例如,PorterRecords 包含一个对用于创建它的 Import 的引用,可以使用 PorterRecords::getImport 访问。

元数据

由于记录集合只是对象,因此可以定义派生类型以实现自定义字段,除了迭代数据外,还可以暴露额外的 元数据。集合非常适合表示重复的数据序列,但某些API发送额外的非重复数据,我们可以将其作为元数据公开。然而,如果数据根本不重复,应将其视为单个记录而不是元数据。

成功的 Porter::import 调用的结果始终是 PorterRecordsCountablePorterRecords 的实例,具体取决于记录数是否已知。如果我们需要访问由提供者返回的原始集合的方法,我们可以在集合上调用 findFirstCollection()。例如,请参阅 CurrencyRecords(来自 欧洲中央银行提供者)及其相关的 测试用例

异步

Porter 自版本 5(2019)以来已支持异步,归功于 Amp 集成。在 v5 中,异步是通过协程实现的,但从版本 6 开始,Porter 使用更简单的 fibers 模型。Fiber 支持 PHP 8.1,并且可以使用 ext-fiber 添加到 PHP 8.0。PHP 7 不支持 fibers,因此如果您卡在 PHP 7 版本,协程是唯一选项。强烈建议升级到 PHP 8.1 以使用异步,以避免导致段错误的非必要错误,并避免陷入难以升级、难以调试和难以推理的协程架构。

在版本5中,Porter提供了双API来支持异步代码路径。也就是说,Porter::import有异步对应物:Porter::importAsync,而Porter::importOnePorter::importOneAsync。在版本6中,我们转向了纤程,但保留了双API,这使得从协程迁移到纤程变得稍微容易一些。从版本7开始,我们统一了双API,因为使用纤程的异步几乎可以完全透明:同步和异步代码路径是相同的,所以除非我们想要在应用中利用其好处,否则我们甚至不必考虑异步。

要从Porter v7版本开始使用异步,只需使用以下两种方法之一将一个import()importOne()调用包裹在一个async()调用中。

use function Amp\async;

async(
    $this->porter->import(...),
    new Import(new MyResource())
);

// -OR-

async(fn () => $this->porter->import(new Import(new MyResource()));

为了让这生效,唯一的要求是底层的连接器支持纤程。要了解某个特定的连接器是否支持纤程,请查阅其文档。最常用的连接器HttpConnector已经支持纤程。

async()调用返回一个代表异步操作最终结果的Future。要了解如何组合和抽象未来,或者如何等待和迭代未来的集合,超出了本文件的范畴。有关异步编程的完整详细信息,请参阅官方的Amp文档

注意:在撰写本文时,Amp v3仍处于测试版,因此您可能需要通过composer.json将项目的最小稳定性降低以包含Amp包。

"minimum-stability": "beta"

为了避免引入除依赖解决器绝对必要的测试版以外的任何测试版,建议在上述设置中也设置稳定包为首选稳定性。

"prefer-stable": true

节流

异步导入模型非常强大,因为它改变了我们应用程序的性能模型,从I/O受限,受限于网络速度,转变为CPU受限,受限于CPU速度。在传统的同步模型中,每个导入操作都必须等待上一个完成,然后下一个才开始,这意味着总导入时间取决于每个导入的I/O操作完成所需的时间。在异步模型中,因为我们并发发送许多请求而不必等待上一个完成。平均而言,每个导入操作只需要CPU处理它的时间,因为我们忙于处理另一个导入,此时网络延迟(除了最初的“启动”期间)。

同步地,我们很少会触发保护措施,即使是高量导入,然而异步导入的简单方法通常充满了危险。如果我们一次性导入10,000个HTTP资源,通常会发生以下两种情况之一:要么我们耗尽PHP内存,进程提前终止,要么HTTP服务器在短时间内发送过多请求后拒绝我们。解决方案是节流。

Async Throttle包含在Porter中,用于节流异步导入。节流通过基于用户定义的限制,防止在并发执行太多操作时启动更多操作来实现。默认情况下,分配了NullThrottle,它不会节流连接。DualThrottle可用于设置两个独立的连接速率限制:每秒最大连接数和最大并发连接数。

可以通过以下方式修改导入规范来分配DualThrottle

(new Import)->setThrottle(new DualThrottle)

ThrottledConnector

可以将节流器分配给实现ThrottledConnector接口的连接器。这允许提供者默认为其所有资源应用节流。当节流器同时分配给连接器和导入规范时,规范的节流器具有优先权。如果我们想使用的连接器没有实现ThrottledConnector,只需扩展连接器并实现该接口即可。

当我们需要许多资源共享相同的节流器或想要通过依赖注入注入节流器时,实现ThrottledConnector可能更可取,因为规范通常在行内实例化,而连接器则不是。也就是说,我们通常会在应用程序框架的服务配置中声明连接器。

转换器

转换器操作导入的数据。转换数据很有用,因为第三方数据很少以我们想要的确切格式到达。通过调用其addTransformer方法将转换器添加到Import的转换队列中,并按照它们添加的顺序执行。

Porter包含一个转换器FilterTransformer,该转换器基于谓词从集合中删除记录。有关更多信息,请参阅过滤。可以使用MappingTransformer设计更强大的数据转换。更多转换器可能来自Porter转换器

编写转换器

转换器实现了Transformer和/或AsyncTransformer接口,这些接口定义了以下方法之一或多个。

public function transform(RecordCollection $records, mixed $context): RecordCollection;

public function transformAsync(AsyncRecordCollection $records, mixed $context): AsyncRecordCollection;

当调用transform()transformAsync()时,转换器可以遍历每个记录并按任何方式更改它,包括删除或插入额外的记录。无论是否进行了更改,记录集合都必须由方法返回。

如果转换器存储任何对象状态,还应实现__clone魔法方法,以便在Porter在导入期间克隆拥有Import时进行深拷贝。

过滤

过滤提供了一种删除某些记录的方法。对于每条记录,如果指定的谓词函数返回false(或假值),则删除该记录,否则保留该记录。谓词接收当前记录作为其第一个参数的数组,并接收上下文作为其第二个参数。

通常,我们想避免过滤,因为导入数据然后立即删除其中一些数据是不高效的,但一些不成熟的API没有提供在服务器上减少数据集的方法,因此客户端过滤是唯一的替代方案。过滤还使一些资源报告的记录数无效,这意味着我们不知道在迭代之前集合中有多少记录。

示例

以下示例过滤掉了任何没有id字段的记录。

$records = $porter->import(
    (new Import(new MyResource))
        ->addTransformer(
            new FilterTransformer(static function (array $record) {
                return array_key_exists('id', $record);
            })
        )
);

持久性

当在Connector::fetch期间发生异常时,Porter会自动重试连接。这有助于缓解由间歇性网络条件引起的暂时性数据获取失败。可以通过调用ImportsetMaxFetchAttempts方法来配置重试尝试的数量。

默认异常处理程序ExponentialSleepFetchExceptionHandler会导致失败的获取在一系列递增的延迟中暂停,每次加倍。鉴于默认的重试尝试次数是五个,异常处理程序可能被调用四次,每次重试延迟约0.1、0.2、0.4和最后0.8秒。在第五次和最后一次失败后,抛出FailingTooHardException异常。

可以通过调用 setFetchExceptionHandler 来更改异常处理程序。例如,以下代码将初始重试延迟更改为1秒。

$specification->setFetchExceptionHandler(new ExponentialSleepFetchExceptionHandler(1000000));

持久性仅在连接器抛出从 RecoverableConnectorException 派生的可恢复异常类型时适用。如果发生意外异常,则将终止获取尝试。有关更多信息,请参阅 实现连接器持久性。异常处理程序接收抛出的异常作为其第一个参数。异常处理程序可以检查可恢复异常,并在决定将异常视为致命而不是可恢复的情况下抛出其自己的异常。

缓存

任何连接器都可以被包装在 CachingConnector 中,为基本连接器提供 PSR-6 缓存功能。Porter 附带了一个缓存实现 MemoryCache,它将获取的数据缓存在内存中,但可以替换为任何其他 PSR-6 缓存实现。《CachingConnector》为每个唯一的请求缓存原始响应,唯一性由 DataSource::computeHash 确定。

请记住,尽管使用 CachingConnector 启用了缓存,但还需要通过调用 Import::enableCache() 在每个导入的基础上启用缓存。

示例

以下示例启用了连接器缓存。

$records = $porter->import(
    (new Import(new MyResource))
        ->enableCache()
);

休息时间 ☕️

恭喜!我们已经涵盖了使用 Porter 所需的一切。

本说明书的其余部分是为那些希望深入了解的人准备的。当你准备好学习如何编写 提供者资源连接器 时,请继续。

架构

以下 UML 类图显示了一个部分架构概述,说明了 Porter 的主要组件以及它们之间的关系。异步实现细节主要被省略,因为它们反映了同步系统。[放大]

Class diagram

提供者

提供者使用 Connector 向其 ProviderResource 对象提供。提供者必须确保它提供正确的类型的连接器来访问其服务资源。提供者实现 Provider,该接口定义了一个方法,其签名如下。

public function getConnector() : Connector;

提供者不知道它有多少资源,也不维护资源列表,Porter 的其他任何部分也是如此。也就是说,可以在任何时候创建一个资源类,并声称它属于给定的提供者,而无需任何正式注册。

编写提供者

提供者必须实现 Provider 接口,并在调用 getConnector 时提供有效的连接器。从 Porter 的角度来看,编写提供者通常只需要在存储连接器实例时提供正确的类型提示,但我们可以在类中添加任何其他我们可能需要的功能。对于 HTTP 服务提供者,通常添加一个基础 URL 常量以及一些静态方法来组合 URL,以减少其资源中的代码重复。

实现示例

在以下示例中,我们创建了一个仅接受 HttpConnector 实例的提供者。我们还创建了一个默认连接器,以防未提供。请注意,并不总是可以创建默认连接器,并且坚持要求调用者提供连接器是完全有效的。

final class MyProvider implements Provider
{
    private $connector;

    public function __construct(Connector $connector = null)
    {
        $this->connector = $connector ?: new HttpConnector;
    }

    public function getConnector(): Connector
    {
        return $this->connector;
    }
}

资源

资源使用提供的连接器获取数据,并将其格式化为数组集合。资源实现 ProviderResource,该接口定义了以下三个方法。

public function getProviderClassName(): string;
public function fetch(ImportConnector $connector): \Iterator;

当调用 getProviderClassName 时,资源提供它期望从连接器获取的提供者类的名称。

当调用 fetch() 方法时,它会传递一个用于获取数据的连接器。资源必须确保数据以数组值的迭代器格式进行格式化,同时尽可能保持原始格式;也就是说,我们必须避免重命名或重新结构化数据,因为调用者有权限在需要时对数据进行自定义。推荐返回迭代器的方式是使用 yield 语句来隐式返回一个 Generator,它还有一次处理一条记录的优势。

fetch 方法接收一个 ImportConnector,这是由提供者提供的底层连接器的运行时包装器。这个包装器用于将连接器的状态与应用程序的其他部分隔离。由于 PHP 没有原生的不可变支持,使用克隆状态是我们唯一可以保证一旦导入开始,就不会出现意外更改的方法。这意味着可以在第一次导入完成之前,安全地导入一个资源,更改连接器的设置,然后开始另一个导入。提供者也可以通过调用 getWrappedConnector() 方法安全地更改底层连接器,因为包装连接器一旦 ImportConnector 被构建,就会被克隆。

通过克隆提供不可变性是一个重要的概念,因为资源通常使用生成器实现,这意味着代码执行是延迟的。可以使用不同的设置开始多次获取,但它们会在稍后以不同的顺序执行,当它们最终枚举时。当 Porter 支持异步获取,并允许并发执行多个获取时,这个问题将变得更加相关。然而,除非我们自己编写连接器,否则我们不需要担心这个实现细节。

编写资源

资源必须实现 ProviderResource 接口。getProviderClassName() 通常返回一个硬编码的提供者类名,并且 fetch() 必须始终返回一个数组值的迭代器。

在这个使用模拟数据和忽略连接器的虚构示例中,假设我们想要返回从一到三的数字序列:以下实现是无效的,因为它返回一个整数值的迭代器,而不是数组值的迭代器。

public function fetch(ImportConnector $connector): \Iterator
{
    return new ArrayIterator(range(1, 3)); // Invalid return type.
}

以下任何一个 fetch() 实现都是有效的。

public function fetch(ImportConnector $connector): \Iterator
{
    foreach (range(1, 3) as $number) {
        yield [$number];
    }
}

由于记录总数是已知的,可以将迭代器包装在 CountableProviderRecords 中,以便向调用者提供这些信息。

public function fetch(ImportConnector $connector): \Iterator
{
    $series = function ($limit) {
        foreach (range(1, $limit) as $number) {
            yield [$number];
        }
    };

    return new CountableProviderRecords($series($count = 3), $count, $this);
}

实现示例

在以下示例中,我们创建了一个从 MyProvider 接收连接器并使用它从硬编码的 URL 获取数据的资源。我们期望数据是 JSON 编码的,因此将其解码为数组,并使用 yield 返回它作为单元素迭代器。

class MyResource implements ProviderResource, SingleRecordResource
{
    private const URL = 'https://example.com';

    public function getProviderClassName(): string
    {
        return MyProvider::class;
    }

    public function fetch(ImportConnector $connector): \Iterator
    {
        $data = $connector->fetch(self::URL);

        yield json_decode($data, true);
    }
}

如果数据代表重复序列,请像以下示例中那样单独 yield 每条记录,并删除 SingleRecordResource 标记接口。

public function fetch(ImportConnector $connector): \Iterator
{
    $data = $connector->fetch(self::URL);

    foreach (json_decode($data, true) as $datum) {
        yield $datum;
    }
}

异常处理

将抛出不可恢复异常,可以像正常一样捕获它们,但好的连接器实现会将其连接尝试包裹在重试块中,并抛出 RecoverableConnectorException。拦截可恢复异常的唯一方法是向 ImportConnector 添加一个 FetchExceptionHandler,方法是调用其 setExceptionHandler() 方法。由于异常处理程序的返回值被忽略,因此不能用于流程控制,因此这些处理程序的主要应用是将可恢复异常作为不可恢复异常重新抛出。

连接器

连接器从在获取时指定的源获取远程数据。从 Porter 连接器 可以获取流行协议的连接器。如果处理不常见或当前不支持的协议,可能需要编写新的连接器。编写提供者和资源是一个常见的任务,应该相对容易,但编写连接器则较少见。

编写连接器

连接器实现了定义了以下签名的 Connector 接口。

public function fetch(DataSource $source): mixed;

当调用 fetch() 方法时,连接器从指定的数据源获取数据。连接器可以以任何方便资源消费的格式返回数据,但通常,这些数据应尽可能原始且未经修改。如果返回多条信息,建议使用专门的对象,例如 HTTP 连接器返回的 HttpResponse 对象,它包含响应头和正文。

数据源

必须实现 DataSource 接口,以提供连接器定位数据源所需的参数。对于 HTTP 连接器,这可能包括 URL、方法、正文和头信息。对于数据库连接器,这可能是一个 SQL 查询。

DataSource 定义了一个具有以下签名的接口。

public function computeHash(): string;

数据源需要返回其状态的唯一哈希值。如果状态发生变化,哈希值必须更改。如果状态实际上是等效的,则哈希值必须相同。这用于缓存系统确定是否之前已见过获取操作,从而可以从缓存中提供服务而不是再次获取新数据。

定义哈希输入的规范顺序很重要,以便以不同顺序呈现的相同状态不会生成不同的哈希值。例如,我们可能在哈希之前按字母顺序对 HTTP 头部进行排序,因为头部顺序不重要,重新排序头部不应产生不同的输出。

持久性

为了支持 Porter 的持久性功能,连接器可以抛出一个 RecoverableConnectorException 的子类,以表示可以重试获取操作。如果抛出任何其他类型的异常,执行将正常中断。建议在获取操作是幂等的时抛出可恢复的异常类型。

限制

可能影响一些用户并在不久的将来得到解决的当前限制。

  • 没有端到端的数据流接口。

测试

Porter 完全经过了单元测试和突变测试。

  • 使用 composer test 命令运行单元测试。
  • 使用 composer mutation 命令运行突变测试。

贡献

欢迎每个人贡献任何东西,从 想法和问题代码和文档

许可

Porter 在开源 GNU Lesser General Public License v3.0 许可下发布。但是,原始的 Porter 角色和艺术作品版权所有 © 2022 Bilge,未经明确书面许可不得复制或修改。

支持

Porter 由 JetBrains for Open Source 产品支持。

快速链接