xmlweb

xmlweb 是一个基于状态机理论设计的 web 服务器,使用它可以设计出高可读性、高可维护性的 web 服务应用。

概述

xmlweb 是一个基于状态机理论设计的 web 服务器,使用它可以设计出高可读性、高可维护性的 web 服务应用。另外,xmlweb 基于 xmlplus 实现,你可以自由地使用 xmlplus 特性来高效地完成开发任务。当然,在阅读本文档之前,请确保你已经熟练使用 xmlplus 框架。

安装

通过 npm,你可以非常方便使用如下命令安装 xmlweb:

$ npm install xmlweb

或者,你也可以通过 git 和 npm 使用如下的命令来安装:

$ git clone https://github.com/qudou/xmlweb.git && cd xmlweb && npm install

下面是项目的基本组织结构:

xmlplus/
├── xmlweb.js
├── docs/
└── example/

在根目录 xmlplus/ 下,xmlweb.js 是源文件,目录 docs/ 和目录 example/ 包含同名的子级目录。目录 docs/ 包含框架的文档文件,example/ 包含与文档相关的配套示例代码。

一个简单的 web 服务器

通过下面给出的代码,可以搭建一个非常简单的静态 web 服务器。

// 00-01
let xmlweb = require("xmlweb");
xmlweb("xp", function (xp, $_, t) {
    $_().imports({
        Index: {
            xml: "<i:HTTP xmlns:i='//xmlweb'>\
                    <i:Static id='static' root='static'/>\
                  </i:HTTP>",
            fun: function (sys, items, opts) {
                console.log("service is ready");
            }
        }
    });
}).startup("//xp/Index");

注意到示例开头的一个注释 00-01,那么你可以根据此注释定位到目录 /example/00-overview/01,注释中的 00 即章节序,01 就是示例所在目录的名称。

在测试此示例之前,你需要在代码文件所在的当前目录创建一个名为 static 的子目录作为静态 web 服务器的根目录,并且在根目录下创建一个简单的 HTML 文件,假设该文件的名称为 index.html。那么,你可以在浏览器中输入如下的 URL 来访问刚才创建的文件。

http://localhost:8080/index.html

除了这个地址外,任何的其它的输入都会返回一个内置的简单的 404 页面。

状态机

由于 xmlweb 是一个基于状态机理论设计的 web 服务器框架,所以在这一章需要讲清楚 xmlweb 中与状态机相关的节点与数据流的概念。在此后的几章内容都是围绕这两个概念进行阐述。

状态机节点

状态机节点可以是任何侦听了 enter 事件的组件对象。可以实例化为状态机节点的组件称为状态机的节点组件,简称节点组件。如下面的组件 Hello 是一个状态机的节点组件:

// 01-01
Hello: {
    fun: function (sys, items, opts) {
        this.on("enter", (e, d) => {
            d.res.setHeader("Content-Type", "text/html");
            d.res.end("hello, world");
        });
    }
}

另外,像 xmlweb 中内置的 Router、Rewrite 以及 Redirect 等组件都包含有事件 enter 的侦听器,它们都可以实例化为状态机节点使用。下面是一个 Router 节点组件的使用示例:

<!-- 01-01 -->
<i:HTTP xmlns:i='//xmlweb'>
    <i:Router url='/index.html'/>
    <Hello id='hello'/>
</i:HTTP>

你可以输入地址 http://localhost:8080/index.html 来测试此示例,该请求的返回值是一个值为 hello, world 的字符串。

状态机节点组件还可以是由组件 Flow 定义的组件,组件 Flow 内部也包含事件 enter 的侦听器,所以它也是一个节点组件。不过它比较特殊,它可以与子节点组件组合成一个状态机。如下面使用 Flow 组件定义了一个状态机:

<!-- 01-02 -->
<i:Flow xmlns:i='//xmlweb'>
    <i:Router url='/index.html'/>
    <Hello id='hello'/>
</i:Flow>

该状态机可以作为由 HTTP 组件定义的状态机的子状态机使用,如下面给的示例所示:

<!-- 01-02 -->
<i:HTTP xmlns:i='//xmlweb'>
    <Machine id='machine'/>
</i:HTTP>

此示例组件 Machine 即上面的由组件 Flow 定义的子状态机。你同样可以输入地址 http://localhost:8080/index.html 来测试此示例。

与组件 Flow 类似,组件 HTTP 也是节点组件并且它可以与子节点组件组合成一个状态机。在一个 web 服务应用中,HTTP 节点只能有一个,且只作为的顶层节点使用。

组件 HTTP 包含一个静态参数 listen,此参数用于指明 web 应用服务的端口号,参数 listen 的默认值是 8080。例如,下面是一个使用 80 端口号的 web 服务应用:

<!-- 01-03 -->
<i:HTTP listen='80' xmlns:i='//xmlweb'>
    <i:Router url='/index.html'/>
    <Hello id='hello'/>
</i:HTTP>

该示例的组件 Hello 与前面给出的一致。你可以输入地址 http://localhost/index.html 来测试此示例。

数据流

数据流是一个普通对象,所谓普通对象指的是使用 {}new Object 创建的对象。数据流由 HTTP 组件对象在接收到用户的请求时生成。在初始状态,它主要包含了如下的内容:

  • req:请求对象(request)
  • res:响应对象(response)
  • ptr:状态机内部使用的指针数组
  • url:与 req.url 一致

在数据流经过的各个节点,你不应该对前三个对象做任何的改动。

默认情况下,状态机的数据流由事件 next 驱动并从上往下流动,这是将状态机组件取名为 Flow 的直接原因。如下面的示例所示:

<!-- 01-04 -->
<i:HTTP xmlns:i='//xmlweb'>
    <i:Router url='/:id.html'/>
    <Creater id='creater'/>
    <i:Static id='static' root='static'/>
</i:HTTP>

此 web 服务接收所有的具有 URL 模式 /:id.html 的 GET 请求。当有匹配的请求时,数据流先进入 Router 组件节点。在 Router 组件节点完成 URL 解码后会派发事件 next,然后数据流进入 Creater 组件节点。在 Creater 组件节点完成数据的相关处理后派发事件 next,于是数据流最终达到 Static 组件节点。为了清楚地认识这个过程,我们来看看组件 Creater 的具体构造。

// 01-04
Creater: {
    xml: "<h1 id='creater'/>",
    fun: function (sys, items, opts) {
        let fs = require("fs");
        this.on("enter", (e, d) => {
            sys.creater.text("hello " + d.args.id);
            fs.writeFileSync("static" + d.req.url, this.serialize(), "utf8");
            this.trigger("next", d);
        });
    }
}

组件 Creater 的函数项部分主要根据传输来的数据生成响应页面并将页面内容写入目录 static,然后交给静态服务器做最后的处理。注意,当输入与模式串 /:id.html 不匹配的 URL 时,你将得到 xmlweb 内置的 404 页面。

数据流的跳转

相对于默认的垂直数据流的单向流动,状态机允许在任一时刻跳转到任意的已命名的组件节点,下面我们通过一个示例来说明:

<!-- 01-05 -->
<i:HTTP xmlns:i='//xmlweb'>
    <i:Router url='/:id.html'/>
    <Jump id='jump'/>
    <Response id='page1' text='hello'/>
    <Response id='page2' text='world'/>
</i:HTTP>

该状态机包含一个 Jump 节点组件,此节点用于过滤 id 值为 index 的请求,也就是 url 为 /index.html 的请求。对于这种请求,数据流将不再进入组件节点 page1,而是直接进入组件节点 page2。下面给出组件 Jump 的具体实现:

// 01-05
Jump: {
    fun: function (sys, items, opts) {
        this.on("enter", (e, d) => {
            let isIndex = d.args.id == "index";
            this.trigger("next", [d, isIndex ? null: "static"]);
        });
    }
}

正如组件 Jump 的函数项内容所指出的,要完成数据流的跳转,需要在派发 next 事件时,在系统函数 trigger 的参数部分提供一个目的状态机节点名。

需要强调的是,数据流只能跳转至当前所在节点的后继节点或者跳转到已命名的组件节点,而无法直接跳转到未命名的其它类型的节点。注意,在数据流跳转时,一定要给定合适的终止条件以避免陷入死循环。

状态机的停机

由事件 next 导致的停机

在 xmlweb 中,状态机的停机指的是结束当前的状态机数据的流动,返回到上一层状态机。状态机的停机有两种情况,一种是由事件 next 导致的停机,请看下面的一个示例:

<!-- 01-06 -->
<i:HTTP xmlns:i='//xmlweb'>
    <i:Router url='/:id.html'/>
    <Machine id='machine'/>
    <Hello id='hello'/>
</i:HTTP>

该示例中,组件 Machine 是一个由组件 Flow 定义的子状态机组件,下面是它的视图项部分:

<!-- 01-06 -->
<i:Flow xmlns:i='//xmlweb'>
    <Next id='next'/>
</i:Flow>

其中的 Next 组件的具体内容如下所示:

// 01-06
Next: {
    fun: function (sys, items, opts) {
        this.on("enter", (e, d) => this.trigger("next", d));
    }
}

组件 Next 的 enter 事件的侦听器直接派发了 next 事件。然而 Next 组件节点不存在后继节点,所以该事件的派发将导致当前状态机停机,也就是直接返回到上一层级的状态机。当数据流到达上一层级状态机后,数据流会向 Machine 组件节点的后继节点流动。也就是说,数据流最终会进入 Hello 组件节点。

由事件 reject 导致的停机

另一种停机由事件 reject 触发,该事件的派发将直接导致状态机停机,现修改上面的组件 Next 如下:

// 01-07
Next: {
    fun: function (sys, items, opts) {
        this.on("enter", (e, d) => this.trigger("reject", d));
    }
}

在示例中,该组件与上述的由事件 next 导致的停机效果是一样的,但如果子状态机是下面这样子:

<!-- 01-08 -->
<i:Flow xmlns:i='//xmlweb'>
    <Next id='next'/>
    <Hello id='hello' text='hello, alice'/>
</i:Flow>

那么,Next 组件节点的 next 事件的派发将不会导致停机,因为 Next 组件节点有一个后继的 Hello 组件节点,数据流最终会进入 Hello 组件节点。而事件 reject 的派发则不同,无论 Hello 组件节点是否拥有后继节点,都会导致当前状态机的停机发生。

停机后的数据流

如前所述,当前状态机停机后,数据流将返回上一层级的状态机。如果不给派发事件的系统函数 trigger 提供目的节点名,那么数据流将试图跳转到当前状态机节点的后继节点。否则,数据流会试图跳转到上层状态机的同名节点。请看下面的示例:

// 01-09
Next: {
    fun: function (sys, items, opts) {
        this.on("enter", (e, d) => this.trigger("reject", [d, "dynamic"]));
    }
}

该组件内部的侦听器在派发 reject 事件时,还携带目的节点名 dynamic。如果当前状态机的上一层状态机包一个含名为 dynamic 的节点,那么最终数据流会跳转到上一层状态机的名为 dynamic 的节点。

最后需要说明的是,一个状态机停机事件发生并且数据流到达上一层状态机后,只要条件满足,上一层状态机还是会停机,这样就形成了停机事件的冒泡现象。

HTTP 组件节点的停机

前面说过,组件 HTTP 是一个比较特殊的状态机节点组件,它只能作为的顶层节点组件使用。如果在 HTTP 节点捕获到停机事件,那么 HTTP 节点将返回 xmlweb 返回内置的 404 页面。下面的一个简单的示例演示了这一点:

<!-- 01-10 -->
<i:HTTP xmlns:i='//xmlweb'>
    <Hello id='hello'/>
</i:HTTP>

组件 Hello 的定义如下:

// 01-10
Hello: {
    fun: function (sys, items, opts) {
        this.on("enter", (e, d) => this.trigger("reject", d));
    }
}

该组件的函数项中只是简单地直接派发 reject 事件,此事件最终只能由 HTTP 组件节点捕获处理。并且无论你发送什么 URL 请求,都将得到一个 404 页面作为回应。

路由

路由组件 Router 是 xmlweb 内置的最重要的组件之一,它可根据请求类型与 URL 模式串引导状态机数据流的走向。它通常作为状态机节点的第一个子节点使用。

请求类型

路由组件 Router 有一静态参数 mothod 用于指明接受的是 GET 请求还是 POST 请求。其中,默认的请求方式是 GET。如下面的示例所示,该 web 服务接收任意路径的 GET 请求。

<!-- 02-01 -->
<i:HTTP xmlns:i='//xmlweb'>
    <i:Router id='router'/>
    <Response id='response'/>
</i:HTTP>

此 web 服务对于不符合要求的请求会导致服务返回内置的 404 页面。再请看下面的一个 POST 请求示例:

<!-- 02-02 -->
<i:HTTP xmlns:i='//xmlweb'>
    <i:Router method='POST'/>
    <Response id='response'/>
</i:HTTP>

该 web 服务接受任何路径的 POST 请求,但不接收任何的 GET 请求。同样一旦接收到不符合要求的请求会导致服务返回内置的 404 页面。下面是组件 Response 的函数项:

// 02-02
function (sys, items, opts) {
    this.on("enter", (e, d) => {
        d.res.setHeader("Content-Type", "application/json;");
        d.res.end(JSON.stringify({data: "hello, world"}));
    });
}

为了避免由于跨域请求所带来的问题,你可以使用如下的 curl 命令来完成 POST 请求的测试。当然,要测试 GET 请求所返回的结果,只需要把上面命令行的 POST 该为 GET 即可。

$ curl -X POST http://localhost:8080

如果你希望接收任意的 GET 或者 POST 请求,可以指定 method 的值为 '*',如下面的示例所示:

<!-- 02-03 -->
<i:HTTP xmlns:i='//xmlweb'>
    <i:Router method='*'/>
    <Response id='response'/>
</i:HTTP>

另外,组件 Router 包含的静态参数 url 用于指明所接受的路径集合,其默认值为 /*。如下面的示例所示:

<!-- 02-04 -->
<i:HTTP xmlns:i='//xmlweb'>
    <i:Router url='/index.html'/>
    <Response id='response'/>
</i:HTTP>

由于 Router 组件对象接受的是 GET 请求,所以该示例接受路径为 '/index.html' 的 GET 请求。

路径匹配

上面说过,路由组件 Router 包含的静态参数 url 用于指明所接受的路径集合,该参数表达式的写法类似于正则表达式。此组件内部由开源模块 path-to-regexp 来解析此参数。下面是一些常用的表达模式:

  • 命名参数:由符号 : 加参数名来定义,如 /:key
  • 可选后缀:由符号 ? 紧跟参数定义,表示参数为可选,如 /:key?
  • 零至多个:由符号 * 紧跟参数定义,表示允许参数为零个或多个,如 /:key*
  • 一至多个:由符号 + 紧跟参数定义,表示允许参数为一个或多个,如 /:key+
  • 自定义参数:可以是任何的合法的正则表达式的字符串表示,如 /:key(\\w+)
  • 星号:星号 * 用于匹配一切子级路径,如 /:key/*

例如,下面的 web 服务应用可以接受路径为 /xml 或者任何以 /xml 开头的 GET 请求:

<!-- 02-05 -->
<i:HTTP xmlns:i='//xmlweb'>
    <i:Router url='/xml:key?'/>
    <Response id='response'/>\
</i:HTTP>

再如,下面的 web 服务应用可以接受路径为 /helo 或者 /hello 的 GET 请求:

<!-- 02-06 -->
<i:HTTP xmlns:i='//xmlweb'>
    <i:Router url='/he(l?)lo'/>
    <Response id='response'/>\
</i:HTTP>

注意,上面的模式串不能写成 /hel?lo,否则问号会被当成字符处理。

命名参数值的获取

如果静态参数 url 中包含有命名参数,那么数据流经过 Router 组件节点时,各命名参数相应的值将会被解析出来作为一个普通的 JSON 对象赋值给数据流的子参数 args。请看下面的一个示例:

<!-- 02-07 -->
<i:HTTP xmlns:i='//xmlweb'>
    <i:Router url='/:foo/:bar'/>
    <Response id='response'/>\
</i:HTTP>

该示例的 Index 组件的函数项的具体内容如下:

// 02-07
function (sys, items, opts) {
    this.on("enter", (e, d) => {
        d.res.setHeader("Content-Type", "text/html");
        d.res.end(JSON.stringify(d.args));
    });
}

运行这个示例,如果输入的 url 是 http://localhost:8080/alice/bob,那么你将会看到如下的输出:

{ "foo": "alice", "bar": "bob" }

GET 请求数据的获取

与命名参数值的获取类似,GET 请求数据的获取也是由数据流的子参数 args 参数得到的。现在让我们对上面的示例做点修改:

<!-- 02-08 -->
<i:HTTP xmlns:i='//xmlweb'>
    <i:Router url='/:foo\\?bar=:bar'/>
    <Response id='response'/>\
</i:HTTP>

该示例的 Response 组件的函数项的与前面的一致。运行这个示例,如果输入的 url 是 http://localhost:81/alice?bar=bob,那么你将会看到与前一个示例一样的输出:

{ "foo": "alice", "bar": "bob" }

另外要注意,示例中的模式串中的问号必需加双斜杆,否则该问号将对前面的 foo 起作用。

POST 请求数据的获取

与 GET 请求不同,如果是 POST 请求,你不但可以获取到上述的两类数据,还可以得到请求报文的主体信息。该信息被解析出来后会赋值给数据流的子参数 body。请看下面的示例:

<!-- 02-09 -->
<i:HTTP xmlns:i='//xmlweb'>
    <i:Router url='/' method='POST'/>
    <Response id='response'/>\
</i:HTTP>

该示例的 Response 组件的函数项的具体内容如下:

// 02-09
function (sys, items, opts) {
    this.on("enter", (e, d) => {
        d.res.setHeader("Content-Type", "text/html");
        d.res.end(JSON.stringify(d.body));
    });
}

为了避免由于跨域请求所带来的问题,你可以使用如下的 curl 命令来完成 POST 请求的测试:

$ curl -H "Content-type: application/json" -X POST -d '{"key":"2017"}' http://localhost:8080

仔细观察,该命令添加了一个请求头 Content-type: application/json,这样后台会试图将目标数据解析为 JSON 组件对象。

有时你需要自己处理请求报文的主体信息,那么你可以设置 Router 组件的静态参数 usebodyfalse 来禁止对主体信息的解析。

静态服务器

为了方便使用,xmlweb 内置了一个简单的静态服务器的节点组件 Static。当然,在搭建 web 应用时,你可以不使用它或者使用自定义的静态服务器的节点组件。

静态接口

为了了解清楚该组件是如何使用的,我们从一个最简单的示例开始:

<!-- 03-01 -->
<i:HTTP xmlns:i='//xmlweb'>
    <i:Static id='static'/>
</i:HTTP>

该静态 web 服务器侦听 8080 端口,并以代码所在的文件目录为工作目录。当然,你最好给它设置一个独立的工作目录。该示例尽管简单,但它可以正常工作,下面是一些可以提供的静态接口属性:

  • url: String 描述了允许接受的请求路径集,默认为 /*
  • rootString 工作目录,默认为代码所在的文件目录

内部结构

为了更好的使用该组件,对 Static 组件的内部做些了解是很有必要的。组件 Static 实质上是一个状态机组件,下面是此组件的视图项:

<Flow xmlns:s='static'>
    <Router id='router'/>
    <s:Status id='status'/>
    <s:Cache id='catch'/>
    <s:Ranges id='ranges'/>
    <s:Compress id='compress'/>
    <s:Output id='output'/>
    <s:Error id='error'/>
</Flow>

从此视图项可以看出,该状态机组件包含若干个子节点组件,下面是各子节点组件的基本用途:

  • Router:过滤掉所有的非 GET 请求
  • Status:获取目录文件的状态属性
  • Catch:文件缓存处理
  • Ranges: 部分资源的范围请求的处理
  • Compress:文件压缩处理
  • Output:响应请求
  • Error: 处理状态码为 304、412、416、500 的响应

自定义 404 页面

Static 组件节点对不存在的 URL 请求会导致停机,从而将后续处理交给 HTTP 组件节点,而 HTTP 组件节点的处理方式是返回一个简单的 404 页面。我们如果想返回不一样的 404 页面,那么可以自己定义一个组件节点并将其作为 Static 组件节点的后继。如下面的示例所示:

<!-- 03-02 -->
<i:HTTP xmlns:i='//xmlweb'>
    <i:Static id='static'/>
    <NotFound id='notfound'/>
</i:HTTP>

此示例中,所有的 404 响应都会由 NotFound 组件节点完成。该组件的具体定义如下:

// 03-03
NotFound: {
    xml: "<h1>This is not the page you are looking for.</h1>",
    fun: function (sys, items, opts) {
        this.on("enter", (e, r) => {
            r.res.statusCode = 404;
            r.res.setHeader("Content-Type", "text/html");
            r.res.end(this.serialize());
        });
    }
}

当然,这个自定义组件返回的 404 页面还是非常简陋的,你可以进一步修改成你想要的样子。

URL 重写

URL 重写是将一个进入的 URL 重新写成另一个 URL 的过程。

不改变原始值的重写

状态机 中说过。数据流由 HTTP 组件对象在接收到用户的请求时生成。在初始状态,它主要包含了如下的内容:

  • req:请求对象(request)
  • res:响应对象(response)
  • ptr:状态机内部使用的指针数组
  • url:与 req.url 一致

URL 的重写并不改变请求对象 req 中的 url 原始值,它改变的是数据流中的 url 参数。请看下面的示例:

<!-- 04-01 -->
<i:HTTP xmlns:i='//xmlweb'>
    <i:Rewrite from='/' to='/index.html'/>
    <Response id='response'/>
</i:HTTP>

此示例的 Response 组件内容如下:

// 04-01
Response: {
    fun: function (sys, items, opts) {
        this.on("enter", (e, d) => {
            d.res.setHeader("Content-Type", "text/html");
            d.res.end(`original URL: ${d.req.url}; rewrited URL: ${d.url}` );
        });
    }
}

在浏览器中输入 http://localhost:8080,那么你将看到原始的 url 以及经过 Response 组件节点重写后的 url

重写规则

URL 的重写规则允许使用 路由 中介绍过的路径匹配规则,请看下面的示例:

<!-- 04-02 -->
<i:HTTP xmlns:i='//xmlweb'>
    <i:Rewrite from='/:id' to='/:id.html'/>
    <Response id='response'/>
</i:HTTP>

此示例的 Response 组件与上一个示例相同。此示例会将任何的具有模式 /:id 的 URL 重写为具有模式 /:id.html 的 URL 输出,其中的 id 值保持一致。

有一点需要提醒,Router 组件中对路径的匹配使用的是原始的 url,而不使用数据流中 url 参数,所以不要试图使用 Router 组件节点过滤使用 Rewrite 组件节点重写后的 url 值。

指定多个重写项

上面给出的 Rewrite 组件节点只提供一个 URL 的重写规则。如果需要提供多个 URL 重写规则,你可以像下面这样定义一组重写规则:

// 04-03
Rewrite: {
    xml: "<i:Rewrite xmlns:i='//xmlweb/rewrite'>\
             <i:Roule from='/' to='/index'/>\
             <i:Roule from='/:id' to='/id.html'/>\
          </i:Rewrite>
}

组件 Roule 可以作为 Rewrite 的子组件使用来定义一条重写规则。下面是使用新定义的 Rewrite 组件的示例:

<!-- 04-03 -->
<i:HTTP xmlns:i='//xmlweb'>
    <Rewrite id='rewrite'/>
    <Response id='response'/>
</i:HTTP>

此示例的组件 Response 与前面给出的一致。你可以在浏览器中输入地址 http://localhost:8080 与地址 http://localhost:8080/index 分别测试两种不同的情况。

URL 重定向

xmlweb 内置的 Redirect 组件支持下面四种类型的重定向:

  • 301: Moved Permanently
  • 302: Found
  • 303: See Other
  • 307: Temporary Redirect

其中,响应码为 301 的重定向为永久重定向;响应码为 302303 的重定向为临时重定向,它们的差别在于后者明确表示客户端应采用 GET 获取资源;响应码为 307 的重定向和 302 类似,由于许多浏览器会错误地响应 302 应答进行重定向,即将原来的 POST 改为 GET 请求,但 307 不会。

Redirect 组件的默认的采用的状态码是 302,如下面的示例所示:

<!-- 05-01 -->
<i:HTTP xmlns:i='//xmlweb'>
    <i:Redirect to='http://xmlplus.cn'/>
</i:HTTP>

该示例对于任何的请求都会重定向到新地址 http://xmlplus.cn

你可以通过静态参数 statusCode 重新指定重定向的类型,如下面的示例指定了一个永久的重定向:

<!-- 05-02 -->
<i:HTTP xmlns:i='//xmlweb'>
    <i:Redirect statusCode='301' to='http://xmlplus.cn'/>
</i:HTTP>

会话

HTTP 协议是一种无状态的协议,为了弥补这种缺陷,需要引入 session 技术。下面从会话的创建、存储以及移除三个方面来讲述。

会话的创建

xmlweb 内置了一个 Session 组件,你可以像下面这样创建一个 Session 组件节点:

<!-- 06-01 -->
<i:HTTP xmlns:i='//xmlweb'>
    <i:Session id='session'/>
    <Response id='response'/>
</i:HTTP>

组件 Session 包含了如下的几个静态参数,你可以按需对默认值进行覆盖:

  • maxAgeInteger Cookie 与 Session 的存活时间,默认为 24 * 3600 * 1000ms
  • secure: 是否仅在 HTTPS 安全连接时才可以发送 Cookie,默认为 false
  • httpOnly: Boolean 是否禁止 JavaScript 脚本访问 Cookie,默认为 true

在上面示例中,一旦数据流经过 Session 组件节点,你就可以在数据流上获取到一个名为 session 的对象。当然,数据流上还会附带一个 cookies 对象,下面是此示例的 Response 组件的具体内容:

// 06-01
Response: {
    fun: function(sys, items, opts) {
        this.on("enter", (e, d) => {
            d.session.count = d.session.count || 0;
            d.session.count++;
            d.res.setHeader("Content-Type", "text/html");
            d.res.end(`you viewed this site ${d.session.count} times`);
        });
    }
}

此示例记录了用户访问站点的次数,每访问一次,sessioncount 属性值便增加 1。数据流中的 session 对象在创建之初包含了两个内容:ssidcreatetime,前者是维系会话的标识符,后者是 session 对象的创建时间。

会话的存储

默认情况下,session 并不进行持久化存储,所以当你重启上述示例,原有的计数器会从 0 开始。如果你想对 session 进行持久化存储,可以在必要的时候发送一个 save-session 的消息,请看下面的示例:

// 06-02
Response: {
    fun: function(sys, items, opts) {
        this.on("enter", (e, d) => {
            d.session.count = d.session.count || 0;
            d.session.count++;
            this.notify("save-session", d.session);
            d.res.setHeader("Content-Type", "text/html");
            d.res.end("you viewed this site ${d.session.count} times");
        });
    }
}

该组件修改自上一节的 Response 组件,每当计数器发生更改的时候,则发送一次 save-session 消息以保存更改后的数据。

xmlweb 内置了一个 session 的存储驱动组件 Storage,它位于命名空间 //xmlweb/session 中,它包含了如下的三个接口:

  • load(): 加载存储的所有的 session 对象,加载完毕需派发 session-loaded 事件,改事件携带一个 session 数组
  • save(ssid, session): 保存或者覆盖一个 session 对象,如果已存在则覆盖,否则新添加一个
  • remove(ssid): 移除一个 session 对象

组件 Storage 将数据以文本形式存放。你可以使用一个实现了上述接口的同名组件来覆盖默认的内置组件,如下面的示例所示:

// 06-03
Storage: {
    xml: "<Sqlite id='db'/>",
    fun: function(sys, items, opts) {
        function load() {
            items.db.all("SELECT * FROM sessions", (err, rows) => {
                if ( err ) { throw err; }
                let result = {};
                rows.forEach(item => result[item.ssid] = JSON.parse(item.data));
                sys.db.trigger("session-loaded", result, false);
            });
        }
        function save(ssid, session) {
            let stmt = items.db.prepare("REPLACE INTO sessions(ssid, data) VALUES(?,?)");
            stmt.run(ssid, JSON.stringify(session), err => {if (err) throw err});
        }
        function remove(ssid) {
            let stmt = items.db.prepare("DELETE FROM sessions WHERE ssid=?");
            stmt.run(ssid, err => {if (err) throw err});
        }
        return { load: load, save: save, remove: remove };
    }
}

该 Storage 组件简单地实现了将 session 数据保存在 sqlite 数据库中。此数据库仅包含两个列:ssiddata,其中列 ssid 是一个 session 标识符,列 data 用于存放 session 对象集。

会话的移除

有时候我们需要移除已存在的 session,比如用户的登出操作。要移除 session,只要发送一个 destroy-session 的消息即可,请看下面的示例:

// 06-04
Response: {
    fun: function(sys, items, opts) {
        this.on("enter", (e, d) => {
            d.session.count = d.session.count || 0;
            d.session.count++;
            if ( d.session.count > 5 )
                this.notify("destroy-session", d.session);
            d.res.setHeader("Content-Type", "text/html");
            d.res.end(`you viewed this site ${d.session.count} times`);
        });
    }
}

该组件内部判断计数器的计数情况,当计数器计数大于 5 时,随即派发一个移除 session 的操作,这样下次计数又得从 0 开始了。