写点什么

为什么我不再使用 MVC 框架

  • 2016-04-20
  • 本文字数:8707 字

    阅读完需:约 29 分钟

在我最近的工作中,最让人抓狂的就是为前端开发人员设计 API。我们之间的对话大致就是这样的:

开发人员:这个页面上有数据元素 x,y,z…,你能不能为我创建一个 API,响应格式为{x: , y:, z: }

我:好吧

我甚至没有进行进一步的争论。项目结束时会积累大量的 API,这些 API 与经常发生变化的页面是关联在一起的,按照“设计”,只要页面改变,相应的 API 也要随之变化,而在此之前,我们甚至对此毫不知情,最终,由于形成因素众多且各平台之间存在些许差异,必须创建非常多的 API 来满足这些需求。Sam Newman 甚至将这种制度化的过程称之为 BFF 模式,这种模式建议为每种设备、平台当然还包含 APP 版本开发特定的 API。 Daniel Jacobson 在接受 InfoQ 的采访时曾指出,Netflix 颇为勉强地将“体验式 API”与“临时 API(Ephemeral API)”划上了等号。 唉……

几个月前,我开始思考是什么造成了如今的这种现象,该做些什么来应对它,这个过程使我开始质疑应用架构中最强大的理念,也就是 MVC,我感受到了函数式反应型编程(reactive)的强大威力,这个过程致力于流程的简化,并试图消除我们这个行业在生产率方面的膨胀情绪。我相信你会对我的发现感兴趣的。

在每个用户界面背后,我们都在使用 MVC 模式,也就是模型 - 视图 - 控制器(Model-View-Controller)。 MVC 发明的时候,Web 尚不存在,当时的软件架构充其量是胖客户端在原始网络中直接与单一数据库会话。但是,几十年之后,MVC 依然在使用,持续地用于 OmniChannel 应用的构建。

Angular 2 正式版即将发布,在这个时间节点重估 MVC 模式及各种 MVC 框架为应用架构带来的贡献意义重大。

我第一次接触到 MVC 是在 1990 年,当时 NeXT 刚刚发布 Interface Builder (让人惊讶的是,如今这款软件依然发挥着重大的作用)。当时,我们感觉 Interface Builder 和 MVC 是一个很大的进步。在 90 年代末期,MVC 模式用到了HTTP 上的任务中(还记得 Struts 吗?),如今,就各个方面来讲,MVC 是所有应用架构的基本原则。

MVC 的影响十分深远,以致于 React.js 在介绍他们的框架时都委婉地与其划清界限:“ React 实现的只是 MVC 中视图(View)的部分”。

当我去年开始使用 React 的时候,我感觉它在某些地方有着明显的不同:你在某个地方修改一部分数据,不需要显式地与 View 和 Model 进行交互,整个 UI 就能瞬间发生变化(不仅仅是域和表格中的值)。这也就是说,我很快就对 React 的编程模型感到了失望,在这方面,我显然并不孤独。我分享一下 Andre Medeiros 的观点

React 在很多方面都让我感到失望,它主要是通过设计不佳的 API 来引导程序员 […] 将多项关注点混合到一个组件之中。

作为服务端的 API 设计者,我的结论是没有特别好的方式将 API 调用组织到 React 前端中,这恰恰是因为 React 只关注 View,在它的编程模型中根本不存在控制器。

到目前为止,Facebook 一直致力于在框架层面弥合这一空白。React 团队起初引入了 Flux 模式,不过它依然令人失望,最近 Dan Abramov 又提倡另外一种模式,名为 Redux ,在一定程度上来讲,它的方向是正确的,但是在将 API 关联到前端方面,依然比不上我下面所介绍的方案。

Google 发布过 GWT、Android SDK 还有 Angular,你可能认为他们的工程师熟知何为最好的前端架构,但是当你阅读 Angular 2 设计考量的文章时,便会不以为然,即便在 Google 大家也达成这样的共识,他们是这样评价之前的工作成果的:

Angular 1 并不是基于组件的理念构建的。相反,我们需要将控制器与页面上各种 [元素] 进行关联(attach),其中包含了我们的自定义逻辑。根据我们自定义的指令如何对其进行封装(是否包含 isolate scope?),scope 会进行关联或继续往下传递。

基于组件的 Angular 2 看起来能简单一点吗?其实并没有好多少。 Angular 2 的核心包本身就包含了 180 个语义(semantics),整个框架的语义已经接近 500 个,这是基于 HTML5 和 CSS3 的。谁有那么多时间学习和掌握这样的框架来构建 Web 应用呢?当 Angular 3 出现的时候,情况又该是什么样子呢?

在使用过 React 并了解了 Angular 2 将会是什么样子之后,我感到有些沮丧:这些框架都系统性地强制我使用 BFF“页面可替换模式(Screen Scraping)”模式,按照这种模式,每个服务端的 API 要匹配页面上的数据集,不管是输入的还是输出的。

此时,我决定“让这一切见鬼去吧”。我构建了一个 Web 应用,没有使用 React、没有使用 Angular 也没有使用任何其他的 MVC 框架,通过这种方式,我看一下是否能够找到一种在 View 和底层 API 之间进行更好协作的方式。

就 React 来讲,我最喜欢的一点在于 Model 和 View 之间的关联关系。React 不是基于模板的,View 本身没有办法请求数据(我们只能将数据传递给 View),看起来,针对这一点进行探索是一个很好的方向。

如果看得足够长远的话,你会发现 React 唯一的目的就是将 View 分解为一系列(纯粹的)函数和 JSX 语法:

它实际上与下面的格式并没有什么差别:

V = f( M )

例如,我当前正在从事项目的 Web 站点, Gliiph ,就是使用这种函数构建的:

(点击放大图像)

图 1:用于生成站点 Slider 组件 HTML 的函数

这个函数需要使用 Model 来填充数据:

(点击放大图像)

图 2:支撑 slider 的 Model

如果用简单的 JavaScript 函数就能完成任务,我们为什么还要用 React 呢?

虚拟 DOM(virtual-dom)?如果你觉得需要这样一种方案的话(我并不确定有很多的人需要这样),其实有这样的可选方案,我也期望开发出更多的方案。

GraphQL ?并不完全如此。不要因为 Facebook 大量使用它就对其产生误解,认为它一定是对你有好处的。GraphQL 仅仅是以声明的方式来创建视图模型。强制要求 Model 匹配 View 会给你带来麻烦,而不是解决方案。React 团队可能会觉得使用“客户端指定查询(Client-specified queries)”是没有问题的(就像反应型团队中那样):

GraphQL 完全是由 View 以及编写它们的前端工程师的需求所驱动的。[…] 另一方面,GraphQL 查询会精确返回客户端请求的内容,除此之外,也就没什么了。

GraphQL 团队没有关注到 JSX 语法背后的核心思想:用函数将 Model 与 View 分离。与模板和“前端工程师所编写的查询”不同,函数不需要 Model 来适配 View。

当 View 是由函数创建的时候(而不是由模板或查询所创建),我们就可以按需转换 Model,使其按照最合适的形式来展现 View,不必在 Model 的形式上添加人为的限制。

例如,如果 View 要展现一个值 v,有一个图形化的指示器会标明这个值是优秀、良好还是很差,我们没有理由将指示器的值放到 Model 中:函数应该根据 Model 所提供的 v 值,来进行简单的计算,从而确定指示器的值。

现在,把这些计算直接嵌入到 View 中并不是什么好主意,使 View-Model 成为一个纯函数也并非难事,因此当我们需要明确的 View-Model 时,就没有特殊的理由再使用 GraphQL 了:

V = f( vm(M) )

作为深谙 MDE 之道的人,我相信你更善于编写代码,而不是元数据,不管它是模板还是像 GraphQL 这样的复杂查询语言。

这个函数式的方式能够带来多项好处。首先,与 React 类似,它允许我们将 View 分解为组件。它们创建的较为自然的界面允许我们为 Web 应用或 Web 站点设置“主题”,或者使用不同的技术来渲染 View(如原生的方式)。函数实现还有可能增强我们实现反应型设计的方式。

在接下来的几个月中,可能会出现开发者交付用 JavaScript 函数包装的基于组件的 HTML5 主题的情况。这也是最近这段时间,在我的 Web 站点项目中,我所采用的方式,我会得到一个模板,然后迅速地将其封装为 JavaScript 函数。我不再使用 WordPress。基本上花同等的工夫(甚至更少),我就能实现 HTML5 和 CSS 的最佳效果。

这种方式也需要在设计师和开发人员之间建立一种新型的关系。任何人都可以编写这些 JavaScript 函数,尤其是模板的设计人员。人们不需要学习绑定方法、JSX 和 Angular 模板的语法,只掌握简单的 JavaScript 核心函数就足以让这一切运转起来

有意思的是,从反应型流程的角度来说,这些函数可以部署在最合适的地方:在服务端或在客户端均可。

但最为重要的是,这种方式允许在 View 与 Model 之间建立最小的契约关系,让 Model 来决定如何以最好的方式将其数据传递给 View。让 Model 去处理诸如缓存、懒加载、编配以及一致性的问题。与模板和 GraphQL 不同,这种方式不需要从 View 的角度来直接发送请求。

既然我们有了一种方式将 Model 与 View 进行解耦,那么下一个问题就是:在这里该如何创建完整的应用模型呢?“控制器”该是什么样子的?为了回答这个问题,让我们重新回到 MVC 上来。

苹果公司了解 MVC 的基本情况,因为他们在上世纪 80 年代初,从 Xerox PARC“偷来了”这一模式,从那时起,他们就坚定地实现这一模式

图 3:MVC 模式

Andre Medeiros 曾经清晰地指出,这里核心的缺点在于, MVC 模式是“交互式的(interactive)”(这与反应型截然不同)。在传统的 MVC 之中,Action(Controller)将会调用 Model 上的更新方法,在成功(或出错)之时会确定如何更新 View。他指出,其实并非必须如此,这里还有另外一种有效的、反应型的处理方式,我们只需这样考虑,Action 只应该将值传递给 Model,不管输出是什么,也不必确定 Model 该如何进行更新。

那核心问题就变成了:该如何将 Action 集成到反应型流程中呢?如果你想理解 Action 的基础知识的话,那么你应该看一下 TLA+ 。TLA 代表的是“Action 中的逻辑时序(Temporal Logic of Actions)”,这是由 Dr. Lamport 所提出的学说,他也因此获得了图灵奖。在 TLA+ 中,Action 是纯函数

data’ = A (data)

我真的非常喜欢 TLA+ 这个很棒的理念,因为它强制函数只转换给定的数据集。

按照这种形式,反应型 MVC 看起来可能就会如下所示:

V = f( M.present( A(data) ) )

这个表达式规定当 Action 触发的时候,它会根据一组输入(例如用户输入)计算一个数据集,这个数据是提交到 Model 中的,然后会确定是否需要以及如何对其自身进行更新。当更新完成后,View 会根据新的 Model 状态进行更新。反应型的环就闭合了。Model 持久化和获取其数据的方式是与反应型流程无关的,所以,它理所应当地“不应该由前端工程师来编写”。不必因此而感到歉意。

再次强调,Action 是纯函数,没有状态和其他的副作用(例如,对于 Model,不会包含计数的日志)。

反应型 MVC 模式很有意思,因为除了 Model 以外,所有的事情都是纯函数。公平来讲,Redux 实现了这种特殊的模式,但是带有 React 不必要的形式,并且在 reducer 中,Model 和 Action 之间存在一点不必要的耦合。Action 和接口之间是纯粹的消息传递。

这也就是说,反应型 MVC 并不完整,按照 Dan 喜欢的说法,它并没有扩展到现实的应用之中。让我们通过一个简单的样例来阐述这是为什么。

假设我们需要实现一个应用来控制火箭的发射:一旦我们开始倒计时,系统将会递减计数器(counter),当它到达零的时候,会将 Model 中所有未定的状态设置为规定值,火箭的发射将会进行初始化。

这个应用有一个简单的状态机:

图 4:火箭发射的状态机

其中 _decrement_ 和 _launch_ 都是“自动”的 Action,这意味着我们每次进入(或重新进入)counting 状态时,将会保证进行转换的评估,如果计数器的值大于零的话,decrement Action 将会继续调用,如果值为零的话,将会调用 _launch_Action。在任何的时间点都可以触发 _abort_ Action,这样的话,控制系统将会转换到 aborted 状态。

在 MVC 中,这种类型的逻辑将会在控制器中实现,并且可能会由 View 中的一个计时器来触发。

这一段至关重要,所以请仔细阅读。我们已经看到,在 TLA+ 中,Action 没有副作用,只是计算结果的状态,Model 处理 Action 的输出并对其自身进行更新。这是与传统状态机语义的基本区别,在传统的状态机中,Action 会指定结果状态,也就是说,结果状态是独立于 Model 的。在 TLA+ 中,所启用的 Action 能够在状态表述(也就是 View)中进行触发,这些 Action 不会直接与触发状态转换的行为进行关联。换句话说,状态机不应该由连接两个状态的元组(S1, A, S2)来进行指定,传统的状态机是这样做的,它们元组的形式应该是(Sk, Ak1, Ak2,…),这指定了所有启用的 Action,并给定了一个状态 Sk,Action 应用于系统之后,将会计算出结果状态,Model 将会处理更新。

当我们引入“state”对象时,TLA+ 提供了一种更优秀的方式来对系统进行概念化,它将 Action 和 _view_(仅仅是一种 _ 状态的表述 _)进行了分离。

我们样例中的 Model 如下所示:

model = {

counter: ,

started: ,

aborted: ,

launched:
}

系统中四个(控制)状态分别对应于 Model 中如下的值:

ready = {counter: 10, started: false, aborted: false, launched: false }

counting = {counter: [0…10], started: true, aborted: false, launched: false }

launched = {counter: 0, started: true, aborted: false, launched: true}

aborted = {counter: [0…10], started: true, aborted: true, launched: false}

这个 Model 是由系统的所有属性及其可能的值所指定的,状态则指定了所启用的 Action,它会给定一组值。这种类型的业务逻辑必须要在某个地方进行实现。我们不能指望用户能够知道哪个 Action 是否可行。在这方面,没有其他的方式。不过,这种类型的业务逻辑很难编写、调试和维护,在没有语义对其进行描述时,更是如此,比如在 MVC 中就是这样。

让我们为火箭发射的样例编写一些代码。从 TLA+ 角度来讲,next-action 断言在逻辑上会跟在状态渲染之后。当前状态呈现之后,下一步就是执行 next-action 断言,如果存在的话,将会计算并执行下一个 Action,这个 Action 会将其数据交给 Model,Model 将会初始化新状态的表述,以此类推。

(点击放大图像)

图 5:火箭发射器的实现

需要注意的是,在客户端 / 服务器架构下,当自动 Action 触发之后,我们可能需要使用像 WebSocket 这样的协议(或者在 WebSocket 不可用的时候,使用轮询机制)来正确地渲染状态表述。

我曾经使用 Java JavaScript 编写过一个很轻量级的开源库,它使用 TLA+ 特有的语义来构造状态对象,并提供了样例,这些样例使用 WebSocket、轮询和队列实现浏览器 / 服务器交互。在火箭发射器的样例中可以看到,我们并非必须要使用那个库。一旦理解了如何编写,状态实现的编码相对来讲是很容易的。

对于要引入的新模式来说,我相信我们已经具备了所有的元素,这个新模式作为 MVC 的替代者,名为 SAM 模式(状态 - 行为 - 模型,State-Action-Model),它具有反应型和函数式的特性,灵感来源于 React.js 和 TLA+。

SAM 模式可以通过如下的表达式来进行描述:

V = S( vm( M.present( A(data) ) ), nap(M))

它表明在应用一个 Action A 之后,View V 可以计算得出,Action 会作为 Model 的纯函数。

在 SAM 中,A(Action)、vm(视图 - 模型,view-model)、nap(next-action 断言)以及 S(状态表述)必须都是纯函数。在 SAM 中,我们通常所说的“状态”(系统中属性的值)要完全局限于 Model 之中,改变这些值的逻辑在 Model 本身之外是不可见的。

随便提一下,next-action 断言,即 nap() 是一个回调,它会在状态表述创建完成,并渲染给用户时调用。

图 6:状态 - 行为 - 模型(SAM)模式

模式本身是独立于任何协议的(可以不费什么力气就能在 HTTP 上实现)和客户端 / 服务器拓扑结构的。

SAM 并不意味着我们必须要使用状态机的语义来获取 View 的内容。如果 Action 是由 View 触发的,那 next-action 断言就是一个空函数。不过,这可能是一个很好的实践,它清晰暴露了底层状态机的控制状态,因为根据(控制)状态的不同,View 看起来可能也是不同的。

另一方面,如果你的状态机涉及到自动化的 Action,那么 Action 和 Model 都不可能做到纯粹的不包含 next-action 断言:有些 Action 将会变得有状态,或者 Model 必须要触发 Action,而这本来并不是它的角色。顺便提一下,也许并不那么直观,状态对象并没有持有任何的“状态”,它同样也是纯函数,它会渲染 View 并计算 next-action 断言,这两者都来源于 Model 的属性值。

这种新模式的好处在于,它清晰地将 CRUD 操作从 Action 中分离了出来。Model 负责它的持久化,将会通过 CRUD 操作来实现,通过 View 是无法进行访问的。尤其是,View 永远不会处于“获取”数据的位置,View 所能做的唯一的事情就是请求系统中当前的状态表述并通过触发 Action 初始化一个反应型流程。

Action 仅仅代表了一种具有权限的通道,以此来建议 Model 该怎样进行变更。它们本身(在 Model 方面)并没有什么副作用。如果必要的话,Action 会调用第三方的 API(同样,对 Model 没有副作用),比如说,修改地址的 Action 可能会希望调用地址校验服务,并将服务返回的地址提交到 Model 中。

如下就是“修改地址”Action 该如何进行实现,它会调用地址校验的 API:

(点击放大图像)

图 7:“修改地址”的实现

模式中的元素,包括 Action 和 Model,可以进行自由地组合:

函数组合

data’ = A(B(data))

端组合(Peer)(相同的数据集可以提交给两个 Model)

M1.present(data’)

M2.present(data’)

父子组合(父 Model 控制的数据集提交给子 Model)

M1.present(data’,M2)

function present(data, child) {

// 执行更新

// 同步 Model

child.present(c(data))

}

发布 / 订阅组合

M1.on(“topic”, present )

M2.on(“topic”, present )

M1.on(“data”, present )

M2.on(“data”, present )

有些架构师可能会考虑到 System of Record 和 Systems of Engagement,这种模式有助于明确这两层的接口(图 8),Model 会负责与 systems of record 的交互。

图 8:SAM 组合模型

整个模式本身也是可以进行组合的,我们可以实现运行在浏览器中的 SAM 实例,使其支持类似于向导(wizard)的行为(如 ToDo 应用),它会与服务器端的 SAM 进行交互:

图 9:SAM 实例组合

请注意,里层的 SAM 实例是作为状态表述的一部分进行传送的,这个状态表述是由外层的实例所生成的。

会话检查应该在 Action 触发之前进行(图 10)。SAM 能够启用一项很有意思的组合,在将数据提交给 Model 之前,View 可以调用一个第三方的 Action,并且要为其提供一个 token 和指向系统 Action 的回调,这个第三方 Action 会进行授权并校验该调用的合法性。

图 10:借助 SAM 实现会话管理

CQRS 的角度来讲,这个模式没有对查询(Query)和命令(Command)做特殊的区分,但是底层的实现需要进行这种区分。搜索或查询“Action”只是简单地传递一组参数到 Model 中。我们可以采用某种约定(如下划线前缀)来区分查询和命令,或者我们可以在 Model 上使用两个不同的 present 方法:

{ _name : ‘/^[a]$/i’ } // 名字以 A 或 a 开头`` { _customerId: ‘123’ } // id=123 的 customerModel 将会执行必要的操作以匹配查询,更新其内容并触发 View 的渲染。类似的约定可以用于创建、更新或删除 Model 中的元素。在将 Action 的输出传递给 Model 方面,我们可以实现多种方式(数据集、事件、Action……)。每种方式都会有其优势和不足,最终这取决于个人偏好。我更喜欢数据集的方式。

在异常方面,与 React 类似,我们预期 Model 会以属性值的形式保存异常信息(这些属性值可能是由 Action 提交的,也可能是 CRUD 操作返回的)。在渲染状态表述的时候,会用到属性值,以展现异常信息。

在缓存方面,SAM 在状态表述层提供了缓存的选项。直观上来看,缓存这些状态表述函数的结果能够实现更高的命中率,因为我们现在是在组件 / 状态层触发缓存,而不是在 Action/ 响应层。

该模式的反应型和函数式结构使得功能重放(replay)和单元测试变得非常容易。

SAM 模式完全改变了前端架构的范式,因为根据 TLA+ 的基础理念,业务逻辑可以清晰地描述为:

  • Action 是纯函数
  • CRUD 操作放在 Model 中
  • 状态控制自动化的 Action

作为 API 的设计者,从我的角度来讲,这种模式将 API 设计的责任推到了服务器端,在 View 和 Model 之间保持了最小的契约。

Action 作为纯函数,能够跨 Model 重用,只要某个 Model 能够接受 Action 所对应的输出即可。我们可以期望 Action 库、主题(状态表述)甚至 Model 能够繁荣发展起来,因为它们现在能够独立地进行组合。

借助 SAM 模式,微服务能够非常自然地支撑 Model。像 Hivepod.io 这样的框架能够插入进来,就像它本来就在这层似得。

最为重要的是,这种模式像 React 一样,不需要任何的数据绑定或模板。

随着时间的推移,我希望能够推动浏览器永久添加虚拟 DOM 的特性,新的状态表述能够通过专有 API 直接进行处理。

我发现这个旅程将会带来一定的革新性:在过去的几十年中,面向对象似乎无处不在,但它已经一去不返了。我现在只能按照反应型和函数式来进行思考。我借助 SAM 所构建的东西及其构建速度都是前所未有的。另外,我能够关注于 API 和服务的设计,它们不再遵循由前端决定的模式。

我要向对本文进行审校的人表达谢意和致敬:Prof. Jean Bezivin、 Prof. Joëlle Coutaz、 Braulio Diez、 Adron Hall、 Edwin Khodabackchian、 Guillaume Laforge、 Pedro Molina、 Arnon Rotem-Gal-Oz.

关于作者

Jean-Jacques Dubray xgen.io gliiph 的创立者。在过去的 15 年中,他一直致力于构建面向服务的架构和 API 平台。他曾经是 HRL 的研究人员,在普罗旺斯大学(吕米尼校园)获取了博士学位,Prolog 语言就是由该学校发明的,同时他是 BOLT 方法学的发明者。

查看英文原文: Why I No Longer Use MVC Frameworks


感谢刘振涛对本文的审校。

给 InfoQ 中文站投稿或者参与内容翻译工作,请邮件至 editors@cn.infoq.com 。也欢迎大家通过新浪微博( @InfoQ @丁晓昀),微信(微信号: InfoQChina )关注我们。

2016-04-20 20:2223401

评论

发布
暂无评论
发现更多内容

架构训练营第三周作业

Geek_ce484f

极客大学架构师训练营

架构师训练营第 1 期 -Week3 - 课后练习

鲁大江

php 单例模式 极客大学架构师训练营 go单例模式

手写单例模式(登记式/静态内部类)

orchid9

第三周第二题

sean

第二讲 学习总结

mm马

极客大学架构师训练营

架构师训练营第三周总结

月殇

极客大学架构师训练营

架构师训练营第三周学习总结-设计模式

郎哲158

学习 极客大学架构师训练营

第三周第一题

sean

设计模式总结

天天向上

极客大学架构师训练营

第 3 周 作业

Pyr0man1ac

架构师1期第三周总结

FG佳

极客大学架构师训练营

第三周作业及笔记

橘子皮嚼着不脆

第三周笔记

orchid9

架构师训练营第 1 期-week3

习习

架构师训练营第 1 期 -Week3 - 代码重构学习总结

鲁大江

设计模式 极客大学架构师训练营

Week 3 作业 01

Croesus

架构训练营第三周作业

Geek_ce484f

极客大学架构师训练营

week-3-part1 手写单例模式

陈龙

架构师1期3周作业

FG佳

极客大学架构师训练营

「架构师训练营」第三周课后练习

L

组合模式程序案例

天天向上

极客大学架构师训练营

架构师训练营第 3 周课后练习

叶纪想

极客大学架构师训练营

架构师训练营 第三周作业

郎哲158

第二讲 作业一

mm马

极客大学架构师训练营

LeetCode题解:49. 字母异位词分组,数组排序,JavaScript,详细注释

Lee Chen

大前端 LeetCode

架构师训练营第 3 周学习总结

netspecial

极客大学架构师训练营

week-3-part2 学习总结

陈龙

单例

scorpion

「架构师训练营」第三周课后练习

L

训练营第三周作业 1

仲夏

极客大学架构师训练营

训练营第三周作业 2

仲夏

极客大学架构师训练营

为什么我不再使用MVC框架_JavaScript_Jean-Jacques Dubray_InfoQ精选文章