angular 2 实现可扩展的 web 单页应用程序架构

1,195 阅读9分钟
原文链接: zcfy.cc

为确保你能够理解本篇文章的内容,你需要掌握面向对象编程和函数式编程。我也极力的推荐你先去了解和学习redux的设计思想。

几个月之前我开始用单页应用(spa)的方式的方式编写一个动态业务需求的项目。和大多数的单页应用一样,随着业务逻辑和状态增多使得我们的应用日益庞大、臃肿。

需求说明

这是我一个创业项目的核心产品,因为还处于早起发展阶段以及商业竞争等因素,这个产品的业务变化是相当大的。

可扩展的通信层

我们具有相对稳定的业务领域,然而还是会有其他的因素影响着产品的状态,我们具有如下的通信需求:

  • 用户
  • RESTful API

在此基础上可能会有(或没有)如下的:;

  • 与现有用户建立 P2P 链接的相关成员
  • 与服务器进行实时的通讯

为支持不同的通信协议(HTTP,WebSocket,UDP[webRTC])我们需要不同格式的数据:

  • HTTP/WebSocket采用JSON的通信格式
  • JSON-RPC格式的WebSocket通信
  • BERT or BERT-RTC格式的WebRTC或WebSocket通信

BERT通信协议非常适合P2P通信方式,尤其对于二进制数据的传输,比如图片以及不适合文本表示的数据。

为实现所有服务之间的通信,RxJS看起来是一个很不错的选择,通过它可以方便的管理各种类型的异步事件。

Given all the services we need to communicate with, RxJS seems like a perfect fit for organization of all the asynchronous events that the application needs to handle. We can multiplex several data streams over the same communication channel using hot-observers and declaratively filter, transform, process them, etc.

可预测的状态管理

在上面所列举的通信因素中很多都是会变化的。变化最多的就是用户、实时推送服务以及通过WebRTC通信的其他成员。当我们需要存储不同版本的store以及数据的时候,可预测的状态管理就显得非常重要。

有许多的架构模式可以帮助我们实现可预测的状态管理。当前最为流行的当属redux了。为了更好的类型安全以及工具,我们决定使用 TypeScript。

也许有人会争论说相比于TypeScript这样的语言,使用纯函数式的语言能够帮助我们降低副作用的影响。我对他们的观点表示赞同,同时我自己也是Elm和ClojureScript这样的函数式语言的粉丝。然而从程序的健壮性考虑,我们团队选择了 TypeScript。

要满足所有的开发者的需要是很难找到一种合适的技术方案的。我们需要牺牲部分人员的诉求而满足大部分人的要求,如同我提出了尝试使用Elm和ClojureScript的需求一样。

对我们来说,在无副作用和健壮性上来考虑最好的解决方案就是redux + TypeScript。Redux可以帮助我们实现可预测的数据状态管理,TypeScript则可帮助我们实现类型的检查以及更容易重构。

模块设计

正如之前提到的,团队会逐渐变得庞大。团队成员在经验方面也会有所不同。这也意味着不同层级经验的开发者需要合作开发同一个项目。最完美的实现就是能让初级的开发者最大化的发挥作用。为了实现这个目的我们将代码实现了比较高程度的抽象而看起来就如简单的MVC模式一般。

下面的图表显示了我们当前阶段核心模块的架构情况:

最上层是视图组件层,即用户直接交互的层,比如对话框、表单等。

facade层是视图层和底下各种服务的通信中间层,主要的目的是用来触发action操作并调用reducers以及异步服务的action调用。图表中的reducers和state即等同于redux中的reducers、state。

为了方便,我们在这里把facades称为models.例如,如果我们要开发一个游戏,那么我们的游戏视图组件GameComponent就会通过GameModel数据层来与store以及异步服务接口的通信。 facades的另外一个核心职能就是将异步服务接口调用转变成相应的actions,并和相应的reducers连接。这样我们就可以通过触发相应的action来调用异步接口并管理返回的数据。我们可以将异步接口用来扩展服务的远程代理服务 。它们将相应的action操作和远程命令对应起来。那为什么不直接调用远程服务而要通过异步调用的方式呢?那是因为通过异步接口的方式可以实现对WebRTC,WebSocket以及IndexDB的统一调用。

如果我们的异步服务对应于一个远程的RESTful API,那么会通过对应的HTTP网关来连接。一旦异步服务接收到一个action调用,他就会将这个action转换成对应的RESTful命令并通过网关传输过去。

需要注意的是,数据模型层(facade)不应该与具体的通信协议耦合,即使是异步服务。这意味着facade应该更具具体的使用场景来决定如何调用异步服务。

上下文依赖的实现

facades的上下文是由其视图部分决定的。例如,假设我们要开发一款可以多人和单人使用的游戏。对于单人的情况,我们需要实现玩家和游戏服务器之间的数据通信,但对于多人玩家的情况除此之外还需要实现玩家与玩家之间的数据通信。

这也就意味着SinglePlayerComponent需要通过GameModel来连接GameServer服务,而MultiPlayerComponent则需要GameModel同时与GameServerGameP2PService通信。

为了实现这样的依赖方式,依赖注入模式成为我们的首选实现方式,而且解决得很完美。

懒加载

应用会变得越来越大,我们的javascript代码可能会操作5万行,这也让js的按需加载变得尤其重要。

以我们上面提到的游戏为例,我们会按如下的结构来组织代码:

.
└── src
    ├── multi-player
    │   ├── commands
    │   ├── components
    │   └── gateways
    ├── single-player
    │   └── components
    ├── home
    │   └── components
    └── shared

当用户打开首页的时候,我们希望加载homeshare目录中的代码.如果玩家进一步的选择了单人模式,那么我们就会去加载single-player目录中的代码,以此类推来实现按需加载。

按照上面的目录结构,我们也可以轻易的将整个应用拆分到多个开发者身上,给每个开发者一定的上下文限制。

其它需求

从架构层面考虑,我们还需要考虑如下的需求:

For the architecture we also have the standard set of requirements including:

技术栈

在我们整理好需求和开发思路后我们决定在几种技术栈中选择其一。我们首先想到的是React 和 Angular2。我们有过react 和 redux模式的成功经验。

但懒加载和依赖注入的问题依然让我们难以在这两者之间选择,react-router很好的支持了懒加载,但依赖注入依然是个问题。而Angular2的一个优势是 WebWorkers 的支持

最终我们选择了如下的技术方案:

在我进一步的说明之前我想声明的是如上的架构并不局限于Angular2,React或任何其他的框架,也可以使用不同的语法以及无需依赖注入功能。

示例程序

这里有一个我们实现了如上架构的示例代码,该示例使用Angular2 和 rxjs,但正如之前提到的,你也可以使用react来替代。

为了更简单的解释相关概念,我将基于上面所提到的游戏来讲解。简单的说,这是一个帮助你提高打字速度的游戏,它有两个数据模块:

  • 单人模式-可以练习打字的速度。该模块会给你一段文本并计算你能以多快的速度敲出来。

  • 多人模式-与其他玩家比拼打字速度。所有的玩家通过WebRTC连接到同一个聊天室,一旦连接建立起来,玩家之间就需要相互交换信息,优先完成信息交换的玩家就会成为赢家。

现在我们根据我们上面图表给出的架构模式来实现这个游戏,我们首先从视图开始:

视图组件

视图组件的实现依赖于具体使用的UI框架(这里我们使用的是angular2)。组件可以保存某些状态,但我们必须清楚组件状态与store之间的对应关系以及组件内部的状态。

所有的组件通过组合的形式形成一颗组件树并通过控制器来将他们联系起来。

下面是GameComponent的简单实现:

@Component({
  // Some component-specific declarations
  providers: [GameModel]
})
export class GameComponent implements AfterViewInit {
  // declarations...
  @Input() text: string;
  @Output() end: EventEmitter = new EventEmitter();
  @Output() change: EventEmitter = new EventEmitter();

  constructor(private _model: GameModel, private _renderer: Renderer) {}

  ngAfterViewInit() {
    // other UI related logic
    this._model.startGame();
  }

  changeHandler(data: string) {
    if (this.text === data) {
      this.end.emit(this.timer.time);
      this._model.completeGame(this.timer.time, this.text);
      this.timer.reset();
    } else {
      this._model.onProgress(data);
      // other UI related logic
    }
  }

  reset() {
    this.timer.reset();
    this.text = '';
  }

  invalid() {
    return this._model.game$
      .scan((accum: boolean, current: any) => {
        return (current && current.get('invalid')) || accum;
      }, false);
  }
}

该组件具有如下的几个特点:

  • 输入输出API
  • 封装组件内部自己的状态,比如当前用户的输入文本就无需存储在Store中
  • 使用 GameModel作为该示例的Facade层

GameModel给组件提供了访问应用状态的途径。例如,GameComponent对当前游戏状态比较感兴趣,所以GameModel就给其提供了访问游戏状态的方法。

使用像GameModel这样的高级抽象能让新团队成员快速的投入开发,他们可以在Model层上直接开发UI组件,然后让Model层去维护应用状态的变化。团队成员只需要会使用angular2和RxJS数据流就可以投入开发,他们不用关心任何的通信协议、包数据格式以及redux等。

Model定义

如下为GameModel的定义:

@Injectable()
export class GameModel extends Model {
  games$: Observable;
  game$: Observable;
  constructor(protected _store: Store,
              @Optional() @Inject(AsyncService) _services: AsyncService[]) {
    super(_services || []);
    this.games$ = this._store.select('games');
    this.game$ = this._store.select('game');
  }
  startGame() {
    this._store.dispatch(GameActions.startGame());
  }
  onProgress(text: string) {
    this.performAsyncAction(GameActions.gameProgress(text, new Date()))
      .subscribe(() => {
        // Do nothing, we're all good
      }, (data: any) => {
        if (data.invalidGame)
          this._store.dispatch(GameActions.invalidateGame());
      });
  }
  completeGame(time: number, text: string) {
    const action = GameActions.completeGame(time, text);
    this._store.dispatch(action);
    this.performAsyncAction(action)
      .subscribe(() => console.log('Done!'));
  }
}

这个类将微服务形式的 ngrx store抽象实例依赖进来并存储在_Store中。

model可以通过分发actions来改变Store.我们可以将actionis当做命令或是对我们应用有意义的指令。他们包含一个 action 类型和一个payload,payload中存储相应的数据并提供给reducers来更改Store.

GameModel可以通过触发startGameaction来开始游戏,如下所示:

`this._store.dispatch(GameActions.startGame());`

触发Store对应的action会调用所有相关的reducers来更新store,接收action传过来的新的参数并创建一个新的store.最后store的变化会反馈到视图上。