bugover/ship

Porto架构的核心包。

1.2.3 2024-09-11 20:23 UTC

This package is auto-updated.

Last update: 2024-09-11 20:23:45 UTC


README

欢迎来到Porto

简介

Porto是一个现代软件架构模板,由一系列指导原则、原则和模板组成,帮助开发者以最方便和可重用的方式组织代码。

Porto是中等和大型Web项目的绝佳选择,因为随着时间的推移,它们通常会变得更加复杂。

通过Porto,开发者可以创建超级可扩展的单体系统,这些系统在需要时可以轻松地分割成多个微服务。同时,确保可以在多个项目中重复使用业务逻辑(应用程序功能)。

Porto继承了来自DDD(领域驱动设计)、模块化微内核MVC(模型-视图-控制器)、分层ADR(动作-领域-响应者)架构的概念。
它遵循一系列方便的设计原则,如SOLIDOOPLIFTDRYCoC、**GRASP **、泛化高内聚低耦合


这最初是一种实验性架构,旨在解决Web开发者在创建大型项目时遇到的一些常见问题。

我们非常重视您的反馈和建议。

"简单是可靠性的必要条件。” — 艾德加·迪科斯塔

开始工作

层概述

本质上,Porto由两个“文件夹”层组成:“容器”和“船”。

这些层可以在任何选择的任何位置创建。

(例如:在Laravel或Rails中,它们可以创建在app/目录中或在根目录中的新src/目录中。)

视觉概述

在解释每种类型代码应放置的位置之前,让我们先了解我们将拥有的不同代码级别

代码级别

  • 低级别代码:框架代码(实现基本操作,例如从磁盘读取文件或与数据库交互)。通常位于供应商(vendors)目录中。
  • 中级别代码:应用程序通用代码(实现服务于高级别代码的功能。它依赖于低级别代码来运行)。必须在“船”层。
  • 高级别代码:业务逻辑代码(封装复杂的逻辑,并依赖于中级代码来运行)。必须在“容器”层。

层架构

容器层(容器)>> 基于层 >> 船层(船)>> 基于框架(海洋)。

从单体到微服务

Porto是为了与您一起扩展而创建的!当大多数公司随着其扩张而从单体迁移到微服务(最近甚至没有服务器)时,Porto提供了灵活性,允许您在任何时候以最小的努力将您的单体产品转换为微服务(或SOA)。

在Porto的术语中,这相当于一艘装满集装箱的货轮,而微服务则相当于几艘集装箱货轮。(无论其规模大小)

Porto提供了灵活性,允许您从一个组织良好的单体服务开始,每次需要时,通过将容器分割成多个服务来增长,以适应您团队的成长。

正如您所想象的,在海上运营两艘或多艘船,而不是一艘,会导致维护成本增加(两个存储库,两个CI管道等),但也会提供灵活性,因为每艘船可以以不同的速度和方向运行。这在技术上导致每个服务根据预期的流量以不同的方式扩展。

服务之间如何交互完全取决于开发者。


1) 船层

“船”层包含父级“基本”类(可以由每个单独的组件扩展的类)和一些服务代码。

“船”层的父级“基本类”提供对容器组件(例如,向基本模型类添加功能使其在容器中的每个模型中都可用)的完全控制。

“船”层还在将应用程序代码与框架代码分离中扮演着重要角色。这简化了框架的更新,而不会影响应用程序代码。

在Porto中,“船”层非常精简,它不包含如身份验证或授权之类的通用多用途功能,因为这些功能都由可按需替换的容器提供。这为开发者提供了更大的灵活性。

船结构

船层包含以下类型的代码

  • 核心代码:这是船的动力,它会自动注册和自动加载所有容器组件以加载您的应用程序。它包含大部分魔法代码,它处理所有不属于您的业务逻辑的部分。它主要包含通过扩展平台功能来简化开发的代码。
  • 容器通用代码
    • 父级类:您的容器中每个组件的基本类。(向父级类添加功能使其在容器中的每个容器中都可用)。父级被创建用来包含容器的通用代码。
    • 通用类:每个容器都可以使用的多用途功能和类。例如,全局异常、应用程序中间件、全局配置文件等。

注意:所有容器组件都必须扩展或从“船”层继承(特别是从父级文件夹)。

在将核心代码分离成独立的包时,船父级必须扩展核心父级(可以命名为抽象的,因为大多数应该为抽象类)。船父级包含您用户应用程序的通用业务逻辑,而核心父级(抽象的)包含您平台的通用代码,主要是所有不属于业务逻辑的部分,都应该从开发的应用程序中隐藏。


2) 容器层

Porto通过将其分解为更小的可管理容器来管理问题的复杂性。

容器层是应用程序业务逻辑(应用程序功能/功能)的居住地。您将在这一层花费90%的时间,创建组件。

以下是如何创建容器的示例

“在应用程序中,任务”、“用户”和“日历”对象将位于不同的容器中,每个容器都有自己的路由(Routes)、控制器(Controllers)、模型(Models)、异常(Exceptions)等。每个容器负责从任何支持的用户界面(Web、API等)接收请求并返回响应。”

建议每个容器使用一个模型,但在某些情况下,您可能需要多个模型,这是完全可以接受的。(即使您只有一个模型,您也可能有值(Values),也称为对象值(Value Objects)(值类似于模型,但不在自己的表中表示,而是作为模型中的数据),这些对象在从数据库提取其数据后自动创建,例如价格、位置、时间……)

请记住,两个模型意味着两个存储库、两个转换器(Transformers)等。如果您不想始终同时使用两个模型,请将它们分开到2个容器中。

注意:如果两个容器在本质上存在强依赖性,则将它们放置在一个部分中可以简化在其他项目中重复使用。

容器的基本结构

Container 1
	├── Actions
	├── Tasks
	├── Models
	└── UI
	    ├── WEB
	    │   ├── Routes
	    │   ├── Controllers
	    │   └── Views
	    ├── API
	    │   ├── Routes
	    │   ├── Controllers
	    │   └── Transformers
	    └── CLI
	        ├── Routes
	        └── Commands

Container 2
	├── Actions
	├── Tasks
	├── Models
	└── UI
	    ├── WEB
	    │   ├── Routes
	    │   ├── Controllers
	    │   └── Views
	    ├── API
	    │   ├── Routes
	    │   ├── Controllers
	    │   └── Transformers
	    └── CLI
	        ├── Routes
	        └── Commands

容器之间的交互和关系

  • 容器可以依赖于一个或多个其他容器。(建议在同一个部分中。)
  • 控制器可以调用另一个容器的任务。
  • 模型可以与另一个容器的模型建立联系。
  • 还可能存在其他形式的联系,例如监听由其他容器触发的事件。

如果您使用基于事件的容器之间的数据交换,您可以在将代码库分割成多个服务后使用相同的机制。

注意。如果您不熟悉模块/域的代码分割,或者出于某种原因不偏好这种做法,您可以在一个容器中创建您的整个应用程序。(不推荐,但绝对可行。)

容器部分

容器“部分”是Porto架构中的另一个非常重要的方面。

将部分视为货轮上的集装箱排。在排中有序组织的集装箱可以加快为特定客户装运和卸载相关集装箱。

为了避免在您的容器文件夹根目录下有数十个集装箱,您可以按部分将相关集装箱分组。

部分本质上是一个包含相关容器的文件夹。然而,优势巨大。将部分视为有限的上下文,其中每个部分代表您系统的部分。

示例:如果您创建一个赛车游戏,例如Need for Speed,您可能有以下两个部分:赛车部分和休息室部分,其中每个部分都包含汽车容器和汽车模型,但具有不同的属性和功能。在这个示例中,赛车部分中的汽车模型可以包含加速和管理汽车的业务逻辑,而休息室部分中的汽车模型可以包含在比赛前设置汽车的业务逻辑。

部分允许将大型模型分割成更小的部分。并且它们可以为您的系统中的不同模型提供边界。

如果您更喜欢简单,或者只有一个团队在处理项目,您甚至可以没有部分(所有容器都在容器文件夹中),这意味着您的项目代表一个部分。在这种情况下,如果项目迅速增长并且您决定需要开始使用部分,您也可以创建一个新的项目,也称为微服务。在微服务中,每个“项目部分”都位于自己的项目(存储库)中,并且它们可以按网络交换数据,通常使用HTTP协议。


组件

在容器级别,有一组具有预定义职责的组件“类”。

您编写的每个单独的代码片段都必须位于组件(类函数)中。Porto为您定义了大量的这些组件,并附带了一组使用建议,以确保开发过程顺利。

组件确保一致性并简化了您的代码维护,因为您已经知道每个代码片段应该在何处找到。

组件类型

每个容器由多个组件组成,在Porto中,组件分为两种类型:主要组件附加组件

1) 主要组件

您应该使用这些组件,因为它们对于几乎所有的Web应用程序都是必需的。

路由(Routes)- 控制器(Controllers)- 请求(Requests)- 操作(Actions)- 任务(Tasks)- 模型(Models)- 视图(Views)- 转换器(Transformers)。

模板:当应用程序处理HTML页面时,应使用模板。
转换器(Transformers):当应用程序处理JSON或XML数据时,应使用转换器。

1.1) 主要组件的交互方案

1.2) 请求的生命周期

基本API调用场景,导航主要组件

  1. 用户端点 文件中调用 端点
  2. 端点 调用 中间件 以处理身份验证。
  3. 端点 调用相应的 控制器
  4. 注入到 控制器请求 自动应用验证和授权规则。
  5. 控制器 调用 操作 并传递 请求 数据。
  6. 操作 执行业务逻辑,*或者可以调用所需数量的任务(Tasks),以重复使用业务逻辑的子集。
  7. 任务 执行可重复使用的业务逻辑子集(任务 可以执行主要操作的单一部分)。
  8. 操作 准备数据以便返回到 控制器一些数据可以来自任务(Tasks)
  9. 控制器 使用 视图(或 转换器)创建响应并将其发送回 用户

1.3) 主要组件,定义和原则

点击下面的箭头,了解每个组件。

路由(Routes)

路由是HTTP请求的第一个接收者。

路由负责将所有传入的HTTP请求与其控制器进行匹配。

路由文件包含端点(端点模板 - URL地址模板,标识传入请求)。

当HTTP请求进入您的应用程序时,端点与URL地址模板匹配并调用相应的控制器函数。

原则

  • 存在三种类型的路由:API路由、Web路由和CLI路由。
  • API路由文件必须与Web路由文件分开,每个文件都在自己的单独文件夹中。
  • Web路由文件夹将只包含Web端点(可供网络浏览器访问);而API路由文件夹将只包含API端点(可供任何用户应用程序访问)。
  • 每个容器都应该有自己的路由。
  • 每个路由文件都应该包含一个端点。
  • 端点的任务是在执行任何类型的请求后调用相应控制器的函数。(不应执行其他任何操作)。

控制器

控制器负责验证请求、处理请求数据以及构建响应。 验证和响应在单独的类中发生,但由控制器启动

控制器的概念与MVC(这是MVC中的C)相同,但具有有限的和预定义的职责。

原则

  • 控制器不能知道任何业务逻辑或任何业务对象。
  • 控制器必须执行以下工作
    1. 读取请求(Request)数据(用户输入)
    2. 调用操作(Action)(并向其传递请求数据)
    3. 创建响应(Response)(通常根据调用操作(Action)收集的数据创建响应)
  • 控制器不能有任何业务逻辑。(必须调用操作(Action)以执行业务逻辑)。
  • 控制器不能调用容器的任务(Tasks)。它们只能调用操作(Actions)。(然后操作(Actions)可以调用容器的任务(Tasks))。
  • 控制器只能由端点(Endpoints)调用。
  • 每个用户界面(UI)容器(Web、API、命令行界面(CLI))文件夹都有自己的控制器。

你可能要问,为什么我们需要控制器!当我们可以直接从路由调用动作时。控制器层帮助在多个用户界面(Web界面和API)中重复使用动作,因为它不会创建响应,这减少了在不同用户界面中代码的重复。

以下是一个例子:

  • UI(Web):路由W-R1 -> 控制器W-C1 -> 动作A1
  • UI(API):路由A-R1 -> 控制器A-C1 -> 动作A1

如上述示例所示,动作A1被W-R1和A-R1两个路由使用,通过在每个用户界面中都存在的控制器层。

请求(Requests)

请求主要用于用户向应用程序输入数据。它们对于自动应用验证和授权规则非常有用。

请求是应用验证的最佳位置,因为验证规则将与每个请求相关联。请求还可以检查授权,例如检查用户是否有访问该控制器功能的权限。(例如:在删除产品之前检查特定用户是否拥有该产品,或者检查用户是否是管理员以编辑某些内容)。

原则

  • 请求可以包含验证/授权规则。
  • 请求必须仅在控制器中实现。实现后,它们将自动检查请求数据是否符合验证规则,如果请求数据无效,将抛出异常。
  • 请求还可以用于授权,它们可以检查用户是否有权执行请求。

动作(Actions)

动作(Actions)代表应用程序的使用场景(用户或软件在应用程序中可以采取的操作)。

动作可以包含业务逻辑或/和组织任务(Tasks)以执行业务逻辑。

动作(Actions)接受结构化数据作为输入,根据公司内的业务规则或使用某些任务(Tasks)进行操作,然后输出新的结构化数据。

动作(Actions)不需要关心数据的收集方式或如何呈现。

只需查看“动作”文件夹,您就可以确定您的容器提供哪些场景(功能)。通过查看所有动作,您可以了解应用程序可以做什么。

原则

  • 每个动作(Action)必须负责在应用程序中执行一个使用场景。
  • 动作可以提取任务(Tasks)中的数据并将其传递给另一个任务(Task)。
  • 动作可以调用多个任务(Tasks)。(它们甚至可以调用其他容器的任务!)
  • 动作可以返回数据给控制器。
  • 动作不应返回响应。(控制器负责返回响应)。
  • 动作不应调用另一个动作(如果需要从多个动作中重复使用大量业务逻辑,并且该片段调用多个任务,您可以创建子动作(SubAction)。请参阅下面的子动作部分。)
  • 动作主要用于控制器。然而,它们也可以从事件监听器(Listeners)、命令和/或其他类中使用。但不应从任务中使用。
  • 每个动作必须只有一个名为run()的功能。
  • 动作的主函数run()可以接受请求对象作为参数。(这有点奇怪,备注翻译。)
  • 动作负责处理所有预期的异常(Exceptions)。

任务(Tasks)

任务(Tasks)是包含多个容器中多个动作(Actions)的通用业务逻辑的类。

每个任务(Task)负责一小部分逻辑。

任务不是必需的,但在大多数情况下都是需要的。

示例:如果您有一个需要根据其标识符在数据库中查找记录的操作(Action)1,然后就会触发事件。您还有一个需要根据其标识符查找同一记录的操作2,然后调用外部API。由于这两个操作都执行“根据标识符查找记录”的逻辑,我们可以将这个业务逻辑放入一个自己的类中,这个类称为任务(Task)。现在这个任务(Task)可以在两个操作(Actions)中使用,也可以在任何其他将来创建的操作中使用。

规则是,每当您看到在操作中重复使用代码片段的机会时,都应该将该代码片段放入任务(Task)。不要盲目地为所有内容创建任务,您始终可以从在操作(Action)中编写所有业务逻辑开始,并且只有在需要重复使用它时,才创建专门的任务。(重构是适应代码增长所必需的)。

原则

  • 每个任务(Task)都必须具有单一责任(工作)。
  • 任务(Task)可以接收和返回数据。(任务不应该返回响应(response),控制器负责返回响应)。
  • 任务不应该调用其他任务。因为这会将我们带回服务架构(Services Architecture),而这将造成很大的混乱。
  • 任务不应该调用操作(Action)。因为这会使您的代码没有逻辑意义!
  • 任务(Tasks)只能从操作(Actions)中调用。(它们也可以从其他容器中的操作中调用!)
  • 任务(Tasks)通常有一个名为 run () 的单个函数。但是,如果需要,它们可以具有更多具有明确名称的函数。让Task类取代丑陋的函数标志概念。示例:FindUserTask 可以有两个函数 byIdbyEmail所有内部函数都必须调用函数 run 在这个例子中,run 可以在向存储库添加标准后调用两个函数中的任何一个。在这种情况下,run 可以在添加标准后调用两个函数中的任何一个。
  • 任务(Task)不能从控制器中调用。因为这会导致在您的代码中出现未记录的函数。拥有许多操作(Actions)是完全正常的,“例如:FindUserByIdActionFindUserByEmailAction,其中两个操作都调用同一个任务”,并且有一个单独的操作 FindUserAction,它决定应该调用哪个任务(Task)是完全正常的。
  • 任务(Task)不应该在任何其函数中接受请求(Request)对象。它可以接受任何函数参数,但不能接受请求(Request)对象。它可以自由地用于任何地方,并且可以独立进行测试。

模型(Models)

模型(Models)提供数据抽象,它们在数据库中代表数据。(这是MVC中的M)。

模型负责处理数据。它们确保数据正确地流向内部存储(例如,数据库)。

原则

  • 模型不应该包含业务逻辑,它只能包含代表自己的代码和数据。(这是与其他模型的关系、隐藏字段、表名、可填充属性等)
  • 一个容器可以包含多个模型。
  • 模型可以定义自己与其他模型之间的关系(如果存在连接)。

视图(Views)

视图(Views)包含由您的应用程序提供的服务HTML代码。

它们的主要目的是将应用程序逻辑与表示逻辑分开。(这是MVC中的V)。

原则

  • 视图只能从Web控制器(Web Controllers)中使用。
  • 视图应该根据它们显示的内容分为多个文件和文件夹。
  • 一个容器可以包含多个视图文件。

转换器(Transformers)

转换器(Transformers)(这是从响应转换器(Responses Transformers)缩写而来的)。

它们等同于视图,但用于JSON响应。当视图接收数据并以HTML的形式表示时,转换器接收数据并以JSON的形式表示。

转换器(Transformers)是负责将模型转换为数组的类。

转换器(Transformers)取模型或模型组“集合”并将其转换为格式化的可序列化数组。

原则

  • 所有API响应都必须使用转换器(Transformers)进行格式化。
  • 每个模型(通过API调用返回的)都必须有转换器。
  • 一个容器中可以有多个转换器。
  • 通常每个模型都有一个转换器。

异常(Exceptions)

异常(Exceptions)也是应预期的输出形式(例如,API异常)并且应该被明确定义。

原则

  • 存在容器异常(位于容器中)和通用异常(位于船体中)。
  • 任务(Tasks)、子任务(Sub-Tasks)、模型以及任何类都可能引发非常具体的异常。
  • 调用者必须处理来自被调用类的所有预期异常。
  • 操作(Actions)必须处理所有异常并确保它们不会渗透到顶层组件并引发意外行为。
  • 异常名称应该尽可能具体,并且应该有明确的描述性消息。

子操作(Sub-Actions)

子操作(SubActions)旨在消除操作(Actions)中的代码重复。不要混淆!子操作(SubActions)不会代替任务(Tasks)。

虽然任务(Tasks)允许操作(Actions)共享部分功能,但子操作(SubActions)允许操作(Actions)共享一系列任务。

子操作(SubActions)旨在解决问题。问题是:有时你需要重复使用大量业务逻辑在多个操作中。这部分代码已经调用了一些任务(Tasks)。(记住,任务不应该调用其他任务)那么如何重复使用这段代码而不创建任务!解决方案是创建子操作(SubAction)。

详细示例:假设操作A1调用Task1、Task2和Task3。另一操作A2调用Task2、Task3、Task4和Task5。请注意,两个操作都调用了任务2和3。为了消除代码重复,我们可以创建一个包含两个操作之间所有公共代码的子操作。

原则

  • 子操作(Sub-Actions)必须调用任务(Tasks)。如果子操作执行整个业务逻辑而不调用至少一个任务(Task),那么这很可能应该不是辅助操作,而是一个任务(Task)。
  • 子操作可以从中提取任务(Tasks)的数据并将数据传递到另一个任务(Task)。
  • 子操作可以调用多个任务(Tasks)。(它们甚至可以调用来自其他容器的任务!)
  • 子操作可以将数据返回到操作(Action)。
  • 子操作不应该返回响应(response)。(控制器任务负责返回响应)。
  • 子操作不应该调用另一个子操作。(尽量避免这种情况)。
  • 子操作应该从操作(Action)中使用。然而,它们也可以从事件(Events)、命令(Commands)以及/或其他类中使用。但它们不应该从控制器或任务(Tasks)中使用。
  • 每个子操作(Sub-Action)必须只有一个名为run()的函数。

2) 其他组件

您可以根据应用程序的需求添加这些组件,但是有些组件强烈推荐使用。

Tests - Events - Listeners - Commands - Migrations - Seeders - Factories - Middlewares - Repositories - Criteria - Policies - Service Providers - Contracts - Traits - Jobs - Values - Transporters - Mails - Notifications...


典型容器结构

包含主要和附加组件的容器。

Container
	├── Actions
	├── Tasks
	├── Models
	├── Values
	├── Events
	├── Listeners
	├── Policies
	├── Exceptions
	├── Contracts
	├── Traits
	├── Jobs
	├── Notifications
	├── Providers
	├── Configs
	├── Mails
	│   ├── Templates	
	├── Data
	│   ├── Migrations
	│   ├── Seeders
	│   ├── Factories
	│   ├── Criteria
	│   ├── Repositories
	│   ├── Validators
	│   ├── Transporters
	│   └── Rules
	├── Tests
	│   ├── Unit
	│   └── Traits
	└── UI
	    ├── API
	    │   ├── Routes
	    │   ├── Controllers
	    │   ├── Requests
	    │   ├── Transformers
	    │   └── Tests
	    │       └── Functional
	    ├── WEB
	    │   ├── Routes
	    │   ├── Controllers
	    │   ├── Requests
	    │   ├── Views
	    │   └── Tests
	    │       └── Acceptance
	    └── CLI
	        ├── Routes
	        ├── Commands
	        └── Tests
	            └── Functional

Porto的质量属性

使用Porto的优势。

模块性和可重用性

在Porto中,您的应用程序的业务逻辑生活在容器中。Porto容器在本质上类似于模块(来自模块化架构)和领域(来自DDD架构)。

容器可以依赖于其他容器,就像层可以依赖于多级架构中的其他层一样。

Porto的规则和建议最小化并定义容器之间依赖的方向,以避免它们之间的循环引用。

这允许将相关的容器分组到不同的部分中,以便在不同的项目中一起重复使用。 (每个部分都包含应用程序业务逻辑的可重用部分。)

至于依赖关系管理,开发者可以自由地将每个容器移动到自己的存储库中,或者将所有容器一起存储在一个存储库中。

易于维护和可扩展性

Porto力求通过节省开发者的时间来降低维护成本。它的结构设计得很好,以实现代码分离和一致性,这有助于简化其维护。

每个类只有一个函数来描述其功能,这简化了添加和删除功能的流程。

Porto具有非常组织化的代码库和零耦合代码。此外,它具有预定义的数据流和依赖关系的清晰开发工作流程。所有这些都促进了其可扩展性。

可测试性和可调试性

通过每个类只有一个函数来严格遵循单一责任原则,导致出现超薄类,这简化了可测试性。

在Porto中,每个组件都期望相同类型的输入和输出,这简化了测试、模拟和桩测试(stubbing)。

Porto的结构本身使自动测试变得流畅。因为它在每个容器的根目录下都有一个tests文件夹来放置任务的模块化测试。并且每个用户界面文件夹都有一个tests文件夹来放置功能测试(用于单独测试每个用户界面)。

简化测试和调试的秘密不仅在于测试的组织和组件责任的预先定义,还在于代码的分离。

适应性和可进化性

使用Porto,您可以轻松适应未来的变化,而无需付出太多努力。

假设您有一个服务于HTML的Web应用程序,您最近决定需要移动应用程序,因此需要API。

Porto具有可插拔的用户界面(WEB、API和CLI),这使得您可以首先编写应用程序的业务逻辑,然后再实现与代码交互的用户界面。

这提供了在需要时添加接口的灵活性,并以最小的努力适应未来的变化。

这一切之所以可能,是因为动作是“非控制器”组织原则的核心,它被多个用户界面共享。用户界面与应用程序的业务逻辑分离,并在每个容器中分开。

易用性和易学性

Porto允许非常容易地找到任何特性/功能,并了解其内部运作。

这得益于使用领域专家语言为“组件”命名类,以及每个类单一功能的黄金法则。这允许您通过查看文件来找到代码中的任何用法(动作(Action))。

Porto承诺您可以在3秒内找到任何功能的实现!(例如:如果您要查找用户地址的验证位置,只需转到地址容器,打开动作列表,找到ValidateUserAddressAction即可。)

可扩展性和灵活性

Porto考虑到未来的增长,并保证您的代码无论项目大小都易于维护。

这通过其模块化结构、任务分离以及内部“组件”类之间的组织化联系来实现。

这允许在不产生不希望副作用的情况下进行更改。

灵活性和可升级性

Porto使您能够快速且轻松地移动。

由于应用程序与框架代码在“船”级别上完全分离,因此框架更新很容易执行。


联系我

您的反馈很重要。

有关反馈、问题或建议?我们可以在 Slack 上找到。

作者

俄语翻译(示例)

捐赠

成为 Github Sponsor
通过Paypal进行直接捐赠。
成为Patreon会员。

许可协议

MIT © Mahmoud Zalt