自上次填坑后,又陆陆续续做了两三个线下兼线上的实验,有了更多的实践体验,因而又到了可以稍作总结分享的时候。和上一篇博文相比,现在的技术路线应该会更成熟一些。

即便对于愿意花费时间研究更自主可控的线上实验范式(这里特指设计、部署、数据收集过程)的人而言,投入的性价比也是需要衡量的问题。由此出发,我们主要讨论最能改善实验实施体验的部分,提供一些本可以做而没有做、而且不难做的功能,或者尽可能澄清上一篇博文没有讲清楚的部分。

实验部署

我们简单回顾线上实验的工作方式。在以 PsychoJS 为代表的线上实验框架中,每个实验的核心交互都是通过静态网页完成的。除非有用户插入的自定义代码,实验从开始到结束经过的过程如下

  1. 打开网址,浏览器根据网页 HTML 的引用下载对应的在线资源;
  2. 实验按预先设计的流程进行,此时浏览器不再需要网络连接;
  3. 实验结束,收集数据文件,上传到对应的线上平台。

动手操作过 PsychoPy Builder,或者使用过线上实验平台的用户应该都清楚,Builder 可以输出一系列 .html.js 文件,包含了实验所需的文件;同时,正常来说,PsychoPy Builder 会下载实验设置版本的依赖[1],保存到 /lib/ 目录下;它们与用户指定的附加资源文件一起构成了运行线上实验所需的全部资源。线上实验平台所做的,不过是将用户打包上传的资源文件解压,提供域名访问,同时重载 /lib/ 的 PsychoJS 库,使得实验程序能按照相同的接口向平台服务器上传实验产生的数据。

想要自己动手完成步骤 (1) 的朋友往往容易陷入思维定势,按照传统的思路自己搭建服务器,高估了操作的难度。实际上,现代云计算平台已经提供了大量的轻量级方案。

最简单直接的是(海外云计算的)S3 兼容对象存储。上一次博文中没有对此详细介绍,这次酌情多加着墨。以 AWS S3 为代表的对象存储相当于扁平化的网盘,不存在目录结构,而是将存储的文件用键值对和元数据的形式存储,键即文件名,可以包含 / 以便按传统目录格式划分层次,值即文件内容;多家云计算平台都提供了类似的服务,用户既可以通过网页控制面板完成上传下载操作,也可以通过各种编程语言的 API 完成自动化操作。重要的是,海外平台(如 AWS S3、Cloudflare R2)会为存储桶(相当于网盘的一个一级存储单元)提供独立的域名,也预期用户使用对象存储部署静态网页。这意味着,就像将实验资源上传到线上平台一样,将资源上传到对象存储的存储桶,就能用给定的域名在公网中访问,平台招募的被试也可以通过这种方式访问实验程序。其中涉及的大部分操作都简单直观,不必我多费口舌,按个人喜好或报价选择一个平台即可(从易用性出发,推荐 Cloudflare R2)。这种方式部署的实验在 PsychoJS 的设定中属于 “debug” 模式,在实验按正常流程结束后,会将数据文件存储到本地。以本地为主线上为辅,或者不介意让被试手动上传数据文件的用户,到此就可以宣告结束了;希望自己完成数据上传的,参考下文数据整理、上传部分。

其次是使用 Cloudflare Pages 部署,对于 Geek 而言的推荐方案。首先,你同样可以用最简单的方式使用它:将素材打包上传,由 Cloudflare 提供的 pages.dev 域名访问。通常来说,Cloudflare Pages 在内地访问性虽然并非完美,但是也足够好用;在内地之外则是唯一真神[2]。其次,你可以发挥它的更多功效,使用 Git 仓库进行版本管理,此时多个分支都可以同时部署,主域名对应于你指定的 Production 分支,其他分支则可以通过子域名访问,甚至还可以通过 hash 访问每一个节点对应的版本,凡是会使用 Git 的读者都应当不难想象这种特性带来的便利。最后,对实验部署来说不常用的功能是,你的代码产物(程序)可以不在仓库中出现,而是通过仓库中的代码构建,就像各种工具包一样。事实上,我的博客现在正是用这种方式部署的。感恩。

本地调试

上一篇博文我提到了自编简易 HTTP 服务器,但这明显效率太低。实际上,我们有相当多的现成工具:安装 node.js (通过官网或 nvm) 和 serve 即可用 npx serve 启用一个监听本地访问的 HTTP 服务器,它基于扩展名提供 MIME 类型,保证实验页面正常运行。这个工具也可以用于局域网环境的文件共享,详见文档

数据整理

默认情况下,PsychoJS 会通过 psychoJS 实例的 ExperimentHandler 维护实验数据,每个试次对应一个 Object,最后通过 XLSX 库整理为输出格式。现在,既然我们决定要接管这一部分,摆在面前的就有两个选项。

一是沿用 PsychoJS 的数据管理,通过 psychoJS.experiment.addData(key, value) 记录自定义数据。此时,翻阅源码可知 value 类型为 Array 时会转换为字符串,为其他类型时则不加处理。这意味着,想通过它存储对象(类比 Python 的字典)时需要特别留心,因为它只会记录一个引用,对象后续的修改都会反映到最终的数据中。一种处理方式是通过 structuredClone 保证独立性。在实验结束时,通过 psychoJS.experimentHandler._trialsData.slice() 可以得到一个 Array[Object] 记录的全部数据,你可以像 PsychoJS 一样整理成 csv,但也不妨直接 JSON.stringify 序列化为 JSON,这可以避免大量空列的产生。

二是自己维护数据记录。这当然是最便于定制化需求,但也需要额外工作的方式。

数据上传,简易版

现在介绍基于 S3 兼容对象存储的(简易)上传方式。上文不加解释地引出了这个术语,现在是时候稍作介绍了。AWS 是最早一批提供类似服务的云计算厂家,但大量的后来者也各自提供了相应的对象存储(既可以作为单独的服务,也可以和云服务器结合使用),而幸运的是主流厂商都选择了兼容 AWS S3 的主要 API,这意味着你可以在不更改代码的前提下,自由地根据需要选择不同的存储后端,比如国内的实验就存储在国内的云计算,国外的实验就选用 Cloudflare R2.

实验部署一节已经提到,S3 兼容对象存储大多提供网页上传界面。然而,在线上实验中我们显然需要通过代码(API)完成这件事。云计算厂商自然会提供各自的 API 以提供完整功能,但这不是我们在线上实验情境下想要的;如果兼容 API 就能完成大部分工作,那没有理由维护多套代码。不幸的是,AWS 在 npm 上发行的 API 结构愈发复杂,文档也异常难懂,好在核心的鉴权接口和过去还是比较一致的。

为了简化操作,我基于旧版[3]SDK 做了一个极简的封装(见仓库),主要代码一目了然:

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
const S3 = require('aws-sdk/clients/s3.js');

export function createClient({
endpoint,
accessKeyId,
secretAccessKey,
maxRetries = 3,
signatureVersion = 'v4',
region = 'auto',
}) {
return new S3({
endpoint,
accessKeyId,
secretAccessKey,
maxRetries,
signatureVersion,
region
});
}

export async function upload({
client,
Bucket,
Key,
Body,
...rest
}) {
return await client.upload({
Bucket,
Key,
Body,
...rest
}).promise();
}

export default {
createClient,
upload,
};

如果需要使用,将仓库克隆下来构建即可。

1
2
3
git clone https://gitea.hoshino.club/HoshinoKoji/s3-simp.git
npm install
npm run build

使用时,将构建输出通过的模块形式引入脚本:

1
import { createClient, upload } from 'your-src';

相对来说,设置后端其实是相对轻松的。正常的流程是:

  1. 注册云计算账号;
  2. 创建存储桶;
  3. 生成子账号和对应的 AccessKeyId 和 AccessKey 或类似物;
  4. 设置存储桶的 CORS 权限,保证跨域访问不受限制
  5. 在文档中找到 S3 兼容 API 的访问节点格式,然后在前端使用。

通过这种方式在前端使用时,你会不得不直接暴露具有上传权限的 AccessKey,因而存在一定的风险。对于小型实验或者不太关心安全性的用户来说,这也相当够用了。吃饱饭没事干的人则需要下文的方法做进一步处理。

数据上传,复杂版

(待填坑)

问卷嵌入

(待填坑)


  1. 1.对熟悉 Python 而不熟悉 JS 的用户:这实际上就相当于用 pip 安装一个特定版本的工具包,不同版本实现的特性细节有所差异。
  2. 2.与之相对的是,workers.dev 由于众所周知的原因,在内地几乎不可使用,于是不得不搭配自己购买的域名。
  3. 3.新版的 SDK 其实也比较类似,我最开始没读懂文档,而当我读懂的时候已经用了更复杂的方案,所以这里的实现没有更新。如果希望使用新版的 SDK,参考复杂版数据上传中使用的接口即可。