node 学习之路(一)—— 网络请求

前言

一直以来想深入学习一下 node,一来是自己目前也没有什么时间去学习服务器端语言,但是有时候又想自己撸一下服务器端,本着爱折腾的精神开始写一写关于 node 的文章记录学习心得。本系列文章不会过多去讲解 node 安装、基本 API 等内容,而是通过一些实例去总结常用用法。本文主要讲解 node 网络操作的相关内容,node 中的网络操作依赖于 http 模块,http 模块提供了两种使用方式:

  • 作为服务器端使用,创建一个 http 服务器,监听 http 客户端请求并返回响应;
  • 作为客户端使用,发起一个 http 客户端请求,获取服务器端响应。

node http 模块创建服务器

node 处理 get 请求实例

毕竟作为一个前端,我们经常需要自己搭建一个服务器做测试,这里我们先来讲一下 node http 模块作为服务器端使用。首先我们需要,使用 createServer 创建一个服务,然后通过 listen 监听客服端 http 请求。

我们可以创建一个最简单的服务器,在页面输出hello world,我们可以创建 helloworld.js,内容如下:

var http = require("http");

http
  .createServer(function(request, response) {
    response.writeHead(200, { "Content-Type": "text-plain" });
    response.end("hello world!");
  })
  .listen(8888);

在命令行输入 node helloworld.js 即可,我们打开在浏览器打开http://127.0.0.1:8888/就可以看到页面输出hello world!。

下面我们在本地写一个页面,通过 jsonp 访问我们创建的 node 服务器:

<!DOCTYPE html>
<html>
	<head>
		<meta charset="utf-8" />
		<title></title>
	</head>
	<body>
		<div id="output"></div>

		<script type="text/javascript">
			// 创建script标签
			function importScript(src){
            	var el = document.createElement('script');
		        el.src = src;
		        el.async = true;
				el.defer = true;
		        document.body.appendChild(el);
            }

			// 响应的方法
			function jsonpcallback(rs) {
		        console.log(JSON.stringify(rs));
		        document.getElementById("output").innerHTML = JSON.stringify(rs);
		    }

			// 发起get请求
			importScript('http://127.0.0.1:8888?userid=xiaoqingnian&callback=jsonpcallback');
		</script>
	</body>
</html>

我们当然需要将上述 node 服务器中的代码稍作修改:

var http = require("http"); // 提供web服务
var url = require("url"); // 解析GET请求

var data = {
  name: "zhaomenghuan",
  age: "22"
};

http
  .createServer(function(req, res) {
    // 将url字符串转换成Url对象
    var params = url.parse(req.url, true);
    console.log(params);
    // 查询参数
    if (params.query) {
      // 根据附件条件查询
      if (params.query.userid === "xiaoqingnian") {
        // 判断是否为jsonp方式请求,若是则使用jsonp方式,否则为普通web方式
        if (params.query.callback) {
          var resurlt =
            params.query.callback + "(" + JSON.stringify(data) + ")";
          res.end(resurlt);
        } else {
          res.end(JSON.stringify(data));
        }
      }
    }
  })
  .listen(8888);

我们在命令行可以看到:

Url {
  protocol: null,
  slashes: null,
  auth: null,
  host: null,
  port: null,
  hostname: null,
  hash: null,
  search: '?userid=xiaoqingnian&callback=jsonpcallback',
  query: { userid: 'xiaoqingnian', callback: 'jsonpcallback' },
  pathname: '/',
  path: '/?userid=xiaoqingnian&callback=jsonpcallback',
  href: '/?userid=xiaoqingnian&callback=jsonpcallback' }

经过服务器端 jsonp 处理,然后返回一个函数:

jsonpcallback({ name: "zhaomenghuan", age: "22" });

而我们在页面中定义了一个 jsonpcallback()的方法,所以当我们在请求页面动态生成 script 调用服务器地址,这样相当于在页面执行了下我们定义的函数。jsonp 的实现原理主要是 script 标签 src 可以跨域执行代码,类似于你引用 js 库,然后调用这个 js 库里面的方法;这是这里我们可以认为反过来了,你是在本地定义函数,调用的逻辑通过服务器返回的一个函数执行了,所以 jsonp 并没有什么神奇的,和 XMLHttpRequest、ajax 半毛钱关系都没有,而且 JSONP 需要服务器端支持,始终是无状态连接,不能获悉连接状态和错误事件,而且只能走 GET 的形式。

node 处理 post 请求实例

当然这里我们可以直接在后台设置响应头进行跨域(CORS),如:

var http = require("http"); // 提供web服务
var query = require("querystring"); // 解析POST请求

http
  .createServer(function(req, res) {
    // 报头添加Access-Control-Allow-Origin标签,值为特定的URL或"*"(表示允许所有域访问当前域)
    res.setHeader("Access-Control-Allow-Origin", "*");

    var postdata = "";
    // 一旦监听器被添加,可读流会触发 'data' 事件
    req.addListener("data", function(chunk) {
      postdata += chunk;
    });
    // 'end' 事件表明已经得到了完整的 body
    req.addListener("end", function() {
      console.log(postdata); // 'appid=xiaoqingnian'
      // 将接收到参数串转换位为json对象
      var params = query.parse(postdata);
      if (params.userid == "xiaoqingnian") {
        res.end('{"name":"zhaomenghuan","age":"22"}');
      }
    });
  })
  .listen(8080);

我们通过流的形式接收前端 post 传递的参数,通过监听 data 和 end 事件,后面在讲解 event 模块的时候再深入探究。

CORS 默认只支持 GET/POST 这两种 http 请求类型,如果要开启 PUT/DELETE 之类的方式,需要在服务端在添加一个"Access-Control-Allow-Methods"报头标签:

res.setHeader(
  "Access-Control-Allow-Methods",
  "PUT, GET, POST, DELETE, HEAD, PATCH"
);

前端访问代码如下:

var xhr = new XMLHttpRequest();
xhr.onload = function() {
  console.log(this.responseText);
};
xhr.onreadystatechange = function() {
  console.log(this.readyState);
};

xhr.open("post", "http://127.0.0.1:8080", true);
xhr.setRequestHeader("Content-Type", "application/x-www-form-urlencoded");
xhr.send("userid=xiaoqingnian");

url 模块 API 详解

url.parse——解析 url 字符串

上述代码中比较关键的是我们通过 url.parse 方法将 url 字符串转成 Url 对象,用法如下:

url.parse(urlStr, [parseQueryString], [slashesDenoteHost]);

接收参数:

  • urlStr:url 字符串
  • parseQueryString:参数为 true 时,query 会被解析为 JSON 格式,否则为普通字符串格式,默认为 false;如: - 参数为 true:query: { userid: 'xiaoqingnian', callback: 'jsonpcallback' } - 参数为 false:query: 'userid=xiaoqingnian&callback=jsonpcallback'
  • slashesDenoteHost:默认为 false,当 url 是 ‘http://’ 或 ‘ftp://’ 等标志的协议前缀打头的,或直接以地址打头,如 ‘127.0.0.1’ 或 ‘localhost’ 时候是没有区别的;当且仅当以 2 个斜杠打头的时候,比如 ‘//127.0.0.1’ 才有区别。这时候,如果其值为 true,则第一个单个 ‘/’ 之前的部分被解析为 ‘host’ 和 ‘hostname’,如 ” host : ‘127.0.0.1’ “,如果为 false,包括 2 个反斜杠在内的所有字符串被解析为 pathname。如:
> url.parse('//www.foo/bar',true,true)
Url {
  protocol: null,
  slashes: true,
  auth: null,
  host: 'www.foo',
  port: null,
  hostname: 'www.foo',
  hash: null,
  search: '',
  query: {},
  pathname: '/bar',
  path: '/bar',
  href: '//www.foo/bar' }
> url.parse('//www.foo/bar',true,false)
Url {
  protocol: null,
  slashes: null,
  auth: null,
  host: null,
  port: null,
  hostname: null,
  hash: null,
  search: '',
  query: {},
  pathname: '//www.foo/bar',
  path: '//www.foo/bar',
  href: '//www.foo/bar' }

这里的 URL 对象和浏览器中的 location 对象类似,location 中如果我们需要使用类似的方法,我们需要自己构造。

url.format——格式化 URL 对象

我们可以通过 url.format 方法将一个解析后的 URL 对象格式化成 url 字符串,用法为:

url.format(urlObj);

例子:

url.format({
  protocol: 'http:',
  slashes: true,
  auth: 'user:pass',
  host: 'host.com:8080',
  port: '8080',
  hostname: 'host.com',
  hash: '#hash',
  search: '?query=string',
  query: 'query=string',
  pathname: '/p/a/t/h',
  path: '/p/a/t/h?query=string',
  href: 'http://user:pass@host.com:8080/p/a/t/h?query=string#hash'
})

结果为:
'http://user:pass@host.com:8080/p/a/t/h?query=string#hash'

url.resolve——拼接 url 字符串

我们可以通过 url.resolve 为 URL 或 href 插入 或 替换原有的标签,接收参数: from 源地址,to 需要添加或替换的标签。

url.resolve(from, to);

例子为:

url.resolve('/one/two/three', 'four')
=> '/one/two/four'
url.resolve('http://example.com/', '/one')
=> 'http://example.com/one'
url.resolve('http://example.com/one', '/two')
=> 'http://example.com/two'

Query String 模块 Query String

querystring.escape——字符串编码

querystring.escape("appkey=123&version=1.0.0+");
// 'appkey%3D123%26version%3D1.0.0%2B'

querystring.unescape——字符串解码

querystring.unescape("appkey%3D123%26version%3D1.0.0%2B");
// 'appkey=123&version=1.0.0+'

querystring.stringify(querystring.encode)——序列化对象

querystring.stringify(obj[, sep][, eq][, options])
querystring.encode(obj[, sep][, eq][, options])

接收参数

  • obj: 欲转换的对象
  • sep:设置分隔符,默认为 ‘&'
  • eq:设置赋值符,默认为 ‘='
querystring.stringify({ foo: "bar", baz: ["qux", "quux"], corge: "" });
// 'foo=bar&baz=qux&baz=quux&corge='

querystring.stringify(
  { foo: "bar", baz: ["qux", "quux"], corge: "" },
  ",",
  ":"
);
// 'foo:bar,baz:qux,baz:quux,corge:'

querystring.parse(querystring.decode)——解析 query 字符串

querystring.parse(str[, sep][, eq][, options])
querystring.decode(str[, sep][, eq][, options])

接收参数

  • str:欲转换的字符串
  • sep:设置分隔符,默认为 ‘&'
  • eq:设置赋值符,默认为 ‘='
  • [options] maxKeys 可接受字符串的最大长度,默认为 1000
querystring.parse('foo=bar&baz=qux&baz=quux&corge=')
// { foo: 'bar', baz: [ 'qux', 'quux' ], corge: '' }

querystring.parse('foo:bar,baz:qux,baz:quux,corge:',',',':')
{ foo: 'bar', baz: [ 'qux', 'quux' ], corge: '' }

node http 模块发起请求

平时喜欢看博客,毕竟买书要钱而且有时候没有耐心读完整本书,所以很喜欢逛一些网站,但是很多时候把所有的站逛一下又没有那么多时间,哈哈,所以就准备把常去的网站的文章爬出来做一个文章列表,一来省去收集的时间,二来借此熟悉熟悉 node 相关的东西。这里我们首先看一个爬虫的小例子,下面以 SF 为例加以说明(希望不要被封号)。

http.request 与 http.get 的区别

http.request(options, callback)

options 可以是一个对象或一个字符串。如果 options 是一个字符串, 它将自动使用 url.parse()解析。http.request() 返回一个 http.ClientRequest 类的实例。ClientRequest 实例是一个可写流对象。如果需要用 POST 请求上传一个文件的话,就将其写入到 ClientRequest 对象。使用 http.request()方法时都必须总是调用 req.end()以表明这个请求已经完成,即使响应 body 里没有任何数据。如果在请求期间发生错误(DNS 解析、TCP 级别的错误或实际 HTTP 解析错误),在返回的请求对象会触发一个'error'事件。

Options 配置说明:

  • host:请求发送到的服务器的域名或 IP 地址。默认为'localhost'。
  • hostname:用于支持 url.parse()。hostname 比 host 更好一些
  • port:远程服务器的端口。默认值为 80。
  • localAddress:用于绑定网络连接的本地接口。
  • socketPath:Unix 域套接字(使用 host:port 或 socketPath)
  • method:指定 HTTP 请求方法的字符串。默认为'GET'。
  • path:请求路径。默认为'/'。如果有查询字符串,则需要包含。例如'/index.html?page=12'。请求路径包含非法字符时抛出异常。目前,只否决空格,不过在未来可能改变。
  • headers:包含请求头的对象。
  • auth:用于计算认证头的基本认证,即'user:password'
  • agent:控制 Agent 的行为。当使用了一个 Agent 的时候,请求将默认为 Connection: keep-alive。可能的值为: - undefined(默认):在这个主机和端口上使用[全局 Agent][]。 - Agent 对象:在 Agent 中显式使用 passed。 - false:在对 Agent 进行资源池的时候,选择停用连接,默认请求为:Connection: close。
  • keepAlive:{Boolean} 保持资源池周围的套接字在未来被用于其它请求。默认值为 false
  • keepAliveMsecs:{Integer} 当使用 HTTP KeepAlive 的时候,通过正在保持活动的套接字发送 TCP KeepAlive 包的频繁程度。默认值为 1000。仅当 keepAlive 被设置为 true 时才相关。

http.get(options, callback)

因为大部分的请求是没有报文体的 GET 请求,所以 Node 提供了这种便捷的方法。该方法与 http.request()的唯一区别是它设置的是 GET 方法并自动调用 req.end()。

爬虫实例

这里我们使用 es6 的新特性写:

const https = require("https");
https
  .get("https://segmentfault.com/blogs", res => {
    console.log("statusCode: ", res.statusCode);
    console.log("headers: ", res.headers);
    var data = "";
    res.on("data", chunk => {
      data += chunk;
    });
    res.on("end", () => {
      console.log(data);
    });
  })
  .on("error", e => {
    console.error(e);
  });

这样一小段代码我们就可以拿到 segmentfault 的博客页面的源码,需要说明的是因为这里请求的网站是 https 协议,所以我们需要引入 https 模块,用法同 http 一致。下面需要做的是解析 html 代码,下面我们需要做的就是解析源码,这里我们可以引入 cheerio,一个 node 版的类 jQuery 模块,npm 地址:https://www.npmjs.com/package/cheerio。

首先第一步安装:

npm install cheerio

然后就是将 html 代码 load 进来,如下:

var cheerio = require('cheerio'),
var $ = cheerio.load(html);

最后我们就是分析 dom 结构咯,通过类似于 jQuery 的方法获取 DOM 元素的内容,然后就将数据重新组装成 json 结构的数据。这里就是分析源码然后,这里我就不详细分析了,直接上代码:

function htmlparser(html) {
  var baseUrl = "https://segmentfault.com";

  var $ = cheerio.load(html);
  var bloglist = $(".stream-list__item");

  var data = [];

  bloglist.each(function(item) {
    var page = $(this);
    var summary = page.find(".summary");
    var blogrank = page.find(".blog-rank");

    var title = summary.find(".title a").text();
    var href = baseUrl + summary.find(".title a").attr("href");
    var author = summary
      .find(".author li a")
      .first()
      .text()
      .trim();
    var origin = summary
      .find(".author li a")
      .last()
      .text()
      .trim();
    var time = summary.find(".author li span")[0].nextSibling.data.trim();
    var excerpt = summary
      .find("p.excerpt")
      .text()
      .trim();
    var votes = blogrank
      .find(".votes")
      .text()
      .trim();
    var views = blogrank
      .find(".views")
      .text()
      .trim();

    data.push({
      title: title,
      href: href,
      author: author,
      origin: origin,
      time: time,
      votes: votes,
      views: views,
      excerpt: excerpt
    });
  });

  return data;
}

结果如下:

[{ title: '转换流',
    href: 'https://segmentfault.com/a/1190000007036273',
    author: 'SwiftGG翻译组',
    origin: 'SwiftGG翻译组',
    time: '1 小时前',
    votes: '0推荐',
    views: '14浏览',
    excerpt: '作者:Erica Sadun,原文链接,原文日期:2016-08-29译者:Darren;校对:shank
s;定稿:千叶知风 我在很多地方都表达了我对流的喜爱。我在 Swift Cookbook 中介绍了一些。现
在,我将通过 Pearson 的内容更新计划...' },
......
]

这里我们只是抓取了文章列表的一页,如果需要抓取多页,只需要将内容再次封装一下,传入一个地址参数?page=2,如:https://segmentfault.com/blogs?page=2 另外我们也没有将详情页进一步爬虫,毕竟文章的目的只是学习,同时方便自己查看列表,这里保留原始地址。

温馨提示:大家不要都拿 sf 做测试哦,不然玩坏了就不好。

模拟登陆

哈哈,写到这里已经很晚了,用 node 试了试模拟登陆 SF,结果 404,暂时没有什么思路,等有时间再试试专门开篇讲解咯。这里推荐一篇之前看到的文章:记一次用 NodeJs 实现模拟登录的思路

文章代码源码下载:https://github.com/zhaomenghuan/learn-javascript/tree/master/example/node-http

参考

写这些代码也许就一两个小时的事,写一篇大家好接受的文章需要几天的酝酿,如果文章对您有帮助请我喝杯咖啡吧!