文档结构  
翻译进度:已翻译     翻译赏金:0 元 (?)    ¥ 我要打赏

本文《构建微博使用Node.js,Git和Markdown》由 Mark BrownJani HartikainenJoan Yin同行评审。 感谢所有SitePoint的同行评审员,他们使SitePoint内容成为最好的!

A writer asleep on her desk, surrounded by components of her microblog

在现代编程中,微字大量出现:微框架,微服务等。对我来说,这意味着解决手头没有膨胀的问题。 同时求解一个轮廓鲜明的单一的兴趣点。 这意味着专注于手头的问题和减少不必要的依赖。

我觉得当Node涉及到网络时它遵循Goldilocks原则。 从低级库获得的API集对于构建微型网站很有用。 这些API不是太复杂,也不是太简单,而是恰恰适用于构建Web解决方案。

第 1 段(可获 1.59 积分)

在本文中,让我们探索使用Node,Git和一些其他依赖项构建一个微博。 此应用程序的目的是为提交到存储库的文件提供静态内容。 您将学习如何构建和测试应用程序,并了解交付解决方案的过程。 到最后,你将搭建一个简单可用的博客应用程序。

微博的主要组成

要建立一个好的博客,首先,你需要几个部分:

  • 用于发送HTTP消息的库
  • 存储博客帖子的存储库
  • 单元测试运行器或库
  • Markdown解析器
第 2 段(可获 1.34 积分)

要发送HTTP消息,我选择Node,因为它满足了我从服务器发送超文本消息的需要。 我特别感兴趣的两个模块是 http 和 fs

http模块将创建一个Node HTTP服务器。 fs模块将读取一个文件。 Node具有使用HTTP构建微博的库。

要存储博客文章的存储库,我选择Git而不是一个完整的数据库。 原因是,Git已经是一个具有版本控制的文本文档的存储库。 这正是我存储博客帖子数据所需要的。 摆脱了添加数据库作为依赖关系使我免于为大量的问题编写代码。

第 3 段(可获 1.46 积分)

我选择以Markdown格式存储博客文章,并使用 marked 解析它们。 如果我决定以后逐步提高原始内容的话,这给了我足够的自由。 Markdown是一个很好的,轻量级的纯HTML替代。

对于单元测试,我选择了称为 roast.it 的优秀测试运行器。 我选择它,是因为它没有依赖性,并解决了我的单元测试需求。 你可以选择另一个测试运行器,如 taper,但它有大约八个依赖项。 我喜欢roast.it的原因是它没有依赖。

有了这个组成列表,我就拥有了构建一个微博所需要的所有依赖项。

第 4 段(可获 1.36 积分)

选择依赖不是一件小事。 我认为关键的是任何外界的直接问题都可以成为依赖。 例如,我不是构建测试运行器也不是数据存储库,于是将它们添加到列表。 任何给定的依赖关系都不能吞并解决方案并挟持代码。 因此,只挑选轻量级组件是有意义的。

本文假设读者对 Nodenpm 和 Git 有些了解,并熟悉各种测试方法。 我不会介绍构建微博所涉及的每个步骤,而是专注于讨论代码的具体领域。 如果你想在本地跟随(follow),代码在GitHub上,你可以尝试每个演示的代码段。

第 5 段(可获 1.68 积分)

测试

测试让你对代码有信心并收紧反馈循环。 编程中的反馈循环是指写新代码和运行代码之间所需的时间。 在任意一种Web解决方案中,这意味着跳过许多层以获得任意反馈。 例如,浏览器,Web服务器,甚至数据库。 随着复杂性的增加,这可能意味着几分钟甚至一个小时才能获得反馈。 通过单元测试,我们删除这些层并获得快速反馈。 这使得焦点集中在手头的问题上。

我喜欢通过写一个快速单元测试开始任意一个解决方案。 这就是我为新代码编写测试时的心态。 接下来是如何使用roast.it启动并运行测试。

第 6 段(可获 1.59 积分)

在 package.json 文件中,添加:

"scripts": {
  "test": "node test/test.js"
},
"devDependencies": {
  "roast.it": "1.0.4"
}

test.js 文件是存储所有单元测试并运行它们的地方。 例如,你可以这样做:

var roast = require('roast.it');

roast.it('Is array empty', function isArrayEmpty() {
  var mock = [];

  return mock.length === 0;
});

roast.run();
roast.exit();

输入 npm install && npm test运行测试。 让我高兴的是,我不再需要千辛万苦地去测试新代码。 这就是测试的意义:一个快乐的程序员既获得了信心又可以保持专注于解决方案。

第 7 段(可获 0.78 积分)

如你所见,测试运行器期望调用 roast.it(strNameOfTest, callbackWithTest)。 为使测试通过,每次测试结束时的返回值必须解析为 true。 在现实世界的应用程序中,你可能不想将所有测试写入单个文件中。 为了解决这个问题,你可以在Node中请求单元测试,并将它们放在不同的文件中。 如果你看看在微博中的test.js文件 ,你会发现我就是这样做的。

提示:你使用 npm run test运行测试。 这可以缩写为 npm test 或甚至是 npm t

骨架

微博将使用Node响应客户端请求。 这样做的一个有效的方法是通过http.CreateServer() Node API。 这可以在app.js的以下摘录中看到:

第 8 段(可获 1.58 积分)
/* app.js */
var http = require('http');
var port = process.env.port || 1337;

var app = http.createServer(function requestListener(req, res) {
  res.writeHead(200, { 'Content-Type': 'text/plain; charset=utf-8'});
  res.end('A simple micro blog website with no frills nor nonsense.');
});

app.listen(port);

console.log('Listening on http://localhost:' + port);

通过 package.json中的npm脚本运行:

"scripts": {
  "start": "node app.js"
}

现在,http://localhost:1337/ 成为默认路由,并回复一个消息给客户端。 我们的想法是添加更多返回其他响应的路由,例如使用博客帖子内容进行响应。

第 9 段(可获 0.48 积分)

文件夹结构

为了搭建应用程序的框架,我决定由以下这些主要部分组成:

The Micro-Blog Skeleton

我将使用这些文件夹来组织代码。 以下是每个文件夹的概述:

  • blog:将原始博客帖子存储在原生Markdown中
  • message:可重用的模块来构建对客户端的响应消息
  • route:除了默认路由的路由
  • test:写单元测试的地方
  • view:放置HTML模板的地方

如前所述,请随时关注,代码在 GitHub 上。 你可以尝试显示的每个代码段。

更多路由与测试

对于第一个用例,我将进一步介绍一个博客帖子的路由。 我选择将此路由放在一个名为BlogRoute的可测试组件中。 我喜欢的是你可以注入依赖关系到这里。 将单元及其依赖之间的关注分离使得能够进行单元测试。 每个依赖在一个孤立的测试中得到模拟。 这允许你编写不可变的,可重复的和快速的测试。

第 10 段(可获 2.06 积分)

例如,构造函数看起来像这样:

/* route/blogRoute.js */
var BlogRoute = function BlogRoute(context) {
  this.req = context.req;
};

一个有效的单元测试是:

/* test/blogRouteTest.js */
roast.it('Is valid blog route', function isValidBlogRoute() {
  var req = {
    method: 'GET',
    url: 'http://localhost/blog/a-simple-test'
  };

  var route = new BlogRoute({ req: req });

  return route.isValidRoute();
});

现在,BlogRoute期望得到一个req对象,它来自Node API。 为了通过测试,这样做就行了:

/* route/blogRoute.js */
BlogRoute.prototype.isValidRoute = function isValidRoute() {
  return this.req.method === 'GET' && this.req.url.indexOf('/blog/') >= 0;
};
第 11 段(可获 0.41 积分)

有了这个,我们可以把它连接到请求管道。 你可以在 app.js 里面这样做:

/* app.js */
var message = require('./message/message');
var BlogRoute = require('./route/BlogRoute');
// Inside createServer requestListener callback...

  var blogRoute = new BlogRoute({ message: message, req: req, res: res });

  if (blogRoute.isValidRoute()) {
    blogRoute.route();
    return;
  }
// ...

做测试的好处是我不必担心实施细节。 我将很快定义消息(message)。 res和req对象来自http.createServer() Node API。

第 12 段(可获 0.61 积分)

你可以在 route/blogRoute.js 文件中随意探索博客路由的使用。

存储库

下一个要解决的问题是在 BlogRoute.route()中读取原始博客帖子数据。 Node提供了一个fs模块,你可以使用它从文件系统中读取。

例如:

/* message/readTextFile.js */
var fs = require('fs');
var path = require('path');

function readTextFile(relativePath, fn) {
  var fullPath = path.join(__dirname, '../') + relativePath;

  fs.readFile(fullPath, 'utf-8', function fileRead(err, text) {
    fn(err, text);
  });
}

此代码片段在 message/readTextFile.js 中。 该解决方案的核心是,你读取的文本文件位于存储库中。 注意 fs.readFile() 是一个异步操作。 这就是它需要一个fn回调函数并使用文件数据调用的原因。 这个异步解决方案使用一个简单的回调函数。

第 13 段(可获 1.13 积分)

这提供了对文件IO的需要。 我喜欢的是它只解决一个单一的问题。 由于这是一个跨领域的问题,例如读取文件,没有必要进行单元测试。 单元测试应该只测试你自己的代码,而不是别人的。

在理论上,你可以模拟内存中的文件系统并以这种方式编写单元测试,但该解决方案将会出现内存泄漏的问题并最终变得一团糟。

跨领域的问题,例如读取文件超出了代码的范围。 例如,读取文件取决于不能直接控制的子系统。 这使得测试变得脆弱,并且增加了反馈回路的时间和复杂性。 这是一个必须与你的解决方案分开的问题。

第 14 段(可获 1.69 积分)

在 BlogRoute.route()函数中,我现在可以做:

/* route/bogRoute.js */
BlogRoute.prototype.route = function route() {
  var url = this.req.url;
  var index = url.indexOf('/blog/') + 1;
  var path = url.slice(index) + '.md';

  this.message.readTextFile(path, function dummyTest(err, rawContent) {
    this.res.writeHead(200, { 'Content-Type': 'text/html; charset=utf-8' });
    this.res.end(rawContent);
  }.bind(this));
};

注意 message 和 res 通过 BlogRoute 构造函数注入,因此:

this.message = context.message;
this.res = context.res;
第 15 段(可获 0.21 积分)

从请求中获取 req 对象,并读取Markdown文件。 不要担心dummyTest()。 现在,像处理响应(response)的任何其他回调函数一样对待它。

单元测试这个 BlogRoute.route() 函数:

/* test/blogRouteTest.js */
roast.it('Read raw post with path', function readRawPostWithPath() {
  var messageMock = new MessageMock();
  var req = {
    url: 'http://localhost/blog/a-simple-test'
  };

  var route = new BlogRoute({ message: messageMock, req: req });

  route.route();

  return messageMock.readTextFileCalledWithPath === 'blog/a-simple-test.md' &&
    messageMock.hasCallback;
});
第 16 段(可获 0.4 积分)

消息(message)模块被注入到BlogRoute 中以模拟 message.readTextFile()。 这样,我可以验证被测系统(即 BlogRoute.route())通过。

你不想在需要他们的代码中require 模块。 原因是,你是热粘合依赖。 这使得任何类型的测试变成完全集成测试 - 例如, message.readTextFile()将读取一个实际的文件。

这种方法称为依赖反转,是SOLID原则之一。 这将解耦软件模块并启用依赖注入。 单元测试与模拟依赖建立在这个原则的基础上。 例如,messageMock.readTextFileCalledWithPath,应该单独测试这个单元的表现。 它不跨越功能边界。

第 17 段(可获 1.35 积分)

不要害怕模拟。 它是一个用于测试事物的轻量级对象。 例如,你可以使用sinon,并为模拟添加这个依赖。

我喜欢的是自定义模拟,因为这为处理许多用例提供了灵活性。 自定义模拟提供的一个优点是它们从测试代码中删除模拟。 这增加了单元测试的精度和清晰度。

所有 MessageMock 现在变为:

/* test/mock/messageMock.js */
var MessageMock = function MessageMock() {
  this.readTextFileCalledWithPath = '';
  this.hasCallback = false;
};

MessageMock.prototype.readTextFile = function readTextFile(path, callback) {
  this.readTextFileCalledWithPath = path;

  if (typeof callback === 'function') {
    this.hasCallback = true;
  }
};
第 18 段(可获 0.85 积分)

你可以在 test/mock/messageMock.js 中找到这个代码。

注意,模拟不需要有任何异步行为。 事实上,它甚至从来不调用回调。 目的是确保它以满足用例的方式被使用。 确保 message.readTextFile()被调用,并具有正确的路径和回调。

被注入到BlogRoute 中的实际消息(message)对象来自message/message.js。 它所做的是将所有可重用的组件都放到一个工具对象中。

例如:

/* message/message.js */
var readTextFile = require('./readTextFile');

module.exports = {
  readTextFile: readTextFile
};
第 19 段(可获 1.06 积分)

这是一个可以在Node中使用的有效模式。 将文件命名为文件夹后,可以从一个位置导出文件夹中的所有组件。

至此,应用程序已全部连接起来,准备发送回原始Markdown数据。 是时候开始端到端的测试,以验证它能否工作。

键入 npm start,然后在单独的命令行窗口中执行 curl -v http://localhost:1337/blog/my-first-post

Curl Command Demo

通过Git发布数据到存储库。 你可以通过git commit保留博客文章更改。

Markdown解析器

对于下一个问题,将存储库中的原始Markdown数据转换为HTML。 此过程有两个步骤:

第 20 段(可获 1.31 积分)
  • 视图(view)文件夹抓取HTML模板
  • 将Markdown解析为HTML并填写模板

在声音编程中,我们的想法是将一个大问题分解成许多小块。 让我们先解决第一个问题:我如何获得基于我在BlogRoute中的HTML模板?

一种方法可以是:

/* route/blogRoute.js */
BlogRoute.prototype.readPostHtmlView = function readPostHtmlView(err, rawContent) {
  if (err) {
    this.res.writeHead(404, { 'Content-Type': 'text/plain; charset=utf-8' });
    this.res.end('Post not found.');
    return;
  }

  this.rawContent = rawContent;
  this.message.readTextFile('view/blogPost.html', this.renderPost.bind(this));
};
第 21 段(可获 0.7 积分)

记住,这取代了在上一节中使用的称为dummyTest的虚拟回调。

要替换回调dummyTest,请执行:

this.message.readTextFile(path, this.readPostHtmlView.bind(this));

是时候编写快速单元测试了:

/* test/blogRouteTest.js */
roast.it('Read post view with path', function readPostViewWithPath() {
  var messageMock = new MessageMock();
  var rawContent = 'content';

  var route = new BlogRoute({ message: messageMock });

  route.readPostHtmlView(null, rawContent);

  return messageMock.readTextFileCalledWithPath !== '' &&
   route.rawContent === rawContent &&
   messageMock.hasCallback;
});
第 22 段(可获 0.3 积分)

我只在这里测试合适的路径。 还有另一个测试,以防它找不到博客文章。 所有 BlogRoute 单元测试都在 test/blogRouteTest中。 如果你感兴趣的话,随时欢迎去那里逛逛。

至此,你已经通过测试! 即使不能验证整个请求管道,你仍有足够的信心继续前进。 再次强调,这就是测试的意义:保持专注,目标明确,并快乐着。 编程时没有理由感到悲伤或沮丧。 我当然认为你应该是幸福的而不是悲伤的。

第 23 段(可获 1.26 积分)

注意实例将原始Markdown发布数据存储在 this.rawContent。 还有更多的工作在进行中,你可以在下一个回调中看到(即 this.renderPost())。

以防你不熟悉 .bind(this),在JavaScript中这是实现范围回调函数的有效方式。 默认情况下,回调获取范围到外部作用域,这在这种情况下是不好的。

将Markdown解析为HTML

下一个小问题是将HTML模板和原始内容数据组合在一起。 我将在上面用作回调的 BlogRoute.renderPost()中实现

第 24 段(可获 1.18 积分)

这是一个可能的实现:

/* route/blogRoute.js */
BlogRoute.prototype.renderPost = function renderPost(err, html) {
  if (err) {
    this.res.writeHead(500, { 'Content-Type': 'text/plain; charset=utf-8' });
    this.res.end('Internal error.');
    return;
  }

  var htmlContent = this.message.marked(this.rawContent);
  var responseContent = this.message.mustacheTemplate(html, { postContent: htmlContent });

  this.res.writeHead(200, { 'Content-Type': 'text/html; charset=utf-8' });
  this.res.end(responseContent);
};

我将再次对快乐路径测试:

第 25 段(可获 0.15 积分)
/* test/blogRouteTest.js */
roast.it('Respond with full post', function respondWithFullPost() {
  var messageMock = new MessageMock();
  var responseMock = new ResponseMock();

  var route = new BlogRoute({ message: messageMock, res: responseMock });

  route.renderPost(null, '');

  return responseMock.result.indexOf('200') >= 0;
});

你可能想知道 responseMock 来自哪里。 记住,mock是用来测试的轻量级对象。 使用 ResponseMock 确保 res.writeHead() 和  res.end() 被调用。

在这个模拟中,我将以下内容放入:

第 26 段(可获 0.4 积分)
/* test/mock/responseMock.js */
var Response = function Response() {
  this.result = '';
};

Response.prototype.writeHead = function writeHead(returnCode) {
  this.result += returnCode + ';';
};

Response.prototype.end = function end(body) {
  this.result += body;
};

如果它能提高你的信心水平,我们将执行这个响应模拟( response mock)。 就信心而言,它是作者的主观感受。 单元测试告诉你写代码的人在想什么。 这增加了你的程序的清晰度。

代码在这里: test/mock/responseMock.js

因为我引入  message.marked()(将Markdown转换为HTML)和message.mustacheTemplate()(一个轻量级的模板函数),所以我可以模拟那些。

第 27 段(可获 0.84 积分)

他们被追加到MessageMock:

/* test/mock/messageMock.js */
MessageMock.prototype.marked = function marked() {
  return '';
};

MessageMock.prototype.mustacheTemplate = function mustacheTemplate() {
  return '';
};

在这一点上,每个组件返回什么内容无关紧要。 我的主要关心是确保两者都是模拟的一部分。

有一个好的模拟的好处在于你可以迭代,并使它们更好。 当你发现错误时,可以加强单元测试,并在反馈循环中添加更多用例。

有了这个,你得以通过测试。 是时候将它连接到请求管道了。

第 28 段(可获 0.98 积分)

message/message.js 中这样做:

/* message/message.js */
var mustacheTemplate = require('./mustacheTemplate');
var marked = require('marked');
// ...

module.exports = {
  mustacheTemplate: mustacheTemplate,
// ...
  marked: marked
};

marked 是我选择添加为依赖的Markdown解析器。

将它添加到 package.json:

"dependencies": {
  "marked": "0.3.6"
}

mustacheTemplate是消息文件夹中的一个可重用组件,位于message/mustacheTemplate.js中。 我决定不把它添加为另一个依赖,因为在我给出的需要的功能列表中,它似乎有点多余。

第 29 段(可获 0.63 积分)

mustache 模板函数(mustache template function)的关键在于:

/* message/mustacheTemplate.js */
function mustache(text, data) {
  var result = text;

  for (var prop in data) {
    if (data.hasOwnProperty(prop)) {
      var regExp = new RegExp('{{' + prop + '}}', 'g');

      result = result.replace(regExp, data[prop]);
    }
  }

  return result;
}

有单元测试来验证这个工作。 也可以在这儿自由地探讨:test / test/mustacheTemplateTest.js

你仍然需要添加HTML模板或视图。 在 view/blogPost.html 中执行类似以下操作:

第 30 段(可获 0.56 积分)
<!-- view/blogPost.html -->
<body>
  <div>
    {{postContent}}
  </div>
</body>

有了这个,就到了在浏览器内演示的时候了。

要尝试它,请键入 npm start,然后转到 http://localhost:1337/blog/my-first-post

Browser View Demo

不要在软件中忽略模块化,可测试和可重复使用的组件。 事实上,不要让任何人说服你使用与此对立的解决方案。 任何代码库都可以有干净的代码,即使是紧密耦合的框架,所以不要失去希望!

期待

这只是给了你一个可以用的应用程序。 从为生产环境做准备这一点来看,还有许多可以做的。

第 31 段(可获 1.08 积分)

可能改进的一些例子包括:

  • Git部署,例如,使用 GitFlow
  • 添加一种方式来管理客户端资源
  • 对客户端和服务器端内容的基本缓存
  • 添加元数据(可能使用 front matter)使帖子搜索引擎友好

没有限制,在你的世界中,你可以随心所欲的改造这个应用程序。

总结

我希望你看到如何在Node.js中构建解决方案,只需要一些轻量级的依赖。 你需要的只是一点想象力并专注于手头的问题。 你可以使用的API集合足以构建一些惊人的东西。

第 32 段(可获 1.33 积分)

很高兴看到KISS原则对于任何解决方案的重要性。 仅解决直接问题,并保持尽可能低的复杂性。

此工作解决方案包含依赖在磁盘上总计占用约172KB。 这个大小的解决方案将在任何Web主机上都具有令人难以置信的性能。 一个响应式和轻量级的应用程序将使用户感到快乐。 最好的部分是,你现在有了一个不错的微博,并能够进一步研究。

我很愿意阅读你的评论和解决问题的方法并听听你的想法!

第 33 段(可获 1.21 积分)

文章评论