用Node.js实现函数

我之前总是希望我的电脑使用起来可以像手机一样简单,现在它终于成真了,因为我已经不知道该怎么用我的手机了。
                                                        ——比雅尼·斯特劳斯特鲁普,C++之父

说明

通常JavaScript在Web开发中用于搜索、抓取脚本、使web页完成特定的功能,但开发人员并不把它当做一个语言看待。这一章我们将彻底消除这个看法。我们将构建一个股票交易引擎,它将采用类似于NASDAQ或者纽约股票交易的方式运转,能够完美的接受订单处理交易。以后我们将这个虚拟的基于Node.js构建的股票交换引擎简称为交易所。你可能会抱怨关于金融这方面的业务知识了解不多,很难实完成这样一个功能。不用担心,我们先把问题拆分开慢慢说明。

首先从一个最基本的场景开始,一个空的交易所,假设你想用40美金/手买一些Facebook的股份,你提交了一个订单想要买100手。不过目前没有人在出售Facebook的股票,所以你的订单将在等待队列里等待合适的卖家。这是情况如 图-2.1。

图-2.1 准备以40美元/手购买Facebook股票

图-2.1 准备以40美元/手购买Facebook股票

假设这是另外一个人准备以41美元/手卖出200手该股票,但是这个价格不是你想要的,所以这个订单也再队列里等待合适的买家。这时情况如 图-2.2。

图-2.2 41美元/手200手的股票不符合要求

图-2.2 41美元/手200手的股票不符合要求

过了很久以后,第三个人准备以40美元/手卖出75手该股票,这时这笔交易是符合双方意愿的,所以发生了。交易完成后你的欲购清单将跟新为以40美元/手购买25手Facebook的股票,而第二个人的交易请求没有变化,仍旧是以41美元/手卖出200手Facebook的股票。这是情况如 图-2.3

图-2.3 40美元/手交易75手之后

图-2.3 40美元/手交易75手之后
我相信这是一个十分容易理解的例子,这也是我们这个项目所需要的所有有关金融业务相关的内容。

测试优先

接下来要做的就是将上述业务通过代码来实现,我们将以TDD(测试驱动开发)的方式进行开发。这意味着我们需要先写单元测试(注意这个单元测试一开始的运行结果会是错误),然后我们完善代码的实现使整个单元测试的运行结果没有错误。

让我们先从这个最简单的场景开始去实习一个股票交易的API,首先我们想买一些股票,我们需要提供交易的价钱和交易的数量作为参数。

如果我们准吗以40美元/手的价钱购买100手股票,那么这个API应该是这样的。

exchange.buy(40, 100);

这段代码看起来似乎很简单,但实际上这跟最终代码已经很接近了。唯一缺少的就是交易状态,换句话说,当要处理一个订单的时候我们怎么去判断队列中其他的订单是否已经处理了。对于这个问题有两种主流的解法,一中是面向对象的思路,在exchange这个对象内部定义一个state属性,已经交易和没有交易的exchange对象的state属性是不同的。

在函数式编程中,函数通常是不会产生副作用的,这意味着通过一种不可改变的方式传递输入数据并获取输出数据。exchange对象在交易前后是不会产生变化的。两种编程风格都是优秀的典范,同时JavaScript对于两种方式也都是支持的,对于这本书,我们将采用函数式风格进行编程。

函数式风格要求我们必须传入另外一个参数来记录订单的状态信息。我们将之前的代码做如下修改。

exchange.buy(40, 100, exchangeData);

exchangeData对象用来存储订单中所有的状态信息,这个函数会处理订单并且返回一个新对象来记录更新后的订单状态。

贯穿整个项目我们都会使用Mocha作为测试框架,使用Mocha可以很方便的进行异步的单元测试,同时它也支持测试驱动这种模式。它可以以多种格式输出测试结果,方便与其他测试工具整合。

新建一个文件夹用来存放这个章节的项目,同时在这个目录下创建一个test文件夹,在test目录下添加exchange.test.js文件,并拷贝如下代码:

/*     chapter02/test/exchange.test.js (excerpt)     */

test('buy should add a BUY nockmarket order', function(done) {
});

每一个测试用例前都会像这样加一段描述,用来说明这个用例的作用。因为有些人可能并不熟悉这段代码,不清楚整个测试的目的,如果有了用例描述,就可以不用去查看大段的测试代码,通过错误描述就清楚是因为什么导致测试失败了。

/*     chapter02/test/exchange.test.js (excerpt)     */

test('buy should add a BUY nockmarket order', function(done) {
  exchangeData = exchange.buy(40, 100, exchangeData);①
  exchangeData.buys.volumes[40].should.eql(100);②
  done();③
});

① 首先,我们通过调用buy方法提交了一个订单。

② 然后我们检查这个订单,这应该是一个以40美元/手购入100手的订单。

should.eql(100) 看起来并不太容易理解, 这段代码对于要匹配的结果是比较明显且容易理解的,但是要匹配的内容并没有显示的体现出来。你可以访问should.js这个网站来了解使用细节。我也会从中选择一些代码帮助你理解它的语法,然后明白它是如何使用的。

[].should.be.empty
[1,2,3].should.eql([1,2,3])
user.age.should.be.within(5, 50)
'test'.should.be.a('string')
user.age.should.not.be.above(100)

③ 最后,done方法是一个回调函数,通知Mocha已经可以运行下一个测试用例了。Mocha会按照顺序执行每一个测试用例,这意味着在运行下一个用例之前,每一个测试用例都会完整的执行。到目前为止我们都还没有提到实现细节,我们只写了测试用例。TDD的优势在于它会强制你像软件架构师一样思考更多更细节的问题。当你完成了测试用例,你会想到客户端需要调用什么API,然后你要去实现它。

售出和购入的情况是相似的:

/*     chapter02/test/exchange.test.js (excerpt)     */

test('sell should add a SELL nockmarket order', function(done) {
  exchangeData = exchange.sell(41, 200, exchangeData);        ①
  exchangeData.sells.volumes['41'].should.eql(200);            ②
  done();
});

① 首先,我们通过调用sell方法提交了一个订单。

② 然后我们检查这个订单,这应该是一个以41美元/手售出200手的订单。

交易逻辑代码细节如下:

/*     chapter02/test/exchange.test.js (excerpt) */

test('sell should produce trades', function(done) {
  exchangeData = exchange.sell(40, 75, exchangeData);    ①
  exchangeData.trades[0].price.should.eql(40);            ②
  exchangeData.trades[0].volume.should.eql(75);            ③
  exchangeData.buys.volumes[40].should.eql(25);            ④
  exchangeData.sells.volumes[41].should.eql(200);        ⑤
  done();
});

① 和之前的例子一样,首先提交一个以40美元/手出售75手的订单。

② 如 图-2.3 产生了一笔40美元/手,70手的交易。把这次交易存在一个数组中,那个这个数组第一个元素的交易价应该为40美元/手。

③ 同理数组第一个元素的交易数量应该为75手。

④ 当这次购入交易结束后,在购入需求队列中,应该还有25手40美元/手的股票需要购入。

⑤ 同理在售出需求队列中,应该有100手41美元/手的股票需要出售。

下面导入需要的模块,并创建一个空的exchangeData。

/*    chapter02/test/exchange.test.js (excerpt)    */

'use strict';
var assert = require('assert')
  , should = require('should');
var exchangeData = {};
suite('exchange', function() {
 ... // place existing test cases here
});

把之前的测试用例添加到suite函数中,准备运行。

严格模式

JavaScript是一门相对宽松的语言,有一些人感觉它过于宽松,因为它允许一些并非良好实践的编程技术。在strict模式下这种编程技术会产生错误,从而限制其使用。比如说对一个未声明的变量赋值(在严格模式下是禁止这样操作的)。jQuery的编写者John Resig曾经做过一些[总结](http://ejohn.org/blog/ecmascript-5-strict-mode-json-and-more/)。这里我建议你使用严格模式进行编码。

按如下内容修改package.json文件。

/*     chapter02/package.json (excerpt)    */

{
    "name": "nockmarket"
  , "version": "0.0.1"
  , "private": true
  , "dependencies": {
          "jquery" : "1.7.3"
        , "mocha": "1.3.0"
        , "should": "1.0.0"
    } 
}

Mocha使用make来运行测试用例,如果你对make不熟悉也没有关系,具体细节并不重要,你只需要创建一个Makefile文件,并粘贴如下代码:

/*     chapter02/Makefile    */

test:
    @./node_modules/.bin/mocha -u tdd
.PHONY: test

注意第二行必须使用制表符(tab键)作为起始字符,空格是不可以的。它会执行mocha的二进制文件,其中tdd表示测试驱动模式。

在命令行中执行npm install命令和make test命令(注意MacOS X需要安装Xcode Command Line Tools才能使用make命令,同样Windows下需要安装Cygwin),你将会看到如下输出:

3 of 3 tests failed:
1) exchange buy should add a BUY nockmarket order:
   ReferenceError: exchange is not defined

正常情况下所有测试用例都会报错,你需要继续往下做。

在没有编写任何逻辑功能的情况下,所有测试用例都报错是再正常不过的。经验告诉我们,在完成开发后才出现bug,然后再试图去修正反而是更加困难的。在这种情况下很有可能只有一行代码存在问题,但是你需要查看上千行才能发现这个问题。测试驱动开发就能有效的避免这种繁琐的工作,所以今早的测试并且 频繁的测试是一个良好的编程习惯。

交易逻辑

现在我们已经写好测试用例,我们现在需要关注需求如何实现了。我们先从exchangeData开始,首先它的结构是什么样的?凭直觉,订单应该分为买和卖两种,所以应该从这两种情况开始分析。买和卖都有一些列的订单,每一个订单都包含价钱和数量。这是一个比较合理的结构,我们通过JSON来组织exchangeData,结果如下:

exchangeData: {
  buys: {prices: ..., volumes: ...}
  sells: {prices: ..., volumes: ...}
}
JavaScript Object Notation

JSON是[JavaScript对象符号](https://developer.mozilla.org/en-US/docs/JSON),它是由Douglas Crockford提出的。它起源于JavaScript的语法,它作为一种数据交互的格式广泛应用,它已经成为Web[通用的数据交互语言](https://dev.twitter.com/docs/twitter-ids-json-and-snowflake),同时Facebook、Twitter这种业界的巨头也再使用它。

现在我们已经清楚exchangeData的组成结构,现在我们需要考虑具体使用那种结构存储数据。我们可以通过JSON来存储交易数量,JSON和其他语言中的哈希表(hash table)是类似的。同时它的查找用时也是稳定的,换句话说无论存储多少数据它在查找某一数据所用时间都是近乎一致的。关于价钱的存储,我想采用 Marijn Haverbeke’s实现的二叉堆,他在电子书上已经充分解释了具体代码实现。这样我就可以直接使用他的代码不必重新实现一个二叉堆了。

哈希表对象

Guillermo Rauch曾经写过一篇[文章](http://www.devthought.com/2012/01/18/an-object-is-not-a-hash/)专门说明哈希对象使用上的一些陷阱,其主要陷阱在于用户可能随意使用key值,但是这点不会发生在我们的项目里,因为我们对key值的输入是严格控制的。

我们需要做两个小的修改,我们允许用户传入一个标志位来说明二叉堆的排序方式(最大二叉堆还是最小二叉堆),我们把源代码 this.content.push(element); 修改为:

if (this.options.max) {
  this.content.push(-element);
} else
  this.content.push(element);

如果用户想使用最大二叉堆,他只需要将插入(堆)的数字取反,原始的二叉堆会找出最小的数字(这里越大的数字取负数值越小,而Marijn实现的二叉堆是最小二叉堆,所以最小的负数先被取出来,对其取绝对值就是最大的数字,作者在这里通过一种变化替代实现了最大二叉堆)

我们只需要记住将取出来的数字取反,从而恢复为正数,然后再返回(这就是最大的数字了)。

if (this.options.max)
  return -result;
else
  return result;

我们之所以需要最大二叉堆和最小二叉堆是因为,交易通常都希望以最低的价格买入,以最高的价格卖出。我们接下来要修改的是,添加一个peek方法,它和pop方法类似,但是不会删除数据,只是更新数据。

peek: function() {
  if (this.options.max)
    return -this.content[0];
  else
    return this.content[0];
}

peek方法其实很简单,这个数据结构本身就是一个数组,我们要做的只是返回数组的第一个元素,如果我们处理的这个数组是一个最大二叉堆,我们只需对里面的元素取反就可以。

对于这个数据结构额外增加的代码我不准备再额外说明了。你也发现,JavaScript已经和你在Algorithms 101(学习网站)学习的一些其他的编程语言越来越接近了,它已不再是一个用于操作浏览器的简单语言了。

你可以从这里下载到全部代码,然后将它拷贝到chapter02/lib/BinaryHeap.js文件里。

引擎核心

JavaScript是一门从上到下执行的语言,我们也以这种方式分析交易引擎的代码。在不特殊说明的情况下,你肯定也会按照同样的顺序输入代码。交易引擎的第一部分内容存储在lib目录下的exchange.js文件中,如下:

/*     chapter02/lib/exchange.js (excerpt)    */

'use strict';
var $ = require('jquery')                        ①
  , BinaryHeap = require('./BinaryHeap');        ②
var BUY = "buys", SELL = "sells";                ③
function createBinaryHeap(orderType) {            ④
  return new BinaryHeap(function (x) {
    return x;
  }, orderType);
}

① 首先导入jQuery库,jQuery库主要应用于客户端,这里我们主要使用它的clone方法,因为它本身考虑了更多的细节,不需要我们额外再实现。 $ 是jQuery库的常用符号,通常为了方便书写。

② 接下来我们导入二叉堆模块。

③ 定义一些常量。

④ 最后创建一个方法用来返回一个二叉堆对象。

服务端jQuery
Node.js的一个核心口号就是一门语言完成所有工作(one language to rule them all)。实际上我们已经实现了前后端的无缝集成,从现在的标准(2012年)来看,一些优秀的实现在出现,但是在前端使用后端代码或者在后端使用前段代码仍然存在一些摩擦。

在后端使用jQuery并不是一个技术标准,甚至有些人会感觉这很不正规。我在这里使用只是想说明你应该多作一些尝试,来发现那种技术组合最适合你的实际项目,而不是墨守成规。

下面是获取存在的exchangeData,拷贝它:

/*     chapter02/lib/exchange.js (excerpt)    */

function createExchange(exchangeData) {
  var cloned = $.extend(true, {}, exchangeData);
  cloned.trades = [];
  init(cloned, BUY);
  init(cloned, SELL);
  return cloned;
  function init(exchange, orderType) {
    if (!exchange[orderType]) {
      exchange[orderType] = {};
      exchange[orderType].volumes = {};
      var options = {};
      if (BUY == orderType) options.max = true;
      exchange[orderType].prices = createBinaryHeap(options);
    }
  }
}

之所以如此大费周章的克隆数据,是因为在纯粹的函数式编程中,函数应该是没有副作用的。这意味着你把一个参数传递给函数后,在函数执行后,它是不应该发生改变的。

这是很有意义的,设想你现在把一些数据传入某个API,在返回的时候数据发生了压缩。这个数据是无法有效使用的因为你不知道API实现细节,也无法判断哪些数据发生了变化。

这就是为什么我们要把传入的数据克隆,如果在这之后有人想在其他上下文继续使用这个数据,他不必担心数据是否产生了意料之外的变化。这种方式不仅在单线程模式下很有必要,在多线程情况下,因为数据被多个线程使用,保持数据不变也是保证安全性的重要手段。

数据突变的危害

原生的MongoDB对Node.js的驱动,再插入一个对象的时候会插入一个_id字段(第三章会详细说明),大多数情况下这是没有任何问题的。但是在一些十分特殊的情况下,比如插入两个相同的对象时就会报错。这是因为第一个对象插入后,第二个对象会因为相同的ID发生冲突。

导致这种问题的原因是因为你对驱动内部工作原理的不理解。在Haskell这种纯粹的函数式编程语言里是不会发生这种问题的,因为你要确保变量不会改变,对于纯粹的函数式编程语言来说是极不可能出现这种现象的,所以无论什么情况下请遵守函数编程的原则。

在createExchange方法中另外一个有意思的特点就是定义了一个名为init的方法,JavaScript允许在函数内部定义函数。在init函数中做了一些初始化操作:如果收到一个空的echangeData就创建一个空的对象来存储交易股数,创建一个二叉堆来存储交易价钱。正如之前讨论的对于购买需求订单使用最大二叉堆,因为交易在最高购入价格下进行。

主要部分

现在开始编写exchange的核心代码,首先创建一些辅助方法。

/*    chapter02/lib/exchange.js (excerpt)     */

      exchange[orderType].prices = createBinaryHeap(options);
    }
}
} module.exports = {
  BUY: BUY,
  SELL: SELL,
  buy:function (price, volume, exchangeData) {
    return order(BUY, price, volume, exchangeData);
  },
  sell:function (price, volume, exchangeData) {
    return order(SELL, price, volume, exchangeData);
},
  order: order
}

module.exports是Node.js提供的语法,作用是将模块内函数暴漏给外部使其他模块可以调用。BUY和SELL是静态变量,buy和sell方法内部调用order方法来完成真正的交易。

代码复用

最简单的实现方法使把buy方法中代码拷贝到sell方法,然后修改需要调整的部分。这种做法是极不推荐的,因为它违法了代码复用原则(DRY, Don't Repeat Yourself)。代码复用是一种编程哲学,它指出任何业务片段都只能有一种表述(编程)方式。如果你发现你的程序中有拷贝部分,那么说明你已经违反了代码复用原则。

注意module.exports实质是一段JSON,通常JSON用来传输数据,在这个例子中我们可以看到它也可以用来存储函数定义。这说明了函数式编程的另一个重要原则:函数是第一等公民。在任何你可以使用变量的地方,你都可以使用函数。如果你在REPL中输入node如下代码,你会发现这是合法的。

var sayHello = function() {console.log('Hi');};
sayHello();

核心逻辑

订单处理是最复杂的一部分逻辑,向exchange.js文件中添加如下代码:

/*     chapter02/lib/exchange.js (excerpt)    */

function order(orderType, price, volume, exchangeData) {
  // Init
  var cloned = createExchange(exchangeData);            ①
  var orderBook = cloned[orderType];                    ②
  var oldVolume = orderBook.volumes[price];                ③

① 先初始化exchangeData对象,克隆这个对象。它是整个交易引擎的核心。

② 根据orderType判断交易类型是购入还是售出。

③ 获取指定价钱的所有股票数,price会自动转换为字符串。

因为orderBook是一个JSON对象,它能在恒定的时间内完成查找操作并且速度相当快。在处理订单前我们用oldVolume来存储指定价钱下的股票数量。

接下来判断交易能否发生:

/*         chapter02/lib/exchange.js (excerpt)     */

...
function getOpposite() {
  return (BUY == orderType) ? SELL: BUY;
}
function isTrade() {
  var opp = cloned[getOpposite()].prices.peek();
  return (BUY == orderType) ? price >= opp : price <= opp;
}
var trade = isTrade();

首先创建一个小的辅助方法getOpposite,如果是购买请求则返回SELL,如果是售出请求则返回BUY。

闭包

orderType的是在getOpposite定义的,那么getOpposite是怎么获取到orderType的?这种编程技术被称作闭包,虽然函数式编程没有对象的概念,闭包允许我们记录并获取状态信息,在这个例子中,即使orderType不是作为参数传递到函数中也是可以被调用的。

交易的逻辑并不复杂,当购入价格高于售出价格时交易就可以发生,比如有一个新的购入需求订单要求40美元/手,如果存在售出价格小于或者40美元/手的订单,那么交易就可以达成。同理,售出价格小于购入价格时交易也可以发生,比如有一个新的售出需求订单要求40美元/手,如果存在购入价格大于或者等于美元/手的订单,那么交易就能达成。我们通过购入还是售出需求来决定是最高价还是最低价数组来调用之前新增的peek方法。

当交易发生时,做如下处理:

/*     chapter02/lib/exchange.js (excerpt)     */

...
var remainingVolume = volume;
var storePrice = true;
if (trade) {
  var oppBook = cloned[BUY]
  if (orderType == BUY)
    oppBook = cloned[SELL]

remainingVolume用来存储需要交易的股票数。如果库存中匹配的股票数满足需求,则完成交易,进行下一场交易,否则更新需要交易的股票数继续在库存中匹配符合要求的股票。storePrice用来标识是否一次性完成交易。

购入订单要和库存中的售出订单进行匹配,反之售出订单要和库存中的购入订单进行匹配。通过oppBook来记录与当前订单匹配的股票信息。

/*   chapter02/lib/exchange.js (excerpt) */

...
while (remainingVolume > 0 && Object.keys(oppBook.volumes).length > 0) {
    var bestOppPrice = oppBook.prices.peek();
    var bestOppVol = oppBook.volumes[bestOppPrice];

循环中判断是否还有需要交易(购入/售出)的股票,库存中是否还有匹配的股票。通过Object.keys可以返回一个对象所有的可枚举属性。如果返回值为true,则获取最优股(如果是购入交易则取售出库中加个最低的股,如果是出售交易则选择购入库存中加个最高的股)。

现在交易过程中有两种可能性,我们通过真实的例子来说明:订单中有一个40美元/手购入100手的需求,第一种情况是,库存中存在一个40美元/手的售出至少100手的需求。

/*   chapter02/lib/exchange.js (excerpt)  */

...
if (bestOppVol > remainingVolume) {
  cloned.trades.push({price:bestOppPrice
    , volume:remainingVolume});                       ①
  oppBook.volumes[bestOppPrice] =
    oppBook.volumes[bestOppPrice] - remainingVolume;  ②
  remainingVolume = 0;                                ③
  storePrice = false;                                 ④
}

这种情况下,需要做如下处理:

① 交易一次性完成,将交易信息存入trade数组。

② 最优股减去需求股,更新库存中该最优股剩余的股票数。

③ 交易一次性完成,设置需求股票数为0(remainingVolume = 0)。

④ 交易一次性结束,设置storePrice = false表示本次交易结束,无需继续匹配。

第二种情况,库存中的股票数无法一次性满足交易需求,

库存中没有超过100手的40美元/手的股票,这样就还需要至少一次匹配交易(获取交易所售出需求库存中40美元/手的股票)。

/*   chapter02/lib/exchange.js (excerpt)  */

...
else{
        if(bestOppVol == remainingVolume)                   ①
          storePrice = false;                               ②
        cloned.trades.push({ price: bestOppPrice,     
         volume: oppBook.volumes[bestOppPrice] });          ③  
        remainingVolume -= oppBook.volumes[bestOppPrice];   ④
        oppBook.prices.pop();                               ⑤                 
        delete oppBook.volumes[bestOppPrice];               ⑥
      } 
  }
}

① 当需求股和最优股正好相等时。

② 交易完成,没无需额外记录临时数据。

③ 将交易记录存入trades数组。

④ 更新需求股数量(需求股-最优股)。

⑤ 从价钱二叉堆中移除该最优股价钱信息。

⑥ 同样移除最优股数目信息,到此移除交易后的最优股。

相对复杂的逻辑已经处理了,现在需要做些调整并返回数据:

/*  chapter02/lib/exchange.js (excerpt)   */

...
  if (!oldVolume && storePrice)
    cloned[orderType].prices.push(price);
  var newVolume = remainingVolume;
  // Add to existing volume
  if (oldVolume) newVolume += oldVolume;
  if (newVolume > 0)
    orderBook.volumes[price] = newVolume;
  return cloned;
}

把上面代码拆解开说明,如果我们需要存储一个新的需求(注意当一个需求在库存中无法匹配时,需要存入库存),代码如下:

if (!oldVolume && storePrice)
  cloned[orderType].prices.push(price);

如果我们需要存储新需求中股票的数量,代码如下:

var newVolume = remainingVolume;
// Add to existing volume
if (oldVolume) newVolume += oldVolume;
if (newVolume > 0)
  orderBook.volumes[price] = newVolume;

最后返回数据

 return cloned;

后续内容

后续的章节我们会构建一个漂亮的界面以便可以从浏览器里直接的看到股票价钱。但是现在我们会通过控制台以ASCII文本的方式输出数据。首先需要添加一个方法将订单转换为文本信息。

/*   chapter02/lib/nocklib.js (excerpt)  */

order: order,
  getDisplay: function(exchangeData){

    //初始化交易所库存
    var options = { max: true }
    , buyPrices = createBinaryHeap(options)
    , sellPrices = createBinaryHeap(options)
    , buys = exchangeData.buys
    , sells = exchangeData.sells;

    if(sells){
      for(var price in sells.volumes){
        sellPrices.push(price);
      }
    }
    if(buys){
      for(var price in buys.volumes){
        buyPrices.push(price);
      }
    }

    var padding = "        | ";
    var stringBook = "\n";

    while (sellPrices.size() > 0) {
      var sellPrice = sellPrices.pop()
      stringBook += padding + sellPrice + ", " + sells.volumes[sellPrice] + "\n";
    }
    while (buyPrices.size() > 0) {
      var buyPrice = buyPrices.pop();
      stringBook += buyPrice + ", " + buys.volumes[buyPrice] + "\n";
    }

    stringBook += "\n\n";
    for (var i=0; exchangeData.trades && i < exchangeData.trades.length; i++) {
      var trade = exchangeData.trades[i];
      stringBook += "TRADE " + trade.volume + " @ " + trade.price + "\n";
    }
    return stringBook;
  }

这段代码没有什么需要特别说明的,其注意目的是用来组织文本使其在浏览器中输出。

之后再lib目录下创建nocklib.js文件

/*    chapter02/nockmarket.js (excerpt)    */

'use strict';

var exchange = require('./exchange')
 ,  priceFloor = 35
 ,  priceRange = 10
 ,  volFloor = 80
 ,  volRange = 40;

 module.exports = {
  generateRandomOrder: function(exchangeData){
    var order = {};
    //随机生成一种订单类型
    if(Math.random() > 0.5)   order.type = exchange.BUY;
    else            order.type = exchange.SELL;

    var buyExists = exchangeData.buys && exchangeData.buys.prices.peek();
    var sellExists = exchangeData.sells && exchangeData.sells.prices.peek();

    var ran = Math.random();
    if(!buyExists && !sellExists) {
      order.price = Math.floor(ran * priceRange) + priceFloor;
    }
    //如果交易所存在库存信息,从库存中取出进行交易。
    else if(buyExists && sellExists){
      if(Math.random()>0.5)   order.price = exchangeData.buys.prices.peek();
      else            order.price = exchangeData.sells.prices.peek();
    }
    else if(buyExists){
      order.price = exchangeData.buys.prices.peek();
    }
    else{
      order.price = exchangeData.sells.prices.peek();
    }

    var shift = Math.floor(Math.random()*priceRange/2);
    if (Math.random() > 0.5)  order.price += shift;
    else            order.price -= shift;
    order.volume = Math.floor(Math.random() * volRange) + volFloor;
      return order;
  }
 }

首先初始化一个随机的订单,然后发送到交易所(exchange)。

关于异步编程

上述代码中用到的setTimeout,在JavaScript一步编程模型中算是稍微复杂了。它的作用是延时调用一个方法。在这个例子中只是用来延时回调submitRandomOrder方法,从而创建可以无限循环的发送订单。

如果现在执行 node nockmarket 命令,你会看到一个打印出来的不断更新的交易订单。

现在回头看我们之前写的测试文件,在exchange.test.js中添加如下一行代码:

/*    chapter02/test/exchange.test.js (excerpt)    */

, should = require('should')
, exchange = require('../lib/exchange');

它会把我们刚刚写的模块导入进来,执行 make test 命令,你会看到绿色的文本,像下面这样:

3 tests complete (5ms)

在测试过程中,绿色将会成为你最想看到的颜色。

What about the real thing?

你们中的大多数肯定在想现实中的交易的股票数据是什么样的,股票交易数据是所有数据里最有趣的,同样最有趣也将意味着它是最难处理的。

我们将要使用的免费数据在无论是经济学上还是技术上,看起来都是枯燥无味的,这些数据免费只有股票价钱,没有数量和订单信息。本章中将要实现一个从真实交易市场中抓取数据的简单例子。

本章的最后我们会写一个小的模块,用仅有20行的代码来实现一个抓取真实股票交易所价格数据的功能。在根目录下创建priceFeed.js文件,拷贝如下代码:

/*    chapter02/priceFeed.js    */

var http = require('http');
var options = {
  host: 'download.finance.yahoo.com',
  port: 80,
  path: '/d/quotes.csv?s=AAPL,FB,GOOG,MSFT&f=sl1c1d1&e=.csv'
};

http.get(options, function(res) {
  var data = '';
  res.on('data', function(chunk) {
    data += chunk.toString();
  })
  .on('error', function(err) {
    console.err('Error retrieving Yahoo stock prices');
    throw err;
  })
  .on('end', function() {
    console.log(data);
  });
});

首先引用内置的http模块,然后声明一个JSON对象option用来存储请求地址、端口号、和具体路径。最后抓取到数据时,通过如下代码将字节码转换为字符串

res.on('data', function(chunk) {
  data += chunk.toString();
})

如果出现错误,抛出错误并输出日志,end在接受到所有数据后调用,这里通过console.log输出获取到的数据。执行 node priceFeed 你会看见类似如下的输出:

 "AAPL",582.10,+4.43,"6/22/2012"

在后续的章节我们会充分利用这些数据。

总结

在这章中介绍了如下内容:

  1. 介绍了TDD(测试驱动开发)以及should.js的基本使用。
  2. 如何在服务端使用客户端流行的jQuery库。
  3. 如何通过module.exports和require模块化组织代码。
  4. 异步编程,比如setTimeout。
  5. 函数优先,函数嵌套和闭包。
  6. 如何用服务端JavaScript结合传统数据结构和算法构建一个复杂的程序。
  7. 通过JavaScript实现像股票交易这样的业务逻辑。
  8. 如何用原生的http模块从Yahoo抓取真实的股票价格数据。