操作系统GDB调试方法

概述

众所周知,我们的xv6操作系统是运行在QEMU上的。因此,想使用GDB调试就离不开QEMU的支持。幸运的是,QEMU确实为我们提供了一个接口:
通过在QEMU的运行命令中指定 -S -gdb tcp:xxx,即可在对应的TCP端口上开启一个GDB调试服务器的进程。
之后,通过自己的GDB远程连接到该Server上即可远程调试。

考虑到每个人的环境不同,本文将讨论三种方案:

  1. 只建立QEMU的GDB的调试服务。这样,您可以根据自己的情况,用比如命令行等手段,只要能把自己的GDB连接到调试服务上即可,然后手动使用b加断点等调试指令或使用其他的工具。请只阅读第一部分即可。
  2. 在虚拟机上使用CLion。优点是可以在IDE中图形化的断点与调试。缺点是虚拟机的性能和分辨率往往不太行,开发体验较差。这种方法只是比使用物理机少了一些准备的工作,不需要配置共享文件夹、SSH和端口转发等,但是CLion中的配置方法与物理机很类似,请直接参考那部分的内容。
  3. 在物理机上使用CLion。整体体验较好,但是配置相对麻烦一点。请阅读全部的部分。

如果在过程中遇到问题,可以联系作者:starrah@foxmail.com

以远程GDB调试服务器的方式运行QEMU

为了获得更好的体验,我编写了gdbhelper.py脚本。其内的原理是调用make qemu-gdb,但增加了更多的选项和做了更好的封装。
(提示:QEMU的GDB服务始终在tcp 25000端口上运行。这是在Makefile中的$(GDBPORT)变量中配置的。)

在虚拟机上打开终端,切换到代码所在目录(gdbhelper.py、Makefile等所在的目录)运行:

script
1
python3 gdbhelper.py

即可打开一个新窗口,但是这个窗口不会出现任何内容。这是因为使用GDB的情况下,qemu在启动后不会自动运行xv6的系统镜像,
需要等待用户GDB接入到调试服务器上后手动使用r运行。

上述命令是会打开一个新的QEMU的窗口的,因此无法在没有SDL图形界面接口的情况下运行。因此如果希望打开一个没有图形界面、直接在控制台上输入输出的QEMU(这样就可以使用SSH运行),请改为使用:

script
1
python3 gdbhelper.py -n

注:脚本的功能是先检查并尝试杀死已经存在调试进程,(以免端口被占用导致新的进程无法打开),然后运行make qemu-gdbmake qemu-nox-gdb。如果只想杀死现存的调试进程,不需要运行新的,请使用python3 gdbhelper.py -k
通过python3 gdbhelper.py -h可以查看该脚本的使用帮助。

至此,GDB的调试服务进程已经构建。如果您不打算使用CLion,则后面的部分可以不看。

配置物理机上CLion的远程调试

需要先做一些准备。这些内容都可以在网上搜索到相关教程,本文不再赘述:

  1. 建议配置一个虚拟机和物理机之间的共享文件夹。如果不这么做的话,那之后可能就得每次修改代码并重新make后,手动把编译结果的kernel文件同步到物理机上,否则由于符号不对应调试会出问题。如果把代码直接放在物理机和虚拟机之间的共享文件夹里就很方便。
    配置方法是在VirtualBox的设置-共享文件夹,例如下图;相关教程可以自行上网查阅。
  2. 虚拟机上配置SSH远程登录,请自行查看网上教程。(我印象中,从tuna的ubuntu-release上下载下来的系统镜像是自带了openssh-server的,应该不用特别配置)。
    之后,需要在VirtualBox中做一个NAT端口映射,使得物理机中可以访问到虚拟机的SSH22端口。打开虚拟机的设置,参见下图做配置:

    这样,在物理机上通过localhost:9022而不是22端口来访问虚拟机的SSH,并在之后通过25000端口可以直接调试到虚拟机中的GDB。
  3. Windows系统下建议安装MinGW并配置到CLion中作为工具链,以获得更好的代码补全的支持。本人在个人的服务器上提供了一个MinGW64的压缩包, 点击这里可以下载 (不保证链接永远有效)。
    用法是解压放置在硬盘任意位置,然后把其中的bin目录添加到环境变量PATH即可。在CLion中要配置工具链,方法可以自行上网查询。
  4. 踩坑记录:Windows上,最好控制台运行一下git config --global core.autocrlf input,之后把项目重新clone一遍,不然如果文件是CRLF行尾的话在Linux上没法过编译!

CLion中配置方法:(本IDE是中文的,英文请自行对应意思,或者去File-Settings-Plugins-Market搜索”Chinese”后安装中文语言包)

  1. 需要先建立一个SSH的配置。文件-设置-工具-SSH配置,加号新建,填写入全部的信息,例如下图填好后可以点下测试连接,出现成功连接即为配置好了,点击确定回到主界面。
  2. 右上角添加配置,加号,自定义构建应用程序
  3. 建立一个空白的自定义构建目标,用于占位,它不做任何操作,但是不能没有。
    点击界面上的“配置自定义构建目标”加号新建一个。名字随便起,比如“无操作”,点击构建右侧省略号,弹出外部工具的窗口,加号新建一个。
    名字随便起,下面的程序、参数全部留空。按照图中配置好。
  4. 一路确定回到开始的配置页面,按照下图中配置好。其中名称随便取,目标选刚才新建的。可执行文件其实也是不需要的、因为我们的核心在于一会要配置的远程外部工具,但这里还是不能不填,所以要随便写一个运行起来没什么意义的程序用作占位。
    比如可以选择C:\Windows\System32\cmd.exe,程序参数写成”/c”,这样就可以打开一个cmd但是不做任何操作。
    工作目录要选成项目所在的目录。
  5. 点击”执行前”右侧的加号,Run Remote External Tool,然后点击加号。弹出外部工具窗口,点击加号弹出创建工具窗口,里面要先点击下面的SSH configuration按钮,下拉菜单中选择刚刚在第1步创建的SSH配置。
    之后,名字随便起比如”QEMU-GDB”,程序选择/usr/bin/python3(就是你的虚拟机上python实际的安装目录,如果不确定可以使用which python3查看,如果没有python3请先安装),
    参数输入”gdbhelper.py -n”(-n表示以QEMU的-nographic模式运行,因为我们是SSH,无法开启独立窗口,只能直接利用shell本身进行)。
    工作目录选择代码在虚拟机上所在的位置(比如通过共享文件夹方式,那么就去找到共享文件夹的挂载点下,找到代码目录)。
    完整配置可参考下图。

    一路确定,最终的运行配置如下图。
  6. 再次点击左上角加号,GDB远程调试,名字随便起,GDB的话如果装了MinGW并配好了工具链建议用MinGW,否则用Bundled GDB应该也可以(我没测试)。
    target remote填”tcp:localhost:25000”,符号文件选择自己所在目录下的kernel文件(如果找不到这个文件的话不要紧,是因为没有make过,直接手动写上就可以,之后make的时候会自动构建出来)。
    路径映射点击右侧加号新建一行,本地填本地的项目路径,远程填虚拟机上对应的路径。完整配置例如下图。

到此,CLion的配置完成。接下来介绍每次的运行方法:

  1. 右上角运行配置的下拉菜单切换到上面第一个建立的配置(我这里的名字叫”运行QEMU-GDB”),点击绿色箭头运行。静待编译运行,直到控制台倒数第二行出现”gdb server is running”的提示,如下图。
  2. 右上角运行配置切换到上面第二个建立的配置(我这里的名字叫”调试”),点击虫子的标志即可。
    这步之前可以先去main.c里的main函数加个断点再进行。发现断点命中了,大功告成!
  3. 结束调试,点击右上角的停止按钮,全部停止。注意这时QEMU-GDB有可能不会马上退出,可能会延迟三五秒,出现Process finished的字样才表明真的退出了。

  4. 之后每次进行调试,都需要依次重复步骤1-2的方法,先运行QEMU-GDB的调试服务器,再运行本地的GDB连接到上面。
    此外,如果需要在操作系统的shell中进行输入输出的话,只要在下方切换到”Run”菜单就可以看见操作系统的shell了。

备忘

vue项目引用资源和配置axios请求路径

无论什么router模式、和是否在vue.config.js中配置publicPath以便部署在子目录下,这个方法一定适用:

使用process.env.BASE_URL,获取publicPath的绝对路径!

例如,若vue.config.js中有publicPath = '/someProj',则在vue项目中的任何地方,process.env.BASE_URL都可以得到/someProj/

因此建议无论是相对资源、还是axios的base,都与process.env.BASE_URL锚定!

1
2
3
4
5
6
7
8
9
10
11
<template>
<img :src="`${publicPath}qwq.png`" />
</template>

<script>
export default {
data: function () {return {
publicPath: process.env.BASE_URL
}}
}
</script>
1
2
3
4
5
6
7
import VueAxios from "vue-axios"
import axios from "axios"
// 假设后端都在vue的publicPath目录的api子目录下。这种情况只发生于前端静态文件的分发是由后端服务器本身完成的情况下。
axios.defaults.baseURL = `${process.env.BASE_URL}api`
// 假如后端与前端静态文件使用不同的子目录(即前端静态文件使用其他的WebServer,如Nginx或dynamicApi进行分发,而不是用后端server本身进行
// 分发)的话,就不必这样做了,直接用绝对的路径如`/someProjApi`就好。
Vue.use(VueAxios, axios)

可行方式表

表格中,依次表示后端的BASE_URL的配置方式:绝对引用、相对引用、根据publicPath引用,是否可行

根目录 子目录,后端在不同的目录 子目录,后端在位于前端目录下的孙子目录
history YNY YNY NNY
hash YYY YYY NYY

表格中,依次表示相对资源的配置方式:绝对引用、相对引用、根据publicPath引用,是否可行

根目录 子目录
history YNY NNY
hash YYY NYY

结论:

  • 根据publicPath引用永远可行
  • 相对路径是否可行,只取决于是否history模式。history模式相对路径一定不可行,hash模式则一定可行!
  • 绝对路径是否可行,要看要访问的路径是否“已知”。即对后端来说确定了目录名的情况就可以用绝对,反之如果作为前端子目录的孙子目录、由于前端子目录未知
    所以自己的目录也就无法知道了。对于前端来说则有根路径就是已知的,反过来如果没有根路径可供部署、只能部署到子路径,那么就是未知的。

利用dynamicApi,部署前端到子目录、后端到孙子目录的示例:

1
2
3
4
5
6
7
8
9
10
11
12
// someProj.js
var express = require("express")
var path = require("path")
var router = express.Router()
router.use("/api", (req, res, next) => {
res.json({u: req.url}) // 后端,作为前端someProj路径的孙子路径,通过 /someProj/api访问
})
router.use(express.static(path.join(__dirname, '../hhh'))) // 在/someProj url下,serve hhh目录下的前端静态文件
router.get("*", ((req, res, next) => res.sendFile(
path.join(__dirname, "../hhh/index.html") // 如果没有对应的静态文件,404 falllback到index.html。
)))
module.exports.default = router

async/await入门教程

什么是async/await

async/await是ES2017所提供的的新特性,其目的是提供简洁的如同顺序编程般丝滑的异步编程。使用async/await进行异步编程,除了在函数上加五个字母async之外,几乎没有任何其他的麻烦了。
async/await道理上是以PromiseGenerator为基础的,然而在我看来,async/await存在的目的就是不要像PromiseGenerator那么麻烦(毕竟,Promise thencatch去的就算不论是否足够优雅的问题,一旦逻辑关系复杂一点就连运行结果很多人也经常会绕蒙罢,从据说有些地方拿Promise的链式调用当做面试题也可见一斑。)
因此,本文的目标就是尽量避免不太常用的Promise和几乎用不到的Generator的相关知识,来开箱即用式的讲解async/await的使用方法。当然,作为一个成熟的程序员随着做的事情不断增多可能也无法避免需要深究一些Promise的细节,不过这就不是本文想做的事情了,相关的教程网上也已经有一大堆了。

基础:Promise

这一节将简单介绍Promise是什么,但是是以接下来讲解async/await为目的导向的,因此讲解的不会很全面,基本上是用到什么讲什么。
Promise,直译叫承诺。它表示一个“延期”的结果,即在将来的某一时刻,你所持有的Promise对象可以让你得到一个值。
打一个比方的话,Promise就像一份股票或者是理财产品。它握在你手上,此时此刻并不能当钱花;然而,它代表着一种你在将来的某一时刻可以兑现为金钱的凭证,等到合适的时机他就会为你带来一大笔钱。

Promise的状态

一个Promise有三种状态:pending(处理中)、resolved(已成功)、rejected(已失败)。刚创建的Promise绝大多数是pending状态,而允许的状态转换也只可能是由pending到resolved或由pending到reject,不可能转换回去或者二次转换。

  • pending表示的是这个值还没有真正拿到,比如发起一个网络请求,在服务器还没有返回内容的期间Promise的状态就是pending。对于pending的Promise,是不可能立刻同步的得到它内部的结果的(因为结果压根没产生)。
  • resolved表示的是这个值已经产生并存在内存中了,你可以立刻获取到它的值。比如发起一个网络请求,服务器返回内容之后,Promise的状态就会变为resolved,同时返回的内容也会存储在这个Promise的一个字段里,你可以随时获取到它。
  • rejected表示的是Promise对应的任务失败了,因此你不可能从里面拿到预期的结果了。但是相应的,你可以拿到一个异常对象用来描述发生了什么错误导致了失败。

Promise的构建

构建一个Promise对象,只需要使用new Promise(),参数中需要传入一个函数。这个传入的函数需要具有resolvereject两个参数,而这两个参数的类型也都是函数类型。

1
2
3
4
5
6
7
8
9
let a = new Promise((resolve, reject) => {
// 构建了一个promise对象,它在3s后resolve一个字符串"qwq"
setTimeout(()=>{ resolve("qwq") }, 3000)
})

console.log(a); // 立即查看a,打印出的是"Promise { <pending> }"
setTimeout(()=>{
console.log(a); // 5s后查看a,打印出的是"Promise { 'qwq' }
}, 5000)

你传入的这个函数(即上面的setTimeout(()=>{ resolve("qwq") }, 3000)这行)会被立即执行。在执行的时候,函数中的resolvereject两个参数当然要被传入实参了,这两个传入的实参是Promise为你提供的,都是仅接受一个参数的函数。作用就是只需在任意时间任意地点调用一下resolve函数,就可以让这个Promise对象变为resolved状态。
上面的例子是在3s后用字符串”qwq”调用resolve函数,所以3s后当你调用resolve函数的一瞬间,这个a对象就会立刻由pending状态变为resolved状态。当然,你也可以用闭包等方法把resolve函数给传递出去,这都无所谓。只要这个resolve函数一被调用,与之关联的Promise对象a就会立即变为resolved状态。
类似的,调用reject函数会立即使Promise变为rejected状态。

没错,关于Promise,我们需要讲的就这么多。是不是比外面的教程少多了?

async函数

async函数是一个函数上带了async关键字的函数。例如:

1
2
3
4
5
async function qwq(){
return 1
}
// 或者也可以用在lambda表达式上
async ()=>"abc"

关于async函数,请记住这两句话:

  • async函数的返回类型总是Promise!
  • 只有async函数内部才能使用await关键字!

async函数的返回类型总是Promise!

是的。如同上面的qwq函数,我们在里面写了return 1。然而,我们试一下

1
console.log(qwq()) // 打印出的是"Promise { 1 }"

一个async函数的真正返回类型总是Promise,这个Promise所resolve的结果就是你在async函数体内return出来的结果。相当于把return的结果封装在了一个Promise里面!
特殊的,如果你在async函数内return的已经是一个Promise类型的对象了,那么真正返回的结果当然还是一个Promise,但它所封装的结果不是直接是return的Promise对象,而是那个Promise对象所进一步resolve的结果。参见下面的小实验:

1
2
3
4
5
6
7
8
9
10
11
let m = Promise.resolve(2) // 构造了一个Promise,它在构造时就立刻resolve出结果2。
async function y() {
return m
}
let r = y()

setTimeout(()=>{
console.log(r === m) // 输出"false",说明y()返回的Promise(r)和写在return后面的Promise对象(m)不是同一个对象
console.log(m) // 输出"Promise { 2 }"
console.log(r) // 输出"Promise { 2 }"。这表明了r resolve的值和m resolve的值是一致的
}, 10) // 延迟个10ms再执行,以让r得以resolve

对应于这个async函数调用的Promise,是在函数里面return值的一刻才会resolve的。也就是说如果你的函数有使用到await进行等待(下文会讲),
则一开始的时候这个Promise是pending状态,直到return语句被执行的一瞬间才立即变为resolve你所return的值。
而如果你的async函数是以抛出异常的方式返回的,那么对应的Promise将会reject,且reject的值就是你抛出的异常对象。

事实上,如果你使用TypeScript,你会看到Promise类实际上是带有泛型的Promise<T>T代表着这个Promise可以resolve出的结果的类型。在TypeScript下你看到的qwq函数的返回类型像这样:

看起来async函数和普通的函数相比没什么特别的,倒是变得更加不自由了,因为返回的类型只能是Promise。那我们为什么要用它呢?
这正是因为,只有async函数内部才能使用await关键字!

await关键字

await关键字用于一个值之前。例如,await a

  • 如果a是一个Promise对象(事实上只要是Promise Like的对象都可以,本文中不对这一概念展开说明),那么await返回的是它resolve的结果!
    • 如果a对象最终是reject的,则await会抛出异常,抛出的异常对象就是a所reject的值。
  • 否则如果a不是Promise对象,那么await直接返回a本身。

await,顾名思义,就是 “等”。如果被await已经是一个真正的结果,或者是一个已经resolved的Promise对象,那么直接返回值就好了;
而如果是一个pending中的Promise对象,await就相当于会使得async函数在此处“暂停”,等到被await的Promise得到结果以后,拿着结果再继续。
(如果拿到的是resolved结果就以该结果作为await表达式的返回值,如果拿到的是rejected结果则直接在此处抛出异常。这个特性同时意味着,如果你在async函数中抛出异常,那么await这个函数也同样会得到这个异常,就像普通的函数调用的异常沿调用栈向上传播一样特性。)

await有什么用?

考虑下面一个场景,你要做一个爬虫。你需要连续的请求5个不同的url,并且下一个请求的url总是在上一个请求的响应中给出的。
假设你有一个回调函数形式的请求函数称为request

1
2
3
4
5
6
7
function request(url, callback) {
let response
// 向指定的url发送请求的逻辑,可能会很复杂。在这里我们用response = "xxx"一笔带过,
// 实际上可能代表一大段很复杂的代码。总之假设"xxx"就是服务器返回的结果
response= "xxxxx"
callback(undefined, response) // 调用回调函数,按惯例第一个参数表示错误,第二个参数表示返回内容
}

那么,为了完成需求,你可能得这么写:

1
2
3
4
5
6
7
8
9
10
11
12
13
let firstUrl = "https://xxx"
let finalResult
request(firstUrl, (err, res1)=>{
request(res1, (err, res2)=>{
request(res2, (err, res3)=>{
request(res3, (err, res4)=>{
request(res4, (err, res5)=>{
finalResult = res5
})
})
})
})
})

这就是所谓的回调地狱。一点也不够优雅,不是吗?
但是,假设你有了一个能返回Promise的可以用于发送请求的函数request_promise

1
2
3
4
5
6
7
async function request_promise(url) {
let response
// 向指定的url发送请求的逻辑,可能会很复杂。在这里我们用response = "xxx"一笔带过,
// 实际上可能代表一大段很复杂的代码。总之假设"xxx"就是服务器返回的结果
response = "xxxxx"
return response // 实际返回的是Promise { response }
}

那么,你只需要这样就可以了:

1
2
3
4
5
6
7
8
9
let firstUrl = "https://xxx";
let finalResult
(async function() {
let res1 = await request_promise(firstUrl)
let res2 = await request_promise(res1)
let res3 = await request_promise(res2)
let res4 = await request_promise(res3)
finalResult = await request_promise(res4)
})()

是不是原来用回调函数方式完成的异步操作,变得像写普通同步代码一样丝滑?这就是await的意义!

理解await和async函数的实质

上面我们强调了async函数的两个特性:一定返回Promise,和await只能在async函数中使用。为了更加深入的理解,我想我们有必要问一下为什么是这样的?

为什么需要区分异步操作与同步操作?(事件循环)

总有一些操作是耗时的,比如请求网络。在Java之类的语言里我们是通过多线程来完成的,但与此而来的数据冲突也是很麻烦的问题。而JS则使用了单线程加事件循环的思路,从根本上杜绝了程序员管理同步锁的麻烦。
但是,耗时操作怎么办?比如一个网络请求需要1s才能返回,那总不能让JS所有执行内容都在这1s内暂停吧?为此,JS的思路是这样的:所有的JS代码都只在一个线程内执行,当我们调用系统库发起网络请求时,它把我们调用的API函数立即返回,同时登记我们传入的请求参数和回调函数。之后,JS引擎异步的开启多线程来请求网络资源,在网络资源被返回后,再以返回的内容为参数调用我们的回调函数。
这告诉我们一个道理:JS代码的执行是不能卡死的。在正常的函数中如果写了一个东西,那么执行第一行结束就一定会马上执行下一行,不会在某一行上卡住。
实际上,JS中有一个“事件循环”的概念。我们写的各种回调函数,其本质都是往某个事件上注册了一个监听器;当我们需要的事件真的发生,我们之前注册过的函数就会因此被触发调用。
借用操作系统中内核态和用户态的概念,我也想把JS引擎分为“系统态”和“用户态”。JS调用栈的最底层肯定都是系统函数;而当系统调用了我们注册过的一个回调函数时,随着这个函数入栈,就是由系统态转移到了用户态,此时的控制权在我们手里,我们可以任意的执行各种代码、调用各种函数;而当我们最初被系统调用的那个函数返回的时候,就是我们把控制权还给了系统,又从用户态过渡到了系统态。由于单线程的特性,一旦处于用户态,系统就不能够打断我们的工作去做他自己的事情(比如调用某个回调函数)的;只有在我们把控制权交还给系统后,其他的回调函数才有可能被调用。

将回调函数封装为返回Promise形式

例如,我们可以构造一个函数,它返回一个在一定毫秒数之后resolve的Promise。我们可以用此来代替setTimeout:

1
2
3
function delay(ms) {
return new Promise(resolve => setTimeout(resolve, ms))
}

之后,只要这样就好:

1
2
3
4
5
async function yyy(){
console.log("start");
await delay(5000);
console.log("after 5s");
}

现在,来稍微想一下为什么

我们认为,async函数是一个语法糖,最终async函数还是用像同步函数一样的方法一行行执行的。那么,async在执行时实际的“同步函数”长什么样呢?
幸运的是,借助TypeScript的编译工具在ES5模式下编译,有助于我们看清上面的函数到底是什么情况。上面的yyy函数编译得到的结果如下:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
function yyy() {
return __awaiter(this, void 0, void 0, function () {
return __generator(this, function (_a) {
switch (_a.label) {
case 0:
console.log("start");
return [4 /*yield*/, delay(5000)];
case 1:
_a.sent();
console.log("after 5s");
return [2 /*return*/];
}
});
});
}

我们今天并不打算探讨__awaiter__generator具体是什么东西,只是用来帮助我们理解。
原本函数中的第一行console.log("start"),对应上面case 0的情况,很显然一上来执行的就是这个。随后是delay(5000),然而在这之后,我们直接从yyy函数中返回出去了!
返回的时候携带的是delay(5000)返回的Promise对象,还有一个数字4(是用来标示下次函数恢复时候应该从哪里起继续执行的。)
这也就是说,每当我们执行await的一瞬间,在同步函数意义下函数就已经被返回了! 控制权在此时被我们交还给了系统态,所以系统可以继续愉快的去做他的工作,不会因为我们的耗时操作而被卡死。
随后当5s钟过去、Promise被resolve的时候,我们的yyy函数会被系统再一次调用, 只是这次,携带的_a.label的值从0变成了1。于是case 1对应的代码console.log("after 5s");就被执行了。
这同时也解答了为什么只能在async函数中await的问题:普通的函数是不可能被编译成上面那种复杂的带case的形式的,而只有async函数,会根据里面有多少个await,编译出多少个case出来;
每次await都立即使函数在同步意义下返回、等到await的对象产生结果时再一次调用函数进入后面的case。

至于第二个问题:为什么async函数只能返回Promise,可以观察到,上面yyy函数一开始就是return __awaiter(xxx),这个__awaiter()函数返回的就是一个Promise对象。这里面的原理相对有点复杂(比如需要先讲generator的概念),我们这里在实现原理上就不做深究了。
只从道理简单的思考一点:不返回Promise是不可能的,因为async函数内需要await等待其他Promise的结果,所以在你调用函数的一刹那是几乎不可能产生确定的结果的(除非这个函数的函数体里面没有调用过任何await,那此时它就和普通函数没有任何区别了)。既然没有确定结果,那就只能返回Promise这种“将来的结果”了。

async/await怎么用

Promise模型,是一个在ES6就已经引入了的东西。如今,已有大量的npm包都提供了返回Promise的API,还有很多把其他的包封装成promise形式的包。比如我们上面的delay函数,其实npm上就有一个包叫delay,功能和我们上面写的一模一样;我现在写一些JS代码,建好项目后首先做的事之一就是npm install delay --save
只要你看到了返回Promise的API,就可以直接把需要调用这个API的函数改为async函数,然后在里面await调用它即可。
如今,很多人(至少包括我)都已经拒绝写回调函数形式的代码了,因为async/await和回调函数的形式相比真的简洁优雅又丝滑。但是对于早期已经以回调函数形式提供的API怎么办呢?我们只要把它封装为返回Promise的形式就好了。NodeJS原本的回调函数规范是(err, res),即回调函数的第一个参数永远是用来装错误的,第二个参数是结果;并且回调函数参数callback永远是作为API调用时的最后一个参数传进去的。
如果API符合这种形式,那我们可以直接用一个NodeJS提供的系统工具函数util.promisify来完成把它到Promise形式的转换,这个过程称为promisify

1
2
3
4
5
const util = require("util");
const fs = require("fs");
(async function () {
console.log(await util.promisify(fs.readdir)("./")); // 打印出当前文件所在目录的所有文件的文件名
})()

而如果不是这种形式,那就需要人来封装一下了。可以先去npm上看看有没有别人已经做好的包(比如delay那种的);
如果没有或者觉得封装一下太简单懒得去npm上找的话,那就自己手写封装吧。相信如果看懂了上面的delay函数的话,这件事并不太难。

解决有中文路径的文件无法双击打开

问题

众所周知,GBK编码是落后时代的产物,UTF-8编码是未来世界的方向,UTF-8终将取代GBK,就像IPV6终将取代IPV4一样。
近日更换了新的SSD,重装了系统,索性决定把自己的系统编码直接切换为UTF-8。可是这一切换带来了问题:路径中含有中文字符的.jar文件,不能通过双击打开了,但是不含中文字符的.jar文件仍正常。
经过分析,发现在Windows10中即使切换了系统编码为UTF-8,但是底层系统调用fork进程的过程却仍然使用的是GBK方式传递命令行参数。我可以理解,因为毕竟现在存在文件系统里的文件夹名都是GBK,总不能切换一下系统编码还得要递归所有文件全部转换文件名编码吧。
虽然内核已经很大程度上解决了这个问题:比如我在python里sys.argv拿到的命令行参数就是正确解码的str格式,不存在编码问题。大多数的软件也都可以正常运行,比如Office、Adobe全家桶之类打开中文文件一点问题都没发生过。然而不知道为什么,Java在这方面却存在严重的问题。(据说还有一些程序比如mc、mathematica也存在这个问题)
解决不了问题的程序,就只能从调用的命令行下手,于是我想到了这样的方式:把带有中文的路径中的路径部分提取出来作为CWD(当前工作目录),从而使创建进程时提供的命令行参数中只有文件名部分。这样只要文件名是全英文的,即使路径中有中文也没关系。指定的文件可以通过相对于CWD的相对路径打开,也就不用把中文的所在目录传进命令行了。

解决

写了一个脚本并发布在Github上,里面有详细的README。
https://github.com/Starrah/chinese_path_open

如何优雅的使用CLion在WSL下调试来完成datalab

前言

本文所提到的方法是在Windows的CLion下,通过CLion编写代码、通过远程执行cmake、make、gcc、gdb来实现的。
代码在本机Windows下保存,并通过CLion的development功能自动同步到远端、通过WSL上的工具完成编译和执行。
基础要求:

  • WSL下安装了cmake、make、gcc、gdb等基础开发工具(通过apt-get均可获得,本文不赘述其安装方法)
  • WSL开启了ssh,例如参考这两篇文章:https://www.jianshu.com/p/cd4d604e0b44https://www.jianshu.com/p/d8874d5a3027 。最好不要使用22端口,引起冲突的概率极高。
  • 在Windows下全局安装一个python包compiledb
    script
    1
    pip3 install compiledb

创建compiledb文件和cmake文件

为了让Clion识别和正确运行,以下步骤是必不可少的。
首先,在Windows控制台中切换到本机代码目录,执行:

script
1
compiledb -n make

之后会看到目录里生成了compile_commands.json文件。

然后在代码目录里添加文件CMakeLists.txt,内容如下:**请注意以下文件中“项目名”三个汉字一共出现了四次,请全部将它替换为自己项目的真实项目名(也就是代码存放目录的文件夹名)

1
2
3
4
5
6
7
8
9
10
cmake_minimum_required(VERSION 3.4.1)
project(项目名)
message(${项目名_SOURCE_DIR})
set(CMAKE_CXX_STANDARD 11)
set(CMAKE_VERBOSE_MAKEFILE 1)
add_custom_target(run
COMMAND make clean -C ${项目名_SOURCE_DIR}
COMMAND make -C ${项目名_SOURCE_DIR}
CLION_EXE_DIR=${PROJECT_BINARY_DIR}
)

请注意:因为Linux和Windows的系统默认编码可能存在差异,请尽量不要让代码的路径中含有任何中文字符,否则(在我的机器上测试)会引起错误!

CLion打开项目

打开CLion,选择File-Open,找到Windows存放代码的目录下的CMakeLists.txt(就是刚才创建的那个),点击Open;
此时如果弹出对话框点击”Open As Project”(如果没弹出则忽略)

配置ToolChains

CLion中,File-Settings-Build, Execution, Deployment-Toolchains,点击加号,出来的列表中点击WSL;
右侧Credentials选好端口号用户名密码,下方选择WSL CMake和WSL GDB,等到所有的自动检测都显示绿色的对勾,点击应用。

配置远程目录

CLion中,Tools-Deployment-Configuration,按照提示配置远程WSL的连接和设置目录映射。这个不是很难,直接看英文大概率能看懂,或者参考这篇https://blog.csdn.net/lihao21/article/details/87425187
并建议Tools-Deployment-Auto Upload打上勾,以免出现本地远程不同步的尴尬情况。

配置CMake

CLion中,File-Settings-Build, Execution, Deployment-CMake,+添加新的,Build type为Debug(或Release),Toolchain选择刚刚建立的WSL上的那个Toolchain。
之后等CMake重新加载(如果没有自动进行,就手动:Tools-CMake-Reload CMake Project),之后应该能看到运行配置里出现了那个我们之前添加的Debug-WSL的配置。

手动make一次(这步不可或缺,但是原因不明)

打开wsl(或者用clion的SSH Terminal也可以),cd到硬盘所在的目录,也就是/mnt里的那个地方,找到Windows下的代码目录,执行一次make。然后应该能在CLion中看到生成的的可执行文件。此时记得上传本地代码到WSL:Tools-Deployment-Upload To …
或者另一种方法也是可以的:先把代码上传到WSL,再在WSL中找到远程映射目录,make,然后回到CLion,选择Tools-Development-Download from …把生成的文件拿回本地。

可以运行了

确保运行配置切换到了WSL对应的cmake配置,点击run按钮,会弹出对话框,需要选择执行文件。Executable中,点击Select Other,找到刚生成的可执行文件,确定,然后Run。大功告成。
之后每次修改代码,不需要重复手动make的步骤(手动make只用执行一次),直接保存后点击run按钮就可以了。

可选:如果想要使用debug功能的话

CMakeLists.txt需要稍作改动,在最下方添加内容如下:

1
2
3
4
5
6
add_custom_target(runWithDebug
COMMAND make clean -C ${项目名_SOURCE_DIR}
COMMAND make -C ${项目名_SOURCE_DIR}
DEBUG=true
CLION_EXE_DIR=${PROJECT_BINARY_DIR}
)

Makefile也需要稍作改动,需要添加一个条件判断,使得当执行make选择传入的DEBUG变量为值true时,能够在gcc编译命令中删掉-O优化、添加-g和-DDEBUG选项。
这个需要根据你的Makefile文件做具体的修改。例如,本次datalab的修改方法如下:在行LIBS = -lm后面加入

1
2
3
ifeq ($(DEBUG), true)
CFLAGS = -Wall -m32 -g -DDEBUG
endif

(当然,如果你在64位WSL下运行,那么所有的-m32都应该改为-m64,无论是这里新加的的还是上面的那个)
以上操作完成并重新加载CMake后,在运行选项里会看到有一个配置叫runWithDebug,选中它,然后点击调试标志即可。

datalab Clion项目配置示例Github项目

点击这里,详见其中的README.md

vio库的使用与easy-api

Why vio?

vio是一个开源的库,是基于express基础上的插件。
Github代码库:https://github.com/vilic/vio
官方还有一个demo,我认为很好,涵盖了很多内容,可以查看:https://github.com/vilic/vio-demos

它具有以下特点:

  • 基于请求URL,自动尝试匹配路径名、文件名、函数名找到合适的处理函数,不需在app或router中注册任何东西。
  • 支持自动判断更改、动态加载已更改的文件,这样对现有API文件做任何增删改,不需要重启服务就可以应用更改;
  • 装饰器特性,语法更简洁清晰;
  • 支持模版渲染并与普通API的定义语法相统一,更易用;
  • 可以自定义用户和鉴权逻辑,并在request中直接拿到,更方便。
  • 相当于express的插件,具有轻量级的特性,可以与express现有的各种组件和特性兼容。

本人使用该库最经典的用途是有时候想写一些很简单但是经常改或者希望很快的部署的API。静态文件部署是很方便的,只要把文件复制到静态文件目录就可以;所以我一直在想,有没有什么方法可以像静态文件一样简洁的方法来执行基于js的API处理函数?当我发现这个库时,我发现它几乎完全是我所想要的:只要在对应于请求URL路径的位置,插入一个合适名字的文件,无需任何额外操作这个API就会生效;想要修改或删除,直接操作文件就可以。这就是我如此喜欢这个库的原因。

基本使用方法

如同上文所述,vio是一个轻量级的插件,在express的架构里就是一个Router。想要在某一个文件夹和对应的URL使用vio,只需要这样:

1
2
3
4
5
6
7
import * as vio from 'vio'
import * as express from 'express'
var app = express();
new vio.Router(app, {
routesRoot:"./api",
prefix: "/api"
});

其中vio.Router的构造函数接受两个参数:第一个是expressapp实例,第二个是一系列参数;由于app被传进去了,执行完这句话以后就自动相当于进行了app.use,因此不用额外的use了。
上例即建立了一个Router,它仅处理前缀为/api的请求,并在文件夹./api下面找寻对应的处理函数。
在找寻处理函数时,你写的某一个函数,可以匹配到的URL的路径为:这个js文件相对于routesRoot的路径+定义的处理函数的指定路径(详见下文所述)。其中,default是保留关键字,以default命名的文件或函数在匹配时不会匹配default字符串,而是作为其上级URL的默认匹配。
例如: (以下路径均指相对于routesRoot、URL均指相对于prefix

  • qwq/yyy.js中的hhh函数与URL/qwq/yyy/hhh匹配;
  • qwq/yyy.js中的default函数与URL/qwq/yyy匹配;
  • qwq/default.js中的aaa函数与URL/qwq/aaa匹配;
  • qwq/default.js中的default函数与URL/qwq匹配;
  • ccc.js中的ddd函数与URL/ccc/ddd匹配;
  • ccc.js中的default函数与URL/ccc匹配;
  • default.js中的bbb函数与URL/bbb匹配;
  • default.js中的default函数与URL/匹配;

而在每一个文件里,express原有的Router实例的写法不能使用了,取而代之的是只写一个类继承vio.Controller、无需实例化,并把这个类export default

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
import {Controller, Request, ExpressResponse, get, post} from 'vio'

export default class Test extends Controller{
@get()
default(req: Request<any>, res:ExpressResponse){
res.json({
method: "GET",
result: "qwq"
})
}

@post({
path: "i_am_real_path"
})
name_not_important(req: Request<any>, res:ExpressResponse){
res.json({
method: "POST",
result: "qwq/yyy"
})
}
}

所需要的所有处理函数都写成上述类的一个成员函数,并用@get@post等修饰器修饰(其他HTTP方法可以用@route('put')这类的修饰)。然后可以传入一个可选的参数,参数中可以包含path字段就是该函数在被匹配时对应的路径。如果没有写path字段,那么函数名如果是default则匹配根路径,如果是其他的则匹配函数名转化为小写字母-下划线格式之后的路径。
函数只有两个参数reqres,没有next了。但是对于长轮询等不希望立即返回的场景,写成async函数是完全没问题的,vio内部已经支持。
res参数就是express.Response类型,各种方法都可用。但是如果当处理函数返回后(包括异步返回,即返回的Promise resolve后),res没有被end,vio则会自动返回一个JSON{data: value}来end这个请求(value是函数执行完实际的返回值),不会被转交给next的。也就是说,希望自己不处理某个摊派给自己的请求而转交给下一个人处理,是不可能的事情了,任何请求一旦离开你定义的处理函数体以后就一定会end掉。因此,可以利用路由机制保证不需要处理的请求不要被路由进来就可以。
req参数是Request类型,而实际上Request的定义中继承了express.Request,也就是说原来expressreq的各种属性和方法可以正常使用。至于带泛型的Request,其实是为了用户管理功能而准备的,详见下文所述;在没有使用用户管理功能的情况下,就写成Request<any>就可以了。

用户管理功能

使用用户管理功能的方法,请见以下示例代码及注释:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
//userDef.ts
import {ExpressRequest, PermissionDescriptor, UserProvider} from "vio";

/**
* (可选的)定义任意的Permission接口,不需要继承任何东西,相当于用户分组
* 如果不需要使用自动鉴权功能的话那就不用定义这个接口
*/
export declare type MyPermission = "superadmin" | "admin" | "user"

/**
* 再定义一个任意的用户接口,无需继承任何类
* 但是如果想要用自带的PermissonCheck功能的话,那么需要把permission: MyPermission字段定义在里面。
*/
export interface MyUser{
name: string,
password: string
session?: string,
permission: MyPermission
}

/**
* 道理上,用户列表应该存在数据库之类的地方,但我们为了简便起见就搞一个静态的数组存着,并且明文存储密码。
*/
export var AllUsers: Array<MyUser> = [
{
name: "张三",
password: "qwq",
permission: "admin"
}
];

/**
* 定义一个用户产生器类,实现UserProvider接口并至少定义get方法、可选定义authenticate方法。这些方法都可以是异步的。
* get方法用于在一般的请求函数中获取用户信息,
* 而authenticate方法用于在装饰器中指定了authentication: true的请求函数中获取用户信息,
* 这时往往还要加上一个设置session之类的操作以便之后的get方法的使用。
*
* 实践中一般只有loginAPI才会是authentication: true,从请求中附带的用户名密码找到用户。
* 通常login接口的处理函数中会有setCookie,设置一个session共之后get方法的鉴权使用。
* 实现了上述方法之后,一般的API则会在进入处理函数前自动调用MyUserProvider的get方法,
* 并把得到的MyUser结果加进req.user字段,从而在处理函数里面就可以直接取用了。
*/
export class MyUserProvider implements UserProvider<MyUser>{
async authenticate(req: ExpressRequest) {
let user = AllUsers.find((u)=>u.name === req.body["name"]);
if(user.password === req.body["password"])return user;
else return null;
}

async get(req: ExpressRequest) {
return AllUsers.find((u)=>u.session === req.cookies.session);
}
}

/**
* 可选的,如果需要使用自动鉴权功能,就定义一个继承了PermissionDescriptor<MyPermission>的类
* 并实现抽象方法validate,接受的参数是MyPermission。
*
* 之后若某个请求函数有permission字段,类型为MyPermissionDescriptor的话,则会自动用get获得用户、
* 自动取用户的permission、自动传入validate函数进行验证、验证失败自动返回403。
* 例如以下定义,则只需要在请求函数的装饰器的参数加入字段:permission: MyPermissionDescriptor(["admin"]),
* 即可实现自动鉴权管理员。
*/
export class MyPermissionDescriptor extends PermissionDescriptor<MyPermission>{
allowedPermissions: Array<MyPermission>;
constructor(allowedPermissions: Array<MyPermission>){
super();
this.allowedPermissions = allowedPermissions;
}
validate(userPermission: MyPermission): boolean | string {
return !!this.allowedPermissions.find((u)=>u === userPermission)
}
}

之后,记得在app.ts的路由定义中,加上userProvider的一个实例:

1
2
3
4
5
6
7
8
9
10
// app.ts
// ...
let userRouter = new vio.Router(app, {
routesRoot: path.join(__dirname, "with-user"),
prefix: "with-user",
production: false
});
import {MyUserProvider} from "./with-user/userDef";
userRouter.userProvider = new MyUserProvider();
// ...

有了以上用户和权限的定义,登录、获得用户、鉴权就十分的简单了:通过req.user即可直接拿到用户对象,而authenticate可以简便登录、permission可以简便鉴权与访问控制。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
// default.ts
import {Controller, ExpressResponse, get, post, Request} from "vio";
import {MyPermissionDescriptor, MyUser} from "./userDef";

export default class Default extends Controller{
@post({
authentication: true
})
login(req: Request<MyUser>, res: ExpressResponse){
if(req.user){
//user非null即为authenticate方法鉴权通过
let session = new Date().getTime().toString();
req.user.session = session;
res.cookie("session", session);
}else{
res.sendStatus(403);
}
}

@get()
all(req: Request<MyUser>, res: ExpressResponse){
//所有请求都会进来,而如果用户判断失败user是undefined
res.json({
result: "success",
name: req.user && req.user.name
})
}

@get({
permission: new MyPermissionDescriptor(["admin"])
})
onlyadmin(req: Request<MyUser>, res: ExpressResponse){
//鉴权失败的会直接403掉,进不到函数里面
res.json({result: "successAdmin"})
}
}

使用WebSocket

vio原生是不支持WebSocket的。这很可以理解,毕竟事实上express都没有对WebSocket的原生支持,而是要通过socket.ioexpress-ws之类的包来实现。
然而,经过我的研究,发现了使用express-ws包结合vio的最好方式。原本的express-ws包通过在appexpress.Router()的原型上植入ws方法,使得我们可以像平时.get.post一样.ws,简单的完成WebSocket连接的建立。具体的express-ws包用法请参看官方文档,这里不再赘述。
事实上,每个vio.Router内部,都封装了一个express.Router实例,vio的路由正是以此实现的。当我们写一个装饰器@route(xxx)时(@get等价于@route("get")),行为正是对一个被封装的express.Router实例调用xxx方法。
因此,我们只要在Controller里这样定义,就可以实现ws的连接了:(当然,前提是要在app.ts里注入express-ws组件)

1
2
3
4
5
// app.ts
import * as express from 'express'
import * as ExpressWS from 'express-ws'
var app = express();
ExpressWS(app);
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
// ws.ts
import {Controller, Request, route} from 'vio'
import * as WebSocket from 'ws';

export default class Test extends Controller{
//必须用@ts-ignore,因为vio包中HttpMethod的声明没有包括ws。
//@ts-ignore
@route("ws")
ws(ws: WebSocket, req: Request<any>){
ws.send("hello client!");
ws.on('message', (data)=>{
console.log(data);
});
ws.on('close', (data)=>{
console.log("ws closed");
});
// 因为vio会自动把函数的第二个参数当成res并在函数返回后检查res是否有被end,
// 因此我们可以添加这样一个属性假装这个res被end了,以免vio对req对象调用并不存在的end方法引起报错。
req["headersSent"] = true;
}

}

上述的原理是装饰器@route("ws")中的ws会被直接当作方法名,在其内的express.Router实例中调用,这样就实现了利用express-ws包的特点完成WebSocket。
补充:如果遇到程序无任何报错、设置的@route(ws)处理函数不会被调用、WebSocket连接能被建立但建立后瞬间被服务器关闭的问题,这其实是一个express-ws包的bug(也可能是特性),与vio无关。由于express-ws包操作了app的原型,被express-ws注入的app必须使用app.listen方法监听,而不能手动通过http.createServer(app).listen方法监听,否则会造成上述ws函数无法被路由到、连接建立后立即关闭的问题。

开箱即用的多功能后端程序

虽然vio如此方便,但是仍然需要一些配置成本和学习成本。为了降低学习成本,我编写了此博客;为了降低配置成本,我打造了一个开箱即用的多功能后端程序:https://github.com/Starrah/easy-api
它的使用方法十分简单:

  1. 下载文件

    1
    git clone https://github.com/Starrah/easy-api.git
  2. 安装依赖
    进入下载后的程序目录

    1
    npm install
  3. 运行程序
    命令行可以带参数指定端口号,不填则默认为8080

    1
    node app.js 8080
  4. 放置你的API
    使用vioController语法编写处理请求的程序,然后直接放到api文件夹下即可。放置的位置就是请求的URL。
    本文章中有很多代码示例,下载到的文件中api文件夹内也已有一定量的示例函数,您可仿照此编写。

协程与异步模型:以JavaScript为例

协程与异步模型:以JavaScript为例

前言与基础概念

在现代计算机软件结构里,异步永远是绕不开的话题。无论是进行一些耗时操作,还是执行网络请求这种需要暂停一段时间才能继续的任务,都不可能允许单个线程阻塞的执行。否则,在该线程执行的期间,UI界面等其余所有的任务都会卡死。
因此,如何让程序员优雅的完成异步编程,就变成了架构师们的难题。

在此之前有必要简述几个概念:

  • 进程:操作系统控制应用程序的单位。任何应用程序都有且只有一个进程,操作系统会为每个进程分配一些独占的内存空间。
  • 线程:经由操作系统,交付给CPU执行指令序列的单位。一个进程可以创建多个线程,它们共享同一块内存空间,交替在CPU上执行任务;具体每个线程执行的时间则由操作系统控制。
  • 协程:一些编程语言或库中的概念,是物理串行(只会占据一个CPU处理)、逻辑并行(在程序员看起来是并发)的。内部指令执行的先后、顺序是由进程自己控制,与操作系统无关。

最早的VC是通过Windows API来调用系统库完成线程的构建的,需要操作句柄等,比较麻烦。C++和Java等经典的面向对象编程语言则是使用了线程对象+多线程阻塞操作的异步模型。
举例而言,在Java中,我们可以:

1
2
3
4
5
6
7
8
Thread th = new Thread(){
@Override
public void run(){
//做一些事情,例如执行网络请求
//这些事情可以阻塞线程直到事情结束,如网络请求,函数在收到响应体后才会返回
}
};
th.start();

为此,在大型的Java框架如Tomcat中都有线程池的概念,由框架维护一系列的线程并分配给具体的事务(如处理一个网络请求)。

这样做的缺点包括:

  1. 线程是基于系统的线程调度的,因此比较“重量”,线程开启、关闭等需要很多的额外开销,且线程的执行状态程序不直接可控。
  2. 线程共享同一块内存区域,因此在并发操作的情况下存在访问冲突的问题,需要加锁。

有过Java编程经验的人对锁肯定是又爱又恨,比如死锁之类的问题,这里就不再赘述了。
而JavaScript语言(这里及以下所有提到js的场合均指Node.js)则提供了一种全新的异步模型:单线程协程模型。

Node.js事件循环

整个js是始终单线程工作的(除了个别对系统api的调用),所有的函数都在一个线程下被依次的执行。但是,很显然实际当中有太多不能够立即完成的操作,这该怎么办呢?
为此,js内部提供了一个事件循环。形象地讲,js的代码始终是跑在一个while(true)的循环里的,而你写的所有代码则是从这个循环里被调用的。在每一轮循环里,node会遍历一个事件队列:它由一系列事件和注册在事件上的回调函数构成。
(注:本文所描述的是Node.js事件循环的一个高度简化的模型。实际上事件循环对不同种类的事件有复杂的调度策略,但在这里我们并不讨论;又由于回调函数和事件之间存在绑定关系,我们不区分事件和它的处理函数的区别,就认为事件循环是在一个一个的直接调用先前注册在其中的一系列函数即可。)
可以用下面的伪代码表示:

1
2
3
4
5
while(true){
for(handler in eventQueue){
handler();
}
}

而许多耗时操作,如网络请求、读写文件等,提供的API不再是同步阻塞线程式的,而是异步式的。
例如发送GET网络请求的API格式为:http.get(url[, options][, callback])。以下是Node.js官方文档给出的示例:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
http.get('http://nodejs.cn/index.json', (res) => {
const { statusCode } = res;
const contentType = res.headers['content-type'];

let error;
if (statusCode !== 200) {
error = new Error('请求失败\n' +
`状态码: ${statusCode}`);
} else if (!/^application\/json/.test(contentType)) {
error = new Error('无效的 content-type.\n' +
`期望的是 application/json 但接收到的是 ${contentType}`);
}
if (error) {
console.error(error.message);
// 消费响应数据来释放内存。
res.resume();
return;
}

res.setEncoding('utf8');
let rawData = '';
res.on('data', (chunk) => { rawData += chunk; });
res.on('end', () => {
try {
const parsedData = JSON.parse(rawData);
console.log(parsedData);
} catch (e) {
console.error(e.message);
}
});
}).on('error', (e) => {
console.error(`出现错误: ${e.message}`);
});

我们注意到,上述定义的第三个参数是callback,需要我们传入一个函数。实际上,get方法本身并没有直接的发送并等待一个请求;当我们调用http.get方法时,它只是简单的在事件循环里注册一个函数表明我们希望发送一个请求,然后get函数就会立即返回
get函数和我们所在的函数全都返回,调用栈当前指针移动回事件循环之后,事件循环处理器会发现我们刚刚通过get方法添加的请求事件,然后通过操作系统的API将请求真正发送出去。
(注:js所谓的单线程指的是js逻辑的代码处理是单线程的,但是在事件循环管理、调用操作系统提供的接口方面,可能还是存在多线程的情况;但这些线程不会直接操作js主线程中定义的数据,因而对我们没有实际影响。)
然后,js就会继续处理事件循环中排队的其他函数去了。这样,事件循环一直运行下去……
直到网络请求回来了,那么系统库会在js的事件循环中加回一个表示网络请求完毕的事件。之后当事件循环处理器发现这个事件时,就会执行绑定在该事件上的处理函数,也就是我们在最初http.get方法中传入的第三个参数callback

往事件循环里添加函数有很多方法,其中为我们所熟知的是setTimeout(fn, ms);该方法执行的实际上就是往事件循环中添加一个事件,而当事件循环遍历到该事件时如果发现设定的时间未到则不会执行事件的处理函数,而是令其继续等待。这也告诉我们,js的setTimeout实际上不能保证准时性;举例而言,假如在计时器还剩5ms的时刻,事件循环执行了一个连续计算耗时10ms才返回的函数,那么这个计时器只有在连续耗时计算返回之后才有可能因为事件循环重新获取控制权而被调用,则时间势必已经晚了至少5ms。

总而言之,js原生的异步模型就是基于事件处理、回调函数的协程模型,任何函数都应该在可接受的时间内尽快的返回出来,然后在后台执行复杂操作,并在上述复杂操作结束后,以向事件队列加入一个回调函数的方式让后续的操作得以继续。每一个被回调的函数都相当于开启了一个小小的协程,它们由js集中管理;对操作系统而言,操作系统只知道js解释器在不停的调用各种指令,却无从知道这些指令由来于哪个地方。

对比上述Java多线程阻塞模型,我们可以得到js异步非阻塞模型的优点:

  1. 对操作系统而言始终是一个线程在切换不同的指令组而已,没有新建线程创建栈、预置缓存等开销,异步操作更“轻量”;
  2. 始终是单线程操作,不存在多个处理器同时访问数据或某个函数执行到一半控制权被转移的情况,也就自然不需要锁了;
  3. 程序员对异步操作的控制力更强。只要是连续写在一起的代码,能够保证被连续的执行,不会因中断造成错误;而希望转移控制权的地方可以主动交出控制权。

更优雅的异步编程:Promise与async/await

但是程序员们仍然认为,上面依靠回调函数的异步还不够优雅和简洁。为此,经过了广泛的讨论和大量的时间,js终于在其标准中加入了利器:Promiseasync/await
Promise是一种对异步回调模式的有效封装,一个Promise对象有三种状态:pending、resolved和rejected,和一个唯一的事件处理函数handlerhandler有两个函数类型的参数resolvereject
resolve被调用时,Promise的状态就会由pending变为resolved,反之当reject被调用时,Promise的状态就会由pending变为rejected。同时我们可以为Promise对象传入两个事件处理函数:then和catch,它们分别在Promise转为resolve和reject的时候被调用。
例如如下代码:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
function somethingAsync(){
return new Promise((resolve, reject)=>{
console.log("开始执行一些复杂操作");
setTimeout(()=>{
console.log("1s之后复杂操作执行完了");
resolve();
}, 1000)
});
}
function main(){
somethingAsync().then(res=>{
console.log("第一个Promise执行完毕");
return somethingAsync();
}).then(res=>{
console.log("第二个Promise执行完毕");
return somethingAsync();
}).then(res=>{
console.log("第三个Promise执行完毕");
});
}
main();

这样看起来Promise好像并没有比普通的回调模式优雅太多,你看res=>console.log("Promise已resolved,执行完毕")这句,和写一个普通的callback有区别吗?
实际上,说Promise并没有什么作用是不科学的,因为它至少可以把回调套回调的模式变成then().then().then()的链式模式。但Promise的真正威力不在于这里,而在和async/await的结合上。

async/await是一组关键字,async用于声明在一个函数上,使其成为异步函数;而await相当于一个一元运算符,它只能在async函数中被使用。
任何函数一旦被声明为async类型,它的返回值就必定是Promise类型;即使你写的是普通的return 1之类的语句,当这个函数被调用时也是会很快的返回一个Promise对象,而非返回数值1的。
正是因为async函数的不会立刻返回结果的特点,为它内部等待其他异步操作的完成提供了可能。而await操作符作用在一个Promise上,它在逻辑上的作用就是让当前的async函数暂停,直到被await的那个Promise变为resolved后,继续函数的剩下部分(如果Promise被reject了,那么await操作符抛出一个异常)。于是上面的代码可以这样写:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
function somethingAsync(){
return new Promise((resolve, reject)=>{
console.log("开始执行一些复杂操作");
setTimeout(()=>{
console.log("1s之后复杂操作执行完了");
resolve();
}, 1000)
});
}
async function main(){
await somethingAsync();
console.log("第一个Promise执行完毕");
await somethingAsync();
console.log("第二个Promise执行完毕");
await somethingAsync();
console.log("第三个Promise执行完毕");
}
main();

async/await让回调函数彻底消失了,书写这样一个要反复在事件循环里异步回调的函数,就像书写普通函数一样简单,这才是优雅的异步编程。

总结

通过以上对异步编程模型的综述和对近年来比较先进得到广泛好评的JavaScript Promise-async/await模型的具体叙述,让我们了解了程序异步执行的发展历史、各种方式的优缺点和演进的必要性。
不可否认的是,大规模计算意义上对并行的追求和软件工程意义上对并行的追求并不完全重合。单线程只靠协程的模型虽然安全简洁,但是却大多数情况下只能利用一个有效核心,效率反而不太高。这也就是为什么许多基于C语言的并行计算框架时至今日还在被广泛使用的原因。
然而,在如今软件工程快速迭代开发的背景下,更简便、易学、易用、接近人的思维习惯的异步模型是更加适合于软件工程,特别是前端等对运行效率要求不太高但开发人力精力成本投入较大的场合所使用的,这也就是异步模型革新变化的源动力。

综合实验日志

相关经验记录部分

  1. Cocos可以善用分包加载和资源远程拉取代替自己请求,但是注意首场景资源要带好,待加载完成后切换下一个场景。

  2. 动态加载资源:

    1
    2
    3
    cc.loader.load("url", (err, res)=>{
    //res就是加载到的资源,图片是Texture2D,音频是AudioClip,可以进一步判断
    })

    可以promisify

    1
    2
    3
    4
    5
    6
    7
    8
    9
    function ccAsyncLoad(url: string){ 
    return new Promise((resolve, reject) => {
    cc.loader.load(url, (err, res)=>{
    //res就是加载到的资源,图片是Texture2D,音频是AudioClip,可以进一步判断
    if(err)reject(err);
    else resolve(res);
    });
    });
    }
  3. 把对象的某一属性设置为undefined,并不能阻止该属性在for in时被遍历到!
    如果不希望被遍历到,要delete才行。

  4. typescript 类型声明的语法:

    • declare

      1
      2
      3
      4
      5
      declare const qwq: string;
      declare function qwqwq(): string;
      declare class YingYingGuai{
      yingyingying: string
      }
    • 此外,还可以利用|关键字表示多种可能;

      1
      2
      3
      4
      5
      6
      function requestPromise(options: {
      method: "GET" | "POST" | "PUT" | "DELETE"
      })
      {

      }
    • 可以用&表示继承一个类并实现一个接口的对象类型:见下面mongoose的用法

  5. mongoose用法:
    schema决定了真正被存进数据库的对象。由mongoose.Schema({})定义:

    1
    2
    3
    4
    5
    6
    7
    8
    interface Data{
    qwq: string
    }
    let DataSchema = mongoose.Schema({
    qwq: String
    });
    //Data & mongoose.Document表示继承了mongoose.Document类(从而定义了save、find等方法),同时具有Data的各种属性的对象。
    let datas = mongoose.model<Data & mongoose.Document>(DataSchema);

    如果Data里面还有更复杂的类对象,可以为每个类对象都定义一个Schema。如果不定义Schema,数据是存不进去的!以下是最保险的:

    1
    2
    3
    4
    5
    6
    7
    8
    9
    10
    11
    12
    13
    class YingYingGuai{
    yingyingying: string
    }
    let YingYingGuaiSchema = mongoose.Schema({
    yingyingying: String
    });
    interface Data{
    qwq: YingYingGuai
    }
    let DataSchema = mongoose.Schema({
    qwq: YingYingGuaiSchema
    });
    let datas = mongoose.model<Data & mongoose.Document>(DataSchema);

    但是也可以定义为Mixed,但是在每次修改内容时必须手动标记修改。

    1
    2
    3
    4
    5
    6
    7
    8
    9
    10
    11
    12
    class YingYingGuai{
    yingyingying: string
    }
    interface Data{
    qwq: YingYingGuai
    }
    let DataSchema = mongoose.Schema({
    qwq: mongoose.Schema.Types.Mixed
    });
    let datas = mongoose.model<Data & mongoose.Document>(DataSchema);
    //但是在每次修改时必须标记修改
    datas.markModified("qwq");
  6. 微信APP开发如果想存储自定义数据,要存到本地用户文件目录。该目录以用户维度隔离。
    目录的常量:wx.env.USER_DATA_PATH,用正常的FileSystem操作,或者是直接uploadFile、downloadFile都可以。

  7. 如果await非async函数,是不会报错,就相当于立即同步调用该函数了。

文档部分

一、程序设计概述

微信小游戏在近年来热度逐渐攀升,每时每刻都有大量的精彩小游戏出现,更是有越来越多的游戏被移植到了微信小游戏平台上。分析其原因,我认为主要有两点:一是它便捷、容易获取,无需安装额外的安装包,通过如今几乎人人都有的微信即可下载,下后便玩,无需额外的安装;其次,它通过与微信的开放能力的有效结合,用户使用微信一键登录,无需注册,并通过微信内分享、关系链数据等实现与好友的密切互动和共同游戏。
本实验利用课上所学和课下了解的Web前端、NodeJS后端、微信小游戏开发等各方面的知识,单人独立完成了一个阿瓦隆微信小游戏的全部内容。
整个项目分为三个部分:基于NodeJS的阿瓦隆应用服务器后端、基于Cocos Creator的微信小游戏前端和微信小游戏开放数据域。在提交的代码和Github中,分别以三个项目目录存放:avalonServer, cocosAvaloncocosAvalonOpenData。每个模块实现的具体内容详见第二部分具体叙述。

本游戏在设计中的亮点包括:

  1. 不只是游戏,更是工程:游戏的前后端实现解耦合,后端的不同模块之间实现解耦合,从而本后端可以被使用于任何形式的阿瓦隆前端,而后端的网络通信、文字和语音聊天、游戏平台模块可以被移植到任何的网络游戏后端。
  2. 使用TypeScript强类型语言完成编写,并大量使用ES6等新标准的Promiseasync/await等模型,使代码编写变得简洁,拒绝回调地狱;后端使用长轮询机制代替轮询,极大的降低了服务器压力的同时优化了用户体验。
  3. 前端部分极少具有游戏逻辑,几乎所有的游戏逻辑都由后端完成;即使是简单的前端逻辑,也进行了解耦合,通过核心逻辑层——控制器——引擎渲染层的方式进一步实现了视图逻辑分离的目标。

本游戏在使用中的亮点包括:

  1. 功能详尽完整。不止有核心的逻辑部分,更有根据玩家信息给出详细的技术统计、利用开放数据域展示好友排行,面向不同层级的新用户的帮助、游戏内的文字和语音聊天系统等丰富的配套支持,让用户真正感觉自己在使用一个APP而不只是执行一段游戏代码。
  2. 游戏的核心逻辑部分也经过仔细的打磨,玩家模块以头像的方式显示,互动感更强;合理筛选应该在游戏界面和不该在游戏界面直接显示的信息,后者以可开关的侧边栏的形式展示,既让游戏界面简洁不乱,也充当了玩家的得力助手,在需要时随时为玩家提供游戏的详细信息,起到了APP应有的游戏助手的作用。
  3. 实现了聚会游戏的重要核心功能之一,虽然较为复杂但是有很大意义,也是目前市面上已有的阿瓦隆小程序都没有实现的功能:语音聊天。
  4. 虽然受本人本能力所限少有原创素材,但所有素材都经过本人精心打磨加工。游戏中图片素材总计达到百余个,语音素材则包括利用百度AI开放平台生成了0-10数字提示、相关提示语音、多个背景音乐等素材。
  5. 游戏UI字号较大,方便阅读;功能简洁明了,游戏交互节点在保持风格基本统一的情况下使用鲜艳而有明确含义的颜色标记,让对阿瓦隆有所了解的人基本上都能不用阅读说明书,仅从标志颜色和图案就推断出其含义。
  6. 游戏UI细节丰富,几乎所有的按钮都有点击特效,按钮附近有炫酷的粒子特效,游戏面板展示图文并茂,力争在充分发挥计算机优势的同时保持游戏的原汁原味。游戏动画效果十足,从面板的展示、按钮的出现、点击与消失、身份的分配、任务结果的揭示、游戏信息的提示、任务结果的切换等多方面都有动画效果和/或渐变效果。

本游戏虽然与一般想象中充满人机互动的游戏不同,更像是个游戏工具;但是游戏的可玩性不应止于人机互动游戏,在如今便携设备高速发展、反而人与人之间交流减少的今天,把传统的人人交互游戏与计算机融合,我认为也是一种很好的游戏形式,对于了解相关规则的人来说具有很好的可玩性。本游戏也尽力让不了解规则的人能够入门,因此制作了许多页的游戏规则介绍在游戏中。对这样一款“以玩家充分互动”为目的的游戏,让玩家互动的开心和扮演法官的角色同样重要,游戏的玩家系统、游戏的交互和游戏核心逻辑同样重要。游戏逻辑虽然简单,但在玩家的聊天互动中变得变化无穷,而APP要做的是给他们这样的平台,让他们忘记APP,找到游戏人的乐趣。
实际上,这个程序的设计思路参考了现有市面上的狼人杀APP。市面上的确有狼人杀APP和狼人杀小游戏,也有阿瓦隆的安卓APP(大小有50多M,需要安装),也有界面简单、除了发牌之外无其他功能的微信小程序,但具有友好的用户界面、丰富的游戏功能和详细的数据统计、内置聊天系统的免安装的微信小游戏,本程序还是第一个,它充分结合了上述所有应用的优点,可以说填补了现有的空白。

二、程序功能特点和实现叙述

1.概述

本实验的全部代码使用TypeScript语言编写。这种语言作为JavaScript的强类型扩展,能够更多的把错误消灭在编译时,综合利用了Java类语言强类型的安全性优势和JavaScript的动态类型的简便性优势。考虑到各平台的整体情况,后端编译到ES.Next标准,前端由Cocos引擎按默认方式编译(ES5标准)。
按照现代Web应用程序的一般规范,前后端之间实现了完全的解耦合,两者之间通过约定好的API进行通信。后端的API与前端渲染完全分离,只发送游戏状态等前端所需要的游戏信息,而把信息转化为对用户友好的显示的过程由前端完成。这样,这个后端具有良好的可复用性,可以无需修改地支持未来可能开发微信公众号后台版本、电脑版本等。

2.后端

后端使用了NodeJS的Express框架编写,主要包括服务器网络通信模块(URM)、聊天模块(talk)、游戏平台模块(platform)和游戏核心逻辑模块(game)四部分构成。

2.1服务器网络通信模块(URM)

与简单的Web页面有所不同,本游戏在客户端不断发送http请求给给服务器的同时。也需要服务器能够向客户端推送消息。最初采用的是轮询模式,但经过测试表明此方式有很大的弊端,主要是如果客户端请求的频率过大,服务器压力会过大;而请求的频率过小,又会导致状态不能及时刷新。为此采用了长轮询的模式:客户端向服务器发起的状态刷新请求(update)不会立即返回,而是挂起,直到服务器认为需要返回此请求时才会返回。
此外,如何设计网络请求模块使得后端的其他部分能够向调用http请求一样简单的往客户端推送消息也是很重要的问题。基于以上,设计了状态刷新请求处理平台(Update Response Manager),独立于后端的逻辑部分。
在用户第一次登陆发起请求时,会得到一个标识符id,之后用户的所有update请求应携带这个id。URM在监听客户端的update请求的同时,暴露emit、json等方法给游戏逻辑部分,同时也为更复杂的应用预留了sendFile等接口。任何时候游戏逻辑部分只需简单的调用此接口传送要发送的数据给URM,就可以在用户的请求到达时将数据返回回去。
该模块从游戏逻辑部分只接受json\file等http响应数据,因此实现了解耦合,可被接入到任何需要长轮询模式发送信息给用户的Web应用中去。

2.2聊天模块(talk)

聊天模块是比较复杂、综合运用了各种Web技术的部分。主要分为文字聊天和语音聊天。
结合实践,聊天模块实例化了一个独立的URM用于消息收发。
对于文字消息,它监听用户发送文字消息的请求,并调用platform提供的接口获得所有与该用户在同一房间内的玩家信息,向他们的URM推送消息。
对于语音消息,受限于微信API的机制,则更为复杂。用户调用微信API完成录制后,将音频以文件的形式上传到服务器。服务器收到文件后,将其存储到静态文件服务可访问的区域,并向所有同房间内用户通知该用户发送了语音消息,并携带该消息的URL。客户端收到后再调用微信API完整下载语音静态文件,交由Cocos引擎的语音模块予以播放。

2.3游戏平台模块(platform)

游戏平台模块基于现有的网络游戏的一般实践,建立了用户管理、游戏房间等系统。
用户管理主要包括用户鉴权与登录态维护、用户数据维护等方面。目前的鉴权采用的是微信认证的方式,通过APPID和APPSecret调用微信的服务端API完成用户登录和用户基本信息获取等操作。对于用户数据,采用了MongoDB数据库进行持久化存储,妥善保管用户的游戏信息。
此外,大多数多人网络游戏都有房间的概念。因此设计了独立的房间模块,内置建立、加入、退出房间、获取房间列表等操作接口。房间中不涉及任何的游戏逻辑部分,仅保存一个Game引用到游戏核心逻辑,同时实现了对URM模块的良好接入。因此只需改变Game对象的定义,以上两个模块即可被用于任何的Web游戏当中。

2.4游戏核心逻辑模块(game)

这一模块是按照阿瓦隆的规则,对游戏逻辑做出的具体实现。由于这是一个桌游,需要对用户的各种不同的行为做出相应这部分是工作量较大但是其中重复劳动量较大、可复用性相对低的部分。
它监听发送到game的请求,改变游戏状态,完成游戏逻辑的计算并返回给玩家(通过调用URM)。一个完整的游戏,从收到房主的gameStart请求开始,构造Game对象。随后,Game对象监听了大部分客户端主动发来的请求和大量使用URM向客户端推送消息。到一局游戏结束,把游戏的结果信息持久化存储到数据库,共未来可能的技术统计、反作弊等使用,同时通知platform模块改变数据库,修改玩家的得分等信息。

3.前端

由于后端的存在,前端事实上已经很少有游戏逻辑相关的内容,即使有也大多是对游戏内容加以简单转化(枚举值转为字面值、利用对象数据生成可渲染文字模板)等。
但是仍然尽最大努力实现了视图与逻辑的分离。整个前端首先基于cocos引擎,因此绕不开的是场景的概念。游戏有三个场景:预加载场景、登录与房间场景、房间内场景。
预加载场景只用于等待分包加载,待加载完成后会自动切换到登录与房间场景。
除此之外的各个场景内部,都采用了核心逻辑——控制器——Cocos节点与组件——渲染的模型。
每个场景有一个CoreLogic节点,仅挂载CoreLogic脚本组件,负责游戏的核心逻辑与数据初始化、与服务器进行通信、提供错误处理\确认面板等接口、操作控制器等。例如具体而言,房间内场景的CoreLogic组件(类名为CoreLogicRoom)具有初始化场景的功能,它的start方法和其中调用的异步方法roomInit利用上个场景传来的数据对游戏进行初始化;如果上次用户是中途退出的,则会自动请求相应接口刷新完整游戏状态供用户恢复到游戏当中,完全避免了“一人断线、全房遭殃”的局面。
此外它还建立了状态刷新请求循环,监听从服务器发来的事件;并提供registerEvent方法供其他地方注册事件监听。这样,其他部分代码只要监听某个事件,这样从外面看来,就如同是服务端主动给客户端推送的消息一样的处理方式即可,弥合了http一般应用中服务器和客户端在事件处理方面的差异。此外还提供了pressFor、pressAgainst等等等等方法,写好了请求API的具体方式,供其他节点只需简单调用就实现了把游戏事件发送给服务器。总之,CoreLogic负责那些不得不由前端完成的游戏逻辑工作,主要是网络请求。
课上讲过,“界面是状态的函数”,那控制器就相当于函数实现。控制器包含很多种,在本项目中大多以Control为结尾,如状态提示控制器StatusControl、玩家控制器PlayersControl、聊天控制器TalkingControl等等。它们暴露接口给CoreLogic(其中一般有一个render接口),CoreLogic利用网络通信等逻辑得到的游戏数据经过简单处理后传给控制器,并调用控制器的render方法,此步骤传输的还是对象数据。而控制器的render方法根据实际情况把对象数据变为文字、图片等数据,再调用该控制器控制的各个Cocos组件的各个接口完成渲染操作。
此外,微信提供的接口较为复杂,不便使用,在本例中由于反复使用而受影响较大的是网络请求接口。为此,实现了一个tool.ts包括对request、upload、download等请求的promisify化封装。对于一些错误处理、提示玩家再次确认、显示加载中等标志也都实现了promisefy化封装,例如加载中动画标志暴露给外界的接口就仅有async函数when,它接受一个promise,作用就是在传入的promise执行期间显示加载中标志,并传递该promise的resolve或reject结果。
基于以上的工具类,项目大量使用async/await编程风格,这样可以对其他函数在必要的时候任意地await,使得网络请求等必须异步的操作即之后的逻辑处理就像同步函数一样清晰;同时除部分动画的插值计算、滚动列表等的状态维护外,极少有在update里每帧调用的内容,避免了一些无谓的函数调用,也使得编程更自由灵活。

三、游戏操作说明

本游戏是一个名为阿瓦隆的多人聚会游戏,有点类似于狼人杀,其主要的游戏环节是所有玩家发言讨论。受篇幅所限,以下仅讲解简单的游戏操作,并且其内容已经完全被游戏内置帮助所覆盖。游戏内置的帮助图文并茂,建议您查阅游戏内置帮助,或自行参考更详尽的规则说明(来自网络)如下:https://www.jianshu.com/p/181377317cb3
您登录并授权后,首先进入的是登录界面。您可以创建房间、加入房间,或是查看好友排行、查阅帮助。(您第一次登陆时,系统会自动为您弹出帮助界面。)创建房间后,您会自动进入房间;在房间列表中点击房间按钮,会出现绿色的加入按钮,再点击即可加入。
游戏界面中,底部有一个大聊天界面,显示所有玩家的发言和游戏的关键信息;下面聊天框,您可以输入并发送消息;右下角语音按钮,按住说话,松开发送,功能同一般的微信语音聊天。
等待房间人满后,房主即可点击开始游戏按钮。之后丰富的语音和文字提示将会提示您当前的状态,足够清晰醒目的按钮只要您对规则稍有了解就会知道如何操作。
整个游戏的环节主要就是组队、投票、任务三个。
当到您组队时,点击组队玩家,他的头像周围出现黄色框表示表示被选中。选好后点击提交即可。游戏中每轮任务有固定的人数,是在游戏中间的五个指示灯提示的。从左到右数,第一个灰色指示灯表示当前第一个未执行任务,也就是本轮组队的所需人数。
投票按钮,绿色为同意,红色为反对;任务按钮蓝色为成功,红色为失败。
测试说明:考虑到您可能无法凑齐足够的人(至少5人才能开启游戏),后端已经开启了大量只缺1个人的房间。您直接加入即可在其中游戏。房间内除您以外的角色由简单的后端脚本控制,可以模拟人的行为。另外考虑到您和他人一起测试的需求,亦开设了一些缺2人的房间,您可以和其他体验者一同进入,测试聊天等功能。如果您人够当然建立一个一般的房间直接游戏更是可以的。

四、游戏优化与游戏测试

针对本游戏实际遇到的问题,进行了大量的测试与优化。
由于本游戏设计是需要许多玩家参与的,为了便于测试,最好有由机器控制角色与测试者一同游戏。为此,专门在后端编写了脚本,脚本中有代码可以模拟人的行为,见test目录下。
此外,还提供了API供测试者随时调用,在测试房间不足时随时新建测试房间;设计了服务器测试脚本,可以同时开启大量的房间,对服务器表现进行测试,测试脚本位于test/LocalTest.ts和TestOnePerson.ts。结果表明对于约200个用户在房间内的情况,服务器运行正常。

针对前端遇到的问题,也进行了各种优化。

  1. 针对图片文件数量过多和尺寸过大的问题,使用了TexturePackerGUI将大量的图打包为图集(Atlas),在Cocos中直接使用;
  2. 针对反复轮询消耗大量哪个服务器资源的问题,考虑到WS具有应对不稳定连接能力较差等问题,最终选用了长轮询作为解决方案。结果表明在长轮询模式下,服务器性能表现良好。
  3. 针对原来使用富文本RichText实现的房间列表在房间数较多时加载较慢的情况,查阅资料知道Cocos对富文本的实现比较复杂;此外渲染房间列表也并不需要反复渲染一些固定的文字,如“梅林”、“派西维尔”等。最终在此处放弃了富文本,改用普通Label进行渲染。结果表明在本人手机的测试条件下,渲染30个房间的列表耗时从约8s下降到2.5s。

一些需要记住的注意事项

博客相关

  1. markdown指令之后要空一格!要不然解释器可能不会识别。
  2. 经过两天的巨大努力自动获取博文详细信息API和本地上传博文的功能已经做好了。现在只要打开本地webstorm项目,hexo deploy -g即可。

Vue相关

这部分麻烦事特别多

1.vue class-style component用法:

首先,在每一个类上头,除了imoort Vue之外,如果使用了@Component标记,就还一定要import Component才可以!

1
2
3
4
5
6
7
8
import Vue from "vue";
import Component from "vue-class-component"
@Component({ //不知道的该写下面的东西,都写这就完事了
props: {
url: String,
//......
}
})

一定注意class prop的写法和普通的 不一样了,不需要在属性名前面加引号

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
export default class App extends Vue {
blogs = null; //data成员直接作为类的成员定义
//method直接作为成员函数定义
async requestBlogList(){
//...
}
//...其他method定义
mounted(){
//mounted等专有的函数式属性,也都写为类的成员
}
//如果想定义computed属性:定义为getter
get qwq{
//...
}
//watch我现在也没太搞清往哪写,等我搞明白了补上
}

2.Bootstrap-Vue用法:

npm install --save bootstrap-vue,再

1
2
3
4
import BootstrapVue from 'bootstrap-vue'
import 'bootstrap/dist/css/bootstrap.css'
import 'bootstrap-vue/dist/bootstrap-vue.css'
Vue.use(BootstrapVue)

比较坑的是不支持自动补全,这个真的特别烦。

3.Vue-Resource用法:

npm install --save vue-resource,再

1
2
import VueResource from 'vue-resource'
Vue.use(VueResource)

使用方法:
Vue.$http.post(url, requestObject/*会自动转json的*/)
和异步请求连在一起写就是:

1
2
3
4
5
6
7
8
async function reqSomething(){
try{
let responseJson = await (await Vue.$http.post(url, requestObject)).json()
//处理
}catch(e){
console.log(e)
}
}

4.Vue项目的部署:

新建vue.config.js

1
2
3
module.exports = {
"publicPath": /dmpj/
}

其中/dmpj/是二级目录名,如果部署根目录不写这个属性或者是写成/就完事了。

5.绑定事件时候事件参数:

<Component v-on:click="clickEventHandler($event)"></Component>
传入事件参数一定要给$event,省略$符号是不行的。

6.善用watch机制、事件emit和on机制、ref机制来实现父子组件之间更复杂的消息互通!(待好好研究之后补充具体内容)

服务器配置相关

apache的安装和配置:
https://blog.csdn.net/qduningning/article/details/38273843
https://cyzgbw.iteye.com/blog/1828770