Node.js 学习(三): 构建 Web 应用(服务器端)

1. 构建 Web 应用(服务器端)

1.1. 基础功能

对象 http.Server 的 'request' 事件发生于网络连接建立,客户端向服务器端发送报文,服务器段解析报文,发现 HTTP 请求报文的报文头时。在已出发 'request' 事件前,http 模块已准备好 IncomingMessage 和 ServerResponse 对象以对应请求和响应报文的操作。

const http = require('http');
http.createServer( function(req, res) {    // 'request' 事件的侦听器
  res.writeHead(200, {'Content-Type': 'text/plain'});
  res.end();
}).listen(1337, '127.0.0.1');

对于一个 Web 应用除了上面的业务还有如下的需求:

  • 请求方法的判断
  • URL 的路径解析
  • URL 中的查询字符串的解析
  • Cookie 和 Session 的解析
  • Basic 认证
  • 表单数据的解析
  • 任意格式文件的上传处理

Web 应用可以看成是将上述需求进行线性组合,最终生成 'request' 事件的侦听器,通过高阶函数将它传递给 http.createServer() 方法。

const app = express();
// TODO
http.createServer(app).listen(1337);

1.1.1. HTTP Parser

Node 底层使用 HTTP_Parser 这个 C 语言模块来解析 HTTP 协议数据, 它解析的主要信息有:

  • 头部字段和对应值(Header)
  • Content-Length
  • 请求方法(Method)
  • 响应状态码(Status Code)
  • 传输编码
  • HTTP 版本
  • 请求 URL
  • 报文主体

1.1.2. 请求方法

HTTP_Parser 在解析请求报文时,将报文头抽取出来并将请求方式抽象为 req.method 属性。

1.1.3. 路径解析

url 模块提供了 URL 的解析。URL 是由多个具有意义的字段组成的字符串,具体描述如下:

HTTP_Parser 将请求报文头的路径字段解析成名为 req.url 的 URL 字符串, 它可通过 url.parse() 方法解析成 URL 对象,对象中的 urlObject.pathname 属性反映了 URL 字符串的 path 字段中的 pathname 部分。

1.1.4. 查询字符串

pathname 部分后就是查询字符串,这部分内容经常需要为业务逻辑所用, Node 提供了 qureystring 模块来处理这部分数据。注意,业务的判断一定要检查值是数组还是字符串。

1.1.5. Cookie

HTTP 是一个无状态协议,无法区分用户之间的身份。如何标识和认证一个用户,最早的方案就是 Cookie 。

Cookie 的处理分为如下几步:

  • 服务器向客户端发送 Cookie
  • 浏览器将 Cookie 保存
  • 之后每次浏览器都会将 Cookie 发送给服务器端

1.1.5.1. 服务器端解析 Cookie

HTTP_Parser 会将请求报文头的所有字段解析到 req.headers 上,Cookie 就是 req.headers.cookie 。 Cookie 值的格式是键值对,Express 的中间件 cookie-parser 将其挂载在 req 对象上,让业务代码可以直接访问。

function cookieParser (options) {
  return function cookieParser (req, res, next) {
    if (req.cookies) {
      return next()
    }
    var cookies = req.headers.cookie
    req.cookies = Object.create(null)
    // no cookies
    if (!cookies) {
      return next()
    }
    req.cookies = cookie.parse(cookies, options) // 这里调用了 cookie 模块 (https://github.com/jshttp/cookie)
    next()
  }
}

1.1.5.2. 客户端初始 Cookie

客户端的 Cookie 最初来自服务器端,服务器端告知客户端的方式是通过响应报文实现的,响应的 Cookie 值在 Set-Cookie 字段中设置。具体格式如下所示:

Set-Cookie: name=value; Path=/; Expires=Sun, 23-Apr-23 09:01:35 GMT; Domain=.domain.com;
  • path 标识这个 Cookie 影响到的路径。
  • ExpiresMax-Age 告知浏览器这个 Cookie 何时过期。
  • Secure 该属性为 true 时,表示 Cookie 只能通过 HTTPS 协议传递。

Express 中间件 express-session 处理 Set-Cookie :

function setcookie(res, name, val, secret, options) {
  var signed = 's:' + signature.sign(val, secret);
  var data = cookie.serialize(name, signed, options);
  var prev = res.getHeader('set-cookie') || [];
  var header = Array.isArray(prev) ? prev.concat(data) : [prev, data];
  res.setHeader('set-cookie', header)
}

1.1.6. Session

Cookie 的缺点是无法保护敏感数据,因此 Session 应运而生。 Session 的数据只保留在服务器端,客户端是无法更改的。服务器端是如何将每个用户和 Session 数据对应起来的?通常是基于 Cookie 来实现映射关系的,具体步骤如下:

  • 服务器端生成 Session 和 sessionID(口令)
  • 将 Session 数据 和 sessionID 映射存储在 Store 中(redis、mongodb、memory)
  • 通过 Set-Cookie 将 sessionID 作为 Cookie 的键值对发送给客户端。
  • 对于客户端的请求,服务器端每次检查 Cookie 中的 sessionID,并对应保留在服务器端 Store 的 Session 数据。
  • [ 服务器端更新存储 Session 数据 ]

Express 的中间件 express-session 将 Session 数据挂载在 req.session,方便业务逻辑使用。同时 express-session 还提供了多种 Store 。

1.1.7. Basic 认证

Basic 认证是一个通过用户名和密码实现的身份认证方式。如果用户首次访问网页, URL 地址中没有携带认证内容,那么浏览器会到得一个 401 未授权的响应。

var http = require('http')
var auth = require('basic-auth')

// Create server
var server = http.createServer(function (req, res) {
  var credentials = auth(req)

  if (!credentials || credentials.name !== 'john' || credentials.pass !== 'secret') {
    res.statusCode = 401
    res.setHeader('WWW-Authenticate', 'Basic realm="example"')
    res.end('Access denied')
  } else {
    res.end('Access granted')
  }
})

// Listen
server.listen(3000)

响应头中的 WWW-Authenticate 字段告知浏览器采用什么样的认证和加密方式。

浏览器在后续请求中都携带上 Authorization 信息,服务器会检查请求报文头中的 Authorization 字段的内容,该字段有认证方式和加密值构成。

function auth (req) {
  // get header
  var header = req.headers.authorization
  // parse header
  var match = CREDENTIALS_REGEXP.exec(string) // CREDENTIALS_REGEXP = /^ *(?:[Bb][Aa][Ss][Ii][Cc]) +([A-Za-z0-9._~+/-]+=*) *$/
  if (!match) {
    return undefined
  }
  // decode user pass
  var userPass = USER_PASS_REGEXP.exec(decodeBase64(match[1]))  // USER_PASS_REGEXP = /^([^:]*):(.*)$/
  if (!userPass) {
    return undefined
  }
  // return credentials object
  return new Credentials(userPass[1], userPass[2])
}

function decodeBase64 (str) {
  return new Buffer(str, 'base64').toString()
}

function Credentials (name, pass) {
  this.name = name
  this.pass = pass
}

1.2. 数据上传

报文头部中的内容已经能够让服务器端进行大多数业务逻辑操作了,但是单纯的报文头部无法携带大量的数据,请求报文中还有携带内容的报文体,这部分需要用户自行接收和解析。通过报文头部的 Transfer-EncodingContent-Length 字段即可判断请求中是否带有报文体。

function hasbody (req) {
  return req.headers['transfer-encoding'] !== undefined ||
    !isNaN(req.headers['content-length'])
}

HTTP_Parser 模块通过触发 'data' 事件获取 req.rawBody ,然后针对不同类型的报文体进行相应的解析。 Express 中间件 body-parser 针对 JSON 的解析如下:

  function parse (body) {
    if (body.length === 0) {
      // special-case empty json body, as it's a common client-side mistake
      // TODO: maybe make this configurable or part of "strict" option
      return {}
    }
    if (strict) {
      var first = firstchar(body)   // FIRST_CHAR_REGEXP = /^[\x20\x09\x0a\x0d]*(.)/ // eslint-disable-line no-control-regex
      if (first !== '{' && first !== '[') {
        throw new SyntaxError('Unexpected token ' + first)
      }
    }
    return JSON.parse(body)
  }

1.2.1. 表单数据

在表单提交的请求头中 Content-Type 字段值为 application/x-www-form-urlencoded ,也就是其内容通过 urlencoded 的方式编码内容形成报文体,node-formidable 模块解析表单提交大概如下:

// 判断报文头
if (this.headers['content-type'].match(/urlencoded/i)) {
  this._initUrlencoded();
  return;
}
// 事件发布
IncomingForm.prototype._initUrlencoded = function() {
  this.type = 'urlencoded';
  var parser = new QuerystringParser(this.maxFields);
  parser.onField = function(key, val) {
    self.emit('field', key, val);
  };
};
// 事件订阅
IncomingForm.prototype.parse = function(req, cb) {
  if (cb) {
    this
      .on('field', function(name, value) {
        fields[name] = value;
      })
  }
}

1.2.2. 附件上传

一种特殊的表单需要提交文件,该表单中可以含有 file 类型的控件,以及需要指定表单属性 enctypemultipart/form-data 。因为表单中含有多种控件,所有使用名为 boundary 的分隔符进行分割。

模块 node-formidable 将解析上传文件和处理普通表单数据进行了统一化处理,以下是文件上传的实例:

var formidable = require('formidable'),
    http = require('http'),
    util = require('util');

http.createServer(function(req, res) {
  if (req.url == '/upload' && req.method.toLowerCase() == 'post') {
    // parse a file upload
    var form = new formidable.IncomingForm();

    form.parse(req, function(err, fields, files) {
      res.writeHead(200, {'content-type': 'text/plain'});
      res.write('received upload:\n\n');
      res.end(util.inspect({fields: fields, files: files}));
    });
    return;
  }

  // show a file upload form
  res.writeHead(200, {'content-type': 'text/html'});
  res.end(
    '<form action="/upload" enctype="multipart/form-data" method="post">'+
    '<input type="text" name="title"><br>'+
    '<input type="file" name="upload" multiple="multiple"><br>'+
    '<input type="submit" value="Upload">'+
    '</form>'
  );
}).listen(8080);

Express 的中间件 Multer 也提供了类似的功能,但是它只能处理特殊的表单也就是表单属性含有 multipart/form-data

var express = require('express')
var multer  = require('multer')
var upload = multer({ dest: 'uploads/' })

var app = express()

app.post('/profile', upload.single('avatar'), function (req, res, next) {
  // req.file is the `avatar` file
  // req.body will hold the text fields, if there were any
})

app.post('/photos/upload', upload.array('photos', 12), function (req, res, next) {
  // req.files is array of `photos` files
  // req.body will contain the text fields, if there were any
})

var cpUpload = upload.fields([{ name: 'avatar', maxCount: 1 }, { name: 'gallery', maxCount: 8 }])
app.post('/cool-profile', cpUpload, function (req, res, next) {
  // req.files is an object (String -> Array) where fieldname is the key, and the value is array of files
  //
  // e.g.
  //  req.files['avatar'][0] -> File
  //  req.files['gallery'] -> Array
  //
  // req.body will contain the text fields, if there were any
})

1.2.3. 跨站请求伪造 ( CSRF )

通常解决 CSRF 的方法是在表单中添加随机值。 首先服务器端生成一个随机值,然后将随机值内嵌到前端表单,前端表单的请求中携带该随机值,服务器端收到并解析后对比判定是否一致。

Express 中间件 csurf 默认情况下会自动生成随机值,并且会将该随机值挂载到 req.session.csrfSecret 上。

1.3. 路由解析

1.3.1. 文件路径型

这种路由的处理方式,就是将请求路径中的文件发送给客户端即可,而请求 URL 中的文件路径与文件所在的具体路径相对应。

1.3.2. MVC

MVC 模型将业务逻辑按职责分离:

  • 模型 (Model) 数据相关的操作和封装
  • 控制器(Controller) 行为的集合
  • 视图 (View) 页面的渲染

它的工作模式:

  • 路由解析,根据 URL 查找到对应的控制器及其所定义的行为
  • 行为调用相关的模型,进行数据操作
  • 将操作后的数据结合相应的视图进行页面渲染,并将页面返回给客户端

在 MVC 模型中,路由也是非常重要的概念,它主要实现了 URL 和控制器的映射,具体实现的方式有:

  • 手工映射
    • 静态映射
    • 正则匹配
    • 参数解析
  • 自然映射

1.3.3. RESTful

REST 的中文含义为表现层状态转化, 符合 REST 规范的设计成为 RESTful 设计。 它的设计哲学是将服务器端提供的内容实体看作为一个资源,并表现在 URL 上。其中 URL 中的 Method 代表了对这个资源的操作方法。

POST /user/jacksontian  // 创建新用户
DELETE /user/jacksontian  // 删除用户
PUT /user/jacksontian    // 更改用户
GET /user/jacksontian   // 查询用户

在 RESTful 设计中,客户端能够接受资源的具体格式由请求报文头中的 Accept 字段给出:

Accept: application/json,application/xml

而服务器端在响应报文中,通过 Content-Type 字段告知客户端是什么格式:

Content-Type: application/json 

所以 RESTful 的设计就是, 通过 URL 设计资源、请求方法定义资源的操作和通过 Accept 决定资源的具体格式。

1.4. 中间件

上述工作有太多的繁琐细节要完成,为了简化和隔离这些基础功能,让开发者关注业务逻辑的实现,引入了中间件这个定义。中间件组件是一个函数,它拦截 HTTP 服务器提供的请求和响应对象,执行逻辑,然后或者结束响应,或者传递给下一个中间件。

Node 的 http 模块提供了应用层协议的封装,但是对具体业务没有支持(小而灵活),因此必须有开发框架对业务提供支持。 通过中间件的形式搭建开发框架,完成各种基础功能,最终汇成强大的基础框架。每一种基础框架对中间件的组织形式不尽相同,下图是基础框架 Express 的实现机制。

Middleware
Middleware

1.4.1. 普通中间件

在 Express 中,中间件按惯例会接受三个参数:一个请求对象,一个响应对象,还有一个通常命名为 next 的参数,它是一个回调函数,表明该组件已经完成了工作,可以执行下一个中间件组件了。中间件的分派主要依赖于 next 这个回调函数的尾触发,这样前一个中间件组件完成后才能进入下一个中间件组件。

在 Web 应用中,路由是个至关重要的概念,它会把请求 URL 映射到实现业务逻辑的函数上。通过中间件和业务逻辑的结合可以完成对路由的执行。首先使用 app.use() 等方法将所有的中间件和业务逻辑以及相应的挂载点有序的放入路由数组,然后通过请求路径与挂载点的对比,将匹配的数据元素重组为新的数组,最后通过分发执行中间件,中间件执行完毕后通过 next() 函数将结果转入到下一个匹配的数组元素。

var handle = function (req, res, stack) {
  var next = function () {
    // 从stack数组中取出中间件并执行
    var middleware = stack.shift();
    if (middleware) {
      // 传入next()函数自身,使中间件能够执行结束后递归
      middleware(req, res, next);
    }
  };

  // 启动执行
  next();
};
dispatch

1.4.2. 异常处理

为了捕获中间件抛出的同步异常,保证 Web 应用的稳定和健壮,我们为 next() 方法添加 err 参数。这主要是因为异步的异常不能直接捕获,中间件的异常需要自己传递出来。

使用中间件的思路将异常的处理交给中间件,同时为了区分异常处理中间件和普通中间件的区别,在其参数中加入 err 参数:

const middleware = function(options) {
  return function(err, req, res, next) {
    // TODO
    next();
  };
};

每个异常处理中间件可通过 next(err) 方法将异常传递给下一个异常处理中间件,其思路与普通中间件完全一致。

如果异常处理中间件没有设置 next(err) 方法,那它后面的异常处理中间件都不会起作用。

1.5. 页面渲染

执行完中间件及业务逻辑后,服务器端该如何响应客户端?一般有两种方式:

  • 内容响应
  • 视图渲染

1.5.1. 内容响应

因为服务器端响应的报文,最终都会被客户端处理,具体终端有可能是命令行,也有可能是浏览器。这就使得响应报文头中的 content-* 字段显得十分重要。

1.5.1.1. MIME

报文头中的 Content-Type 字段的值决定采用不同的渲染方式,而这个值就是 MIME值。不同的文件类型具有不同的 MIME 值:

  • JSON 文件: application/json
  • XML 文件: application/xml
  • PDF 文件: application/pdf

1.5.1.2. 附件下载

报文头中的 Content-Disposition 字段影响的行为是客户端会根据它的值判断是应该将报文数据当做及时浏览的内容(inline),还是可以下载的附件(attachment)。

1.5.1.3. 响应 JSON

为了快捷的响应 JSON 数据, Express 封装了响应对象的 res.json() 方法:

res.json = function json(obj) {
  var val = obj;
  var body = JSON.stringify(val);

  // content-type
  if (!this.get('Content-Type')) {
    this.set('Content-Type', 'application/json');
  }
  return this.send(body);
};

1.5.1.4. 响应跳转

当前 URL 因为某些原因不能处理, 需要将用户跳转到别的 URL 时,Express 同样封装了一个快捷方式 res.redirect():

res.redirect = function redirect(url) {
  var address = url;
  var body;
  var status = 302;

  // Set location header
  address = this.location(address).get('Location');

  // Support text/{plain,html} by default
  this.format({
    text: function(){
      body = statuses[status] + '. Redirecting to ' + address
    },

    html: function(){
      var u = escapeHtml(address);
      body = '<p>' + statuses[status] + '. Redirecting to <a href="' + u + '">' + u + '</a></p>'
    },

    default: function(){
      body = '';
    }
  });

  // Respond
  this.statusCode = status;
  this.set('Content-Length', Buffer.byteLength(body));

  if (this.req.method === 'HEAD') {
    this.end();
  } else {
    this.end(body);
  }
};
最后编辑于
©著作权归作者所有,转载或内容合作请联系作者
  • 序言:七十年代末,一起剥皮案震惊了整个滨河市,随后出现的几起案子,更是在滨河造成了极大的恐慌,老刑警刘岩,带你破解...
    沈念sama阅读 205,033评论 6 478
  • 序言:滨河连续发生了三起死亡事件,死亡现场离奇诡异,居然都是意外死亡,警方通过查阅死者的电脑和手机,发现死者居然都...
    沈念sama阅读 87,725评论 2 381
  • 文/潘晓璐 我一进店门,熙熙楼的掌柜王于贵愁眉苦脸地迎上来,“玉大人,你说我怎么就摊上这事。” “怎么了?”我有些...
    开封第一讲书人阅读 151,473评论 0 338
  • 文/不坏的土叔 我叫张陵,是天一观的道长。 经常有香客问我,道长,这世上最难降的妖魔是什么? 我笑而不...
    开封第一讲书人阅读 54,846评论 1 277
  • 正文 为了忘掉前任,我火速办了婚礼,结果婚礼上,老公的妹妹穿的比我还像新娘。我一直安慰自己,他们只是感情好,可当我...
    茶点故事阅读 63,848评论 5 368
  • 文/花漫 我一把揭开白布。 她就那样静静地躺着,像睡着了一般。 火红的嫁衣衬着肌肤如雪。 梳的纹丝不乱的头发上,一...
    开封第一讲书人阅读 48,691评论 1 282
  • 那天,我揣着相机与录音,去河边找鬼。 笑死,一个胖子当着我的面吹牛,可吹牛的内容都是我干的。 我是一名探鬼主播,决...
    沈念sama阅读 38,053评论 3 399
  • 文/苍兰香墨 我猛地睁开眼,长吁一口气:“原来是场噩梦啊……” “哼!你这毒妇竟也来了?” 一声冷哼从身侧响起,我...
    开封第一讲书人阅读 36,700评论 0 258
  • 序言:老挝万荣一对情侣失踪,失踪者是张志新(化名)和其女友刘颖,没想到半个月后,有当地人在树林里发现了一具尸体,经...
    沈念sama阅读 42,856评论 1 300
  • 正文 独居荒郊野岭守林人离奇死亡,尸身上长有42处带血的脓包…… 初始之章·张勋 以下内容为张勋视角 年9月15日...
    茶点故事阅读 35,676评论 2 323
  • 正文 我和宋清朗相恋三年,在试婚纱的时候发现自己被绿了。 大学时的朋友给我发了我未婚夫和他白月光在一起吃饭的照片。...
    茶点故事阅读 37,787评论 1 333
  • 序言:一个原本活蹦乱跳的男人离奇死亡,死状恐怖,灵堂内的尸体忽然破棺而出,到底是诈尸还是另有隐情,我是刑警宁泽,带...
    沈念sama阅读 33,430评论 4 321
  • 正文 年R本政府宣布,位于F岛的核电站,受9级特大地震影响,放射性物质发生泄漏。R本人自食恶果不足惜,却给世界环境...
    茶点故事阅读 39,034评论 3 307
  • 文/蒙蒙 一、第九天 我趴在偏房一处隐蔽的房顶上张望。 院中可真热闹,春花似锦、人声如沸。这庄子的主人今日做“春日...
    开封第一讲书人阅读 29,990评论 0 19
  • 文/苍兰香墨 我抬头看了看天上的太阳。三九已至,却和暖如春,着一层夹袄步出监牢的瞬间,已是汗流浃背。 一阵脚步声响...
    开封第一讲书人阅读 31,218评论 1 260
  • 我被黑心中介骗来泰国打工, 没想到刚下飞机就差点儿被人妖公主榨干…… 1. 我叫王不留,地道东北人。 一个月前我还...
    沈念sama阅读 45,174评论 2 352
  • 正文 我出身青楼,却偏偏与公主长得像,于是被迫代替她去往敌国和亲。 传闻我的和亲对象是个残疾皇子,可洞房花烛夜当晚...
    茶点故事阅读 42,526评论 2 343

推荐阅读更多精彩内容

  • Spring Cloud为开发人员提供了快速构建分布式系统中一些常见模式的工具(例如配置管理,服务发现,断路器,智...
    卡卡罗2017阅读 134,598评论 18 139
  • 1. 网络基础TCP/IP HTTP基于TCP/IP协议族,HTTP属于它内部的一个子集。 把互联网相关联的协议集...
    yozosann阅读 3,436评论 0 20
  • 本篇文章篇幅比较长,先来个思维导图预览一下。 一、概述 1.计算机网络体系结构分层 2.TCP/IP 通信传输流 ...
    涤生_Woo阅读 54,938评论 24 557
  • 从三月份找实习到现在,面了一些公司,挂了不少,但最终还是拿到小米、百度、阿里、京东、新浪、CVTE、乐视家的研发岗...
    时芥蓝阅读 42,182评论 11 349
  • 基础功能 之前我们通过http模块创建了一个简单的服务器,但是对于一个网络应用来说肯定是远远不够的,在聚义的业务中...
    exialym阅读 875评论 1 22