MinweiShen

Horizon: 一个可扩展的完美适合JavaScript移动应用的后端

MinweiShen · 2016-07-22翻译 · 431阅读 原文链接

Horizon是一个适合跨平台的JavaScript移动应用的可扩展的后端,尤其适合那些需要实时功能的应用。它是由RethinkDB的优秀程序员们实现的,因此它利用RethinkDB作为默认数据库。如果你对 RethinkDB不熟悉,告诉你,它是一个有实时性能的开源数据库。

Horizon提供了一套客户端API让你和底层的数据库交互。这意味着你不需要写任何后端的代码。你需要做的仅仅是配置一个服务器,让它工作起来。剩下来的都会由Horizon处理。数据在服务器以及相连的客户端里会实时同步。

如果你想了解更多关于Horizon的东西,你可以看看他们的FAQ页面

在这个教程里,你会用Ionic和Horizon实现一个井字棋游戏。我假设你不是个Ionic和Cordova新手,所以我不会深入解释Ionic相关的代码。如果你想先了解一些背景,我建议你去看看 Ionic网站上的开始向导。如果你想跟着做,你可以在Github上克隆这个应用。它最终完成时是这样的:

tic-tac-toe app

安装Horizon

因为RethinkDB是Horizon的数据库,所以在安装Horizon之前,你需要安装RethinkDB。你可以在这里了解如何安装RethinkDB。

RethinkDB安装好之后,你可以通过npm安装Horizon。在终端里执行下面的代码:

npm install -g horizon

Horizon服务器

Horizon服务器会作为这个应用的后端。只要应用执行代码,它就会跟数据库交流。

你可以在终端里执行下面的代码来建立一个新的Horizon服务器:

hz init tictactoe-server

这会创建一个RethinkDB数据库以及Horizon需要的服务器文件。

服务器建立后,你可以通过执行这个来启动它:

hz serve --dev

在上面的命令里,你指定了--dev参数。这意味着你想启动一个开发环境下的服务器。下面的参数会默认随着它设置:

  • --secure no:这意味着网络套接字以及文件不是通过加密链接提供的。

  • --permissions no:关闭权限限制。这意味着随便哪个客户端都可以在数据库做任何想做的操作。Horizon的权限系统是基于白名单的。这意味着默认情况下,所有的Horizon用户都没有权限做任何事。你必须明确指明哪些操作是允许的。

  • --auto-create-collection yes:在第一次使用集合的时候,自动创建它。Horizon中的集合相当于关系型数据库里的表。把这个设置为yes意味着每次客户端使用一个新的集合,它都会被自动创建。

  • --auto-create-index yes:在第一次使用的时候,自动创建索引。

  • --start-rethinkdb yes:在当前目录下自动启动一个新的RethinkDB实例。

  • --allow-unauthenticated yes:允许未认证的用户进行数据库操作。

  • --allow-anonymous yes:允许匿名用户进行数据库操作。

  • --serve-static ./dist:启动静态文件分发。如果你想在浏览器里测试跟Horizon的API交互,这会很有用。Horizon服务器默认在端口8181启动,你可以通过http://localhost:8181访问它。

注意:永远不要把--dev选项用在生产环境中,因为它会打开很多攻击者可以利用的漏洞。

应用实现

现在你准备好实现这个应用了。首先建立一个新的Ionic应用:

ionic start tictactoe blank

安装Chance.js

接着你需要安装chance.js, 一个生成随机数据的JavaScript库。在这个应用里,你会用它给玩家生成第一无二的ID。你能用bower安装chance.js,只要执行下面的命令:

bower install chance

index.html

打开www/index.html文件并添加下面的代码:

<!DOCTYPE html>
<html>
  <head>
    <meta charset="utf-8">
    <meta name="viewport" content="initial-scale=1, maximum-scale=1, user-scalable=no, width=device-width">
    <title></title>

    <link href="lib/ionic/css/ionic.css" rel="stylesheet">
    <link href="css/style.css" rel="stylesheet">

    <!-- IF using Sass (run gulp sass first), then uncomment below and remove the CSS includes above
    <link href="css/ionic.app.css" rel="stylesheet">
    -->
    <!-- chance.js -->
    <script src="lib/chance/dist/chance.min.js"></script>
    <!-- ionic/angularjs js -->
    <script src="lib/ionic/js/ionic.bundle.js"></script>

    <!-- cordova script (this will be a 404 during development) -->
    <script src="cordova.js"></script>

    <!-- horizon script -->
    <script src="http://127.0.0.1:8181/horizon/horizon.js"></script>

    <!-- your app's js -->
    <script src="js/app.js"></script>

    <!--main app logic -->
    <script src="js/controllers/HomeController.js"></script>
  </head>
  <body ng-app="starter">
    <ion-nav-view></ion-nav-view>
  </body>
</html>

上面代码绝大部分都只是从Ionic一开始生成的模板引用的,我们只是在里面添加了chance.js:

<script src="lib/chance/dist/chance.min.js"></script>

Horizon脚本则是由Horizon服务器提供的。

注意:如果你之后要部署这个,要记得改URL。

<script src="http://127.0.0.1:8181/horizon/horizon.js"></script>

应用的主要逻辑放在这个JavaScript文件里:

<script src="js/controllers/HomeController.js"></script>

app.js

app.js存放了应用初始化的代码。 打开www/js/app.js并且把下面的代码添加在run函数下面:

.config(function($stateProvider, $urlRouterProvider) {
  $stateProvider

  .state('home', {
    cache: false,
    url: '/home',
    templateUrl: 'templates/home.html'
  });
  // if none of the above states are matched, use this as the fallback
  $urlRouterProvider.otherwise('/home');
});

这给默认的应用页面启动了一个路由。路由设置了页面的模板以及访问它的URL。

HomeController.Js

www/js/controllers目录下创建HomeController.js并添加下面的代码:

(function(){
  angular.module('starter')
  .controller('HomeController', ['$scope', HomeController]);

  function HomeController($scope){

    var me = this;
    $scope.has_joined = false;
    $scope.ready = false;

    const horizon = Horizon({host: 'localhost:8181'});
    horizon.onReady(function(){
      $scope.$apply(function(){
        $scope.ready = true;
      });
    });

    horizon.connect();

    $scope.join = function(username, room){

      me.room = horizon('tictactoe');

      var id = chance.integer({min: 10000, max: 999999});
      me.id = id;

      $scope.player = username;
      $scope.player_score = 0;

      me.room.findAll({room: room, type: 'user'}).fetch().subscribe(function(row){
        var user_count = row.length;

        if(user_count == 2){
          alert('Sorry, room is already full.');
        }else{
          me.piece = 'X';
          if(user_count == 1){
            me.piece = 'O';
          }

          me.room.store({
            id: id,
            room: room,
            type: 'user',
            name: username,
            piece: me.piece
          });

          $scope.has_joined = true;

          me.room.findAll({room: room, type: 'user'}).watch().subscribe(
            function(users){

              users.forEach(function(user){

                if(user.id != me.id){

                  $scope.$apply(function(){
                    $scope.opponent = user.name;
                    $scope.opponent_piece = user.piece;
                    $scope.opponent_score = 0;
                  });

                }

              });

            },
            function(err){
              console.log(err);
            }
          );

          me.room.findAll({room: room, type: 'move'}).watch().subscribe(
            function(moves){
              moves.forEach(function(item){

                var block = document.getElementById(item.block);
                block.innerHTML = item.piece;
                block.className = "col done";

              });

              me.updateScores();

            },
            function(err){
              console.log(err);
            }
          );
        }

      });

    }

    $scope.placePiece = function(id){

      var block = document.getElementById(id);

      if(!angular.element(block).hasClass('done')){
        me.room.store({
          type: 'move',
          room: me.room_name,
          block: id,
          piece: me.piece
        });
      }

    };

    me.updateScores = function(){

      const possible_combinations = [
        [1, 4, 7],
        [2, 5, 8],
        [3, 2, 1],
        [4, 5, 6],
        [3, 6, 9],
        [7, 8, 9],
        [1, 5, 9],
        [3, 5, 7]
      ];

      var scores = {'X': 0, 'O': 0};
      possible_combinations.forEach(function(row, row_index){
        var pieces = {'X' : 0, 'O': 0};
        row.forEach(function(id, item_index){
          var block = document.getElementById(id);
          if(angular.element(block).hasClass('done')){
            var piece = block.innerHTML;
            pieces[piece] += 1;
          }
        });

        if(pieces['X'] == 3){
          scores['X'] += 1;
        }else if(pieces['O'] == 3){
          scores['O'] += 1;
        }
      });

      $scope.$apply(function(){
        $scope.player_score = scores[me.piece];
        $scope.opponent_score = scores[$scope.opponent_piece];
      });
    }

  }

})();

把上面的代码分解一下,首先设置默认状态。has_joined表示用户是否已经加入房间,ready表示用户是否已经连接到Horizon服务器。当readyfalse时,你不必向用户显示应用界面。

$scope.has_joined = false;
$scope.ready = false;

连接Horizon服务器:

const horizon = Horizon({host: 'localhost:8181'});
horizon.onReady(function(){
  $scope.$apply(function(){
    $scope.ready = true;
  });
});

horizon.connect(); //connect to the server

正如我之前所说,Horizon默认在端口8181启动,这是为什么你设置端口为localhost:8181。如果你连接的是一个远程服务器,这里应该用那个服务器的IP或域名。当用户连接到服务器时,onReady函数被激发。这里你设置readytrue,以便你向用户显示应用界面。

horizon.onReady(function(){
  $scope.$apply(function(){
    $scope.ready = true;
  });
});

加入房间

接着,当用户点击Join按钮时,执行join函数:

$scope.join = function(username, room){
    ...
};

在这个函数内,连接一个叫tictactoe的集合。

注意:因为你在开发模式下,如果这个集合不存在,它会被自动创建。

me.room = horizon('tictactoe');

生成一个ID,并给当前用户设置:

var id = chance.integer({min: 10000, max: 999999});
me.id = id;

设置用户名以及默认分数。

注意:这些变量跟模板是绑定的,所以你可以在任何时候显示或更新他们。

$scope.player = username;
$scope.player_score = 0;

在这个表里,查询room是当前的房间并且类型是user的文档。注意不要被subscribe搞混了,你没有监听任何改动。你用了fetch函数,这意味着只有在用户加入房间后,它才会执行。

me.room.findAll({room: room, type: 'user'}).fetch().subscribe(function(row){
    ...
});

一旦得到结果,检查用户数。显然井字棋只能由2个人玩,如果该房间里已经有2个人了,发出警告。

var user_count = row.length;

if(user_count == 2){
  alert('Sorry, room is already full.');
}else{
    ...
}

不然的话,接着执行代码接受这个用户,通过当前的玩家数决定他用的棋。第一个加入的用"X", 第二个用"O"。

me.piece = 'X';
if(user_count == 1){
    me.piece = 'O';
}

决定完棋子后,把这个新用户加入集合,并且把has_joined 变成true,显示井字棋的棋盘。

me.room.store({
    id: id,
    room: room,
    type: 'user',
    name: username,
    piece: me.piece
});

$scope.has_joined = true;

接着,监听集合中的改动。这次,用watch而不是fetch。当一个满足查询语句的新的文档被加入集合,或者现有文档被更新(或删除)时,执行回调函数。回调函数执行时,遍历所有的结果,如果文档的用户跟当前用户不匹配,就可以设置对手的信息。如此一来,你可以给当前用户显示他们的对手是谁。

me.room.findAll({room: room, type: 'user'}).watch().subscribe(
  function(users){

    users.forEach(function(user){

      if(user.id != me.id){

        $scope.$apply(function(){
          $scope.opponent = user.name;
          $scope.opponent_piece = user.piece;
          $scope.opponent_score = 0;
        });

      }

    });

  },
  function(err){
    console.log(err);
  }
);

接着,每次有玩家在棋盘上放子后,监听move类型文档的改动。如果有,遍历所有的move,给相应的格子加上文字。从现在开始,我会用格子来指代棋盘上每一小格

加上的文字是由每个用户拿的棋子决定的,同时,给每个格子加上类名col donecol是给Ionic实现的网格使用的,done则用来表示这个格子已经被棋子占了。你用它来检查用户是否可以在上面放棋子。在更新棋盘的UI后,调用updateScores函数更新比分(你会在后面添加这个函数)。

me.room.findAll({room: room, type: 'move'}).watch().subscribe(
  function(moves){
    moves.forEach(function(item){

      var block = document.getElementById(item.block);
      block.innerHTML = item.piece;
      block.className = "col done";

    });

    me.updateScores();

  },
  function(err){
    console.log(err);
  }
);

放棋子

每次当用户点击棋盘上的格子时,调用placePiece函数,并用格子的ID作函数参数,这使你可以自由操纵它。在这个例子里,你用它来检查对应的格子是否有done类。如果没有,新建一个move,用来表示这个房间、格子的ID以及放下的棋子。

$scope.placePiece = function(id){

  var block = document.getElementById(id);

  if(!angular.element(block).hasClass('done')){
    me.room.store({
      type: 'move',
      room: me.room_name,
      block: id,
      piece: me.piece
    });
  }

};

####分数更新

为了方便更新比分,构造一个包含了所有可能的获胜组合的数组。

const possible_combinations = [
  [1, 4, 7],
  [2, 5, 8],
  [3, 2, 1],
  [4, 5, 6],
  [3, 6, 9],
  [7, 8, 9],
  [1, 5, 9],
  [3, 5, 7]
];

[1, 4, 7]是第一列,[1, 2, 3]是第一行,以此类推。只要对应的数字在,其实顺序并不重要。为了更好地帮助你理解,这里有张图:

tic-tac-toe combinations

你也可以用对角线([1, 5, 9][3, 5, 7]) ,不过我用的编辑器不让我选它们,不好意思。

接着,分别给棋子初始化分数,并且遍历所有可能的组合。每次迭代中,给每种棋子初始化已经在棋盘上放下的棋子数,接着遍历可能的组合。通过id,检查这个格子上是否已经有棋子。如果有,得到这个棋子并给对应的棋子加分。遍历完之后,分别检查每种棋子的数目是否是3。如果是,给对应的棋子加分。我们一直这么做,直到遍历完所有的可能组合。完成之后,更新双方的分数。

var scores = {'X': 0, 'O': 0};
possible_combinations.forEach(function(row, row_index){
  var pieces = {'X' : 0, 'O': 0};
  row.forEach(function(id, item_index){
    var block = document.getElementById(id);
    if(angular.element(block).hasClass('done')){ //check if there's already a piece
      var piece = block.innerHTML;
      pieces[piece] += 1;
    }
  });

  if(pieces['X'] == 3){
    scores['X'] += 1;
  }else if(pieces['O'] == 3){
    scores['O'] += 1;
  }
});

//update current player and opponent score
$scope.$apply(function(){
  $scope.player_score = scores[me.piece];
  $scope.opponent_score = scores[$scope.opponent_piece];
});

主模板

www/templates文件夹下新建home.html文件并添加下面的代码:

<ion-view title="Home" ng-controller="HomeController as home_ctrl" ng-init="connect()">
  <header class="bar bar-header bar-stable">
    <h1 class="title">Ionic Horizon Tic Tac Toe</h1>
  </header>

  <ion-content class="has-header" ng-show="home_ctrl.ready">
    <div id="join" class="padding" ng-hide="home_ctrl.has_joined">
      <div class="list">
        <label class="item item-input">
          <input type="text" ng-model="home_ctrl.room" placeholder="Room Name">
        </label>
        <label class="item item-input">
          <input type="text" ng-model="home_ctrl.username" placeholder="User Name">
        </label>
      </div>

      <button class="button button-positive button-block" ng-click="join(home_ctrl.username, home_ctrl.room)">
        join
      </button>
    </div>

    <div id="game" ng-show="home_ctrl.has_joined">
      <div id="board">
        <div class="row">
          <div class="col" ng-click="placePiece(1)" id="1"></div>
          <div class="col" ng-click="placePiece(2)" id="2"></div>
          <div class="col" ng-click="placePiece(3)" id="3"></div>
        </div>
        <div class="row">
          <div class="col" ng-click="placePiece(4)" id="4"></div>
          <div class="col" ng-click="placePiece(5)" id="5"></div>
          <div class="col" ng-click="placePiece(6)" id="6"></div>
        </div>
        <div class="row">
          <div class="col" ng-click="placePiece(7)" id="7"></div>
          <div class="col" ng-click="placePiece(8)" id="8"></div>
          <div class="col" ng-click="placePiece(9)" id="9"></div>
        </div>
      </div>
      <div id="scores">
        <div class="row">
          <div class="col col-50 player">
            <div class="player-name" ng-bind="player"></div>
            <div class="player-score" ng-bind="player_score"></div>
          </div>
          <div class="col col-50 player">
            <div class="player-name" ng-bind="opponent"></div>
            <div class="player-score" ng-bind="opponent_score"></div>
          </div>
        </div>
      </div>
    </div>
  </ion-content>
</ion-view>

分解下上面的代码,你有一个主要的包裹类,只有在用户连接到Horizon服务器后,你才显示它。

<ion-content class="has-header" ng-show="home_ctrl.ready">
    ...
</ion-content>

用于加入房间的表单:

<div id="join" class="padding" ng-hide="home_ctrl.has_joined">
  <div class="list">
    <label class="item item-input">
      <input type="text" ng-model="home_ctrl.room" placeholder="Room Name">
    </label>
    <label class="item item-input">
      <input type="text" ng-model="home_ctrl.username" placeholder="User Name">
    </label>
  </div>

  <button class="button button-positive button-block" ng-click="join(home_ctrl.username, home_ctrl.room)">
    join
  </button>
</div>

井字棋棋盘:

<div id="board">
  <div class="row">
    <div class="col" ng-click="placePiece(1)" id="1"></div>
    <div class="col" ng-click="placePiece(2)" id="2"></div>
    <div class="col" ng-click="placePiece(3)" id="3"></div>
  </div>
  <div class="row">
    <div class="col" ng-click="placePiece(4)" id="4"></div>
    <div class="col" ng-click="placePiece(5)" id="5"></div>
    <div class="col" ng-click="placePiece(6)" id="6"></div>
  </div>
  <div class="row">
    <div class="col" ng-click="placePiece(7)" id="7"></div>
    <div class="col" ng-click="placePiece(8)" id="8"></div>
    <div class="col" ng-click="placePiece(9)" id="9"></div>
  </div>
</div>

玩家分数:

<div id="scores">
  <div class="row">
    <div class="col col-50 player">
      <div class="player-name" ng-bind="player"></div>
      <div class="player-score" ng-bind="player_score"></div>
    </div>
    <div class="col col-50 player">
      <div class="player-name" ng-bind="opponent"></div>
      <div class="player-score" ng-bind="opponent_score"></div>
    </div>
  </div>
</div>

样式

这是这个应用的样式:

#board .col {
  text-align: center;
  height: 100px;
  line-height: 100px;
  font-size: 30px;
  padding: 0;
}

#board .col:nth-child(2) {
  border-right: 1px solid;
  border-left: 1px solid;
}

#board .row:nth-child(2) .col {
  border-top: 1px solid;
  border-bottom: 1px solid;
}

.player {
  font-weight: bold;
  text-align: center;
}

.player-name {
  font-size: 18px;
}

.player-score {
  margin-top: 15px;
  font-size: 30px;
}

#scores {
  margin-top: 30px;
}

运行这个应用

在这个应用的根目录里,执行下面的命令以便在浏览器测试你的应用:

ionic serve

这会在本地执行你的项目,并且在默认浏览器里打开一个新标签。

如果你想找个朋友测试,你可以用Ngrok把你的Horizon服务器暴露到互联网上:

ngrok http 8181

这会生成一个URL,你可以用它来作为连接到Horizon服务器时的 host的值。

const horizon = Horizon({host: 'xxxx.ngrok.io'});

同时修改index.html文件里horizon.js的引用:

<script src="http://xxxx.ngrok.io/horizon/horizon.js"></script>

为了生成一个移动版本的,在你的项目里添加平台名(例如Android)。这假设你已经在你的机器上安装了Android SDK。

ionic platform add android

接着生成 .apk文件:

ionic build android

然后你就可以把生成的.apk文件发送给你的朋友,一起享受这个游戏。或者你也可以自己一个人玩。

接着做什么

在这个教程里,你实现了一个简单的应用。其实,还有很多东西可以改进。下面是一些你可能想要尝试的东西。把它们当做深化你所学技能的作业吧。

  • 做一个4×4或5×5的版本:你做的3x3的版本几乎总是导致平局,特别是当2个玩家都是井字棋专家高手的时候。

  • 算分的逻辑:仅仅是为了得到每个玩家的分数,你就需要做很多循环。也许你可以想一个更好的方法来改进它。

  • 改进样式:目前的样式很普通,模仿了小时候在纸上玩的井字棋。

  • 加入动画:你可能想在用户加入房间时给棋盘添加一个下滑的动画,或者玩家放棋子时,添加一个跳动的动画。你可以用 animate.css实现这些动画。

  • 添加社交账户登录功能:对这样一个简单的应用来说,这可能太过了。不过如果你想学Horizon中的用户认证是如何工作的,这可能是个很好的练习。有了Horizon的用户认证,你就可以让用户通过他们的Facebook,,Twitter或Github登录。

  • 添加再玩一次功能:在游戏结束后,显示一个“再玩一次”按钮。一旦点击,棋盘和分数都会清空,以便玩家可以再玩一次。

  • 添加一个实时的排行榜:添加一个排行榜,显示谁赢了最多的比赛。另一个排行榜,显示哪个房间重启比赛的次数最多(如果你已经实现了再玩一次的功能)。

如果你有任何问题、评论或改进这个应用的主意,请在下面的评论里告诉我

相关文章