Node-构建web应用2

数据上传

单纯的头部报文无法携带大量的数据,在业务中,我们往往需要接收一些数据,比如表单提交、文件提交、JSON上传、XML上传等。

如果请求中还带有内容部分(如POST请求,它具有报头和内容),内容部分需要用户自行接收和解析,通过报头的Transfer-EncodingContent-Length即可判断请求中是否带有内容名。

var hasBody = function(req) {
  return 'transfer-encoding' in req.headers || 'content-length' in req.headers;
};

表单数据

默认的表单提交,请求头中的Content-Type字段值为application/x-www-form-urlencoded,由于它的报文体内容跟查询字符串相同foo=bar&baz=val,因此可以直接使用queryString解析:

if (req.headers['content-type'] === 'application/x-www-form-urlencoded') {
  req.body = querystring.parse(req.rawBody); 
}

其他格式

常见的提交还有JSON和XML文件等,判断它们都是依据Content-Type字段,其中JSON类型的值为application/json,XML的值为application/xml。需要注意的是,在Content-Type中可能还附带如下所示的编码信息。

Content-Type: application/json; charset=utf-8

使用JSON.parse()解析JSON,使用第三方库解析XML

var xml2js = require('xml2js');

var handle = function (req, res) {
  if (mime(req) === 'application/xml') {
    xml2js.parseString(req.rawBody, function (err, xml) { 
      if (err) {
        // 异常内容,响应Bad request 
        res.writeHead(400); 
        res.end('Invalid XML'); 
        return;
      }
      req.body = xml; 
      todo(req, res);
    }); 
  } else if (mime(req) === 'application/json') {
    try {
      req.body = JSON.parse(req.rawBody);
    } catch (e) {
      // 异常内容,响应Bad request 
      res.writeHead(400); 
      res.end('Invalid JSON'); 
      return;
    } 
  }
};

附件上传

在前端HTML代码中,特殊表单与普通表单的差异在于该表单中可以含有file类型的控件,以及需要指定表单属性enctype为multipart/form-data

浏览器在遇到multipart/form-data表单提交时,构造的请求报文与普通报文完全不同,首先它的报头中最为特殊的如下所示:

Content-Type: multipart/form-data; boundary=AaB03x 
Content-Length: 18231

它代表本次提交的内容是多部分构成的,其中boundary=AaB03x指定的是每部分内容的分界符,AaB03x是随机生成的一段字符串,报文体的内容将通过在它前面添加--进行分割,报文结束时在它前后都加上--标识结束,另外Content-Length的值必须确保是报文体的长度。

数据上传与安全

内存限制

在解析表单、JSON和XML部分,我们采取的策略是先保存用户提交的所有数据,然后再解析处理,最后才传递给业务逻辑。这种策略存在的问题就是,仅仅适合数据量小的提交请求,如果攻击者每次提交1MB的内容,那么内存很快就会被占光。

解决这个问题有两个方案:

1.限制上传内容的大小,一旦超过限制,停止接受数据,并响应400状态码

2.通过流式解析,将数据导向到磁盘中,Node只保留文件路径等小数据。

// 限制上传数据量
var bytes = 1024;
function (req, res) {
  var received = 0;
  var len = req.headers['content-length'] ? parseInt(req.headers['content-length'], 10) : null;
  // 内容超过长度限制,返回请求实体过长的状态码 
  if (len && len > bytes) {
    res.writeHead(413); res.end();
    return;
  }
  // limit
  req.on('data', function (chunk) {
    // 内容长度累积超过限制。
    received += chunk.length;
    if (received > bytes) {
      // 停止接收数据,触发end()
      req.destroy(); 
    }
  });
  handle(req, res); 
};

CSRF: Cross-Site Request Forgery

通常而言,用户通过浏览器访问服务器端的Session ID是无法被第三方知道的,但是CSRF的攻击者并不需要知道Session ID就能让用户中招。

举例:用户C访问网站A,输入用户名密码成功通过验证,此时网站A返回Cookie信息给浏览器,用户可以成功发送请求到网站A,如果用户未退出网站A,被攻击者引诱访问了攻击者拥有的网站B,网站B有一段自执行代码去访问网站A,此时用户与网站A的cookie还未失效,那么就可以成功访问,并且这次请求的参数是由攻击者决定的,这样就能以你的名义操作你的账号。

解决方案:

  • 验证 HTTP Referer 字段

如果黑客要对网站实施 CSRF 攻击,他只能在他自己的网站构造请求,当用户通过黑客的网站发送请求到后台时,该请求的 Referer 是指向黑客自己的网站, 然而,这种方法并非万无一失。Referer 的值是由浏览器提供的。事实上,对于某些浏览器,比如 IE6 或 FF2已经有一些方法可以篡改 Referer 值。即便是使用最新的浏览器,黑客无法篡改 Referer 值,这种方法仍然有问题。因为 Referer 值会记录下用户的访问来源,有些用户认为这样会侵犯到他们自己的隐私权,特别是有些组织担心 Referer 值会把组织内网中的某些信息泄露到外网中。

  • 在请求地址中添加 token 并验证

为每一个请求的用户,在Session中赋予一个随机值,在做页面渲染的过程中,将这个token告知前端,使得前端才发送请求的时候会携带上token,后端收到请求判断token是否是自己给前端的,以此辨别该请求是否是伪造的。如果是动态生成的HTML或者是使用前端框架渲染,则需要前端程序员代码中去处理携带上token。

路由解析

MVC

MVC是一个分层模式,它的工作模式是:

1、路由解析,根据URL寻找到对应的控制器和行为(Controller)

2、行为调用相关的模型,进行数据操作(Model)

3、数据操作结束后,调用视图和相关数据进行页面渲染,输出到客户端。(View)

如何做到路由映射?

手工映射

var routes = [];
// Controller的集合
var use = function (path, action) {
  routes.push([path, action]);
};

function (req, res) {
  var pathname = url.parse(req.url).pathname;
  for (var i = 0; i < routes.length; i++) {
    var route = routes[i];
    if (pathname === route[0]) {
      // 根据URL寻找到对应的控制器和行为
      var action = route[1];
      action(req, res);
      return;
    }
  }
  // 处理404请求
  handle404(req, res);
}

上面手工映射的问题有两个,

1、采用的是硬匹配,对于动态路径不方便处理。/profile/jacksontian/profile/hoover两个路径是一样的模型,会出现重复的代码操作。

2、如果使用正则匹配还需要知道到底匹配到了什么,例如上面的username,到底是jacksontian还是hoover,将其匹配到的内容抽取出来设置到req.params处。

自然映射

路由按照一种约定的方式自然而然地实现路由,例如:

/controller/action/param1/param2/param3

/user/setting/12/1987为例,它会按照约定去找controllers目录下的user文件,将其require出来,调用这个文件模块的setting方法,而其余的值作为参数传递给这个方法。

function (req, res) {
  var pathname = url.parse(req.url).pathname;
  var paths = pathname.split('/');
  var controller = paths[1] || 'index';
  var action = paths[2] || 'index';
  var args = paths.slice(3);
  var module;
  try {
    // require的缓存机制使得只有第一次是阻塞的
    module = require('./controllers/' + controller);
  } catch (ex) {
    handle500(req, res);
    return;
  }
  var method = module[action];

  if (method) {
    method.apply(null, [req, res].concat(args));
  } else {
    handle500(req, res);
  }
}

RESTful

REST的全称是Representational State Transfer,中文含义为表现层状态转化,它的设计哲学主要是将服务端提供的内容实体看做一个资源,并表示在URL上,对这个资源的操作,主要体现在HTTP请求方法上,而不是原来的URL上。

对一个资源的增删查改,采用同一个URL,但是请求方式不同,后端根据不同的请求方式,做出不同的操作。

PUT /user/jacksontian
DELETE /user/jacksontian
GET /user/jacksontian
POST /user/jacksontian

过去设计资源的格式与后缀有很大的关联,在RESTful设计中,资源的具体格式由请求报头中的Accept字段和服务器端的支持情况来决定,如果客户端同时接受JSON和XML格式的响应,那么它的Accept字段是:Accept: application/json,application/xml,服务器根据自己能够响应的格式做出响应,在响应中通过Content-Type字段告知客户端是什么格式:Content-Type: application/json,所以RESTful设计就是,通过URL设计资源、请求方法定义资源的操作、通过Accept决定资源的表现形式。

中间件

对于web应用而言,我们希望不用接触到这么多细节性的处理,为此我们引入了中间件(middleware)来简化和隔离这些基础设施和业务逻辑之间的细节,让开发者能够关注在业务的开发上,以达到提升开发效率的目的。

中间件用来帮我们完成基本功能,例如查询字符串的解析,cookie和session的处理,并把处理结果反映在req上传递下去,由于Node异步的原因,我们需要提供一种机制,在当前中间件处理完成后,通知下一个中间件执行。

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

这里带来的疑问是,像querystring、cookie、session这样基础的功能中间件是否需要为每一个路由都进行设置呢?如果都设置将会演变成如下的路由配置:

app.get('/user/:username', querystring, cookie, session, getUser);
app.put('/user/:username', querystring, cookie, session, updateUser);

这并不是一个好的设计,我们需要将路由和中间件结合起来。

app.use(querystring);
app.use(cookie);
app.use(session);
app.get('/user/:username', getUser);
app.put('/user/:username', authorize, updateUser);
app.use = function (path) {
  var handle;
  // 如果第一参数是路径
  if (typeof path === 'string') {
    handle = {
      path: pathRegexp(path),
      stack: Array.prototype.slice.call(arguments, 1)
    };
  } else {
    // 如果不是路径,则相当于是对所有路径进行中间件的处理 
    // 之后的所有请求都可以拿到处理后的结果,用与不用,取决于业务逻辑。
    handle = {
      path: pathRegexp('/'),
      stack: Array.prototype.slice.call(arguments, 0)
    };
  }
  routes.all.push(handle);
};

异常处理

如果某一个中间件出现错误该怎么办,我们需要为自己构建的web应用的稳定性和健壮性负责,应该为next()方法添加err参数,并捕获中间件直接抛出的同步异常。

var handle = function (req, res, stack) {
  var next = function (err) {
    if (err) {
      return handle500(err, req, res, stack);
    }

    var middleware = stack.shift();
    if (middleware) {
      try {
        middleware(req, res, next);
      } catch (ex) {
        next(err);
      }
    }
  };
  // 启动执行
  next();
};

由于异步方法的异常不能直接捕获,因为异步调用都是直接返回,使用try/catch无法捕获到请求阶段的异常,所以需要在异步执行完的回调中将异常手动传递出来。

var session = function (req, res, next) {
  var id = req.cookies.sessionid;
  store.get(id, function (err, session) {
    if (err) {
      // 将异常通过next()传递
      return next(err);
    }
    req.session = session;
    next();
  });
}

中间件与性能

使用中间件,我们可以发现业务逻辑往往是最后执行,为了让业务逻辑提早执行,尽早响应给终端用户,中间件的编写和使用是需要一番考究的,下面是两个主要提升的点:

编写高效的中间件

编写高效的中间件其实就是提升单个处理单元的处理速度,以尽早调用next()执行后续逻辑,因为中间件一旦匹配,那么每个请求都会使该中间件执行一次,哪怕只浪费1毫秒的执行时间,都会让我们的QPS显著下降(一秒内可以处理的请求数量称之为服务器的QPS)

常见的优化方法有:

1、使用已有API中高效的方法,而不是自己尝试编写。

2、缓存需要重复计算的结果

3、避免不必要的计算,比如HTTP报文体的解析,对于GET方法完全不需要。

合理使用路由,避免不必要的中间件的执行

合理的路由使得不必要的中间件不参与请求处理的过程,假设我们这里有一个静态文件的中间件,它会对请求进行判断,如果磁盘上存在对应文件,就响应对应的静态文件,否则就交给下游中间件处理。

var staticFile = function (req, res, next) {
  var pathname = url.parse(req.url).pathname;
  fs.readFile(path.join(ROOT, pathname), function (err, file) {
    if (err) {
      return next();
    }
    res.writeHead(200);
    res.end(file);
  });
};

如果我们以如下方式注册路由app.use(staticFile);,那么意味着对根路径下所有的URL请求都会进行判断,对于这种情况,我们需要做的是提升匹配成功率app.use('/public', staticFile);,这样只有/public路径会匹配上,其他路径就不会涉及到该中间件。

页面渲染

内容响应

服务器端响应的报文,最终都要被终端处理,这个终端可能是命令行终端,也可能是代码终端,也可能是浏览器终端。服务器端的响应从一定程度上决定了客户端该如何处理响应的内容。

浏览器通过Content-Type的值来决定采用不同的渲染方式,这个值我们简称为MIME(Multipurpose Internet Mail Extensions),最早用于电子邮件,后来应用到浏览器中,不同的文件类型具有不同的MIME值,为了方便获知文件的MIME值,社区有专有的mime模块可以用来判断文件类型。

var mime = require('mime');
mime.lookup('/path/to/file.txt'); // => 'text/plain'
mime.lookup('file.txt'); // => 'text/plain'
mime.lookup('.TXT'); // => 'text/plain'
mime.lookup('htm'); // => 'text/html

// JSON  application/json
// XML application/xml
// PDF application/pdf
// ...

除了MIME值外,Content-Type的值还可以包含一些参数,如字符集:

Content-Type: text/javascript; charset=utf-8

附件下载

在一些场景下,无论响应的内容是什么样的MIME值,需求中并不要求客户端去打开它,只需弹出并下载它即可,Content-Disposition字段就是为了满足这种需求,客户端会根据它的值判断是应该将报文数据当做即时浏览的内容,还是可下载的附件,当内容值需即时查看时,它的值为inline,当数据可以存为附件时,它的值为attachment,另外Content-Disposition还能通过参数指定保存时应该使用的文件名。

Content-Disposition: attachment; filename="filename.ext"

如果我们要设计一个响应附件下载的API,大致如下:

res.sendfile = function (filepath) {
  fs.stat(filepath, function(err, stat) {
    var stream = fs.createReadStream(filepath);
    // 设置内容
    res.setHeader('Content-Type', mime.lookup(filepath));
    // 设置长度
    res.setHeader('Content-Length', stat.size);
    // 设置为附件
    res.setHeader('Content-Disposition' 'attachment; filename="' + path.basename(filepath) + '"');
    res.writeHead(200);
    stream.pipe(res);
  });
};

响应JSON

res.json = function (json) {
  res.setHeader('Content-Type', 'application/json');
  res.writeHead(200);
  res.end(JSON.stringify(json));
};

响应跳转

res.redirect = function (url) {
  res.setHeader('Location', url);
  res.writeHead(302);
  res.end('Redirect to ' + url);
};

视图渲染

模板是带有特殊标签的HTML片段,通过与数据的渲染,将数据填充到这些特殊标签中,最后生成普通的带数据的HTML片段,通常我们将渲染方法设计为render(),参数就是模板路径和数据。

res.render = function (view, data) {
  res.setHeader('Content-Type', 'text/html');
  res.writeHead(200);
  // 实际渲染
  var html = render(view, data);
  res.end(html);
};

模板

为了使HTML与逻辑代码分离开来,催生了一些服务器端动态网页技术,如ASP、PHP、JSP。它们将动态语言部分通过特殊的标签(ASP和JSP以<% %>作为标志,PHP则以作为标志)包含起来,通过HTML和模板标签混排,将开发者从输出HTML的工作中解脱出来,这种方法虽然一定程度上减轻了开发维护的难度,但是页面还是充斥着大量的逻辑代码,这催生了MVC在动态网页技术中的发展,MVC将逻辑、显示、数据分离开来的方式,大大提高了项目的可维护性。

模板技术虽然多种多样,但它的实质就是将模板文件和数据通过模板引擎生成最终的HTML代码,形成模板技术的也就如下4个要素:模板语言、包含模板语言的模板文件、拥有动态数据的数据对象、模板引擎。模板技术使得网页中的动态内容和静态内容变得不互相依赖,数据开发者和模板开发者只要约定好数据结构,两者就不用互相影响了。

但模板技术并不是什么神秘的技术,它干的实际上是拼接字符传这样和底层的活,做的就是替换特殊标签的技术,只是各种模板有着各自的优缺点和技巧。

模板安全

模板技术用来替换特殊标签然后形成新的HTML代码,这样就很容易形成XSS攻击,例如将某个数据的值改为,如果渲染到页面上,这段脚本就会被执行,为了提高安全性,大多数模板提供了转义的功能,转义就是将能形成HTML标签的字符转换成安全的字符,这些字符主要有&<>"'

var escape = function (html) {
  return String(html)
    .replace(/&(?!\w+;)/g, '&')
    .replace(//g, '>')
    .replace(/"/g, '"')
    .replace(/'/g, ''');
};

集成文件系统

var cache = {};
var VIEW_FOLDER = '/path/to/wwwroot/views';

res.render = function (viewname, data) {
  if (!cache[viewname]) {
    var text;
    try {
      text = fs.readFileSync(path.join(VIEW_FOLDER, viewname), 'utf8');
    } catch (e) {
      res.end('模板文件错误');
      return;
    }
    cache[viewname] = complie(text);
  }
  var complied = cache[viewname];
  res.writeHead(200, {'Content-Type': 'text/html'});
  var html = complied(data);
  res.end(html);
};

这个res.render()的实现中,虽然有同步读取文件的情况,但是由于采用了缓存,只会在第一次读取的时候造成整个进程的阻塞,一旦缓存生效,将不会反复读取文件,其次,缓存之前已经进行了编译,也不会每次读取都编译,并且与文件系统集成了,调用的时候只需要指定模板文件的名称和数据即可完成渲染。

app.get('/aaa', function (req, res) {
  res.render('viewname', {});
});

你可能感兴趣的:(Node-构建web应用2)