云函数的运行环境是 Node.js,我们可以在云函数中使用Nodejs内置模块以及使用 npm 安装第三方依赖来帮助我们更快的开发。借助于一些优秀的开源项目,避免了我们重复造轮子,相比于小程序端,能够大大扩展云函数的使用,接下来我们会介绍一些在云函数里如何引入这些模块来实现一些通常后端编程语言才具备的能力。
由于云函数与Nodejs息息相关,需要我们对云函数与Node的模块以及Nodejs的一些基本知识有一些基本的了解。下面只介绍一些基础的概念,如果你想详细深入了解,建议去翻阅一下Nodejs的官方技术文档:
技术文档:Nodejs API 中文技术文档
在前面我们已经接触过Nodejs的fs模块、path模块,这些我们称之为Nodejs的内置模块,内置模块不需要我们使用npm install下载,就可以直接使用require引入:
const fs = require('fs')const path = require('path')const url = require('url')
Nodejs的常用内置模块以及功能如下所示,这些模块都是可以在云函数里直接使用的:
和JavaScript的全局对象(Global Object)类似,Nodejs也有一个全局对象global,它以及它的所有属性(一些全局变量都是global对象的属性)都可以在程序的任何地方访问。下面就来介绍一下Nodejs在云函数里比较常用的全局变量。
dirname是获得当前执行文件所在目录的完整目录名,node还有另外一个常用变量filename,它是获得当前执行文件的带有完整绝对路径的文件名。我们可以新建一个云函数比如nodefile,然后在nodefile云函数的index.js里输入以下代码:
const cloud = require('wx-server-sdk')cloud.init({ env: cloud.DYNAMIC_CURRENT_ENV })exports.main = async (event, context) => { console.log('当前执行文件的文件名', __filename ); console.log('当前执行文件的目录名', __dirname );}
将云函数部署上传之后,通过小程序端调用、本地调试或云端测试就可以执行云函数,得到如下的打印结果(还记得云函数的打印日志可以在哪里查看么?):
当前执行文件的文件名 /var/user/index.js当前执行文件的目录名 /var/user
由此可见云函数在云端Linux环境就放置在/var/user
文件夹里面。
还有一些变量比如module,module.exports,exports等实际上是模块内部的局部变量,它们指向的对象根据模块的不同而有所不同,但是由于它们通用于所有模块,也可以当成全局变量。
以/
为前缀的模块是文件的绝对路径,放到云函数里require('/var/user/config/config.js')
会加载云函数目录里的config文件夹里的config.js,这里require('/var/user/config/config.js')
在云函数的路径里等同于相对路径的require('./config/config.js')
。当没有以 '/'、'./' 或 '../' 开头来表示文件时,这个模块必须是一个核心模块或加载自node_modules 目录。
在nodefile云函数的目录下面新建一个config文件夹,在config文件夹里创建一个config.js,云函数的目录结构如下图所示:
nodefile // 云函数目录├── config //config文件夹│ └── config.js //config.js文件└── index.js└── config.json └── package.json
然后再在config.js里输入以下代码,通常我们用这样的方式申明一些比较敏感的信息,或者比较通用的模块:
module.exports = { AppID: 'wxda99ae45313257046', //可以是其他变量,这里只是参考 AppKey: 'josgjwoijgowjgjsogjo', }
然后在nodefile云函数的index.js里输入以下代码(下面并非实际代码,大家看着添加):
//下面两句放在exports.main函数的前面const config = require('./config/config.js')const {AppID,AppKey} = config//省略了部分代码exports.main = async (event, context) => { console.log({AppID,AppKey})}
将云函数的所有文件都部署上传到云端之后,再来执行云函数,我们就可以看到config/config.js里面的变量就被传递到了index.js里了,这同时也说明在云函数目录之下不仅可以创建文件(前面创建过图片),还可以创建模块,通过module.exports和require来达到创建并引入的效果。
process 对象提供有关当前 Node.js 进程的信息并对其进行控制,它有一个比较重要的属性process.env,返回包含用户环境的对象。
比如上面的nodefile云函数,打开云开发控制台,在云函数列表里找到nodefile,然后点击配置在弹窗的环境变量里添加一些环境变量,比如NODE_ENV、ENV_ID、name(因为是常量,建议用大写字母),它的值为字符串,然后我们将nodefile云函数的index.js代码改为如下:
const cloud = require('wx-server-sdk')cloud.init({ env: cloud.DYNAMIC_CURRENT_ENV})exports.main = async (event, context) => { return process.env //process可以不必使用require就可以直接用}
右键云函数增量上传之后,调用该云函数,然后在云函数的返回的对象里就可以看到除了有我们设置的变量以外,还有一些关于云函数环境的信息。因此我们可以把一些需要手动可以修改或者比较私密的变量添加到配置里,然后在云函数里调用,比如我们想在小程序上线之后修改小程序的云开发环境,可以添加ENV_ID字段,值到时根据情况来修改:
const cloud = require('wx-server-sdk')const {ENV_ID} = process.envcloud.init({ env: ENV_ID})
再来回顾一下wx-server-sdk这个第三方模块,它也是云开发必备的核心依赖,云开发的诸多API都是基于此。我们可以在给云函数安装了wx-server-sdk之后(也就是右键云函数,在终端执行了 npm install),在电脑上打开云函数的node modules文件夹,可以看到虽然只安装了一个wx-server-sdk,但是却下载了很多个模块,这些模块都是通过三个核心依赖@cloudbase/node-sdk(原tcb-admin-node)、protobuf、jstslib来安装的。
要想对wx-server-sdk有一个深入了解,我们可以研究一下最核心的@cloudbase/node-sdk(原tcb-admin-node),具体可以参考@cloudbase/node-sdk的Github官网,同时由于wx-server-sdk顺带下载了很多依赖,比如@cloudbase/node-sdk、xml2js、request等,这些依赖可以在云函数里直接引入。
const request = require('request')
request模块虽然是第三方模块,但是已经通过wx-server-sdk下载了,在云函数里直接通过require就可以引入。由于wx-server-sdk模块是每个云函数都会下载安装的,我们完全可以把它当成云函数的内置模块来处理,而通过wx-server-sdk顺带下载的N多个依赖,我们也可以直接引入,不必再来下载,而在使用npm install
安装完成之后的package-lock.json里查看这些依赖的版本信息。
Nodejs生态所拥有的第三方模块是所有编程语言里最多了,比Python、PHP、Java还要多,借助于这些开源的模块,可以大大节省我们的开发成本,这些模块在npm官网地址都可以搜索到,不过npm官网的第三方模块大而全,哪些才是Nodejs开发人员最常用最优秀的模块呢?我们可以在Github上面找到awesome Nodejs,这里有非常全面的推荐。
在awesome-nodejs里,这些优秀的模块被分为了近50个不同的类别,而其中大多数都是可以用于云函数的,可见云函数的强大远不只停留在云开发的技术文档上,我们接下来会在这一章会选取一些比较有代表性的模块来结合云函数进行讲解。
当我们要在云函数里引入第三方模块时,需要先在该云函数package.json里的dependencies里添加该模块并附上版本号"第三方模块名": "版本号"
,版本号的表示方法有很多,npm install 会下载相应的版本(只列举一些比较常见的):
latest
,会下载最新版的模块;1.2.x
,等同于1.2,会下载>=1.2.0<3.0.0的版本;~1.2.4
,会下载>=1.2.4 <1.3.0的版本;^1.2.4
,会下载>=1.2.3 <2.0.0的版本比如我们要在云函数里引入lodash的最新版,就可以去该云函数package.json里添加"lodash": "latest"
,注意是添加到dependencies属性里面,而且package.json的写法也要符合配置文件的格式要求,尤其要注意最后一项不能有逗号,
,以及不能在json配置文件里写注释:
"dependencies": { "lodash": "latest" }
在 npm install
时候生成一份package-lock.json文件,用来记录当前状态下实际安装的各个npm package的具体来源和版本号。不同的版本号可能对运行的结果造成不一样的影响,所以为了保证版一致会有package-lock.json,通常我们用最新的即可。
云函数运行在服务端Linux的环境中,一个云函数在处理并发请求的时候会创建多个云函数实例,每个云函数实例之间相互隔离,没有公用的内存或硬盘空间,因此每个云函数的依赖也是相互隔离的,所以每个云函数我们都要下载各自的依赖,无法做到复用。
云函数实例的创建、管理、销毁等操作由平台自动完成。每个云函数实例都在 /tmp 目录下(这里是服务端的绝对路径/tmp,不是云函数目录下的./tmp)提供了一块 512MB 的临时磁盘空间用于处理单次云函数执行过程中的临时文件读写需求,需特别注意的是,这块临时磁盘空间在函数执行完毕后可能被销毁,不应依赖和假设在磁盘空间存储的临时文件会一直存在。如果要持久化的存储,最好是使用云存储。
云函数应是无状态的,也就是一次云函数的执行不依赖上一次云函数执行过程中在运行环境中残留的信息。为了保证负载均衡,云函数平台会根据当前负载情况控制云函数实例的数量,并且会在一些情况下重用云函数实例,这使得连续两次云函数调用如果都由同一个云函数实例运行,那么两者会共享同一个临时磁盘空间,但因为云函数实例随时可能被销毁,并且连续的请求不一定会落在同一个实例(因为同时会创建多个实例),因此云函数不应依赖之前云函数调用中在临时磁盘空间遗留的数据。总的原则即是云函数代码应是无状态的。
return
返回之后就会停止运行, 和普通 node 本地运行的行为有些差异,这个要注意一下;/tmp
里,云函数的目录是没有写权限的;await
,以免任务没有执行完,云函数就终止了;通过Nodejs的模块,我们可以实现云函数与服务端的文件系统进行一定的交互,比如在前面我们就使用云函数将服务端的图片先使用fs.createReadStream读取,然后上传到云存储。Nodejs的文件处理能力让云函数也能操作服务端的文件,比如文件查找、读取、写入乃至代码编译。
还是以nodefile云函数为例,使用微信开发者工具在nodefile云函数下新建一个文件夹,比如assets,然后在assets里放入demo.jpg图片文件以及index.html网页文件等,目录结构如下所示:
nodefile // 云函数目录├── config //config文件夹│ └── config.js //config.js文件├── assets //assets文件夹│ └── demo.jpg│ └── index.html└── index.js└── config.json └── package.json
然后再在nodefile云函数的index.js里输入以下代码,使用fs.createReadStream读取云函数目录下的文件:
const cloud = require('wx-server-sdk')const fs = require('fs')const path = require('path')cloud.init({ env: cloud.DYNAMIC_CURRENT_ENV})exports.main = async (event, context) => { const fileStream = fs.createReadStream(path.join(__dirname, './assets/demo.jpg')) return await cloud.uploadFile({ cloudPath: 'demo.jpg', fileContent: fileStream, })}
上面的案例使用到了Nodejs文件处理必不可少的fs模块,fs 模块: 可以实现文件目录的创建、删除、查询以及文件的读取和写入:
上面只大致列举了fs模块的一些方法,关于如何使用大家可以去参考Nodejs官方技术文档,当然还有fs.Stats类,封装了文件信息相关的操作;fs.Dir类,封装了和文件目录相关的操作;fs.Dirent类,封装了目录项的相关操作等等。
Nodejs fs模块中的方法都有异步和同步版本,比如读取文件内容的方法有异步的 fs.readFile() 和同步的 fs.readFileSync()。异步的方法函数最后一个参数为回调函数callback,回调函数的参数里都包含了错误信息(error),通常建议大家使用异步方法,性能更高,速度更快,而且没有阻塞。
操作文件之时,不可避免的都会使用到path模块,path 模块: 提供了一些用于处理文件路径的API,它的常用方法有:
Node读取文件有两种方式,一是利用fs.readFile来读取,还有一个是使用流fs.createReadStream来读取。如果要读取的文件比较小,我们可以使用fs.readFile,fs.readFile读取文件是将文件一次性读取到本地内存。而如果读取一个大文件,比如当文件超过16M左右的时候(文件越大性能也就会越大),一次性读取就会占用大量的内存,效率比较低,这个时候需要用流来读取。流是将文件数据分割成一段段的读取,可以控制速率,效率比较高,不会占用太大的内存。无论文件是大是小,我们都可以使用fs.createReadStream来读取文件。
为了让大家看的更加明白一些,我们再看下面这个案例,使用云函数来读取云函数在云端的目录下有哪些文件(也就是列出云函数目录下的文件清单):
const cloud = require('wx-server-sdk')const fs = require('fs')cloud.init({ env: cloud.DYNAMIC_CURRENT_ENV })exports.main = async (event, context) => { const funFolder = '.';//.表示当前目录 fs.readdir(funFolder, (err, files) => { files.forEach(file => { console.log(file); }); });}
上面就用到了fs.readdir()方法,以异步的方式读取云函数在服务端的目录下面所有的文件。
我们需要注意的是,云函数的目录文件只有读权限,没有写权限,我们不能把文件写入到云函数目录文件里,也不能修改或删除里面的文件。但是每个云函数实例都在 /tmp 目录下提供了一块 512MB 的临时磁盘空间用于处理单次云函数执行过程中的临时文件读写需求,我们可以用云函数来对 /tmp 进行文件的增删改查等的操作,这些模块知识依然派的上用场。
我们还可以结合结合Nodejs文件操作的知识,使用云函数在 /tmp 临时磁盘空间创建一个txt文件,然后将创建的文件上传到云存储。
const cloud = require('wx-server-sdk')const fs = require('fs')cloud.init({ env: cloud.DYNAMIC_CURRENT_ENV })exports.main = async (event, context) => { //创建一个文件 const text = "云开发技术训练营CloudBase Camp. "; await fs.writeFile("/tmp/tcb.txt", text, 'utf8', (err) => { //将文件写入到临时磁盘空间 if (err) console.log(err); console.log("成功写入文件."); }); //将创建的txt文件上传到云存储 const fileStream = await fs.createReadStream('/tmp/tcb.txt') return await cloud.uploadFile({ cloudPath: 'tcb.txt', fileContent: fileStream, })}
上面创建文件使用的是fs.writeFile()方法,我们也可以使用fs.createWriteStream()的方法来处理:
const writeStream = fs.createWriteStream("tcb.txt"); writeStream.write("云开发技术训练营. "); writeStream.write("Tencent CloudBase."); writeStream.end();
注意,我们创建文件的目录也就是临时磁盘空间是一个绝对路径/tmp
,而不是云函数的当前目录.
,也就是说临时磁盘空间独立于云函数,不在云函数目录之下。
临时磁盘空间有512M,可读可写,因此我们可以在云函数的执行阶段做一些文件处理的周转,但是这块临时磁盘空间在函数执行完毕后可能被销毁,不应依赖和假设在磁盘空间存储的临时文件会一直存在。
Nodejs Buffer类的引入,让云函数也拥有操作文件流或网络二进制流的能力,云函数通过downloadFile接口从云存储里下载的数据类型就是Buffer,以及uploadFile接口可以将Buffer数据上传到云存储。Buffer 类在全局作用域中,因此我们无需使用 require('buffer')引入。
使用Buffer还可以进行编码转换,比如下面的案例是将云存储的图片下载(这个数据类型是Buffer)通过buffer类的toString()方法转换成base64编码,并返回到小程序端。使用开发者工具新建一个downloading的云函数,然后在index.js里输入以下代码:
const cloud = require('wx-server-sdk')cloud.init({ env: cloud.DYNAMIC_CURRENT_ENV,})exports.main = async (event, context) => { const fileID = 'cloud://xly-xrlur.786c-xly-xrlur-1300446086/cloudbase/1576500614167-520.png' //换成你云存储内的一张图片的fileID,图片不能过大 const res = await cloud.downloadFile({ fileID: fileID, }) const buffer = res.fileContent return buffer.toString('base64')}
在小程序端创建一个事件处理函数getServerImg()来调用云函数,将云函数返回的数据(base64编码的图片)赋值给data对象里的img,比如在一个页面的js文件里输入以下代码:
data: { img:""},getServerImg(){ wx.cloud.callFunction({ name: 'downloadimg', success: res => { console.log("云函数返回的数据",res.result) this.setData({ img:res.result }) }, fail: err => { console.error('云函数调用失败:', err) } })}
在页面的wxml文件里添加一个image组件(注意src的地址),当点击button时,就会触发事件处理函数来调用云函数将获取到的base64图片渲染到页面。
<button bindtap="getServerImg">点击渲染base64图片</button><image width="400px" height="200px" src="data:image/jpeg;base64,{{img}}"></image>
云函数在处理图片时,将图片转成base64是有很多限制的,比如图片不能过大,返回到小程序的数据大小不能超过1M,而且这些图片最好是临时性的文件,通常建议大家把处理好的图片以云存储为桥梁,将图片处理好后上传到云存储获取fileID,然后在小程序端直接渲染这个fileID即可。
Buffer还可以和字符串String、JSON等转化,还可以处理ascii、utf8、utf16le、ucs2、binary、hex等编码,可以进行copy拷贝、concat拼接、indexOf查找、slice切片等等操作,这些都可以应用到云函数里,就不一一介绍了,具体内容可以阅读Nodejs官方技术文档。
通过云存储来进行大文件的传输从成本的角度上讲也是有必要的,云函数将文件传输给云存储使用的是内网流量,速度快零费用,小程序端获取云存储的文件走的是CDN,传输效果好,成本也比较低,大约0.18元/GB;云函数将文件发送给小程序端消耗的是云函数外网出流量,成本相对比较高,大约0.8元/GB。
云函数经常需要处理一些非常基础事情,比如时间、数组、数字、对象、字符串、IP等,自己造轮子的成本很高,这时候我们可以到前面提到的awesome nodejs的Github里去找一些别人已经写好的开源模块,我们直接下载引入即可,下面就列举一些比较好用的工具并会结合云函数给出一些详细的案例。
开发小程序时经常需要格式化时间、处理相对时间、日历时间以及时间的多语言问题,这个时候就可以使用比较流行的momentjs了,可以参考moment中文文档
使用开发者工具新建一个云函数,比如moment,然后在package.json增加moment最新版latest的依赖:
"dependencies": { "wx-server-sdk": "latest", "moment": "latest"}
在index.js里的代码修改为如下,我们将moment区域设置为中国,将时间格式化为 十二月 23日 2019, 4:13:29 下午
的样式以及相对当前时间多少分钟前
:
const cloud = require('wx-server-sdk')const moment = require("moment");cloud.init({ env: cloud.DYNAMIC_CURRENT_ENV, })exports.main = async (event, context) => { moment.locale('zh-cn'); time1 = moment().format('MMMM Do YYYY, h:mm:ss a'); time2 = moment().startOf('hour').fromNow(); return { time1,time2}}
值得注意的是,云函数中的时区为 UTC+0,不是 UTC+8,格式化得到的时间和在国内的时间是有8个小时的时间差的,如果在云函数端将时间格式转换为字符串需要给小时数+8(这个处理需要注意一些细节,不会处理的建议修改时区),也可以修改时区。
云函数修改时区我们可以使用timezone依赖(和moment是同一个开源作者),timezone技术文档
在package.json增加moment-timezone最新版latest的依赖,然后修改上面相应的代码即可,
"dependencies": { "wx-server-sdk": "latest", "moment-timezone": "latest"}
然后使用在云函数里使用如下代码,即可完成时区的转换。
const moment = require('moment-timezone');time1 = moment().tz('Asia/Shanghai').format('MMMM Do YYYY, h:mm:ss a');
云函数的时区除了可以使用moment来处理外,还可以通过配置云函数的环境变量的方法(在云开发控制台),添加一个字段 TZ,值为Asia/Shanghai
来指定时区即可。
有时我们希望能够获取到服务器的公网IP,比如用于IP地址的白名单,或者想根据IP查询到服务器所在的地址,ipify就是一个免费好用的依赖,通过它我们也可以获取到云函数所在服务器的公网IP,ipify Github地址。
使用开发者工具新建一个getip的云函数,然后输入以下代码,并在package.json的”dependencies”里增加最新版的ipify依赖:
"dependencies": { "wx-server-sdk": "latest", "ipify": "latest"}
在index.js里的代码修改为如下,调用ipify返回ipv4的服务器地址:
const cloud = require('wx-server-sdk')const ipify = require('ipify');cloud.init({ env: cloud.DYNAMIC_CURRENT_ENV,})exports.main = async (event, context) => { return await ipify({ useIPv6: false })}
然后右键getip云函数根目录,选择在终端中打开,输入npm install安装依赖,之后上传并部署所有文件。我们可以在小程序端调用这个云函数,就可以得到云函数服务器的公网IP,这个IP是随机而有限的几个,反复调用getip,就能够穷举所有云函数所在服务器的ip了。可能你会在使用云函数连接数据库或者用云函数来建微信公众号的后台时需要用到IP白名单,我们可以把这些ip都添加到白名单里面,这样云函数就可以做很多事情啦。
crypto模块是nodejs的核心模块之一,它提供了安全相关的功能,包含对 OpenSSL 的哈希、HMAC、加密、解密、签名、以及验证功能的一整套封装。由于crypto模块是内置模块,我们引入它是无需下载,就可以直接引入。
使用开发者工具新建一个云函数,比如crypto,在index.js里输入以下代码,我们来了解一下crypto支持哪些加密算法,并以MD5加密为例:
const cloud = require('wx-server-sdk')cloud.init({ env: cloud.DYNAMIC_CURRENT_ENV,})const crypto = require('crypto');exports.main = async (event, context) => { const hashes = crypto.getHashes(); //获取crypto支持的加密算法种类列表 //md5 加密 CloudBase2020 返回十六进制 var md5 = crypto.createHash('md5'); var message = 'CloudBase2020'; var digest = md5.update(message, 'utf8').digest('hex'); return { "crypto支持的加密算法种类":hashes, "md5加密返回的十六进制":digest };}
将云函数部署之后调用从返回的结果我们可以了解到,云函数crypto模块支持46种加密算法。
Lodash是一个一致性、模块化、高性能的 JavaScript 实用工具库,通过降低 array、number、objects、string 等数据类型的使用难度从而让 JavaScript 变得更简单。Lodash 的模块化方法非常适用于:遍历 array、object 和 string;对值进行操作和检测;创建符合功能的函数。
技术文档: Lodash官方文档、Lodash中文文档
使用开发者工具新建一个云函数,比如lodash,然后在package.json 增加 lodash最新版latest的依赖:
"dependencies": { "lodash": "latest" }
在index.js里的代码修改为如下,这里使用到了lodash的chunk方法来分割数组:
const cloud = require('wx-server-sdk')var _ = require('lodash');cloud.init({ env: cloud.DYNAMIC_CURRENT_ENV, })exports.main = async (event, context) => { //将数组拆分为长度为2的数组 const arr= _.chunk(['a', 'b', 'c', 'd'], 2); return arr;}
右键lodash云函数目录,选择“在终端中打开”,npm install 安装模块之后右键部署并上传所有文件。我们就可以通过多种方式来调用它(前面已详细介绍)即可获得结果。Lodash作为工具,非常好用且实用,它的源码也非常值得学习,更多相关内容则需要大家去Github和官方技术文档里深入了解。
在awesome Nodejs页面我们了解到还有Ramba、immutable、Mout等类似工具库,这些都非常推荐。借助于Github的awesome清单,我们就能一手掌握最酷炫好用的开源项目,避免了自己去收集收藏。
在移动互联网时代,二维码是一个非常重要的入口,有时候我们需要将一些文本、网址乃至文件、图片、名片等信息放置到一个小小的二维码里,让用户可以通过手机扫码的方式来获取传递的信息。云函数也可以借助于第三方模块,比如node-qrcode来创建二维码。
技术文档:node-qrcode Github地址
使用开发者工具,创建一个云函数,如qrcode,然后在package.json增加qrcode最新版latest的依赖并用npm install安装:
"dependencies": { "qrcode": "latest" }
然后再在index.js输入如下代码,这里会先将创建的二维码写入到临时文件夹,然后再用fs.createReadStream方法读取图片,上传到云存储,还是以云存储为过渡,实现文件由服务端到小程序端的一个操作。
const cloud = require('wx-server-sdk')const fs = require('fs')const QRCode = require('qrcode')cloud.init({ env: cloud.DYNAMIC_CURRENT_ENV })exports.main = async (event, context) => {//text为二维码里包含的内容,将创建的二维码图片先存储到/tmp临时文件夹里,命名为qrcode.png const text="二维码里的有腾讯云云开发" await QRCode.toFile('/tmp/qrcode.png',text, { color: { dark: '#00F', // 蓝点 ight: '#0000' // 透明底 }}, function (err) { if (err) throw err console.log('done') }) //读取存储到/tmp临时文件夹里的二维码图片并上传到云存储里,返回fileID到小程序端 const fileStream = await fs.createReadStream('/tmp/qrcode.png') return await cloud.uploadFile({ cloudPath: 'qrcode.jpg', fileContent: fileStream, })}
执行云函数之后就能在云存储里看到我们生成的二维码图片qrcode.jpg了。如果想要深度定制更加符合你要求的二维码,可以去翻阅上面给的技术文档链接。
sharp是一个高速图像处理库,可以很方便的实现图片编辑操作,如裁剪、格式转换、旋转变换、滤镜添加、图片合成(如添加水印)、图片拼接等,支持JPEG, PNG, WebP, TIFF, GIF 和 SVG格式。在云函数端使用sharp来处理图片,而云存储则可以作为服务端和小程序端来传递图片的桥梁。
由于图片处理是一件非常消耗性能的事情,不仅会对云函数的内存有要求,也可能会造成云函数的超时,以下只是研究使用云函数来处理图片的可行性,大家在实际开发中不要这么处理,建议使用云开发的拓展能力来对图像进行处理,功能更加强大(在后面的章节里,我们会介绍)。
技术文档:sharp官方技术文档
使用开发者工具,创建一个云函数,如sharp,然后在package.json增加node-qrcode最新版latest的依赖,并右键云函数目录选择在终端中打开输入命令npm install安装依赖:
"dependencies": { "sharp": "latest" }
然后再在index.js输入如下代码,这里我们假定图片来源是云存储,我们需要先下载图片,然后用sharp对图片进行处理,处理完之后再把图片传回云存储。
const cloud = require('wx-server-sdk')const fs = require('fs')const path = require('path')cloud.init({ env: cloud.DYNAMIC_CURRENT_ENV,})const sharp = require('sharp');exports.main = async (event, context) => {//这里换成自己的fileID,也可以在小程序端上传文件之后,把fileID传进来event.fileID const fileID = 'cloud://xly-xrlur.786c-xly-xrlur-1300446086/1572315793628-366.png'//要用云函数处理图片,需要先下载图片,返回的图片类型为Buffer const res = await cloud.downloadFile({ fileID: fileID, }) const buffer = res.fileContent //sharp对图片进行处理之后,保存为output.png,也可以直接保存为Buffer await sharp(buffer).rotate().resize(200).toFile('output.png')// 云函数读取模块目录下的图片,并上传到云存储 const fileStream = await fs.createReadStream(path.join(__dirname, 'output.png')) return await cloud.uploadFile({ cloudPath: 'sharpdemo.jpg', fileContent: fileStream, })}
也可以让sharp不需要先toFile转成图片,而是直接转成Buffer,这样就可以直接作为参数传给fileContent上传到云存储,如:
const buffer2 = await sharp(buffer).rotate().resize(200).toBuffer(); return await cloud.uploadFile({ cloudPath: 'sharpdemo2.jpg', fileContent: buffer2, })
需要说明的是sharp有一定的前置条件,Node.js 的版本需要是v10.13.0+,以及云函数所在的服务器配置了libvips binaries,目前云开发的云函数不太支持,未来云开发会升级Nodejs的版本。关于图片处理的库,我们也可以去Github awesome-nodejs 项目里去翻翻看有没有其他合适的解决方案,不过更加推荐的是使用云开发的图像处理拓展能力,更或者说强烈建议所有有图片处理需求的用户都应该安装图像处理拓展能力。
借助于第三方模块Nodemailer,我们还可以实现使用云函数来发邮件。结合发邮件的功能,我们可以在用户注册了用户,或者评论有人回复,有重要的活动信息需要通知时发送邮件通知用户。用云函数这全套用户通知的流程实现起来也很简单。
技术文档:Nodemailer Github地址、Nodemailer官方文档
自己搭建邮件服务器是非常麻烦的,我们可以借助于QQ邮箱、Gmail、163个人邮件系统或企业邮件系统开启IMAP/SMTP服务,IMAP是互联网邮件访问协议,通过这种协议可以从邮件服务器获取邮件的信息、下载邮件,也就是接收邮件;SMTP也就是简单邮件传输协议,通过它可以控制邮件的中转方式,帮助计算机在发送或中转信件时找到下一个目的地,也就是发送邮件。这里我们只介绍如何使用云函数来发送邮件,所使用的就主要是smtp服务。
不同的邮件系统有着不同的smtp发送邮件服务器,端口号也会有所不同,这些都可以去相应的邮箱的设置里看到相关的说明的,这里仅以QQ邮箱为例,登录QQ邮箱,在邮件设置-账户里开启SMTP服务,QQ邮箱的发送邮件服务器:smtp.qq.com,使用SSL,端口号465或587。
QQ开启SMTP服务之后会获取到邮件授权码(邮件授权码不是邮箱密码),这个后面会用得到。
使用开发者工具创建一个云函数,比如nodemail,然后在package.json增加nodemailer最新版latest的依赖,并右键云函数目录选择在终端中打开输入命令npm install安装依赖:
"dependencies": { "nodemailer": "latest"}
然后再在index.js里输入以下代码,并根据你的实际情况来修改一下里面的参数,如:
const cloud = require('wx-server-sdk')cloud.init({ env: cloud.DYNAMIC_CURRENT_ENV,})exports.main = async (event, context) => { const nodemailer = require("nodemailer"); let transporter = nodemailer.createTransport({ host: "smtp.qq.com", //SMTP服务器地址 port: 465, //端口号,通常为465,587,25,不同的邮件客户端端口号可能不一样 secure: true, //如果端口是465,就为true;如果是587、25,就填false auth: { user: "3441****02@qq.com", //你的邮箱账号 pass: "你的QQ邮箱授权码" //邮箱密码,QQ的需要是独立授权码,不是QQ邮箱的密码 } }); let message = { from: '来自李东bbsky <888888@qq.com>', //你的发件邮箱 to: '你要发送给谁', //你要发给谁 // cc:'', 支持cc 抄送 // bcc: '', 支持bcc 密送 subject: '欢迎大家参与云开发技术训练营活动', //支持text纯文字,html代码 text: '欢迎大家', html: '<p><b>你好:</b><img src="https://hackwork-1251009918.cos.ap-shanghai.myqcloud.com/handbook/html5/weapp.jpg" rel="external nofollow" /></p>' + '<p>欢迎欢迎<br/></p>', attachments: [ //支持多种附件形式,可以是String, Buffer或Stream { filename: 'image.png', content: Buffer.from( 'iVBORw0KGgoAAAANSUhEUgAAABAAAAAQAQMAAAAlPW0iAAAABlBMVEUAAAD/' + '//+l2Z/dAAAAM0lEQVR4nGP4/5/h/1+G/58ZDrAz3D/McH8yw83NDDeNGe4U' + 'g9C9zwz3gVLMDA/A6P9/AFGGFyjOXZtQAAAAAElFTkSuQmCC','base64'), }, ], }; let res = await transporter.sendMail(message); return res;}
部署上传云函数之后,在小程序端或者云开发控制台调用该云函数就能收到邮件啦,这个只是定向给某个发送邮件,只适合发给管理员进行邮件通知的场景。
尽管邮件已经没落,但是邮箱几乎是所有互联网用户都会使用的一个互联网产品,用云函数结合邮件的发送可以拓展和传统的后端一样的发送邮件的能力。
结合云数据库给指定的人发邮件
当用户在小程序端进行一些业务操作时,我们可以结合数据库定向给该用户或目标用户发邮件,比如用户绑定注册或找回密码,当用户A给用户B写的文章或留言评论时可以给B发邮件,当用户参与活动需要通知时,管理员可以给目标用户发邮件等。不同的业务场景接收邮件的人也会不同,邮件里面的内容根据业务的需求也会有所不同,因此在邮件发送的过程中,数据库扮演着非常重要的角色。
不过由于QQ邮箱是个人邮件系统,每天只能发送几百封邮件,不太适合用户量比较大的小程序作为企业业务来进行邮件的发送。
实现密码校验与邮件的定时发送
当用户在个人资料里绑定自己的邮箱时,可以发送邮件以及校验码,校验码可以是数据库的一个字段,它的值可以是一些随机生成的字符串,但是有一定的生命周期,比如半个小时之后会失效,这个自动失效的操作则需要使用到定时触发器;邮件也可以是周报、日报的周期性定时发送,在每天或每周的某个时间点,批量收集当天或当周的数据自动发送给用户,这个也是依赖定时触发器,这个我们会在后面定时触发器的章节进行说明。
Excel是存储数据比较常见的格式,它是日常办公的运营数据的载体,也是很多非技术人士常用于数据转移的一个方式,使用非常频繁,因此研究如何将Excel(CSV)的数据导入数据库,将数据库里的数据导出为Excel(CSV)是一个比较重要的话题。我们除了可以在云开发控制台里导入导出csv文件外,还可以在云函数使用Nodejs的一些模块来处理Excel文档。
我们可以在Github上搜索关键词“Node Excel”,去筛选Star比较多,条件比较契合的,这里推荐使用node-xlsx,Github地址:node-xlsx。
使用开发者工具新建一个云函数比如node-excel,在package.json里添加latest最新版的node-xlsx,并右键云函数目录选择在终端中打开输入命令npm install安装依赖:
"dependencies": { "wx-server-sdk": "latest", "node-xlsx": "latest"}
然后再在index.js里输入以下代码,这里有几点需要注意:
const cloud = require('wx-server-sdk')cloud.init({ env: cloud.DYNAMIC_CURRENT_ENV})const xlsx = require('node-xlsx');const db = cloud.database()exports.main = async (event, context) => { const fileID = 'cloud://xly-xrlur.786c-xly-xrlur-1300446086/china.csv' //你需要将该csv的地址替换成你的云存储的csv地址 const res = await cloud.downloadFile({ fileID: fileID, }) const buffer = await res.fileContent const sheets = await xlsx.parse(buffer); //解析下载后的Excel Buffer文件,sheets是一个对象,而sheets['data']是数组,Excel有多少行数据,这个数组里就有多少个数组; const sheet = sheets[0].data //取出第一张表里的数组,注意这里的sheet为数组 const tasks = [] for (let rowIndex in sheet) { //如果你的Excel第一行为字段名的话,从第2行开始 let row = sheet[rowIndex]; const task = await db.collection('chinaexcel') .add({ data: { city: row[0], province: row[1], city_area: row[2], builtup_area: row[3], reg_pop: row[4], resident_pop: row[5], gdp: row[6] } }) tasks.push(task) //task是数据库add请求返回的值,包含数据添加之后的_id,以及是否添加成功 } return tasks;}
使用xlsx.parse解析Excel文件得到的数据是一个数组,也就是上面所说的sheets,数组里的值都是Excel的每张表,而sheets[0].data
则是第一张表里面的数据,sheets[0].data
仍然是一个数组,数组里的值是Excel表的每一行数据。
在解析返回的对象里,每个数组都是Excel的一行数据,
[ { name: 'Sheet1', data: [ [Array], [Array], ... 233 more items ] }]
发现有不少人使用云函数往数据库里导入大量数据的时候,使用的是Promise.all()方法,这个方法会出现并发的问题,会报
[LimitExceeded.NoValidConnection] Connection num overrun
的错误,这是因为数据库的同时连接数是有限制的,不同套餐数据库的连接数不同,比如免费的是20。针对这个问题还有其他解决方法,这里就不介绍啦;还有尽管你可能已经把云函数的超时时间设置到了60s,但是仍然会出现,数据并没有完全导入的情况,显然你的Excel文件过大或者一次性导入的数据太多,超出了这个云函数的极限,建议分割处理,这种方法只适用于几百条的数据。
node-xlsx不仅可以解析Excel文件从中取出数据,还能将数据生成Excel文件,因此我们可以将云数据库里面的数据取出来之后保存为Excel文件,然后再将保存的Excel文件上传到云存储。
我们可以将node-excel的云函数修改为如下代码之后直接更新文件(因为依赖相同所以不需要安装依赖):
dataList.data[index].key
的形式取出相应的value,因此这种方式也支持嵌套子文档,比如dataList.data[index].key.subkey
取出嵌套子文档里面的值;const cloud = require('wx-server-sdk')cloud.init({ env: 'xly-xrlur'})const xlsx = require('node-xlsx');const db = cloud.database()const _ = db.commandexports.main = async (event, context) => { const dataList = await db.collection("chinaexcel").where({ _id:_.exists(true) }).limit(1000).get() const data = dataList.data //data是获取到的数据数组,每一个数组都是一个key:value的对象 let sheet = [] // 其实最后就是把这个数组写入excel let title = ['id','builtup_area','city','city_area','gdp','province','reg_pop','resident_pop']//这是第一行 await sheet.push(title) // 添加完列名 下面就是添加真正的内容了 for(let rowIndex in data){ // let rowcontent = [] //这是声明每一行的数据 rowcontent.push(data[rowIndex]._id) //注意下面这个与title里面的值的顺序对应 rowcontent.push(data[rowIndex].builtup_area) rowcontent.push(data[rowIndex].city) rowcontent.push(data[rowIndex].city_area) rowcontent.push(data[rowIndex].gdp) rowcontent.push(data[rowIndex].province) rowcontent.push(data[rowIndex].reg_pop) rowcontent.push(data[rowIndex].resident_pop) await sheet.push(rowcontent) //将每一行的字段添加到rowcontent里面 } const buffer = await xlsx.build([{name: "china", data: sheet}]) return await cloud.uploadFile({ cloudPath: 'china.xlsx', fileContent: buffer, })}
在前面我们已经了解到,要将Excel里面的数据导入到数据库,会出现将数据库新增请求add放在循环里的情况,这种做法是非常低效的,即使是将云函数的超时时间设置为60s,也仍然只能导入少量的数据,如果你的业务经常需要往数据库里导入数据,我们应该如何处理呢?我们可以使用内嵌子文档的设计。
数据库的请求add是往数据库里一条一条的增加记录,有多少条就会请求多少次,而数据库的请求是非常耗时、耗资源、耗性能,而且数据量比较大时成功率也很难把控,但是如果把你要添加的所有数据,作为一整个数组添加到某个字段的值里时,就只需要执行一次数据库请求的操作即可,比如某个集合可以设计为:
{ china:[{...//几百个城市的数据 }]}
由于是记录里的某个字段的值,我们可以使用更新指令,往数组里面push数组,这样就能大大提升数据导入的性能了。
db.collection('china').doc(id).update({ data: { china: _.push([数组]) }})
以下是一个脚本文件,是在自己电脑的本地运行的哦,不是在云函数端执行的。该脚本文件只是将Excel文件转成云数据库所需要json格式,实用性其实并没有非常大。
使用Excel导入云开发的数据库,数据量比较大的时候会出现一些问题,我们可以将Excel转成CSV文件,让CSV的第一行为字段名(要是英文哦),然后使用以下代码将CSV文件转成json文件。
//用vscode打开文件之后,npm install csvtojson replace-in-fileconst csv=require('csvtojson')const replace = require('replace-in-file');const fs = require('fs')const csvFilePath='china.csv' //把要转化的csv文件放在同一个目录,这里换成你的文件即可//后面的代码都不用管,然后打开vscode终端,就会生成两个文件,一个是json文件,一个是可以导入到csv().fromFile(csvFilePath).then((jsonObj)=>{ // console.log(jsonObj); var jsonContent = JSON.stringify(jsonObj); console.log(jsonContent); fs.writeFile("output.json", jsonContent, 'utf8', function (err) { if (err) { console.log("保存json文件出错."); return console.log(err); } console.log("JSON文件已经被保存为output.json."); fs.readFile('output.json', 'utf8', function (err,data) { if (err) { return console.log(err); } var result = data.replace(/},/g, '}
').replace(/[/,'').replace(/]/,'') fs.writeFile('data.json', result, 'utf8', function (err) { if (err) return console.log(err); }); }); });})
MySQL可以说是互联网企业最为流行的数据库,也是最流行的关系型数据库(云开发数据库是文档型数据库)。如果你的项目已经使用了MySQL,数据库不方便迁移到云开发数据库或者你的业务更偏向于使用MySQL,在这种情况下,也是可以使用连接自建的MySQL数据库并把数据传到小程序端进行渲染的。
在服务器自建的MySQL或者在腾讯云等云服务公司购买的关系型数据库服务在开放了外部网络连接和IP白名单的情况下,都是可以使用云函数连接的,也就是说云函数是部署在公共网络之中的,只能访问公网的数据库资源(内网或本地的数据库是不行的),而你的数据库要能被公网访问就需要开放外部网络连接和IP白名单。不过云函数目前没有固定的IP,因此数据库需要添加的IP白名单列表会比较长。
如果你不想让数据库暴露在公网环境之下,但是又能被云函数访问,这里推荐使用腾讯云的私有网络。处于私有网络的腾讯云产品(比如云开发的云函数和腾讯云的MySQL数据库),可以通过腾讯云提供的对等连接进行互联。对等连接服务相比公网传输有极大的优势:
如果你希望云开发的云函数使用图形图像、音视频处理等比较消耗资源的服务,以及对安全有比较高要求的MySQL、Redis、TencentDB、Kafka 等服务,我们都建议使用私有网络。
(1)创建上海可用区的私有网络
腾讯云控制台需要登录时,选择微信公众号(小程序账号属于公众号体系)的登录方式,扫码选择相应的小程序账号进行登录。
打开腾讯云控制台的私有网络中免费创建私有网络,由于私有网络具有地域(Region)属性,我们需要在函数所在的地域来新建私有网络。云开发的云函数的服务器部署在上海,因此你的私有网络里的资源也要选择上海。私有网络有三个核心组成成分:私有网络网段IPv4 CIDR、子网和路由表。一个私有网络下的子网可以属于该地域下不同可用区,同一私有网络下各个子网内资源无论是否在同一可用区内,均默认内网互通。而初始子网的可用区,你可以根据你的MySQL等数据库所在的可用区来选,如果你之前在腾讯云没有数据库等,选择上海的任意可用区即可。
(2)在腾讯云购买MySQL并加入同一私有网络
打开腾讯云控制台的MySQL中购买一个实例,创建实例时最主要的是在网络这里找到你之前创建的私有网络以及对应的子网(下拉即可)。
(3)将需要连接MySQL的云函数加入私有网络
打开腾讯云控制台的云开发CloudBase中选择指定的环境,然后点击云函数菜单,然后新建一个云函数或者选择指定的云函数比如mysql,进入配置页面后,点击右上角的“编辑”,在网络配置选择和MySQL服务一样的私有网络。这样云函数就可以通过私有网络的方式连接MySQL了。
然后我们可以根据需要或者结合自身的业务需求,往mysql数据库里导入数据或数据文件,比如可以使用腾讯与自带的DMS往里面导入sql文件。
支持Nodejs连接MySQL数据库的库有很多,比如mysql、mysql2,这里推荐使用mysql2。mysql2是支持async/await的Promise写法的。
使用开发者工具打开之前有的mysql云函数(只要绑定了私有网络即可,没有的话可以参照上一步的说明),在package.json里添加latest最新版的mysql2,并右键云函数目录选择在终端中打开输入命令npm install安装依赖:
"dependencies": { "wx-server-sdk": "latest", "mysql2": "latest" }
然后在index.js里输入以下代码,这里需要注意的是我们引入的是mysql2/promise,.query
的第一个参数是sqlString,也就是SQL命令行语句的字符串格式;当所有数据库请求结束之后,注意要使用.end
断开连接,不然云函数会报超时错误。
const cloud = require('wx-server-sdk')cloud.init({ env: cloud.DYNAMIC_CURRENT_ENV,})const mysql = require('mysql2/promise');exports.main = async (event, context) => { try { const connection = await mysql.createConnection({ host: "10.168.0.7", //内网ip地址 user: "root", //数据库的用户名 password: "tcb123", //数据库密码 database: "tcb", //数据库名称 }) const [rows, fields] = await connection.query( 'SELECT * FROM `country` WHERE `country` = "china"', ); connection.end(function(err) { //注意要断开连接,不然尽管获取到了数据,云函数还是会报超时错误 console.log('断开连接') }); console.log(rows) console.log(fields) return rows } catch (err) { console.log("连接错误", err) return err }}
mysql2模块的很多参数的使用与mysql模块的比较一致,更多配置相关的信息可以查看mysql模块技术文档.
mysql2支持数据库的增删改查,下面只大致列举一些简单的案例,更多的资料可以去查看mysql相关的技术文档:
#创建一个名称为tcb的数据库CREATE DATABASE `tcb`#创建一个包含name、address字段的users表格与删除表格CREATE TABLE `users` (`name` VARCHAR(255), `address` VARCHAR(255))DROP TABLE `users`#向users表格里插入记录INSERT INTO `users`(`name`, `address`) VALUES ('李东bbsky', '深圳')#查询users表格SELECT * FROM `users`#限制查询到的记录数为20,建议记录比较多的数据表查询都需指定limitSELECT * FROM `users` LIMIT 20#查询users表格里字段等于某个值的记录SELECT * FROM `users` WHERE `name` = '李东bbsky'#将查询结果按名称来排序SELECT * FROM `users` ORDER BY `name`#删除满足条件的记录DELETE FROM `users` WHERE `address` = '深圳'#更新满足条件的记录的字段值UPDATE `users` SET `address` = "广州" WHERE `address` = '深圳'#使用Join进行联表查询SELECT `users.name` AS `user`, `products.name` AS `favorite` FROM `users` JOIN `products` ON `users.favorite_product` = products.id
下面还推荐一个比较好用的包serverless-mysql,具体使用文档可以参考serverless-mysql技术文档
使用开发者工具打开之前有的mysql云函数,在package.json里添加latest最新版的mysql2,并右键云函数目录选择在终端中打开输入命令npm install安装依赖:
"dependencies": { "wx-server-sdk": "latest", "serverless-mysql": "latest" }
然后在index.js里输入以下代码,这里需要注意的是我们引入的是mysql2/promise,.query
的第一个参数是sqlString,也就是SQL命令行语句的字符串格式;当所有数据库请求结束之后,注意要使用.end
断开连接,不然云函数会报超时错误。
const cloud = require('wx-server-sdk')cloud.init({ env: cloud.DYNAMIC_CURRENT_ENV,})const mysql = require('serverless-mysql')exports.main = async (event, context) => { const connection = await mysql({ config: { host : "10.168.0.7",//你 database : "country", user : "root", password : "lidongyx327" } }) let results = await connection.query('INSERT INTO country(Country, Region) VALUES ("中国","亚洲")') await connection.end() return results}
当然,你还可以使用Sequelize,Sequelize是针对node.js和io.js提供的ORM框架。具体就是突出一个支持广泛,配置和查询方法统一。它支持的数据库包括:PostgreSQL、 MySQL、MariaDB、 SQLite 和 MSSQL。技术文档:Sequelize,这里就不多举例了。
在云函数中使用MySQL,每个云函数在执行时就都会与MySQL的server有连接),但数据库的最大连接数是非常少的(几百几千),我们可以在数据库管理里看到并设置这个值max_connections。由于数据库的连接数比较少,因此建议将数据库的增删改查都写在一个云函数里。这样会减少云函数冷启动的概率以及减少对数据库连接数的占用,而将增删改查的处理集中到一个云函数,我们可以使用到云函数路由tcb-router,后面会有介绍。
Redis 是一个开源高性能基于key-value的NoSQL 数据库,支持多种类型的数据结构,如字符串(strings)、散列(hashes)、列表(lists)、集合(sets)、有序集合(sorted set)等而且对数据的操作都是原子性的。Redis运行在内存中,所以具有极高的读写速度,同时也支持数据的持久化,将内存中的数据保存在磁盘中。
在创建了上海可用区的私有网络之后(可以参考上一节的内容),我们可以购买腾讯云在上海可用区的Redis服务,网络类型找到你创建的私有网络以及相应的子网即可。
在腾讯云网页云开发控制台中,找到需要配置的云函数,比如函数名为redis,点击右上角编辑进入配置界面,在函数配置界面中,修改网络配置为Redis所在的同一私有网络子网。
为了连接和操作 Redis 实例,我们需要一个 Redis 客户端,推荐使用ioredis(类似的还有node_redis、tedis等)。使用开发者工具打开云函数目录中的 package.json ,新增最新版ioredis 依赖,右键云函数目录选择在终端中打开输入命令npm install安装依赖::
"dependencies": { "wx-server-sdk":"latest", "ioredis":"latest"}
然后在index.js里输入以下代码,里面涉及redis多个命令行,其中zadd命令是往redis里添加有序集合,zscore命令返回有序集合元素相应的分数值,zrevrank命令返回有序集合元素的排名(Redis有多种数据结构,不同的数据结构的数据的增删改查都有着相应的命令,这里就不多介绍了):
const cloud = require('wx-server-sdk')cloud.init({ env: cloud.DYNAMIC_CURRENT_ENV,})const Redis = require('ioredis')const redis = await new Redis({ port: 6379, host: '10.168.0.11', family: 4, // 4 (IPv4) 或 6 (IPv6) password: 'cloudbase2020',//redis的密码 db: 0,})exports.main = async (event, context) => { await redis.zadd('Score',145,'user1') await redis.zadd('Score',134,'user2') await redis.zadd('Score',117,'user3') await redis.zadd('Score',147,'user4') await redis.zadd('Score',125,'user5') const score = await redis.zscore('Score','user3') console.log('用户3的分数',score) const rank = await redis.zrevrank('Score','user5') console.log('用户5的排名',rank) return {'用户3的分数':score,'用户5的排名':rank}}
Redis常用的数据类型有五种:字符串(strings)、散列(hashes)、列表(lists)、集合(sets)、有序集合(sorted set),而JavaScript和云开发数据库的数据类型主要有字符串(String)、数字(Number)、布尔值(Boolean)、数组(Array)、对象(Object)。当我们要将云数据库或JavaScript的数组和对象这种比较复杂的数据类型存储到Redis时,应该怎么做呢?下面我们只粗略讨论一下Redis与JavaScript以及云开发数据库之间的关联关系。
字符串Strings
Redis的字符串是二进制安全的,在传输数据时,保证二进制数据的信息安全,也就是不被篡改、破译等,也不会对这些数据进行编码、序列化等。字符串存储的结构为key:value
,可以用于存储JavaScript的字符串、数值类型,通常也用于存储HTML的节点或者网页。当然也可以用于存储图片等,尽管一个key的存储上限为512M,但是通常不建议存储的值过长(比如不要超过1024 bytes,不然内存成本和key的比对成本太高)也不建议太短(只是建议)。
我们还能给字符串的值设置过期时间,以及如果值为整数(Redis没有专门的整数类型,所以key储存的值在执行原子操作命令时会被解释为十进制 64 位有符号整数)可以对数值进行类似于云开发数据库的原子操作,比如INCR storage
就是给字符串storage(表示商品库存)原子增加1,而DECRBY storage 30
,就是给库存原子减少30。
我们可以在云函数里使用ioredis、node-redis等依赖,通过redis.set key value
或redis.mset key1 value1 key2 value2
设置一个或多个key,获取时通过redis.get key
或redis.mget key1 key2
获取redis数据库中已有的key的值,字符串在redis的结构如下:
SecretId "AKIDpZ9Wp1pyhFdqrioDF5dmDkMoQ7oVF2shUOE" //用于存储一些key、token等数据openId "oUL-m5FuRmuVmxvbYOGuXbuEDsn8" //可以存储云开发经常用到的openIDstorage 1017 //表示商品库存为1017,执行原子操作命令会被解释为十进制有符号(正负)整数
关于字符串string的命令,有SET、GET、MSET、MGET、INCR、DECR、INCRBY、DECRBY等命令,具体可以阅读Redis技术文档。
散列哈希表Hashes
Redis的散列哈希表 Hashes是一个 string 类型的 field 和 value 的映射表,特别适合用于存储JavaScript的对象,因此也是使用非常频繁的一个数据类型。Redis 中每个 hash 可以存储的键值对没有上限(除非内存的量不允许)。
当我们使用JavaScript创建一个对象或者要往云开发数据库里获取/传入数据时,就会涉及到如下的数据样式(下面是一篇文章的数据),那我们应该怎么把这样的数据存储到Redis呢?
{ "title": "为什么狗会如此亲近人类?", "id": 9717547, "url": "https://daily.zhihu.com/story/9717547", "image": "https://pic4.zhimg.com/v2-60f220ee6c5bf035d0eaf2dd4736342b.jpg", "body": "<p>让狗从凶猛的野兽变成忠实的爱宠...</p>"}
我们可以使用Redis哈希表的hmset命令HMSET key field value
,我们把key的值设置为post-${id}
,而对象里的属性和值对应的写法如下:
hmset post-9717547 title "为什么狗会如此亲近人类?" id 9717547 url "https://daily.zhihu.com/story/9717547" image "https://pic4.zhimg.com/v2-60f220ee6c5bf035d0eaf2dd4736342b.jpg" body "<p>让狗从凶猛的野兽变成忠实的爱宠...</p>"
而当我们要获取哈希表的值以及要对哈希表里的数据进行增删改查时,相应的操作命令如下(只是列举了部分,更多内容请查看技术文档):
//HGETALL以列表形式返回哈希表的字段及字段值hgetall post-9717547//HMGET命令返回哈希表中一个或多个给定字段的值,比如获取2个key title和id的值;HGET是只返回一个hmget post-9717547 title idhget post-9717547 body//HMSET同时将多个键值对设置到哈希表中,比如我们同时设置两个键值对,HSET是只设置一个;如果key相同就会覆盖hmset post-9717547 author "李东bbsky" city "深圳"hset post-9717547 position "杂役"
还有删除哈希表字段的hdel、查看字段是否存在的hexists、为指定字段的整数原子添加增量(可以为正或负)的hincrby、获取字段数量的hlen、获取所有字段的hkeys等等,这些具体可以看文档。总之,有了哈希表,我们就可以用来存储一些简单的对象(没有嵌套和嵌套数组)了。
列表Lists
Redis的列表类型可以用来存储多个有序的字符串,列表里的值是可以重复的,有点类似于JavaScript的数组(还是有很多不同的哦),主要的应用场景是用户最新的动态信息、最新博客、朋友圈最新动态。在Redis中,可以对列表两端插入(push)和弹出(pop),也可以获取指定范围的元素列表以及指定索引下标的元素等,可以充当栈和队列的角色。
//rpush在列表的尾部(右边)添加一个或多个值,类似于数组方法里的push;lpush在列表的头部(左边)添加一个或多个值,类似于数组方法里的unshiftrpush code "Python" "JavaScript" "Java" "C++" "Golang" "Dart" "C" "C#"//rpop移除并返回列表最后一个元素,类似于数组方法里的pop;lpop移除并返回列表第一个元素,类似于数组方法里的shiftrpop code//llen返回列表的长度,有点类似于数组的属性lengthllen code//lindex通过索引获取列表中的元素,有点类似于数组的array[n]获取数组第n+1位的元素lindex code 3//lrange返回列表中指定区间内的元素,有点类似于数组方法里的slicelrange code 2 5//linsert key before|after pivot value,在列表的元素前或者后插入元素。当指定元素不存在于列表中时,不执行任何操作,如下方式是把SQL插入到Dart前,数组的slice方法可以在指定位置插入元素linsert code before "Dart" "SQL"//lset通过索引来设置元素的值,有点类似于数组的array[n]=""lset code 4 "Go"
集合Sets
Redis的集合是字符串类型的无序集合,集合里的元素是无序且唯一的,不能出现重复的数据。Redis支持集合内元素的增删改查,还支持多个集合的交集、并集、差集以及跨集合移动元素,特别适合社交系统、电商系统、视频App里等常见的打标签,比如你最感兴趣的人、话题、项目等,网站和App会根据用户的兴趣点来推荐不同的内容。
//sadd 将一个或多个成员元素加入到集合中,已经存在于集合的成员元素将被忽略sadd cloudbase "云函数" "云数据库" "云存储" "云接入" "云应用" "云调用"//smembers返回集合中的所有成员smembers cloudbase//scard返回集合中元素的数量scard cloudbase//srandmember返回集合中一个或多个随机数,spop移除集合中的指定的一个或多个随机元素,移除后会返回移除的元素srandmember cloudbase 2spop cloudbase//sismember判断元素是否在集合中,在则返回1,不在返回0sismember cloudbase "云调用"
Redis处理跨集合的命令如求并集sunion
,存储并集sunionstore
,交集sinter
、存储交集sinterstore
,差集sdiff
、存储差集sdiffstore
,跨集合移动元素smove
,等等这里就不一一举例了。
有序集合Sorted sets
Redis的有序集合和集合一样也字符串类型元素且元素不重复的集合,不同的是,有序集合多了一个排序属性score(分数),也就是每个存储元素由两个值构成,一个是元素值,一个是排序值。有序集合的元素是唯一的,但分数(score)却可以重复。有序集合特别适合做排行榜系统,比如点赞排名、销量最多、播放最多、成绩最好、分数排名等。
下面我们把文章的阅读量以及文章的id写入到Redis的有序集合里,我们可以很方便的将文章按一些要求来排序:
//zadd命令用于将一个或多个元素及分数值加入到有序集中。如果元素已经存在,会更新这个元素的分数值,并通过重新插入这个元素,来保证该元素在正确的位置上。zadd read:rank 9932 post-323 3211 post-123 1234 post-77 987 post-33 532 post-21//zrange把元素按分数递增来排序,0为第一位,-1为最后一位,0,-1会把所有元素都排序;而1,3则是取排序的第2、4位;zrevrange则是递减zrange read:rank 0 -1 withscoreszrange read:rank 1 3 withscoreszrevrange read:rank 1 3 withscores//zcount显示分数score在 min 和 max 之间的元素的数量zcount read:rank 1000 3000//zrank返回有序集合指定元素的排名(排名以0为底),按分数值递增(从小到大)顺序排列;zrevrank是从大到小zrank read:rank post-323zrevrank read:rank post-987
和连接MySQL一样,建议在云函数中使用Redis时,把同一个Redis实例的增删改查等操作都集中写在一个云函数里,这样会减少云函数冷启动的概率以及减少对数据库连接数的占用,而将增删改查的处理集中到一个云函数,我们可以使用到云函数路由tcb-router,后面会有介绍。
结合一些第三方提供的短信API,使用云开发的云函数也能发送短信验证码、系统和活动通知等,下面以腾讯云的短信服务为例。腾讯云有针对Node环境的tencentcloud-sdk-nodejs模块,集成了腾讯云多项服务,除了短信之外,腾讯云服务的几乎所有能力都会集成在这个开发者工具套件(SDK)里。
登录短信控制台,这里的账号不限于小程序的账号,其他账号也可以;也不限于是个人账号还是企业账号,不过账号需要进行实名认证,个人认证用户只能发送短信验证码、短信通知等,不能用于营销短信;企业认证用户可以发送短信验证码、短信通知、营销短信等。如果账号已经认证,直接申请短信服务就可以开通了。
创建应用可用于个性化管理短信发送任务,例如设置不同的发送频率和发送超量提醒等。打开左侧菜单里的应用管理-应用列表,点击创建应用,应用名称可以为你的小程序名称+云开发,便于区分管理。创建后,会有一个SDKAppID
,这个之后会用到。
国内短信由签名+正文组成,签名符号为【】,发送短信内容时必须带签名。所以要发送短信,需要申请短信签名和正文模板,两者都通过审核后,就可以开始发送短信了。
(1)创建签名
打开左侧菜单里的国内短信-签名管理,点击创建签名,创建完签名后,这个签名内容
之后会用到。
(2)创建正文模板
打开左侧菜单里的国内短信-正文模板管理,点击创建正文模板,创建完模板后,会有一个模板ID
,这个之后会用到,也要记住你模板的变量位置。
{1}
和{2}
是你要在代码里传入的变量,变量的编码必须是从{1}开始,传入变量时也要按照顺序传入在使用云API之前,用户首先需要在腾讯云控制台上申请安全凭证(API密钥),安全凭证包括 SecretID 和 SecretKey。打开腾讯云访问密钥的API密钥管理,点击新建密钥,就可以创建密钥了,创建之后,就可以看到 SecretID
和SecretKey
,这两个之后会用到。
API 密钥是构建腾讯云 API 请求的重要凭证,使用腾讯云 API 可以操作你这个账号名下的所有腾讯云资源,一定要妥善保管和定期更新,不要分享给别人或者上传到网络上。
使用开发者工具新建一个云函数,比如sms,打开云函数目录中的 package.json ,新增最新版tencentcloud-sdk-nodejs 依赖,右键云函数目录选择在终端中打开输入命令npm install安装依赖::
"dependencies": { "wx-server-sdk":"latest", "tencentcloud-sdk-nodejs":"latest"}
然后再在云函数的目录下面新建一个config文件夹,在config文件夹里创建一个config.js,云函数的目录结构如下图所示:
sms // 云函数目录├── config //config文件夹│ └── config.js //config.js文件└── index.js└── config.json └── package.json
然后再在config.js里输入以下代码,填入获取安全凭证里的SecretID 和 SecretKey:
module.exports = { secretId: 'wxda99ae45313257046', secretKey: 'josgjwoijgowjgjsogjo', }
再在index.js里输入以下代码,代码的内容比较多,但是基本都是从腾讯云短信的技术文档里直接Copy过来的,我们只需要改里面相应的参数即可,比如
SDKAppID
,签名内容
,ID
,修改完以上内容之后,就可以触发该云函数给相应的手机号发送短信了:
const cloud = require('wx-server-sdk')const tencentcloud = require("tencentcloud-sdk-nodejs");cloud.init({ env: cloud.DYNAMIC_CURRENT_ENV,})const config= require("./config/config.js")const {secretId,secretKey} = configexports.main = async (event, context) => { const smsClient = tencentcloud.sms.v20190711.Client; const models = tencentcloud.sms.v20190711.Models; const Credential = tencentcloud.common.Credential; const ClientProfile = tencentcloud.common.ClientProfile; const HttpProfile = tencentcloud.common.HttpProfile; let cred = new Credential(secretId,secretKey) let httpProfile = new HttpProfile(); httpProfile.reqMethod = "POST"; httpProfile.reqTimeout = 30; httpProfile.endpoint = "sms.tencentcloudapi.com"; let clientProfile = new ClientProfile(); clientProfile.signMethod = "HmacSHA256"; clientProfile.httpProfile = httpProfile; let client = new smsClient(cred, "ap-guangzhou", clientProfile); let req = new models.SendSmsRequest(); req.SmsSdkAppid = "1400364657"; req.Sign = "HackWeek"; req.ExtendCode = ""; req.SenderId = ""; req.SessionContext = ""; req.PhoneNumberSet = ["+86185****3"]; req.TemplateID = "597853"; req.TemplateParamSet = ["1234","5"]; client.SendSms(req, function (err, response) { if (err) { console.log(err); return; } console.log(response.to_json_string()); });}
在小程序端我们可以使用wx.request来与第三方api服务进行数据交互,那云函数除了可以直接给小程序端提供数据以外,能不能从第三方服务器获取数据呢?答案是肯定的,而且在云函数中使用HTTP请求访问第三方服务可以不受域名限制,即不需要像小程序端一样,要将域名添加到request合法域名里;也不受http和https的限制,没有域名只有IP都是可以的,所以云函数可以应用的场景非常多,即能方便的调用第三方服务,也能够充当一个功能复杂的完整应用的后端。不过需要注意的是,云函数是部署在云端,有些局域网等终端通信的业务只能在小程序里进行。
node流行的HTTP库比较多,比如got、superagent、request、axios、request-promise、fech等等,推荐大家使用axios,axios是一个基于promise的HTTP库,可以使用在浏览器和Nodejs环境中,下面也会以axios为例。
使用开发者工具,创建一个云函数,如axios,然后在package.json增加axios最新版latest的依赖并用npm install安装:
"dependencies": { "wx-server-sdk":"latest", "axios": "latest"}
然后在index.js里输入以下代码,在前面章节里,我们在小程序端调用过知乎日报的API,下面还以知乎日报的API为例:
const cloud = require('wx-server-sdk')cloud.init({ env: cloud.DYNAMIC_CURRENT_ENV,})const axios = require('axios')exports.main = async (event, context) => { const url = "https://news-at.zhihu.com/api/4/news/latest" try { const res = await axios.get(url) //const util = require('util') //console.log(util.inspect(res,{depth:null})) return res.data; } catch (e) { console.error(e); }}
在小程序端调用这个云函数,就能返回从知乎日报里获取到的最新文章和热门文章,云函数端获取知乎日报的数据就不需要添加域名校验,比小程序端的wx.request方便省事很多。
注意,在上面的案例中,我们返回的不是整个res(response对象),而是response对象里的data。直接返回整个res对象,会报
Converting circular structure to JSON
的错误,如果你想返回整个res,可以取消上面代码里面的注释。Node的util.inspect(object,[showHidden],[depth],[colors])
是一个将任意对象转换为字符串的方法,通常用于调试和错误输出。
上面的知乎链接本来就是API,返回的是json格式的数据,所以可以直接使用axios.get(),axios还可以用于爬虫,爬取网页,比如下面的代码就是爬取百度首页,并返回首页里的<title></title>
里的内容(也就是网页的标题):
const cloud = require('wx-server-sdk')cloud.init({ env: cloud.DYNAMIC_CURRENT_ENV,})const axios = require('axios')exports.main = async (event, context) => { try { const res = await axios.get("https://baidu.com") const htmlString = res.data return htmlString.match(/<title[^>]*>([^<]+)</title>/)[1] } catch (e) { console.error(e); }}
如果想使用云函数做爬虫后台,抓取网页数据,可以使用cheerio和puppeteer等第三方开源依赖,这里就不多做介绍了。
结合前面在网络API里讲过的聚合数据历史上的今天API,我们也可以在云函数端发起post请求:
const now = new Date(); //在云函数字符串时间时,注意要修改云函数的时区,方法在云函数实用工具库里有详细介绍const month = now.getMonth()+1 //月份需要+1const day = now.getDate()const key = "" //你的聚合KEYconst url ="http://api.juheapi.com/japi/toh"const cloud = require('wx-server-sdk')cloud.init({ env: cloud.DYNAMIC_CURRENT_ENV,})const axios = require('axios')exports.main = async (event, context) => { try { const res = await axios.post(url,{ key:key, v:1.0, month:month, day:day })// const res = await axios.post(`url?key=${key}&v=1.0&month=${month}&day=${day}`) return res } catch (e) { console.error(e); }}
要使用axios下载文件,需要将axios的responseType由默认的json修改为stream,然后将下载好的文件上传到云存储里,也可以将下载好的文件写入到云函数临时的tmp文件夹里,用于更加复杂的操作。
const cloud = require('wx-server-sdk')cloud.init({ env: cloud.DYNAMIC_CURRENT_ENV,})const axios = require('axios')//const fs = require('fs');exports.main = async (event, context) => { try { const url = 'https://tcb-1251009918.cos.ap-guangzhou.myqcloud.com/weapp.jpg'; const res = await axios.get(url,{ responseType: 'stream' }) const buffer = res.data //我们也还可以将下载好的图片保存在云函数的临时文件夹里 // const fileStream = await fs.createReadStream('/tmp/axiosimg.jpg') return await cloud.uploadFile({ cloudPath: 'axiosimg.jpg', fileContent: buffer, }) } catch (e) { console.error(e); }}
tcb-router是基于Nodejs koa风格的云开发云函数轻量级的类路由库,可以用于优化前端(小程序端)调用服务端的云函数时的处理逻辑。我们可以使用它在一个云函数里集成多个类似功能的云函数,比如针对某个集合的增删改查;也可以把后端的一些零散功能集成到一个云函数里,便于集中管理等。
tcb-router主要用于小程序端调用云函数时的处理逻辑,在小程序端使用wx.cloud.callFunction调用云函数时,我们需要在name里传入要调用的云函数名称,以及在data里传入要调用的路由的路径;而在云函数端使用app.router来写对应的路由的处理函数。
使用开发者工具,创建一个云函数,如router,然后在package.json增加tcb-router最新版latest的依赖并用npm install安装:
"dependencies": { "wx-server-sdk":"latest", "tcb-router": "latest"}
然后在index.js里输入以下代码,其中app.use
表示该中间件适用于所有的路由,而app.router('user')
则适用于路由为字符串'user'的中间件,ctx.body
为返回给小程序端的数据,返回的方式是通过return app.serve()
:
const cloud = require('wx-server-sdk')cloud.init({ env: cloud.DYNAMIC_CURRENT_ENV,})const TcbRouter = require('tcb-router');exports.main = async (event, context) => { const app = new TcbRouter({event}) const {OPENID} = cloud.getWXContext() app.use(async (ctx, next) => {//适用于所有的路由 ctx.data = {} //声明data为一个对象 await next(); }) app.router('user',async (ctx, next)=>{//路由为user ctx.data.openId = OPENID ctx.data.name = '李东bbsky' ctx.data.interest = ["爬山","旅游","读书"] ctx.body ={ //返回到小程序端的数据 "openid":ctx.data.openId, "姓名":ctx.data.name, "兴趣":ctx.data.interest } }) return app.serve()}
而在小程序端,我们可以用事件处理函数或者生命周期函数来调用创建好的router云函数,就能在res对象里获取到云函数router返回的ctx.body里的对象了:
wx.cloud.callFunction({ name: 'router', data: { $url: "user", //路由为字符串user,注意属性为 $url }}).then(res => { console.log(res)})
使用tcb-router还可以管理数据库的集合,我们可以把一个集合(也可以是多个集合)的add、remove、update、get等集成到一个云函数里,可以看下面具体的案例,我们在router云函数里输入以下代码:
const cloud = require('wx-server-sdk')cloud.init({ env: cloud.DYNAMIC_CURRENT_ENV,})const TcbRouter = require('tcb-router');const db = cloud.database()const _ = db.commandconst $ = db.command.aggregateexports.main = async (event, context) => { const collection= "" //数据库的名称 const app = new TcbRouter({event}) const {adddata,deleteid,updatedata,querydata,updateid,updatequery} = event app.use(async (ctx, next) => { ctx.data = {} await next(); }); app.router('add',async (ctx, next)=>{ const addresult = await db.collection(collection).add({ data:adddata }) ctx.data.addresult = addresult ctx.body = {"添加记录的返回结果":ctx.data.addresult} }) app.router('delete',async(ctx,next)=>{ const deleteresult = await db.collection(collection).where({ id:deleteid }).remove() ctx.data.deleteresult = deleteresult ctx.body = {"删除记录的返回结果":ctx.data.deleteresult} }) app.router('update',async(ctx,next)=>{ const getdata = await db.collection(collection).where({ id:updateid }).update({ data:updatedata }) ctx.data.getresult = getdata ctx.body = {"查询记录的返回结果":ctx.data.getresult} }) app.router('get',async(ctx,next)=>{ const getdata = await db.collection(collection).where(querydata).get() ctx.data.getresult = getdata ctx.body = {"查询记录的返回结果":ctx.data.getresult} }) return app.serve();}
然后再在小程序端相应的事件处理函数里使用wx.cloud.callFunction传入相应的云函数以及相应的路由$url
以及传入对应的data值即可:
//新增一条记录wx.cloud.callFunction({ name: 'router',//router云函数 data: { $url: "add", adddata:{ id:"202006031020", title:"云数据库的最佳实践", content:"<p>文章的富文本内容</p>", createTime:Date.now() } }}).then(res => { console.log(res)})//删除一条记录wx.cloud.callFunction({ name: 'router', data: { $url:"delete", deleteid:"202006031020" }}).then(res => { console.log(res)})//查询记录wx.cloud.callFunction({ name: 'router', data: { $url:"get", querydata:{ id:"202006031020", } }}).then(res => { console.log(res)})
关于tcb-router更多进阶用法,可以查看技术文档:tcb-router Github地址。使用tcb-router时的一些说明:
云开发的数据库是文档型数据库,相比于关系型数据库比如MySQL,目前还并没有一个比较好的工具类似于phpMyAdmin、MySQL workbench等可以对数据进行可视化管理,那我们应该如何进行管理呢?相比于关系型数据库,它又有哪些优势,在使用过程中应该注意什么,它的数据模型我们应该如何设计,云开发数据库又有哪些特点,在这个章节里我们会进行一个比较详细的阐述。
在云开发控制台的数据库管理页中可以编写和执行数据库脚本,脚本可对数据库进行增删查改以及聚合的操作,语法与之前的API语法相同。通过数据库脚本的操作可以弥补云开发控制台可视化操作的不足。
脚本已经有了以下全局变量,这样我们就可以直接在脚本里面使用db
、指令_
和聚合了$
了
const db = wx.cloud.database()const _ = db.commandconst $ = db.command.aggregate
数据库脚本还支持以下表达式,主要是常量、变量和对象的声明以及数据库API的调用表达式:
表达式 | 支持方式 | 示例 |
---|---|---|
获取属性 | 支持获取对象的合法属性,对象如 db、_,合法属性如 db 的 collection 属性 | db.collection |
函数调用 | 支持 | db.collection() |
new | 支持 | new db.Geo.Point(113, 23) |
变量声明 | 支持变量声明,同时支持对象解构器的声明方式 | const Geo = db.Geo const { Point } = db.Geo |
对象声明 | 支持 | const obj = { age: _.gt(10) } |
常量声明 | 支持 | const max = 10 |
负数 | 支持 | const min = -5 |
注释 | 支持 | // comment / comment / |
其他 | 不支持 |
云开发控制台的数据可视化管理和高级操作还可以实现很多类似于关系型数据库GUI管理工具的功能,毕竟GUI管理的背后就是数据库的脚本操作,更多功能大家可以自己多探索,下面只简单介绍一些例子:
我们在开发的过程中,一个集合内有几百条、几千条数据希望全部清空,但是又不想删掉该集合再重建,那应该如何做呢,总不能一条一条删除吧?云开发控制台的可视化操作目前无法做到批量删除一个集合内的多条记录的,但是这个功能我们可以通过控制台数据库高级操作的脚本来轻松进行批量删除,而且还可以创建一个脚本模板,有需要直接点击执行脚本模板做到长期复用。比如我们要删除集合为china的所有记录:
db.collection('china') .where({ _id: _.exists(true) }) .remove()
由于remove请求只支持通过匹配 where 语句来删除,我们可以在where里包含一个条件只要存在_id就删除,由于基本每个记录都有_id,所以就能都删除了。
我现在一个集合内有N条数据,由于数据库初期设计的问题,现在想给所有记录新增一个字段,想像进行关系型数据库和Excel新增一列的类似操作,那我应该怎么做呢?同样我们也可以通过控制台数据库高级操作的脚本。比如我们想给china集合内的所有记录都新增一个updateTime的字段,我们可以查询到需要新增字段的记录,然后使用update请求,当记录内没有updateTime字段就会新增:
const serverDate = db.serverDatedb.collection('china') .where({ _id: _.exists(true) }) .update({ data: { updateTime: serverDate(), } })
我在小程序端批量上传了图片、文章,但是发现它们的显示顺序并不是按照我上传顺序来进行排序,但是我有不少功能却非常依赖排序这个功能,请问我应该怎么做?
批量上传或者你按时间上传,记录的排序并不会按照你认为的顺序来排序是很正常的,查询到的数据的顺序一般也不会是控制台数据库显示的顺序,这个都是非常正常的。你如果对排序有需求,有两种方式,一种是你在开发时就能设计好排序的字段,比如想让文章能按时间来排序,就应该在小程序发表文章时就设置一个字段来记录文章的发布时间,还有一种方式就是手动加字段来自定义,比如轮播的顺序,文章置顶或调整顺序这些,可能你还没有来得及开发相关功能,我们可以使用控制台来自定义,比如给你要排序的记录新增一个字段来自定义你想要的排序顺序,然后再在数据查询时使用orderBy。
使用数据库脚本可以实现一次性增加多条数据,目前即使用云函数也无法做到一次增加多条数据库到集合里,在语法上,这两者的差异在于,数据库脚本的data支持Array数组,而API db.collection('').add({data:{}})
里的data目前只支持对象Object.
db.collection('china') .add({ data: [ { "_id":"202003041020001", "city":"驻马店", "province":"河南", "city_area":15000, "builtup_area":75.1, "reg_pop":905.0, "resident_pop":696.0, "gdp":1807.69, }, { "_id":"202003041020002", "city":"绍兴", "province":"浙江", "city_area":8279, "builtup_area":199.4, "reg_pop":443.11, "resident_pop":496.8, "gdp":4465.97, } ] })
除了可以使用云开发控制台以及腾讯云网页的云开发控制台对数据库里面的数据进行导入导出以外,在前面我们也介绍了如何使用云函数的后端能力对数据进行导入导出,当然方法也不仅限于此,我们还可以用以下方法:
我有很多图片、文件批量导入到了云存储,但是我批量获取这些文件的fileID应该怎么做?我的数据库有几十个集合,数据库经常需要备份,每次都要一个个导出非常麻烦,有没有好的方法?
如果大家有类似的功能,大家可以使用cloudbase-manager-node。cloudbase-manager-node的功能非常强大,里面有相比于@cloudbase/node-sdk更加丰富的接口,当然这些功能都需要开发人员可以结合接口进行一定的开发。
比如我们想批量获取云存储文件的fileID,可以使用listDirectoryFiles(cloudPath: string): Promise<IListFileInfo[]>
列出文件夹下所有文件的名称,也可以使用downloadDirectory(options): Promise<void>
来下载文件夹,比如我们想对所有集合的数据进行备份,可以使用listCollections(options: object): object
来获取所有集合的名称,然后使用export(collectionName: string, file: object, options: object): object
接口来导出所有记录到指定的json或csv文件里。这个在后面我们会大致介绍如何使用。
如果我们想要将云存储里面的文件或文件夹下载备份,将本地电脑的文件或文件夹批量上传到云存储,可以使用Cloudbase CLI工具,这个非常简单,在后面的章节CloudBase CLI会介绍到。
HTTP API是一个非常通用的方式,无论是哪个平台、哪种语法都可以使用HTTP API对云开发资源里的数据进入导入和导出,这里就不具体介绍代码细节了,我们可以使用以下接口实现导入:
POST https://api.weixin.qq.com/tcb/databasemigrateimport?access_token=ACCESS_TOKEN
可以使用以下接口进行导出:
POST https://api.weixin.qq.com/tcb/databasemigrateexport?access_token=ACCESS_TOKEN
云开发提供了数据库回档功能,系统会自动开启数据库备份,并于每日凌晨自动进行一次数据备份,最长保存 7 天的备份数据。开发者可以在数据库操作错误或者出现其他情况时,可在云控制台上通过新建回档任务将集合回档(还原)至指定时间点,实现部分数据找回,保证数据的安全。
回档期间,数据库的数据访问不受影响。回档完成后,开发者可在集合列表中看到原有数据库集合和回档后的集合。这样之前的数据就可以找回来了,并与已有的集合里的数据进行比对。回档已完成后,开发者可以根据情况,在集合列表中选择对应集合,右键重命名该集合名称。看是否启用回档后的数据。
安全规则是一个可以灵活地自定义数据库和云存储读写权限的权限控制方式,通过配置安全规则,开发者可以在小程序端、网页端精细化的控制云存储和集合中所有记录的增、删、改、查权限,自动拒绝不符合安全规则的前端数据库与云存储请求,保障数据和文件安全。
在前面我们建议使用安全规则取代简易版的权限设置,当使用安全规则之后,这里有一个重要的核心就是 {openid} 变量 ,无论在前端(小程序端、web端)查询时,它都是必不可少的(也就是说云函数,云开发控制台不受安全规则控制)。
{openid} 变量在小程序端使用时无需先通过云函数获取用户的 openid,直接使用'{openid}'
即可,而我们在查询时都需要显式传入openid。之前我们使用简易权限配置时不需要这么做,这是因为查询时会默认给查询条件加上一条 _openid 必须等于用户 openid,但是使用安全规则之后,就没有这个默认的查询条件了。
比如我们在查询collection时,都需要在where里面添加如下如下的条件,{openid}变量就会附带当前用户的openid。
db.collection('china').where({ _openid: '{openid}', //安全规则里有auth.openid时都需要添加})
更新、删除等数据库的写入请求也都需要明确在where里添加这样的一个条件(使用安全规则后,在小程序端也可以进行批量更新和删除)。
db.collection('goods').where({ _openid: '{openid}', category: 'mobile'}).update({ //批量更新 data:{ price: _.inc(1) }})
开启安全规则之后,都需要在where查询条件里指定
_openid: '{openid}'
,这是因为大多数安全规则里都有auth.openid
,也就是对用户的身份有要求,where查询条件为安全规则的子集,所以都需要添加。当然你也可以根据你的情况,安全规则不要求用户的身份,也就可以不传入_openid: '{openid}'
了。
由于我们在进行执行doc操作db.collection('china').doc(id)
时,没法传入openid的这个条件,那应该怎么控制权限呢?这时候,我们可以把doc操作都转化为where操作就可以了,在where查询里指定 _id 的值,这样就只会查询到一条记录了:
db.collection('china').where({ _id: 'tcb20200501', //条件里面加_id _openid: '{openid}', //安全规则里有auth.openid时都需要添加})
至于其他的doc操作,都需要转化为基于collection的where操作,也就是说以后不再使用doc操作db.collection('china').doc(id)
了。其中doc.update、doc.get和doc.remove可以用基于collection的update、get、remove取代,doc.set可以被更新指令_.set
取代。当然安全规则只适用于前端(小程序端或Web端),后端不受安全规则的权限限制。
在使用简易权限配置时,用户在小程序端往数据库里写入数据时,都会给记录doc里添加一个_openid的字段来记录用户的openid,使用安全规则之后同样也是如此。在创建记录时,可以把{openid}变量赋值给非_openid的字段或者写入到嵌套数组里,后台写入记录时发现该字符串时会自动替换为小程序用户的 openid:
db.collection('posts').add({ data:{ books:[{ title:"云开发快速入门", author:'{openid}' },{ title:"数据库入门与实战", author:'{openid}' }] }})
以往要进行openid的写入操作时需要先通过云函数返回用户openid,使用安全规则之后,直接使用{openid}变量即可,不过该方法仅支持add添加一条记录时,不支持update的方式。
使用安全规则之后,我们可以在控制台(开发者工具和网页)对每个集合以及云存储的文件夹分别配置安全规则,也就是自定义权限,配置的格式是json,仍然严格遵循json配置文件的写法(比如数组最后一项不能有逗号,,配置文件里不能有注释等)。
我们先来看简易权限配置所有用户可读,仅创建者可写
、仅创建者可读写
、所有用户可读
、所有用户不可读写
所对应的安全规则的写法,这个json配置文件的key
表示操作类型,value
是一个表达式,也是一个条件,解析为true时表示相应的操作符合安全规则。
// 所有人可读,仅创建者可读写{ "read": true, "write": "doc._openid == auth.openid"}//仅创建者可读写{ "read": "doc._openid == auth.openid", "write": "doc._openid == auth.openid"}//所有人可读{ "read": true, "write": false}//所有用户不可读写{ "read": false, "write": false}
简易的权限配置只有读read与写write,而使用安全规则之后,支持权限操作有除了读与写外,还将写权限细分为create新建、update更新、delete删除,也就是既可以只使用写,也可以细分为增、删、改,比如下面的案例为 所有人可读,创建者可写可更新,但是不能删除
"read": true, "create":"auth.openid == doc._openid", "update":"auth.openid == doc._openid", "delete":false
操作类型无外乎增删改查,不过安全规则的value是条件表达式,写法很多,让安全规则也就更加灵活。值得一提的是,如果我们不给read或者write赋值,它们的默认值为false。
安全规则还可以配置所有人可读可写的类型,也就是如下的写法,让所有登录用户(用户登录了之后才有openid,即openid不为空)可以对数据可读可写。
{ "read": "auth.openid != null", "write": "auth.openid != null"}
在小程序端,我们可以把数据库集合的安全规则操作read和write都写为true(这是所有人可读可写,而这里强调的是所有用户),因为只要用户使用开启了云开发的小程序,就会免鉴权登录有了openid,但是上面安全规则的写法则通用于云存储、网页端的安全规则。
集合里的数据让所有用户可读可写在很多方面都有应用,尤其是我们希望有其他用户可以对嵌套数组和嵌套对象里的字段进行更新时。比如集合posts存储的是所有资讯文章,而我们会把文章的评论嵌套在集合里。
{ _id:"tcb20200503112", _openid:"用户A", //用户A也是作者,他发表的文章 title:"云开发安全规则的使用经验总结", stars:223, comments:[{ _openid:"用户B", comment:"好文章,作者有心了", }]}
当用户A发表文章时,也就会创建这条记录,如果用户B希望可以评论(往数组comments里更新数据)、点赞文章(使用inc原子更新更新stars的值),就需要对该记录可读可写(至少是可以更新)。这在简易权限配置是无法做到的(只能使用云函数来操作),有了安全规则之后,一条记录就可以有被多个人同时维护的权限,而这样的场景在云开发这种文档型数据库里比较常见(因为涉及到嵌套数组嵌套对象)。
安全规则与查询where里的条件是相互配合的,但是两者之间又有一定的区别。所有安全规则的语句指向的都是符合条件的文档记录,而不是集合。使用了安全规则的where查询会先对文档进行安全规则的匹配,比如小程序端使用where查询不到记录,就会报错
errCode: -502003 database permission denied | errMsg: Permission denied
,然后再进行条件匹配,比如安全规则设置为所有人可读时,当没有符合条件的结果时,会显示查询的结果为0。我们要注意无权查询和查询结果为0的区别。
要搞清楚安全规则写法的意思,我们还需要了解一些全局变量,比如前面提及的auth.openid
表示的是登录用户的openid,而doc._openid
表示的是当前记录_openid
这个字段的值,当用户的openid与当前记录的_openid值相同时,就对该记录有权限。全局变量还有now(当前时间戳)和resource(云存储相关)。
变量 | 类型 | 说明 |
---|---|---|
auth | object | 用户登录信息,auth.openid 也就是用户的openid,如果是在web端它还有loginType登录方式、uid等值 |
doc | object | 表示当前记录的内容,用于匹配记录内容/查询条件 |
now | number | 当前时间的时间戳,也就是以从计时原点开始计算的毫秒 |
resource | object | resource.openid为云存储文件私有归属标识,标记所有者的openid |
安全规则的表达式还支持运算符,比如等于==
,不等于!=
,大于>
,大于等于>=
,小于<
,小于等于<=
,与&&
,或||
等等,后面会有具体的介绍。
运算符 | 说明 | 示例 | |
---|---|---|---|
== | 等于 | auth.openid == 'zzz' | 用户的 openid 为 zzz |
!= | 不等于 | auth.openid != 'zzz' | 用户的 openid 不为 zzz |
> | 大于 | doc.age>10 | 查询条件的 age 属性大于 10 |
>= | 大于等于 | doc.age>=10 | 查询条件的 age 属性大于等于 10 |
< | 小于 | doc.age<10 | 查询条件的 age 属性小于 10 |
<= | 小于等于 | doc.age<=10 | 查询条件的 age 属性小于等于 10 |
in | 存在在集合中 | auth.openid in ['zzz','aaa'] | 用户的 openid 是['zzz','aaa']中的一个 |
!(xx in []) | 不存在在集合中,使用 in 的方式描述 !(a in [1,2,3]) | !(auth.openid in ['zzz','aaa']) | 用户的 openid 不是['zzz','aaa']中的任何一个 |
&& | 与 | auth.openid == 'zzz' && doc.age>10 | 用户的 openid 为 zzz 并且查询条件的 age 属性大于 10 |
|| | 或 | auth.openid == 'zzz'|| doc.age>10 | 用户的 openid 为 zzz 或者查询条件的 age 属性大于 10 |
. | 对象元素访问符 | auth.openid | 用户的 openid |
[] | 数组访问符属性 | doc.favorites[0] == 'zzz' | 查询条件的 favorites 数组字段的第一项的值等于 zzz |
全局变量auth与doc的组合使用可以让登录用户的权限依赖于记录的某个字段,auth表示的是登录用户,而doc、resource则是云开发环境的资源相关,使用安全规则之后用户与数据库、云存储之间就有了联系。resource只有resource.openid,而doc不只有_openid,还可以有很多个字段,也就让数据库的权限有了很大的灵活性,后面我们更多的是以doc全局变量为例。
auth.openid是当前的登录用户,而记录doc里的openid则可以让该记录与登录用户之间有紧密的联系,或者可以说让该记录有了一个身份的验证。一般来说doc._openid所表示的是该记录的创建者的openid,简易权限控制比较的也是当前登录用户是否是该记录的创建者(或者为更加开放且粗放的权限)。
//登录用户为记录的创建者时,才有权限读"read": "auth.openid == doc._openid", //不允许记录的创建者删除记录(只允许其他人删除)"delete": "auth.openid != doc._openid",
安全规则和where查询是配套使用的,如果你指定记录的权限与创建者的openid有关,你在前端的查询条件的范围就不能比安全规则的大(如果查询条件的范围比安全规则的范围大就会出现database permission denied
:
db.collection('集合id').where({ _openid:'{openid}' //有doc._openid,因此查询条件里就需要有_openid这个条件, key:"value"}).get().then(res=>{ console.log(res)})
1、把权限指定给某个人
安全规则的身份验证则不会局限于记录的创建者,登录用户的权限还可以依赖记录的其他字段,我们还可以给记录的权限指定为某一个人(非记录的创建者),比如很多个学生提交了作业之后,会交给某一个老师审阅批改,老师需要对该记录有读写的权限,在处理时,可以在学生提交作业(创建记录doc)时时可以指定teacher的openid,只让这个老师可以批阅,下面是文档的结构和安全规则示例:
//文档的结构{ _id:"handwork20201020", _openid:"学生的openid", //学生为记录的创建者, teacher:"老师的openid" //该学生被指定的老师的openid}//安全规则{ "read": "doc.teacher == auth.openid || doc._openid == auth.openid", "write": "doc.teacher == auth.openid || doc._openid == auth.openid", }
让登录用户auth.openid依赖记录的其他字段,在功能表现上相当于给该记录指定了一个角色,如直属老师、批阅者、直接上级、闺蜜、夫妻、任务的直接指派等角色。
对于查询或更新操作,输入的where查询条件必须是安全规则的子集,比如你的安全规则如果是
doc.teacher == auth.openid
,而你在where里没有teacher:'{openid}'
这样的条件,就会出现权限报错。
由于安全规则和where查询需要配套使用,安全规则里有doc.teacher
和doc._openid
,在where里也就需要写安全规则的子集条件,比如_openid:'{openid}'
或teacher:'{openid}'
,由于这里老师也是用户,我们可以传入如下条件让学生和老师共用一个数据库请求:
const db = wx.cloud.database()const _ = db.command//一条记录可以同时被创建者(学生)和被指定的角色(老师)读取db.collection('集合id').where(_.or([ {_openid:'{openid}' }, //与安全规则doc._openid == auth.openid对应 {teacher:'{openid}' } //与安全规则doc.teacher == auth.openid对应])).get().then(res=>{ console.log(res)})
2、把权限指定给某些人
上面的这个角色指定是一对一、或多对一的指定,也可以是一对多的指定,可以使用in
或!(xx in [])
运算符。比如下面是可以给一个记录指定多个角色(学生创建的记录,多个老师有权读写):
//文档的结构{ _id:"handwork20201020", _openid:"学生的openid", //学生为记录的创建者, teacher:["老师1的openid","老师2的openid","老师3的openid"] }//安全规则{ "read": "auth.openid in doc.teacher || doc._openid == auth.openid", "write": "auth.openid in doc.teacher || doc._openid == auth.openid", }
这里要再强调的是前端(小程序端)的where条件必须是安全规则权限的子集,比如我们在小程序端针对老师进行如下查询('{openid}'
不支持查询指令,需要后端获取)
db.collection('集合id').where({ _openid:'{openid}', teacher:_.elemMatch(_.eq('老师的openid'))}).get().then(res=>{ console.log(res)})
前面我们实现了将记录的权限指定给某个人或某几个人,那如何将记录的权限指定给某类人呢?比如打车软件为了数据的安全性会有司机、乘客、管理员、开发人员、运维人员、市场人员等,这都需要我们在数据库里新建一个字段来存储用户的类型,比如
{role:3}
,用1、2、3、4等数字来标明,或者用{isManager:true}
boolean类型来标明,这个新增的字段可以就在查询的集合文档里doc.role
,或者是一个单独的集合(也就是存储权限的集合和要查询的集合是分离的,这需要使用get函数跨集合查询),后面会有具体介绍。
3、doc.auth与文档的创建者
下面有一个例子可以加深我们对安全规则的理解,比如我们在记录里指定文档的auth为其他人的openid,并配上与之相应的安全规则,即使当前用户实际上就是这个记录的创建者,这个记录有该创建者的_openid,他也没有操作的权限。安全规则会对查询条件进行评估,只要符合安全规则,查询才会成功,违反安全规则,查询就会失败。
//文档的结构,比如以下为一条记录{ _id:"handwork20201020", _openid:"创建者的openid", auth:"指定的auth的openid" }//安全规则{ "权限操作": "auth.openid == doc.auth" //权限操作为read、write、update等}//前端查询,不符合安全规则,即使是记录的创建者也没有权限db.collection('集合id').where({ auth:'{openid}'})
简易版权限设置没法在前端实现记录跨用户的写权限(含update、create、delete),也就是说记录只有创建者可写。而文档型数据库一个记录因为反范式化嵌套的原因可以承载的信息非常多,B用户操作A用户创建的记录,尤其是使用更新指令update字段以及内嵌字段的值这样的场景是非常常见的。除此之外,仅安全规则可以实现前端对记录的批量更新和删除。
比如我们可以把评论、收藏、点赞、转发、阅读量等信息内嵌到文章的集合里,以往我们在小程序端(只能通过云函数)是没法让B用户对A用户创建的记录进行操作,比如点赞、收藏、转发时用更新指令inc更新次数,比如没法直接用更新指令将评论push写入到记录里:
{ _id:"post20200515001", title:"云开发安全规则实战", star:221, //点赞数 comments:[{ //评论和子评论 content:"安全规则确实是非常好用", nickName:"小明", subcomment:[{ content:"我也这么觉得", nickName:"小军", }] }], share:12, //转发数 collect:15 //收藏数 readNum:2335 //阅读量}
在开启安全规则,我们就可以直接在前端让B用户修改A用户创建的记录,这样用户阅读、点赞、评论、转发、收藏文章等时,就可以直接使用更新指令对文章进行字段级别的更新。
"read":"auth.openid != null","update":"auth.openid != null"
这个安全规则相比于所有人可读,仅创建者可读写
,开放了update的权限,小程序端也有limit 20的限制。而如果不使用安全规则,把这些放在云函数里进行处理不仅处理速度更慢,而且非常消耗云函数的资源。
db.collection('post').where({ _id:"post20200515001", openid:'{openid}'}).update({ data:{ //更新指令的应用 }})
我们还可以把访问权限的控制信息以字段的形式存储在数据库的集合文档里,而安全规则可以根据文档数据动态地允许或拒绝访问,也就是说doc的规则匹配可以让记录的权限动态依赖于记录的某一个字段的值。
doc规则匹配的安全规则针对的是整个集合,而且要求集合里的所有记录都有相应的权限字段,而只有在权限字段满足一定条件时,记录才有权限被增删改查,是一个将集合的权限范围按照条件要求收窄的过程,where查询时的条件不能比安全规则规定的范围大(查询条件为安全规则子集);配置了安全规则的集合里的记录只有两种状态,有权限和没有权限。
这里仍然再强调的是使用where查询时要求查询条件是安全规则的子集,在进行where查询前会先解析规则与查询条件进行校验,如果where条件不是安全规则的子集就会出现权限报错,不能把安全规则看成是一个筛选条件,而是一个保护记录数据安全的不可逾越的规则。
doc的规则匹配,特别适合每个记录存在多个状态或每个记录都有一致的权限条件(要么全部是,要么全部否),而只有一个状态或满足条件才有权限被用户增删改查时的情形,比如文件审批生效(之前存在审批没有生效的多个状态),文章的发布状态为pubic(之前为private或其他状态),商品的上架(在上架前有多个状态),文字图片内容的安全检测不违规(之前在进行后置校验),消息是否撤回,文件是否删除,由于每个记录我们都需要标记权限,而只有符合条件的记录才有被增删改查的机会。
比如资讯文章的字段如下,每个记录对应着一篇文章,而status则存储着文章的多个状态,只有public时,文章才能被用户查阅到,我们可以使用安全规则"read": "doc.status=='public'"
。而对于软删除(文章假删除),被删除可以作为一个状态,但是文章还是在数据库里。
{ _id:"post2020051314", title:"云开发发布新能力,支持微信支付云调用", status:"public"},{ _id:"post2020051312", title:"云函数灰度能力上线", status:"edit"},{ _id:"post2020051311", title:"云开发安全规则深度研究", status:"delete"}
而在前端(小程序端)与之对应的数据库查询条件则必须为安全规则的子集,也就是说安全规则不能作为你查询的过滤条件,安全规则会对查询进行评估,如果查询不符合安全规则设置的约束(非子集),那么前端的查询请求没有权限读取文档,而不是过滤文档:
db.collection('集合id').where({ status:"public" //你不能不写这个条件,而指望安全规则给你过滤}).get().then(res=>{ console.log(res)})
有时候我们需要对某些记录有着非常严格的要求,禁止为空,如何为空一律不予被前端增删改查,比如已经上架的shop集合里的商品列表,有些核心数据如价格、利润、库存等就不能为空,给企业造成损失,相应的安全规则和查询如下:
//安全规则{ "权限操作": "doc.profit != null",}//权限操作,profit = 0.65就是安全规则的子集db.collection('shop').where({ profit:_.eq(0.65)})
安全规则记录的字段值不仅限于一个状态(字符串类型),还可以是可以运算的范围值,如大于>
,小于<
、in
等,比如商品的客单价都是100以上,管理员在后端(控制台,云函数等)把原本190元的价格写成了19,或者失误把价格写成了负数,这种情况下我们对商品集合使用安全规则doc.price > 100
,前端将失去所有价格低于100的商品的操作权限,包括查询。
//安全规则"操作权限":"doc.price > 100"//相应的查询db.collection('shop').where({ price:_eq(125)})
安全规则的全局变量now表示的是当前时间的时间戳,这让安全规则可以给权限的时间节点和权限的时效性设置一些规则,这里就不具体讲述了。
全局函数get可以实现跨集合来限制权限。doc的权限匹配更多的是基于文档性质的权限,也就是集合内所有文档都有相同的字段,根据这个字段的值的不同来划分权限。但是有时候我们希望实现多个用户和多个用户角色来管理集合的文档,拥有不同的权限,如果把用户和角色都写进文档的每个记录里,就会非常难以管理。也就是说doc的权限匹配并不适合复杂的用户管理文档的权限体系。
我们可以把单个复杂的集合文档(反范式化的设计)拆分成多个集合文档(范式化设计),将用户和角色从文档里分离出来。比如博客有文章post集合,而user集合除了可以把用户划分为作者、编辑、投稿者这样的用户身份,还可以是管理员组,编辑组等。如果我们把记录的权限赋予给的人员比较多或群组比较复杂,则需要把角色存储在其独立的集合中,而不是作为目标文档中的一个字段,用全局函数get来实现跨集合的权限限制。
get 函数是全局函数,可以跨集合来获取指定的记录,用于在安全规则中获取跨集合的记录来参与到安全规则的匹配中,get函数的参数格式是 database.集合名.记录id
。
比如我们可以给文章post集合设置如下安全规则,只有管理员才可以删除记录,而判断用户是否为管理员则需要跨集合用user集合里的字段值来判断:
//user集合的结构{ _id:"oUL-m5FuRmuVmxvbYOGuXbuEDsn8", //用户的openid isManager:true}//post集合的权限{ "read": "true", "delete": "get(`database.user.${auth.openid}`).isManager== true"}db.collection('post').where({ //相应的条件,并不受子集的限制})
get函数还可以接收变量,值可以通过多种计算方式得到,例如使用字符串模版进行拼接,这是一个查询的过程,如果相应的文档里有记录,则函数返回记录的内容,否则返回空(注意反引号的写法):
`(database.${doc.collction}.${doc._id})`
get函数的限制条件
读操作触发与配额消耗说明
get 函数的执行会计入数据库请求数,同样受数据库配额限制。在未使用变量的情况下,每个 get 会产生一次读操作,在使用变量时,对每个变量值会产生一次 get 读操作。例如:
假设某集合 shop 上有如下规则:
{ "read": "auth.openid == get(`database.shop.${doc._id}`).owner", "write": false}
在执行如下查询语句时会产生 5 次读取。
db.collection('shop').where(_.or([{_id:1},{_id:2},{_id:3},{_id:4},{_id:5}])).get()
在我们要构建一个项目(应用程序)时,通常第一件事情就要设计数据库。和关系型数据库将数据存储在固定的表格(这些表格由行和列组成)里所不同的是,云开发的数据库使用结构化的文档来存储数据,不再是关系型数据库里每个行列交汇处都必须有且只有一个值,它可以是一个数组、一个对象,或者更加复杂的嵌套。
实现云开发数据库之前,需要了解存储的数据的性质,如何存储这些数据,以及将如何访问它们,这需要你预先就要做出决定,进而通过组织数据和页面数据交互来获得最佳性能。具体地说,你需要先预先思考如下问题:
应用程序复杂业务功能的背后,都是简单的数据,在设计数据库的时候要清楚的知道哪些功能会执行什么样的数据操作,集合与集合、集合与字段之间有着什么关系。
范式化(normalization) 是将数据像关系型数据库一样分散到不同的集合里,而不同的集合之间是可以通过唯一的ID来相互引用数据的。不过要引用这些数据往往需要进行多次查询或使用lookup进行联表查询。
而 反范式化(denormalization) 则是将文档所需的数据都嵌入到文档的内部,如果要更新数据,可能整个文档都要查出来,修改之后再存储到数据库里,如果没有更新指令这种可以进行字段级别的更新,大文档要新增字段性能会比较低下。反观范式化设计,由于集合比较分散,也就比较小,更新数据时可以只更新一个相对较小的文档。
数据既可以内嵌(反范式化),也可以采用引用(范式化),两种策略并没有优劣之分,也都有各自的优缺点,关键是要选择适合自己应用场景的方案。完全反范式化的设计(将文档所需要的所有数据都嵌入到一个文档里面)可以大大减少文档查询的次数。如果数据更新更频繁那么范式化的设计是一个比较好的选择,而如果数据查询更频繁,而不需要怎么更新,那就没有必要把数据分散到不同的集合而牺牲查询的效率。对于复杂的应用比如博客系统、商城系统,只用一个集合(完全反范式化设计)会导致集合过大,冗余数据更多,数据写入性能差等问题,这时候就需要进行一定的范式化设计,也就是用更多的集合,而不是更大的集合。
更适合内嵌 | 更适合引用 | 说明 |
---|---|---|
内嵌文档最终会比较小 | 内嵌文档最终会比较大 | 一个记录的上限是16M,业务会持续不断增长的数据不适合内嵌,比如一个博客的文章会持续增长就不能内嵌到记录里,博客的评论虽然也会增长,但是增长量有限就可以内嵌 |
记录不会改变 | 记录经常会改变 | 当新建一个记录之后,如果业务只需要更新记录里的字段或嵌套里的字段,而不是更新整个记录,那可以用内嵌 |
最终数据一致即可 | 中间阶段的数据必须一致 | 内嵌会影响数据的一致性,但是大多数业务并不需要强一致,比如把用户评论内嵌在文章集合里,用户更改头像后以前评论的头像不会马上更改,这不会有太大影响 |
文档数据小幅增加 | 文档数据大幅增加 | 如果业务需要大幅度更新记录里的很多值或者大幅新增记录,比如有大量用户下订单,用户的订单数据就不要内嵌,而是以记录的形式存在 |
数据通常需要二次查询才能获得 | 数据通常不包含在结果中 | 内嵌文档的可以通过一次查询就能获取到嵌套的数组和对象,比如文章记录内嵌套评论,查询文章就能把该文章的评论全部获取到,减少了查询次数 |
需要快速查询 | 需要快速增删改 | 如果你的数据增删改等写入比较频繁,用嵌套数组和对象处理就会比较麻烦 |
像云开发数据库这种非关系型数据库,它的存储单位是文档,而文档的字段是可以嵌套数组和对象的,这种内嵌的方式把非关系型数据库的表与表之间的关系嵌套在了一个文档里,也就减少了需要跨集合操作的关联关系。
在前面我们了解到云开发数据库的一个文档里可以内嵌非常多的数据,甚至做到一个完整的应用只需一个集合。比如一个用户,只有一个购物车在关系型数据库里,我们需要建两张表来存储数据,一张表是存储所有客户信息的用户列表User,还有一张存储所有用户订单的订单列表Order,但是云开发数据库可以将原本的多张表内嵌成一张表。
{ "name": "小明", "age": 27, "address":"深圳南山腾讯大厦", "orders": [{ "item":"苹果", "price":15, "number":3 },{ "item":"火龙果", "price":18, "number":4 }] }}
采用这个内嵌式的设计模型,当我们要查询一个用户的信息和他的所有订单时,就可以只通过一次查询做到将用户的信息、所有的订单都获取到,而不像关系型数据库需要先在User表里查用户的信息,再根据用户的id去查所有订单。
同样一篇文章会有N个用户去评论产生N条评论数据,而这N条评论是只属于这一篇文章的,不存在评论既属于A文章,又属于B文章的情况。这种我们还是可以采用反范式化设计,将与该文章相关的评论都嵌入到这篇文章里:
{ "title": "为什么要学习云开发", "content": "云开发是腾讯云为移动开发者提供的一站式后端云服务", "comments": [{ "name": "李东bbsky", "created_on": "2020-03-21T10:01:22Z", "comment": "云开发是微信生态下的最推荐的后台技术解决方案" }, { "name": "小明", "created_on": "2020-03-21T11:01:22Z", "comment": "云开发学起来太简单啦" }]}
在我们要进入文章的详情页时,除了需要获取文章的信息,还要一次性把评论都读取出来,这种反范式化内嵌文档就能做到,也就是可以通过一次查询就能获取到所有需要的数据。但是如果文章都是属于大V一样的热点,经常会有几千条几万条的评论,将所有的评论都内嵌到文章记录里可能会存在记录溢出(比如超过16M)、增删改查效率也会下降,这个时候就不适合用内嵌的方式,而是引用。
有时候数据与数据之间的关系会比较复杂,不再是一对一或者一对多的关系,比如共享协作时,一个用户可以发N个文档,而一个文档又有N个作者(用户),这种N对N的复杂关系,使用内嵌文档就不那么好处理了。
试想一下如果你只创建一个用户表,把A所参与编辑的文档都内嵌到相应记录的字段里,B用户的也是,如果A,B用户都参与编辑过同一份文档,那么一份文档就被内嵌到了连个用户的记录了,如果这个文档有N个作者,就会被重复内嵌N次。如果我们只需要查用户编辑过哪些文档,这种方式就没有问题,但是如果要查一份文档被多少个作者编辑过,就比较困难了;如果文档更新比较频繁,那操作起来就更加复杂了,这时内嵌文档显然不合适,应该采用范式化的设计。
比如我们将用户存储到user集合里,将所有的文档存储到file集合里,集合与集合的会通过唯一的_id
来连接,下面user集合主要存储用户的信息,而把需要引用的files集合记录的_id
也写到user集合里,
{ "_id": "author10001", "name": "小云", "male":"female", "file": ["file200001","file200002","file200003"]}{ "_id": "author10002", "male":"male", "name": "小开", "books": ["file200001","file200004"]}
而在files集合里,则存储所有文档的信息,在files集合里只需要有user集合引用的_id
即可:
{ "_id": "file200001", "title": "云开发实战指南.pdf", "categories": "PDF文档", "size":"16M"}{ "_id": "file200002", "title": "云数据库性能优化.doc", "categories": "Word文档", "size":"2M"}{ "_id": "file200003", "title": "云开发入门指南.doc", "categories": "Word文档", "size":"4M"}{ "_id": "file200004", "title": "云函数实战.doc", "categories": "Word文档", "size":"4M"}
如果我们想一次性查询用户参与编辑了哪些文件以及相应的文件信息,可以在云函数端使用聚合的lookup,这样相当于两个集合整合到一个集合里面了。
const cloud = require('wx-server-sdk')cloud.init({ env: cloud.DYNAMIC_CURRENT_ENV,})const db = cloud.database()const _ = db.command const $ = db.command.aggregateexports.main = async (event, context) => { const res = db.collection('user').aggregate() .lookup({ from: 'files', localField: 'file', foreignField: '_id', as: 'bookList', }) .end() return res}
而如果我们要修改某个指定文档的信息,直接根据files集合的_id来查询就可以了。文档更新一次,所有参与编辑该文档的信息都会更新,保证了文件内容的一致性。
值得一提的是,尽管我们将复杂的关系通过范式化设计把数据分散到了不同的集合,但是和关系型数据库、Excel一个字段一列还是不一样,我们还是可以把关系不那么复杂的数据用数组、对象的方式内嵌。
如果每个用户参与编辑的文档特别多而每个文档参与共同编辑的用户又相对比较少,把file都内嵌到user集合里就比较耗性能了,这时候可以反过来,把user的id嵌入files集合里,所以数据库的设计与实际业务有着很大的关系。
//由于file数组过大,user集合不再内嵌file了{ "_id": "author10001", "name": "小云", "male":"female",}//把用户的id嵌入到files集合里,相当于以文档为主,作者为辅{ "_id": "file200001", "title": "云开发实战指南.pdf", "categories": "PDF文档", "size":"16M", "author":["author10001","author10002","author10003"]}
这里再说明一下,跨表查询和联表查询是两码事,跨表查询我们可以通过集合与集合之间有关联的字段(意义相同的字段)多次查询来查找结果;而联表查询则是通过关联的字段将多个集合的数据整列整列的合并到一起处理。如果你不需要返回跨集合的整列数据,就不建议用联表查询,更不要妄图联N张表,能跨表查询就跨表查询。
云开发数据库的数据模式比较灵活,关系型数据库要求你在插入数据之前必须先定义好一个表的模式结构,而云数据库的集合 collection 则并不限制记录 document 结构。关系型数据库对有什么字段、字段是什么类型、长度为多少等等,而云数据库既不需要预先定义,而且记录的结构也没有限制,同一个集合的记录的字段可以有很大的差异。
这种灵活性让对象和数据库文档之间的映射变得很容易。即使数据记录之间有很大的变化,每个文档也可以很好的映射到各条不同的记录。当然在实际使用中,同一个集合中的文档最好都有一个类似的结构(相同的字段、相同的内嵌文档结构)方便进行批量的增删改查以及进行聚合等操作。
随着应用程序使用时间的增长和需求变化,数据库的数据模式可能也需要相应地增长和改变。最简单的方式就是在原有的数据模式基础之上进行添加字段,这样就能保证数据库支持所有旧版的模式。比如用户信息表,由于业务需要需要增加一些字段,比如性别、年龄,云数据库可以很轻松添加,但是这会出现一些问题,就是以往收集的用户信息性别、年龄这些字段是空的,而只有新添加的用户才有。如果业务的数据变动比较大,文档的数据模式也会存在版本混乱的冲突,这个在数据库设计之初也是要思考的。
如果已经知道未来要用到哪些字段,在第一次插入的时候就将这些字段预填充了,以后用到的时候就可以使用更新指令进行字段级别的更新,而不再需要再给集合来新增字段,这样的效率就会高很多。
{ "_id":"user20200001", "nickname": "小明", "age": 27, "address":"", "school":[{ "middle":"" },{ "college":"" }]}
比如简历网站的用户信息表的address、school,用户登录的时候不必填,但是投递简历前这些信息必填,如果没有预先设置这些字段,收集这些信息时就需要使用doc对文档进行记录级别的更新。
db.collection("user").doc("user20200001") .update({ data:{ "address":"深圳", "school":[{ "middle":"华中一附中" },{ "college":"清华大学" }] } })
但是如果预先设置了这些字段,就是使用更新操作符进行字段级别的更新,当集合越大,修改的内容又比较少时,使用更新操作符来更新文档,性能会大大提升。
db.collection("user").doc("user20200001") .update({ data:{ "address":_.set("深圳"), "school.0.middle":_.set("华中一附中"), "school.1.college":_.set("清华") } })
采用内嵌文档这种反范式化设计在查询时是有很大的好处的,但是有一些文档的更新操作,会在内嵌文档的数组里增加元素或者增加一个新字段,如果随着业务的需求这类操作导致文档的大小变大,比如我们为了方便把评论内置到内嵌文档里,早期这样的设计是没有问题的,但是如果评论常年累积的增加会导致内嵌文档过大,越是往后新增的评论会越是影响性能,而且云数据库的一个记录的上限是16M。如果出现这种数据增长的情况,也会影响到反范式化的设计模式,那么你可能要重新设计下数据模型,在不同文档之间使用引用的方式而非内嵌的数据结构。
由于更新指令不仅可以对数据进行字段级别的微操(增删改),而且还是原子操作,因此它不仅性能优异还支持高并发。更值得一提的是,通过反范式化设计内嵌文档的方式,更新指令的原子操作可以替代一部分事务的功能,这个在原子操作和事务章节会有介绍。
订阅消息是小程序能力中的重要组成,当用户自主订阅之后,可以向用户以服务通知的方式发送消息的能力,当用户点击订阅消息卡片可以跳转到小程序的页面,这样就可以实现服务的闭环和更优的体验,提高活跃度和用户粘性。
要获取订阅消息授权,首先要调用接口wx.requestSubscribeMessage,这个接口会调起小程序订阅消息界面,返回用户订阅消息的操作结果。注意这个接口只能在小程序端使用tap点击或支付完成后触发。如果是使用页面加载或其他非用户点击类的事件来调用这个接口,就会报requestSubscribeMessage:fail can only be invoked by user TAP gesture
的错误。
要调用wx.requestSubscribeMessage,需要我们首先要有订阅消息的模板ID,一次性模板 id 和永久模板 id 不可同时使用,基础库2.8.4之后一次性可以调起3个模板ID(不能多于3个)。
使用开发者工具新建一个页面,如subscribe,然后在subscribe.wxml里输入以下代码,我们通过点击tap来触发事件处理函数:
<button bindtap="subscribeMessage">订阅订阅消息</button>
然后再在subscribe.js里输入以下代码,我们在事件处理函数subscribeMessage里调用wx.requestSubscribeMessage接口:
subscribeMessage() { wx.requestSubscribeMessage({ tmplIds: [ "qY7MhvZOnL0QsRzK_C7FFsXTT7Kz0-knXMwkF1ewY44",//模板 "RCg8DiM_y1erbOXR9DzW_jKs-qSSJ9KF0h8lbKKmoFU", "EGKyfjAO2-mrlJQ1u6H9mZS8QquxutBux1QbnfDDtj0" ], success(res) { console.log("订阅消息API调用成功:",res) }, fail(res) { console.log("订阅消息API调用失败:",res) } })},
建议大家在手机上进行真机调试这个接口,点击订阅消息button,就能弹出授权弹窗。
errcode":"43101","errmsg":"user refuse to accept the msg hint...
。注意该接口调用成功之后返回的对象,[TEMPLATE_ID]是动态的键,即模板id,值包括'accept'、'reject'、'ban'。'accept'表示用户同意订阅该条id对应的模板消息,'reject'表示用户拒绝订阅该条id对应的模板消息,'ban'表示已被后台封禁,如下所示(以下值仅为案例):
{errMsg: "requestSubscribeMessage:ok", RCg8DiM_y1erbOXR9DzW_jKs-qSSJ9KF0h8lbKKmoFU: "accept", qY7MhvZOnL0QsRzK_C7FFsXTT7Kz0-knXMwkF1ewY44: "reject", EGKyfjAO2-mrlJQ1u6H9mZS8QquxutBux1QbnfDDtj0: "accept"}
订阅消息的累积次数决定了我们是否可以给用户发送订阅消息,也决定了可以发送几次,因此记录用户给某个模板ID授权了多少次这个也就显得很重要了,比如我们可以结合接口返回的res对象和inc原子自增在数据库里记录订阅次数,当发送一次也会消耗一次,再用inc自减:
subscribeMessage() { const tmplIds= [ "qY7MhvZOnL0QsRzK_C7FFsXTT7Kz0-knXMwkF1ewY44", "RCg8DiM_y1erbOXR9DzW_jKs-qSSJ9KF0h8lbKKmoFU", "EGKyfjAO2-mrlJQ1u6H9mZS8QquxutBux1QbnfDDtj0" ]; wx.requestSubscribeMessage({ tmplIds:tmplIds, success(res) { console.log("订阅消息API调用成功:",res) tmplIds.map(function(item,index){ if(res[item] === "accept"){ console.log("该模板ID用户同意了",item) //可以使用原子自增指令inc往数据库里某个记录授权次数的字段+1 } }) }, fail(res) { console.log("订阅消息API调用失败:",res) } }) },
wx.requestSubscribeMessage的参数tmplIds是数组可以容纳3个模板ID,当用户点击授权弹窗,三个模板ID都是默认勾选的,只要用户点击允许,就会同时给三个模板ID累积次数;如果用户取消勾选了其中一个模板ID,并点击总是允许,那另外两个勾选的模板ID将不会再有授权弹窗。
订阅消息最核心的在于用户的授权与授权次数,也就是你在写订阅消息代码时或在发送订阅消息之前,最好是先用数据库记录用户是否已经授权以及授权的次数,关于订阅消息的授权次数的累积需要再说明的是:
订阅消息的种类很多,比如有的订阅消息用户接收一次之后就不会再接收,这时我们侧重于记录订阅消息是否被用户同意就可以了;但是有的订阅消息记录用户授权的次数有利于我们可以更好的为用户服务,比如日报、周报、活动消息等一些与用户交互比较频繁的信息。在前面我们已经多次强调了云数据库的原子操作,这里再以订阅消息次数累积的增加(授权只能增加)为例,来看原子操作是如何处理的。
使用云开发控制台新建一个messages集合,messages集合的记录结构如下所示,在设计上我们把同一个用户多个不同类型的订阅消息内嵌到一个数组templs里面。
_id:"" //可以直接为用户的openid,这样我们可以使用db.collection('messages').doc(openid)来处理;不过我们的案例的_id不是openid_openid:"" //云开发自动生成的openidtempls:[{ //把用户授权过的模板列表都记录在这里 templateId:"qY7MhvZOnL0QsRzK_C7FFsXTT7Kz0-knXMwkF1ewY44",//订阅 page:"", data:{}, //订阅消息内容对象,建议内嵌到里面,免得查两次 status:1, //用户对该条模板消息是否接受'accept'、'reject'、'ban', subStyle:"daily", //订阅类型,比如是每天daily,还是每周weekly done:false, //本次是否发送了 subNum:22, //该条订阅消息用户授权累积的次数; },{}]
下面是用户在小程序端点击订阅消息之后的完整代码,记录不同的订阅消息被用户点击之后,次数的累积。代码没有记录用户是否拒绝reject,如果业务上有需要也是可以记录的,不过拒绝不存在累积次数的问题。
subscribeMessage() { const that = this //模板ID建议放置在数据库中,便于以后修改 const tmplIds= [ "qY7MhvZOnL0QsRzK_C7FFsXTT7Kz0-knXMwkF1ewY44", "RCg8DiM_y1erbOXR9DzW_jKs-qSSJ9KF0h8lbKKmoFU", "EGKyfjAO2-mrlJQ1u6H9mZS8QquxutBux1QbnfDDtj0" ]; wx.requestSubscribeMessage({ tmplIds:tmplIds, success: res => { console.log("订阅消息API调用成功:",res) that.addMessages().then( id =>{ tmplIds.map(function(item,index){ if(res[item] === "accept"){ console.log("该模板ID用户同意了",item) that.subscribeNum(item,id) } }) }) }, fail(res) { console.log("订阅消息API调用失败:",res) } })},async addMessages(){ //查询用户订阅过的订阅消息,只会有一条记录,所以没有limit等限制 const messages = await db.collection('messages').where({ _openid:'{openid}' }).get() //如果用户没有订阅过订阅消息,就创建一条记录 if(messages.data.length == 0){ var newMsg = await db.collection('messages').add({ data:{ templs:[] } }) } var id = messages.data[0] ? messages.data[0]._id : newMsg._id return id},async subscribeNum(item,id){ //注意传入的item是遍历,id为addMessages的id const subs = await db.collection('messages').where({ _openid:'{openid}', "templs":_.elemMatch({ templateId:item }) }).get() console.log('用户订阅列表',subs) //如果用户之前没有订阅过订阅消息就创建一个订阅消息的记录 if(subs.data.length == 0){ db.collection('messages').doc(id).update({ data: { templs:_.push({ each:[{templateId:item,//订阅 page:"", data:{}, status:1, subStyle:"daily", done:false, subNum:1}], position:2 }) } }) }else{ db.collection('messages').where({ _id:id, "templs.templateId":item }) .update({ data:{ "templs.$.subNum":_.inc(1) } }) }}
这里的"templs.$.subNum":_.inc(1)
就是当用于同意哪条订阅消息,就会给该订阅消息的授权次数进行原子加1。
当我们在小程序端累积了某个模板ID的授权次数之后,就可以通过云函数来调用subscribeMessage.send接口发送订阅消息了。而这个云函数我们可以在小程序端调用,也可以使用云函数来调用云函数,还能使用定时触发器来调用云函数。
云函数调用subscribeMessage.send接口的方式有两种,一种是HTTPS调用,还有一种就是云调用,建议使用云调用。调用subscribeMessage.send接口时有很多细节需要注意,尤其是data格式,必须符合格式要求。
订阅消息的data必须与模板消息一一对应
比如我们申请到一个订阅课程开课提醒的模板,它的格式如下:
姓名{{phrase1.DATA}}课程标题{{thing2.DATA}}课程内容{{thing3.DATA}}时间{{date5.DATA}}课程进度{{character_string6.DATA}}
与之相应的data的写法如下phrase1、thing2、thing3、date5、character_string6,这些需要一一对应,参数不能多也不能少,参数后面的数字比如date5不能改成date6,否则会报"openapi.subscribeMessage.send:fail argument invalid! hint:
的错误,也就是模板里有什么参数,你就只能按部就班写什么参数:
data: { "phrase1": { "value": '李东' }, "thing2": { "value": '零基础云开发技术训练营第7课' }, "thing3": { "value": '列表渲染与条件渲染' }, "date5": { "value": '2019年10月20日 20:00' }, "character_string6": { "value": 3 }}
订阅消息参数值的内容格式必须要符合要求
在技术文档里,有一个关于订阅消息参数值的内容格式要求,这个在写订阅消息内容的时候需要严格的一一对应,否则会出现格式错误。
参数类别 | 参数说明 | 参数值限制 | 说明 |
---|---|---|---|
thing.DATA | 事物 | 20个以内字符 | 可汉字、数字、字母或符号组合 |
number.DATA | 数字 | 32位以内数字 | 只能数字,可带小数 |
letter.DATA | 字母 | 32位以内字母 | 只能字母 |
symbol.DATA | 符号 | 5位以内符号 | 只能符号 |
character_string.DATA | 字符串 | 32位以内数字、字母或符号 | 可数字、字母或符号组合 |
time.DATA | 时间 | 24小时制时间格式(支持+年月日) | 例如:15:01,或:2019年10月1日 15:01 |
date.DATA | 日期 | 年月日格式(支持+24小时制时间) | 例如:2019年10月1日,或:2019年10月1日 15:01 |
amount.DATA | 金额 | 1个币种符号+10位以内纯数字,可带小数,结尾可带“元” | 可带小数 |
phone_number.DATA | 电话 | 17位以内,数字、符号 | 电话号码,例:+86-0766-66888866 |
car_number.DATA | 车牌 | 8位以内,第一位与最后一位可为汉字,其余为字母或数字 | 车牌号码:粤A8Z888挂 |
name.DATA | 姓名 | 10个以内纯汉字或20个以内纯字母或符号 | 中文名10个汉字内;纯英文名20个字母内;中文和字母混合按中文名算,10个字内 |
phrase.DATA | 汉字 | 5个以内汉字 | 5个以内纯汉字,例如:配送中 |
下面列举一些在使用过程中容易犯的错误:
姓名{{phrase1.DATA}}
,因为姓名只能是中文,且必须5个字以内,那你就没法擅自改动,只能去申请或复用其他的模板ID;在前面我们说过,在小程序端哪个用户点击授权就只会给哪个用户增加授权次数,而借助于云函数发送订阅消息则用户可以给任何人发送订阅消息,发给哪个人就需要哪个人有授权次数,就会减少哪个人的授权次数,这一点要注意区分。
新建一个云函数比如subscribeMessage,然后再在config.json的添加subscribeMessage.send权限,使用云函数增量上传更新这个配置文件。
{ "permissions": { "openapi": [ "subscribeMessage.send" ] }}
然后再在index.js里输入以下代码,注意这里的openid,是用户自己的,这种适用于用户在小程序端完成某个业务操作之后,就给用户自己发订阅消息;当然这里的openid可以是其他累积了授权次数的用户的,也就是当我们在小程序端调用该云函数就能给其他人发订阅消息了,这主要适用于管理员:
const cloud = require('wx-server-sdk')cloud.init({ env: cloud.DYNAMIC_CURRENT_ENV,})exports.main = async (event, context) => { const { OPENID } = cloud.getWXContext() try { const result = await cloud.openapi.subscribeMessage.send({ touser: "oUL-m5FuRmuVmxvbYOGuXbuEDsn8", page: 'index', templateId: "qY7MhvZOnL0QsRzK_C7FFsXTT7Kz0-knXMwkF1ewY44", data: { "phrase1": { "value": '小明' }, "thing2": { "value": '零基础云开发技术训练营第7课' }, "thing3": { "value": '列表渲染与条件渲染' }, "date5": { "value": '2019年10月20日 20:00' }, "character_string6": { "value": 3 } } }) return result } catch (err) { console.log(err) return err }}
由于subscribeMessage.send的参数templateId和touser都是字符串,因此执行一次subscribeMessage.send只能给一个用户发送一条订阅消息,那要给更多用户比如1000人以内(云函数一次可以获取到1000条数据)发订阅消息,则需要结合数据库的查询数据库内所有有授权次数的用户然后循环执行来发消息,并在发完之后使用inc自减来减去授权次数。
由于我们把用户授权的所有订阅消息内嵌到templs这个数组里,而要发送的订阅消息的内容则来自templs数组里符合条件的对象,这里涉及到相对比较复杂的数组的处理,因此数据分析处理神器聚合就派上用场了(当然我们也可以使用普通查询,普通查询得到的是记录列表,再使用一些数组方法如filter、map等取出列表里的templs嵌套的对象列表)。
const cloud = require('wx-server-sdk')cloud.init({ env: cloud.DYNAMIC_CURRENT_ENV})const db = cloud.database()const _ = db.commandconst $ = db.command.aggregateexports.main = async (event, context) => { const templateId ="qY7MhvZOnL0QsRzK_C7FFsXTT7Kz0-knXMwkF1ewY44" try { const messages = (await db.collection('messages').aggregate() .match({ //使用match匹配查询 "templs.templateId":templateId, //注意这里templs.templateId的写法 "done":false, "status":1 }) .project({ _id:0, templs: $.filter({ //从嵌套的templs数组里取出模板ID满足条件的对象 input: '$templs', as: 'item', cond: $.eq(['$$item.templateId',templateId]) }) }) .project({ message:$.arrayElemAt(['$templs', 0]), //符号条件的是只有1个对象的数组,取出这个对象 }) .end()).list //使用聚合查询到的是一个list对象 const tasks = [] for (let item in messages) { const promise = cloud.openapi.subscribeMessage.send({ touser: item.message._openid, page: 'index', templateId: item.message.templateId, data: item.message.data }) tasks.push(promise) } return (await Promise.all(tasks)).reduce((acc, cur) => { return { data: acc.data.concat(cur.data), errMsg: acc.errMsg, } }) } catch (err) { console.log(err); return err; }}
特别注意的是,不要把查询数据库的语句放到循环里面,也就是我们可以一次性取出1000条需要发订阅消息的用户,然后再结合map和Promise.all方法给这1000个用户发送订阅消息,然后再一次性给所有这1000条数据进行原子自增,不能一条一条处理,否则会造成数据库性能的极大浪费以及超出最大连接数,而且也会导致云函数在最高60s的生命周期里也发送不了几百条订阅消息。
但是当要发送订阅消息的用户有几十万几百万,那应该怎么处理呢?如果全部让云函数来执行,即使将云函数的执行超时时间修改为60s,也应该会超时,这时候我们可以结合定时器来发送订阅消息。
使用定时触发器来发送订阅消息,也就是在小程序的云开发服务端,用定时触发器调用订阅消息的云调用接口openapi.subscribeMessage.send。当我们每天要给数十万人定时发送订阅消息时,这时候定时触发器就不仅仅需要比如每天早上9点触发,而且还需要在9点之后能够每隔一段时间比如40s,就来执行一次云函数以便给数十万用户发送订阅消息。
这时候Cron表达式可以这样写,意思是每天早上9点到11点每隔40s执行一次云函数:
0/40 * 9-11 * * * *
当然这里的周期设置可以结合云函数实际执行的时间来定,要充分考虑到云函数的超时时间。
云调用还支持组合模板并添加至帐号下的个人模板库的接口
subscribeMessage.addTemplate
、删除帐号下的个人模板subscribeMessage.deleteTemplate
、获取小程序账号的类目subscribeMessage.getCategory
、获取当前帐号下的个人模板列表subscribeMessage.getTemplateList
等等接口,这里就不一一介绍啦。
云开发数据库支持实时推送变更数据的能力,给定查询条件,每当数据库更新而导致查询条件对应的查询结果发生变更时,在小程序端就可收到一个更新事件,通过更新时间返回的对象就可获取更新内容和更新后的查询结果快照。
当用户在小程序端停留时间比较长,需要关注其他用户的行为而导致的一些数据的实时变化时,就可以使用实时数据推送,只要数据发生变动,都能实时的在前端呈现出来。实时数据推送有着广泛的应用场景:
1、实时数据推送只支持小程序端(Web端)
实时数据推送的watch请求只支持在小程序端(Web端)等前端调用,不支持云函数端。
2、不要滥用实时数据推送
只有在小程序端需要快速同步数据变动响应时,才需要使用实时数据推送,一般情况用页面刷新即可;也只有在用户在页面停留时间比较长的情况下才可以使用实时数据推送,
3、注意集合的权限设置
集合的权限需要设置为所有人可读,仅创建者可读写。集合的读权限设置在实时数据推送里同样生效,如果权限是设置为仅可读用户自己的数据,则监听的时候无法监听到非用户自己创建的数据。
4、查询不支持field
监听集合中符合查询条件的数据的更新事件。使用 watch 时,支持 where, orderBy, limit,不支持 field。在监听中,orderBy 最多可以指定 5 个排序字段,limit 最大值为 200。limit 默认不存在即取所有数据。
5、只监听必要的数据
监听时应明确查询条件,只监听必须用到的数据,避免监听不必要的数据,以此提高初次加载数据的性能以及接收数据变更的性能。
6、监听返回的数据不受默认 20 条限制
监听返回的数据可能超过 20 条,不受小程序端默认 20 条上限限制。一次监听的记录数上限为 5000,若超出上限会抛错并停止监听。监听过大量的数据时初始化会较慢,对监听效率也有影响,如果预期监听发起时少于 5000,但后续有可能超过 5000,请注意在即将超过时重新监听并保证不超过 5000。
数据库的实时监听既可以监听集合中符合查询条件的文档的变化,也可以监听单个doc,下面以监听单个doc为例。使用开发者工具新建一个页面,比如snapshot,然后在snapshot.wxml里输入一个可以修改数据的button,比如点击一下就会增加点赞次数:
<button bindtap="addStar">点赞{{stars}}</button>
然后在数据库里新建一个集合比如livevideo(注意要修改集合的权限可以用安全规则修改为所有人可读可写,或者将点赞采用调用云函数的方式),添加一个简单的记录比如:
"_id":"room2020032101","star":0
再在snapshot.js的data属性里声明stars,以及添加事件处理函数addStar,还有就是在页面的onLoad生命周期函数里监听数据的变化,并将数据的变化渲染到页面。
data:{ stars:0 }, onLoad: function() { const that =this const watcher = db.collection('livevideo').doc('room2020032101') .watch({ onChange: function(snapshot) { that.setData({ stars:snapshot.docs[0].star }) console.log('文档数据发生变化', snapshot) }, onError: function(err) { console.error('监听因错误停止', err) } }) } addStar(){ db.collection('livevideo').doc('room2020032101').update({ data: { star: _.inc(1) }, success: console.log, fail: console.error }) }
watch有两个属性,onError是失败回调,onChange是成功回调,成功回调传入的参数 snapshot 是变更快照。onChange 和 onError 是必传参数。onChange 用于接收变更快照,onError 用于处理监听错误。如果监听发起失败或监听过程中出现不可恢复的错误,则会终止监听并通过 onError 抛出异常。onChange 会在第一次监听初始化及后续数据变更时收到推送事件。第一次初始化时会收到的查询条件对应的查询结果,后续变更事件会包含变更内容和变更后的查询结果快照。
如果想要预览实时数据推送的效果,可以使用开发者工具的多账号调试,在开发者工具栏-工具-多账号调试即可。当A用户点击点赞的button,点赞的数量就会实时同步给所有在线用户的小程序端。在多账号的控制台里也能实时看到打印的信息。
我们可以留意打印的snapshot对象,这个对象包含的信息非常多,主要有
docChanges对象里的queueType和dataType字段,前者表示更新事件对监听列表的影响,后者表示记录的具体更新类型。注意这两者的不同,比如上面的案例我们只是更新了字段的值,所以它为update;如果监听的记录出现增加,或者减少,queueType的值就会有所不同,而removedFields也会有删除的字段;而dataType字段的值则与数据库请求的方法相对应,而所有更新的字段及字段更新后的值都会在updatedFields对象里。
数据库的索引与书籍的索引/目录类似,有了索引就不需要翻整本书,数据库可以直接在索引中查找,在索引中找到条目之后,就可以直接跳转到目标文档的位置,这就能使查找速度提高几个数量级。不使用索引的查询称为全表扫描,也就是说服务器必须查找完一整本书才能找到查询结果,对于大集合来说,应该尽量避免全表扫描,否则效率非常低。建立索引是保证数据库性能、保证小程序体验的重要手段。我们应为所有需要成为查询条件的字段建立索引。
我们可以在云开发控制台数据库标签页对每个集合的字段添加索引,设置索引的属性为唯一或非唯一,排序方式为升序或降序,也能查看该索引所占的空间和命中次数。索引是一个文件,它是要占据物理空间的,因此我们可以留意不要过度索引浪费空间,而命中次数也可以用于判断索引是否有效。
云开发数据库会给每个集合默认建立_id索引和_openid索引。_id索引在我们进行db.collection('集合名').doc("_id值")的请求时就会命中。而当我们在where里添加_openid为查询条件,就会命中_openid索引,在小程序端进行db.collection('集合名')查询时,由于自带默认条件_openid为用户的openid,因此在小程序端查询都会命中_openid索引。
单字段索引是最常见的索引,它不会自动创建。对需要作为查询条件筛选的字段,我们可以创建单字段索引。如果需要对嵌套字段进行索引,那么可以通过 "点表示法" 用点连接起嵌套字段的名称。比如我们需要对如下格式的记录中的 color 字段进行索引时,可以用 style.color 表示。在设置单字段索引时,指定排序为升序或降序都可以,。
{ "_id": '', "style": { "color": '' }}
组合索引即一个索引包含多个字段,组合索引在添加时要注意字段的顺序,顺序不同索引的效果也会不同。当查询条件使用的字段包含在索引定义的所有字段或前缀字段里时,会命中索引。
组合索引遵循最左前缀原则,比如在 A, B, C 三个字段定义的组合索引(A, B, C),那么查询条件A,{A, B},{A, C},{A, B, C}索引都会有效,查询条件B,C,{B, C}则不会命中索引。根据最左前缀原则,我们可以明白组合索引(A, B)和调换字段顺序的(B, A)效果是不一样的。当定义组合索引为(A, B)时,索引会先按 A 字段排序再按 B 字段排序。因此当组合索引设为 (A, B) 时,即使我们没有单独对字段 A 设立索引,但对字段 A 的查询可以命中 (A, B) 索引。
定义索引时字段的排序方式也决定排序查询是否有效,比如我们对字段 A 和 B 设置以下索引:(A: 升序,B: 降序),那么当我们查询需要对 A, B 进行排序时,可以指定排序结果为 A 升序、B 降序以及完全相反的排序A 降序、B 升序有效,而A升B升,A降B降都不会命中索引。
还有一些查询条件,需要进行范围查询或者排序,那么范围查询和排序的字段就要尽量往后放,因为范围查询以后的字段索引是不能命中的。组合索引的好处已经在上面有提到了,如果数据库有a索引,现在b列也需要索引,那么直接建立(a,b)即可
创建索引时可以指定增加唯一性限制,具有唯一性限制的索引会要求被索引集合不能存在被索引字段值都相同的两个记录。需特别注意的是,假如记录中不存在某个字段,则对索引字段来说其值默认为 null,如果索引有唯一性限制,则不允许存在两个或以上的该字段为空 / 不存在该字段的记录。
db.collection("china") .where({ gdp: _.gt(3000), city_area:_.lt(10000), reg_pop:_.gt(6000) }) .field({ _id:false, city: true, city_area: true, gdp:true }) .orderBy('gdp', 'desc') .orderBy('city_area', 'asc')
由于有三个查询条件,为你可以给三个查询条件按照顺序创建索引,由于这几个值无法做到非唯一,且存在空值的可能(有些城市没有数据),所以创建时选择非唯一。
索引虽然能非常高效的提高查询速度,同时却会降低更新表的速度。实际上索引也是一张表,该表保存了主键与索引字段,并指向实体表的记录,所以索引列也是要占用空间的。索引需要进行两次查找,一次是查找索引条目,一次是根据索引指针去查找相应的文档,而全表查询只需要进行一次查找。集合较大、文档较大、选择性查询就比较适合用索引。
其实建索引的原理就是将磁盘I/O操作的最小化,不在磁盘中排序,而是在内存中排好序,通过排序的规则去指定磁盘读取就行,也不需要在磁盘上随机读取。
索引并非越多越好,一个表中如有大量的索引,不仅占用磁盘空间,而且会影响增删改等语句的性能,因为当表中的数据更改的同时,索引也会进行调整和更新。避免对经常更新的表设计过多的索引,并且索引中的列尽可能要少,而对经常用于查询的字段应该创建索引,但要避免添加不必要的字段。
为了减少索引的数量,可以建立组合索引,组合索引就是可以使用多个列一起建立一个索引。建立索引时要优先在已经存在的索引上扩展成组合索引,或者在已经存在的组合索引上继续添加字段。因为,索引越多,维护成本就越高,还会导致插入速度变慢等负面效应。
哪些情况需要创建索引:
哪些情况不需要创建索引:
在云开发控制台每个集合都有相应的索引管理,在这里除了可以创建索引外,还可以了解每个索引占据的空间以及判断查询时索引是否命中的命中数。每个索引建议创建的索引数不要超过5个,索引占据的空间
1、最好是使用唯一索引
当唯一性是某种数据本身的特征时,指定唯一索引。使用唯一索引需能确保定义的列的数据完整性,以提高查询速度
2、和简单的字段为索引
Innodb 表的普通索引都会保存主键的键值,所以主键要尽可能选择较短的数据类型,可以有效的减少索引的磁盘占用,提高索引的缓存效果。索引的太长首先会占用大量的磁盘空间,其次索引太长会使索引变得臃肿,导致索引查询变慢。通过目录查询书籍指定的章节之所以快,就是因为索引足够轻量,如果索引太长那么这个优势就不明显了。而且索引里的数据和表里的数据本身就是冗余的,如果索引太长,那么磁盘空间浪费的就越多。
3、用区分度比较高的列建索引
具有多个重复值的字段,其索引效果最差。比如存放身份证的字段因为值都不同,很容易区分,索引效果比较好,而用来记录性别的字段,因为只含有“男”,“女”,不管搜索哪个值,都会得出大约一半的值,这样的索引对性能的提升不高。如果有几个列都是唯一的,要选择最常作为访问条件的列作为索引的主键。简单枚举值的列不要建立索引。在条件表达式中经常用到的不同值较多的列上建立索引,在不同值较少的列上不要建立索引,比如性别字段只有男和女,就没必要建立索引。如果建立索引不但不会提高查询效率,反而会严重降低更新速度。由于我们建立索引并想让索引能达到最高性能,这个时候我们应当充分考虑该列是否适合建立索引,可以根据列的区分度来判断,区分度太低的情况下可以不考虑建立索引,区分度越高效率越高。
4、索引的字段的值最好不要有空值
索引的字段最好不要有空值,有空值的字段建立索引时要选择非唯一性。唯一性的索引是不允许有空值的。
5、索引的字段最好不要参与计算
索引列不能参与计算,保持列“干净”;索引列参与计算;应尽量避免在 where 子句中对字段进行表达式操作
6、查询尽量都走在索引上
保证索引包含的字段独立在查询语句中,不能是在表达式中,当查询的列都在索引的字段中时,查询的效率更高,所以应该尽量避免使用 select *,需要哪些字段,就只查哪些字段。在索引基数高的地方建立索引(比如邮箱,用户名,而不是性别)
7、避免重复索引和冗余索引
重复索引:在同一列或者相同顺序的几个列建立了多个索引,成为重复索引,没有任何意义,删掉 冗余索引:两个或多个索引所覆盖的列有重叠,比如对于列m,n ,加索引index m(m),indexmn(m,n),称为冗余索引。
8、使用索引获取有序数据
索引本身是有序的,利用有序索引获取有序数据(Using Index)。使用索引来优化或者避免排序排序的字段上加入索引,可以提高速度。在频繁排序或分组(即group by或order by操作)的列上建立索引,如果待排序的列有多个,可以在这些列上建立组合索引
9、组合索引遵循最左前缀原则
建立组合索引,要同时考虑列查询的频率和列的区分度,区分度大的优先放在前面。比如一张全球人口的用户表,该表有性别,国籍,年龄等字段。那么一般情况下国籍的区分度就要比性别的区分度更高,比如满足中国人这个条件的要比满足男人这个条件的人要更少。因此建组合索引时国籍优先考虑放在性别的前面。
10、索引碎片与维护
在数据表长期的更改过程中,索引文件和数据文件都会产生空洞,形成碎片。修复表的过程十分耗费资源,可以用比较长的周期修复表。
11、注意索引的唯一性和非唯一性
唯一索引一定要小心使用,它带有唯一约束,由于前期需求不明等情况下,可能造成我们对于唯一列的误判。
12、建议最好不要用随机生成的_id做主键
最好是使用自增ID字段做索引的主键,而不要使用随机的_id做主键,因为非递增的主键会导致频繁的页分裂,从而降低了插入的效率。所以一般情况下,我们会在表中使用一个自增ID字段来代替_id(使用原子更新指令inc来做自增),用该字段来作表的主键。如果需要按照_id查询时,查找就需要回表,查找的效率会低一点。如果表中只需要一个_id的唯一索引,那么就可以使用_id来做主键;如果不满足这个条件就用自增ID做索引;
使用更新指令(如 inc、mul、addToSet)可以对云数据库的一条记录和记录内的子文档(结合反范式化设计)进行原子操作,但是如果要跨多个记录或跨多个集合的原子操作时,就需要使用云数据库的事务能力。
关系型数据库是很难做到通过一个语句对数据强制一致性的需求来表示的,只能依赖事务。但是云开发数据库由于可以反范式化设计内嵌子文档,以及更新指定可以对单个记录或同一个记录内的子文档进行原子操作,所以通常情况下,云开发数据库不必使用事务。
比如调整某个订单项目的数量之后,应该同时更新该订单的总费用,我们可以设计采用如下方式设计该集合,比如订单的集合为order:
{ "_id": "2020030922100983", "userID": "124785", "total":117, "orders": [{ "item":"苹果", "price":15, "number":3 },{ "item":"火龙果", "price":18, "number":4 }]}
客户在下单的时候经常会调整订单内某个商品比如苹果的购买数量,而下单的总价又必须同步更新,不能购买数量减少了,但是总价不变,这两个操作必须同时进行,如果是使用关系型数据库,则需要先通过两次查询,更新完数据之后,再存储进数据库,这个很容易出现有的成功,有的没有成功的情况。但是云开发的数据库则可以借助于更新指令做到一条更新来实现两个数据同时成功或失败:
db.collection('order').doc('2020030922100983') .update({ data: { "orders.0.number": _.inc(1), "total":_.inc(15) } })
这个操作只是在单个记录里进行,那要实现跨记录要进行原子操作呢?更新指令其实是可以做到事务仿真的,但是比较麻烦,这时就建议用事务了。
事务就是一段数据库语句的批处理,但是这个批处理是一个atom(原子),多个增删改的操作是绑定在一起的,不可分割,要么都执行,要么回滚(rollback)都不执行。比如银行转账,需要做到一个账户的钱汇出去了,那另外一个账户就一定会收到钱,不能钱汇出去了,但是钱没有到另外一个的账上;也就是要执行转账这个事务,会对A用户的账户数据和B用户的账户数据做增删改的处理,这两个处理必须一起成功一起失败。
一般来说,事务是必须满足4个条件(ACID): Atomicity(原子性)、Consistency(稳定性)、Isolation(隔离性)、Durability(可靠性):
(1)不支持批量操作,只支持单记录操作
在事务中不支持批量操作(where 语句),只支持单记录操作(collection.doc, collection.add),这可以避免大量锁冲突、保证运行效率,并且大多数情况下,单记录操作足够满足需求,因为在事务中是可以对多个单个记录进行操作的,也就是可以比如说在一个事务中同时对集合 A 的记录 x 和 y 两个记录操作、又对集合 B 的记录 z 操作。
(2)云数据库采用的是快照隔离
对于两个并发执行的事务来说,如果涉及到操作同一条记录的时候,可能会发生问题。因为并发操作会带来数据的不一致性,包括脏读、不可重复读、幻读等。
云开发的数据库系统的事务过程采用的是快照隔离(Snapshot isolation),可以避免并发操作带来数据不一致的问题。
云开发数据库的事务提供两种操作风格的接口,一个是简易的、带有冲突自动重试的 runTransaction 接口,一个是流程自定义控制的 startTransaction 接口。通过 runTransaction 回调中获得的参数 transaction 或通过 startTransaction 获得的返回值 transaction,我们将其类比为 db 对象,只是在其上进行的操作将在事务内的快照完成,保证原子性。transaction 上提供的接口树形图一览:
transaction|-- collection 获取集合引用| |-- doc 获取记录引用| | |-- get 获取记录内容| | |-- update 更新记录内容| | |-- set 替换记录内容| | |-- remove 删除记录| |-- add 新增记录|-- rollback 终止事务并回滚|-- commit 提交事务(仅在使用 startTransaction 时需调用)
以下提供一个使用 runTransaction 接口的,两个账户之间进行转账的简易示例。事务执行函数由开发者传入,函数接收一个参数 transaction,其上提供 collection 方法和 rollback 方法。collection 方法用于取数据库集合记录引用进行操作,rollback 方法用于在不想继续执行事务时终止并回滚事务。
const cloud = require('wx-server-sdk')cloud.init({ env: cloud.DYNAMIC_CURRENT_ENV})const _ = db.commandexports.main = async (event) => { try { const result = await db.runTransaction(async transaction => { const aaaRes = await transaction.collection('account').doc('aaa').get() const bbbRes = await transaction.collection('account').doc('bbb').get() if (aaaRes.data && bbbRes.data) { const updateAAARes = await transaction.collection('account').doc('aaa').update({ data: { amount: _.inc(-10) } }) const updateBBBRes = await transaction.collection('account').doc('bbb').update({ data: { amount: _.inc(10) } }) console.log(`transaction succeeded`, result) return { aaaAccount: aaaRes.data.amount - 10, } } else { await transaction.rollback(-100) } }) return { success: true, aaaAccount: result.aaaAccount, } } catch (e) { console.error(`事务报错`, e) return { success: false, error: e } }}
事务执行函数必须为 async 异步函数或返回 Promise 的函数,当事务执行函数返回时,SDK 会认为用户逻辑已完成,自动提交(commit)事务,因此务必确保用户事务逻辑完成后才在 async 异步函数中返回或 resolve Promise。
const cloud = require('wx-server-sdk')cloud.init({ env: cloud.DYNAMIC_CURRENT_ENV})const db = cloud.database({ throwOnNotFound: false,})const _ = db.commandexports.main = async (event) => { try { const transaction = await db.startTransaction() const aaaRes = await transaction.collection('account').doc('aaa').get() const bbbRes = await transaction.collection('account').doc('bbb').get() if (aaaRes.data && bbbRes.data) { const updateAAARes = await transaction.collection('account').doc('aaa').update({ data: { amount: _.inc(-10) } }) const updateBBBRes = await transaction.collection('account').doc('bbb').update({ data: { amount: _.inc(10) } }) await transaction.commit() return { success: true, aaaAccount: aaaRes.data.amount - 10, } } else { await transaction.rollback() return { success: false, error: `rollback`, rollbackCode: -100, } } } catch (e) { console.error(`事务报错`, e) }}
也就是说对于多用户同时操作(主要是写)数据库的并发处理问题,我们不仅可以使用原子更新,还可以使用事务。其中原子更新主要用户操作单个记录内的字段或单个记录里内嵌的数组对象里的字段,而事务则主要是用于跨记录和跨集合的处理。
云开发的数据库虽然是高性能、支持弹性扩容,但是很多用户在使用的过程中,更加注重功能的实现,而忽视了数据库的设计、索引的创建以及语句的优化等对性能的影响,因此会遇到很多影响数据库性能的问题,因此这里特意总结一下云开发数据库性能优化的注意事项。
以下是一些影响数据库性能的优化建议,当然要结合具体的业务情况来处理,不能一概而论。尤其是一些请求量比较大、比较频繁,比如小程序首页的数据请求,数据库的优化要格外重视。
1、要合理使用索引
使用索引可以提高文档的查询、更新、删除、排序操作,所以要结合查询的情况,适当创建索引。要尽量避免全表扫描,首先应考虑在 where 及 order by 涉及的列上建立索引。更多索引的细节在索引的章节里有介绍。
2、擅于结合查询情况创建组合索引
对于包含多个字段(键)条件的查询,创建包含这些字段的组合索引是个不错的解决方案。组合索引遵循最左前缀原则,因此创建顺序很重要,如果对组合索引不了解,可以结合索引的命中情况来判断组合索引是否生效。要善于使用组合索引做到用最少的索引覆盖最多的查询。
3、查询时要尽可能通过条件和limit限制数据
在查询里where可以限制处理文档的数量,而在聚合运算中match要放在group前面,减少group操作要处理的文档数量。无论是普通查询还是聚合查询都应该使用limit限制返回的数据数量。
其实云开发针对普通查询db.collection('dbName').get()默认都有limit限制,在小程序端的限制为20条(自定义上限也是20条),在云函数端的限制为100条(自定义上限可以设置为1000条),聚合则在小程序端和云函数端默认都为20条(自定义没有上限,几万条都可以,前提是取出来的数据不能大于16M),也就是云开发数据库已经自带了一些性能优化,我们不应该把这些默认的限制当成是一种束缚,而去随意突破这些限制。
4、推荐在小程序端增删改查数据库
可以结合数据库的安全规则,让数据库的增删改查在小程序端进行,这样速度会更快,而且还可以节省云函数的资源。
云开发数据库的增删改查可以在小程序端进行,也可以在云函数端进行,那到底应该把数据库的增删改查放在小程序端还是云函数端呢?一般情况下建议放在小程序端,这样就只会消耗数据库请求的次数,而不会额外增加消耗云函数的资源使用量GBs、外网出流量。而云函数虽然有数据库操作的更高的权限,但是小程序端结合安全规则也是可以让数据库的权限粒度更细,也能满足大部分权限要求。
5、尽可能限制返回的字段等数据量
如果查询无需返回整个文档或只是用来判断键值是否存在,普通查询可以通过filed、聚合查询可以通过project来限制返回的字段,减少网络流量和客户端的内存使用。
{ "title": "为什么要学习云开发", "content": "云开发是腾讯云为移动开发者提供的一站式后端云服务", "comments": [{ "name": "李东bbsky", "created_on": "2020-03-21T10:01:22Z", "comment": "云开发是微信生态下的最推荐的后台技术解决方案" }, { "name": "小明", "created_on": "2020-03-21T11:01:22Z", "comment": "云开发学起来太简单啦" }]}
云数据库是关系型数据库,一个记录里可以嵌套非常多的数组和对象,如果取出整个记录里的所有嵌套内容就太耗性能流量了,比如上面的嵌套数组,有时候业务上并不需要显示comments里的某些字段,是可以通过field的点表示法来限制返回的字段的。
//不显示comments里的created_on.field({ "comments.created_on":0 })//只显示comments里的comment,comments里的其他字段都不显示.field({ "comments.comment":1})
6、查询量大时建议不要用正则查询
正则表达式查询不能使用索引,执行的时间比大多数选择器更长,所以业务量比较大的地方,能不用正则查询就不用正则查询(尽量用其他方式来代替正则查询),即使使用正则查询也一定要尽可能的缩写模糊匹配的范围,比如使用开始匹配符 ^ 或结束匹配符 $ 。
比如有人是这样用正则查询的,他想根据省市来筛选客户来源数据,但是客户来源的地址address填写的是”广东省深圳市“或”广东深圳“,省市数据并不规范一致,于是使用正则进行模糊查询,但是如果你需要经常根据地址来筛选客户来源,那你应该在数据库对数据进行处理,比如province和city来清洗重组数据从而替代模糊查询。
7、尽可能使用更新指令
通过更新指令对文档进行修改,通常可以获得更好的性能,因为更新指令不需要查询到记录就可以直接对文档进行字段级的更新,尤其是不需要更新整个文档只需要更新部分字段的场景。
还是上面的那个记录为例,比如我们需要给文章添加评论,也就是往comments数组里添加值,我们可以使用 _.push
来给数组字段进行字段级别的操作,而不是取出整个记录,然后把评论用数组的concat或push的方法添加到记录里,再更新整个记录:
.update({ data:{ comments:_.push([{ "name": "小明", "created_on": "2020-03-21T11:01:22Z", "comment": "云开发学起来太简单啦" }]) }})
云开发数据库一个记录可能会嵌套很多层,因此也会很大,使用更新指令进行字段级别的微操比直接使用update这种记录级别的更新性能要更好。
8、不要对太多数据进行排序
不要一次性取出太多的数据并对数据进行排序,如果需要排序,请尽量限制结果集中的数据量,比如我们可以先用where、match等操作限制数据量,也就是通常要把orderBy放在普通查询或聚合查询的最后面。
这里尤其强调的是,发现有不少人由于对数据库的排序orderBy与翻页skip没有理解,竟然把数据库所有数据使用遍历取出来之后再来排序,哪怕是数据量只有百千条,这也是不正确的处理方式,应该禁止这么干。排序使用数据库的普通查询或聚合查询的orderBy就可以做到了,云开发默认的limit数据限制不会影响排序的结果,禁止遍历取出所有数据再来排序的愚蠢行为。
当然如果业务会需要经常对同一数据的多个字段来排序,比如商品经常会按最新上架、价格高低、产地、折扣力度等进行排序,则建议一次性取出这些数据,存储在缓存中,使用JavaScript的数组来进行排序,而不是用数据库查询。
9、尽量少在业务量大的地方用以下查询指令
查询中的某些查询指令可能会导致性能低下,如判断字段是否存在的exists
,要求值不在给定的数组内的nin
,表示需满足任意多个查询筛选条件or
,表示需不满足指定的条件not
,尽量少在业务使用量比较大的地方用这些查询指令。
这里所说的尽量少用不代表不用,而是能够用最直接的方式就用最直接的方式代替,让数据库查询尽可能的简单而不是搞的过于复杂,尽可能少让查询指令做这些复杂的事情。
10、集合中文档的数量可以定期归档
集合中文档的数据量会影响查询性能,对不用的数据或过期的数据可以进行定期归档并删除。比如我们也可以借助于定时触发器周期性的对数据库里的数据进行备份、删除。
11、不要让数据库请求干多余的事情,尽量少干事
能够使用JavaScript替代的计算、数组、对象操作等,就尽量用JavaScript处理;能通过数据库设计让数据库查询少计算的就尽量合理设计数据库,要尽可能的让数据库少干活,不能一次查询多个指令、正则查询套来套去的。
12、在数据库设计时可以用内嵌文档来取代lookup
云开发数据库是非关系型数据库,可以对经常要使用lookup跨表查询的情况做反范式化的内嵌文档设计,通过这种方式取代联表查询lookup可以提升不少性能。
减少使用联表查询lookup的使用的方式要注意两点,一是通过内嵌文档的方式是可以减少关系型数据库那种表与表之间的关联关系的,比如要联表取出博客里最新的10篇文章以及文章里相应的评论,这在关系型数据库里原本是需要联表查询的,但是当把评论内嵌到文章的集合里时,就不需要联表了;二是有的时候我们只是需要跨表而不是联表,可以通过多次查询来取代联表。
13、推荐使用短字段名
和关系型数据库不同的是,云开发数据库是文档型数据库,集合中的每一个文档都需要存储字段名,因此字段名的长度相比关系型数据库来说会需要更多的存储空间。
"comments": [{ "name": "李东bbsky", "created_on": "2020-03-21T10:01:22Z", "comment": "云开发是微信生态下的最推荐的后台技术解决方案" }, { "name": "小明", "created_on": "2020-03-21T11:01:22Z", "comment": "云开发学起来太简单啦" }]
这里的字段名name、created_on、comment有多少个记录,有多少个嵌套的对象就会被写多少次,有时候比字段的值还要长,是比较占空间的。
在业务上有些关键的数据可以通过间接的方式查询获取到,但是由于查询时会存在计算、跨表等问题,这个时候建议新增一些冗余字段。
比如我们要统计文章下面的评论数,可能你将文章的评论独立建了一个集合如comments,这时候要获取每篇文章的评论数是可以根据文章的id条件来count该文章有多少条评论的。或者你也可以把每篇文章的评论数组作为子文档内嵌到每个文章记录的comments字段,这个时候可以通过数组的长度来算出该文章的评论数。类似于评论数的还有点赞量、收藏量等,这些虽然都是可以通过count或数组length的方式来间接获取到的,但是在评论数很多的情况下,count和数组的length是非常耗性能的,而且count还需要独立占据一个请求。
遇到这种情况,建议在数据库设计时,要用所谓的冗余字段来记录每篇文章的点赞量、评论数、收藏量,在小程序端直接用inc原子自增的方式更新该字段的值。
{ "title": "为什么要学习云开发", "content": "云开发是腾讯云为移动开发者提供的一站式后端云服务", "commentNum":2, //新增一个评论数的字段 "comments": [{ "name": "李东bbsky", "created_on": "2020-03-21T10:01:22Z", "comment": "云开发是微信生态下的最推荐的后台技术解决方案" }, { "name": "小明", "created_on": "2020-03-21T11:01:22Z", "comment": "云开发学起来太简单啦" }]}
比如我们希望在博客的首页展示文章列表,而每篇文章要显示评论总数。虽然我们可以通过comments的数组长度以及如果存在二级三级评论(尤其是这种情况),也是可以通过数组方法获取到评论数,但是不如直接查询新增的冗余字段commentNum
来得直接。
有时候我们的业务会需要用户经常删除数据库里面的记录或记录里的数组的情况,但是删除数据是非常耗费性能的一件事,碰到业务高峰期,数据库就会出现性能问题。这个时候,建议新增冗余字段做虚假删除,比如给记录添加delete的字段,默认值为false,当执行删除的时候,可以将字段的值设置true,查询时只显示delete为false的记录,这样数据在前端就不显示了,做到了虚假删除,在业务低谷时比如凌晨可以结合定时触发器每天这个时候清理一遍。
我们经常会有查询数据库里的数据,并对数据进行处理之后再写回数据库的需求,如果查询到的数据有很多条时,就会需要我们进行循环处理,不过这个时候一定要注意,不要把数据库请求放到循环体内,而是先一次性查询多条数据,在循环体内对数据进行处理之后再一次性写回数据库。
当然小程序有些接口不能进行数组操作,只能一条一条执行,比如发送订阅消息、上传文件等操作等,这个避免的不了的例外。但是有些是可以通过数据库的设计来规避这个问题的,比如把经常要新增大量记录的数据库设计为只需要新增内嵌文档的数组数据等。
在数据库的设计上以及在数据库请求的代码上,尽可能用一个数据库请求来代替多个数据库请求,尤其是用户最常访问的首页,如果一个页面的数据库请求太多,会导致数据库的并发问题。有些数据能够缓存到小程序端就缓存到小程序度,不必过分强调数据的一致性。
我们有这样一个集合user,最终会用来存储用户的个人信息,比如当我们在用户点击登录时会获取用户的昵称和头像,于是一般的逻辑是我们会在数据库创建一个记录,如下所示:
_id:"",userInfo:{ "name":"李东bbsky", "avatarUrl":"头像地址"}
但是更好的方式是,我们应该创建一个完整的记录(按照最终的字段设计),哪怕现在还没有数据,也要一致性建好这些空字段,方便以后直接使用update的方式来往里面填充数据。
_id:"",userInfo:{ "name":"李东bbsky", "avatarUrl":"头像地址", "email":"", "address":"" ...},stars:[],//存储点赞的文章collect:[] //存储收藏的文章
目前我们没法直接查看数据库请求所花费的时间,但是有一些其他数据作为佐证,在云函数端进行数据库请求,如果云函数的执行时间超过100ms甚至更多,则基本可以判定为慢查询,数据库需要优化。这时,慢查询不仅会影响数据库的性能,还会影响云函数的性能。
我们知道云函数和云数据库的并发都是非常依赖他们的耗时的,如果数据库查询速度变慢,查询一次耗时由几十毫秒增加到几百毫秒,甚至以秒计算,都是十分耗费资源和影响并发的:
Connection num overrun
的报错。我们可以在云开发控制台设置-告警设置来给指定的云函数尤其是业务调用最频繁的云函数设置运行时间以及云函数运行错误的告警,以便随时了解云开发环境的运行状况。
云调用和拓展能力是云开发为了让开发者能够更加方便的使用各类云服务而推出的,它们有的让开发者不再处理繁杂的鉴权,有的可以一键开通云服务所需要的权限,有的则提供了一整套完整的代码,这些都让我们更加方便的发送消息(客服消息、订阅消息、动态消息等)、使用各类云服务(内容安全、图像处理、OCR、生物认证等)以及调用小程序的开放接口。
在小程序开发技术文档的服务端接口列表中罗列了所有的服务端接口,如果接口支持云调用,则在接口名称旁会带有云调用
的标签。在支持云调用的接口文档中,会分别列出 HTTPS 调用的文档及云调用的文档。
也就是说,服务端接口的开发方式(方法)有两种,一种是HTTPS调用,一种是云调用,HTTPS调用通用于所有的开发语言,是一种比较传统的开发方式;而云调用则是云开发提供的更加方便的开发方式。值得一提的是,我们也可以使用云函数来调用HTTPS接口,比如使用axios,方法在HTTP处理章节有介绍。
云调用是云开发提供的基于云函数使用小程序开放接口的能力,支持在云函数调用服务端开放接口。在云函数中使用云调用调用服务端接口无需换取access_token,只要是在从小程序端触发的云函数中发起的云调用都会经过微信自动鉴权,可以在登记权限后直接调用如发送订阅消息、客服消息等开放接口。
这里需要注意的是,当我们在调试云调用的云函数时,直接使用云端测试会出现报错,这是因为云端测试是无法获取用户登录态信息的,所以建议在小程序端触发来调试。
云调用大大方便了我们使用小程序开放能力的接口,以订阅消息为例,在没有云调用之前,我们想要发送订阅消息,需要以下几个步骤:
而如果是使用云调用则无需换取access_token,只需要进行如下步骤即可,这些步骤也是通用于其他云调用接口:
也就是说云开发的云调用能力都是可以通过HTTPS调用的方式来实现,但是使用云调用却方便很多。这里需要强调的是,要使用云调用有两个步骤,一是在云函数的配置文件里添加接口权限(方法见下,后面章节也会介绍),二是用云函数来处理。
使用云调用需要配置云调用权限,每个云函数需要声明其会使用到的接口,否则无法调用,声明的方法是在云函数目录下的config.json配置文件的permissions.openapi
字段中增加要调用的接口名(对应的接口名,可以去云调用的相关接口的文档里查找),permissions.openapi
是个字符串数组字段,值必须为所需调用的服务端接口名称。
在每次使用微信开发者工具上传云函数时均会根据配置更新权限,该配置有10分钟左右的缓存,如果更新后提示没有权限,稍等10分钟后再试。
如果你是在微信开发者工具通过“新建Nodejs云函数”创建的云函数,云函数的目录里就都会有config.json的配置文件,目录结构如下,如果你是通过其他方式创建的云函数,也建议如下3个文件都要有(没有的话,可以自己copy来创建):
test //云函数目录├── config.json //权限和定时触发器等的配置文件├── index.js //云函数├── package.json //云函数的依赖管理
config.json文件还可以用来配置定时触发器,比如该云函数需要使用到订阅消息和内容安全两个权限,以及每5秒钟定时发送一次订阅消息,config.json的写法如下:
{ "permissions": { "openapi": [ "subscribeMessage.send", "security.imgSecCheck" ] }, "triggers": [ { "name": "tomylove", "type": "timer", "config": "*/5 * * * * * *" } ]}
config.json配置文件的格式和前面介绍的json文件配置的格式一样,比如数组最后一项不能有逗号,
,配置文件里不能有注释等,千万不要写错哦。
拓展能力可以让云开发更方便调用腾讯云的服务,比如图像处理、短信验证、数据库,这些除了会使用到云开发的一些资源外(有免费额度),还会使用到腾讯云的一些服务,因此会产生一些费用(也就是和云开发分开计费),云开发资源的费用可以去云开发控制台查看;而使用腾讯云服务产生的额外费用,可以去腾讯云费用中心查看。
拓展能力还可以更方便使用腾讯云的账号体系实现跨云开发资源、跨多端来调用,以及云开发团队为了方便开发者和运营人员使用而开发的CMS内容管理系统等等。和云调用一样,拓展能力也会不断增加一些更好用的功能。尤其是图像处理能力、CMS内容管理,强烈建议把这两个作为云开发的核心拓展安装一下。
要安装拓展能力,我们需要登录到腾讯云云开发的网页控制台,登录时一定要选择其他登录方式-微信公众号,然后扫码授权选择关联的小程序账户进行登录。选择云开发环境之后,就可以点击左侧拓展能力的管理菜单,安装拓展能力到指定的云开发环境。
拓展能力的安装,其实就是根据不同的拓展能力所对应的服务执行了以下一些步骤,可以让开发者不必关注腾讯云一些过于复杂的概念或写云函数等:
拓展能力可以根据需要安装和卸载,即使安装了只会占据云函数和集合的名额,不使用是不会产生费用的;卸载时也是可以删除这些角色和权限策略的(强烈建议不要删除角色和权限策略)以及相应的云函数和数据库。卸载了还可以再次安装。
注意,拓展能力的角色和权限策略是适用于所有云开发环境,也就是你在一个云开发环境安装拓展能力时创建了角色和指定了权限策略,那在其他云开发环境就不需要再配置了;而云函数和云数据库里的集合,还是需要你通过安装来创建或者自己创建的。
通过服务端云函数可以获取一个小程序任意页面的小程序码,扫描该小程序码就可以直接进入小程序对应的页面,所有生成的小程序码永久有效,可长期使用。小程序码具有更好的辨识度,且拥有展示“公众号关注组件”等高级能力。当用户扫小程序码打开小程序时,开发者可在小程序内配置公众号关注组件official-account
,用户可以快捷关注公众号。
wxacode.get 和 wxacode.createQRCode 总共生成的码数量限制为10万个,也就是究极你的小程序的一生,只能通过这两种方式生成10万个小程序码和小程序二维码,不过如果参数相同,是不算次数的,所以10万个还是挺多的。
wxacode.get和wxacode.getUnlimited的区别
如果你的小程序页面参数是动态更新的,建议使用wxacode.getUnlimited,如果你的小程序页面包含了非常多的运营类的参数,32个字符不够用,或者动态页面较少,那可以使用wxacode.get,通常用wxacode.getUnlimited比较稳妥。
wxacode.getUnlimited可能32个字符不够用,比如想追踪分享小程序码的用户的openid,比如希望记录更多运营数据,不过即使不够用,也是有替代方法的,就是在数据库里添加一个字段ID,将你要记录的这些参数与这个简短而独一无二的ID对应,这个会浪费一点数据库的性能,不过也在可以接受范围之类。
除此之外,在云调用时传递的参数上,wxacode.get是必须填写path的(path为小程序的页面路径,即包含page和scene),而wxacode.getUnlimited的page和scene是分开的,可以只填scene,不必填写page。
首先我们使用开发者工具,新建一个云函数比如wxacode,然后在config.json里添加如下权限配置(前面已经反复强调权限配置文件json的格式),也就是我们在处理云调用时,一定要先添加权限,而且权限文件的格式不能出错。
{ "permissions": { "openapi": [ "wxacode.get", "wxacode.getUnlimited" ] }}
然后在index.js里添加如下代码,我们先以wxacode.getUnlimited这个接口为例获取小程序码,然后再把小程序码上传到云存储里,
const cloud = require('wx-server-sdk')cloud.init({ env: cloud.DYNAMIC_CURRENT_ENV})exports.main = async (event, context) => { const wxacodeResult = await cloud.openapi.wxacode.getUnlimited({ scene: 'uid=1jigsdff', //只支持数字,大小写英文以及部分特殊字符:!#$&'()*+,/:;=?@-._~,不能有空格之类的其他字符 page: 'page/index/index', //注意这个必须是已经发布的小程序存在的页面(否则报错),根路径前不要填加 /,不能携带参数(参数请放在scene字段里),如果不填写这个字段,默认跳主页面;但是你要填写就不要写错路径 }) const uploadResult = await cloud.uploadFile({ cloudPath: `wxacode.jpg`, fileContent: wxacodeResult.buffer, }) return uploadResult.fileID}
而如果是使用wxacode.get这个接口,它传递的参数会有所不同,
const result = await cloud.openapi.wxacode.get({ path: 'page/index/index?uid=1jigsdff',})
调用这个云函数,就能在云存储里看到生成的wxacode.jpg小程序码了。我们可以把集合的某个字段的id,或者页面id等参数写进小程序码里。
通过追踪带有参数的小程序码,我们就能知道用户到底是通过我们生成的哪个小程序码进入到小程序的,这个功能应用的场景有很多,尤其是运营上特别有用,比如追踪用户的分享来增加积分或返利,追踪各个渠道的运营效果等等,要完成这样的步骤,除了生成带参数的小程序外,还需小程序能识别该小程序码。
场景值用来描述用户进入小程序的路径,比如公众号文章的自定义菜单、模板消息、文章等,二维码的扫描、长按、通过识别手机相册的二维码等,微信群聊或单聊等,微信首页顶部搜索框等,也就是用户到底是通过什么方式进入到我们的小程序的,会有一个对应的场景值,扫描小程序码的是1047,长按图片识别小程序码为1048,扫描手机相册中选取的小程序码为1049。
我们可以在 App生命周期的 onLaunch 和 onShow,或wx.getLaunchOptionsSync
(注意,这个接口是一个对象,不是一个函数) 中获取上述场景值,在下面的options对象里就会包含scene
onLaunch (options) { console.log('onLaunch方法',options) }, onShow (options) { console.log('onShow方法',options) },
在options对象里就包含着scene这个属性,属性的值即为场景值:
path: "" //页面路径query: {} //页面的参数referrerInfo: {} //来源小程序、公众号或 App 的 appIdscene: 1047 //场景值shareTicket: //带 shareTicket 的转发可以获取到更多的转发信息,例如群聊的名称以及群的标识 openGId
值得注意的是,使用cloud.openapi.wxacode.get和cloud.openapi.wxacode.getUnlimited生成的小程序码所带的参数在调试时需要使用开发工具的条件编译自定义参数 scene=xxxx 进行模拟,开发工具模拟时的 scene 的参数值需要进行 encodeURIComponent。
首先我们需要encodeURIComponent()方法将我们要传递的参数进行编码,比如我们要传递“a=3&b=4&c=5”这样的参数,我们可以直接在控制台里进行编码:
encodeURIComponent('a=3&b=4&c=5')
编码之后的结果为"a%3D3%26b%3D4%26c%3D5",调试时可以添加编译模式,在启动参数里填入
scene=a%3D3%26b%3D4%26c%3D5
小程序码不只是一个技术问题,更多的是涉及到运营,让运营的效果可以量化追踪,是增长黑客、数据运营的关键,场景值可以让我们了解小程序的增长来源;而将一些参数写进小程序码,可以让我们根据参数的不同来采取不同的运营策略,比如广告点击、返利、分销、拼团、分享追踪等等。作为开发人员,可以多和运营交流,让小程序的增长更有效果。
云调用有些接口属于AI服务的范畴,比如借助于人工智能来进行智能裁剪、扫描条码/二维码、图片的高清化等图像处理和识别银行卡、营业执照、驾驶证、身份证、印刷体、驾驶证等OCR,有了这些接口我们也能在小程序里使用人工智能了。接下来我们以小程序的条码/二维码识别和识别印刷体为例来介绍一下云调用。
使用开发者工具新建一个云函数,如scancode,然后在config.json里添加img.scanQRCode云调用的权限,使用npm install安装依赖之后,上传并部署所有文件(此时也会更新权限)。
{ "permissions": { "openapi": [ "img.scanQRCode" ] }}
然后再在index.js里输入以下代码,注意cloud.openapi.img.scanQRCode
方法和img.scanQRCode
权限的对应写法,不然会报604100的错误。
const cloud = require('wx-server-sdk')cloud.init({ env: cloud.DYNAMIC_CURRENT_ENV })exports.main = async (event, context) => { try { const result = await cloud.openapi.img.scanQRCode({ imgUrl:"https://tcb-1251009918.cos.ap-guangzhou.myqcloud.com/demo/qrcodetest.png" //注意二维码必须是条码/二维码,不能是小程序码 }) return result } catch (err) { console.log(err) return err }}
调用该云函数之后,返回的result对象里包含result对象,在codeResults的data里可以得到二维码里包含的内容。
codeResults: [{ data: "使用云开发来开发微信小程序可以免费。。。", pos: {leftTop: {…}, rightTop: {…}, rightBottom: {…}, leftBottom: {…}},typeName: "QR_CODE"}]errCode: 0errMsg: "openapi.img.scanQRCode:ok"imgSize: {w: 260, h: 260}
使用开发者工具新建一个云函数,如ocrprint,然后在config.json里添加ocr.printedText云调用的权限,使用npm install安装依赖之后,上传并部署所有文件(此时也会更新权限)。
{ "permissions": { "openapi": [ "ocr.printedText" ] }}
调用该云函数之后,返回的result对象里包含result对象,在codeResults的data里可以得到二维码里包含的内容。
const cloud = require('wx-server-sdk')cloud.init({ env: cloud.DYNAMIC_CURRENT_ENV })exports.main = async (event, context) => { try { const result = await cloud.openapi.ocr.printedText({ imgUrl: 'https://tcb-1251009918.cos.ap-guangzhou.myqcloud.com/demo/ocrprint.png' }) console.log(result) return result } catch (err) { console.log(err) return err }}
调用该云函数之后,返回的result对象里包含result对象,在的items里可以返回图片包含的文字内容。
items: Array(4)0: {text: "JavaScript入门", pos: {…}}1: {text: "JavaScript是目前世界上最流行的编程语言之一,它也是小程序开发最重要的基础语言。要做出一个功能复杂的小程序,除了需要掌握JavaScript的基本语", pos: {…}}2: {text: "法,还要了解如何使用JavaScript来操作小程序(通过API接口)", pos: {…}}3: {text: "过API接口)。", pos: {…}}
图片是小程序非常重要的元素,尤其是旅游照片、社交图片、电商产品图片、相册类小程序、媒体图文等,图片的加载速度、清晰度、图片的交互、图片效果的处理以及图片加载的CDN消耗都是一个不得不需要去关注的问题。而云开发图像处理拓展能力结合云存储则可以非常有效的解决很多问题。
强烈建议所有有图片处理需求的用户都应该安装图像处理拓展能力,这个能力大大弥补和增强了云存储在图片处理能力,尤其是图片按照需求的规格进行缩放可以大大减少CDN的消耗以及图片的加载速度以及我们可以按照不同的业务场景使用快速缩略模板,而这一切的操作和云存储的结合都是非常实用且易用的。
云开发图像处理能力结合的是腾讯云数据万象的图片解决方案,图像处理提供多种图像处理功能,包含智能裁剪、无损压缩、水印、格式转换等,图像处理拓展能力所包含的功能非常丰富,使用如下图片处理的费用是按量计费的,计费周期为月,10TB以内免费,超出10TB,按0.025元/GB 来计费,省事而便宜:
当我们在腾讯云云开发网页控制台(注意要使用微信公众号的方式登录)
添加完图像处理的拓展能力之后,我们可以在腾讯云的数据万象存储桶里看到云开发的云存储,
而关于图像处理能力的深入使用,也可以参考腾讯云数据万象的技术文档。
在小程序云开发里使用图像处理能力的方法有三种:
在了解图像处理能力之前,我们需要先了解一下云存储文件的fileID、下载地址以及下载地址携带的权限参数sign(图像处理能力的参数拼接就是基于下载地址的),如下图所示:
在安装了图像处理拓展能力的情况下,我们可以直接拿云存储的下载地址进行拼接,拼接之后的链接我们既可以在小程序里使用,也可以用于图床,比如原始图片下载地址为:
https://786c-xly-xrlur-1300446086.tcb.qcloud.la/hehe.jpg?sign=b8ac757538940ead8eed4786449b4cd7&t=1591752049
而相关的图像处理能力的拼接案例如下,具体的操作可以看技术文档,实际的效果,可以复制粘贴链接到浏览器或小程序里体验(换成自己的地址),注意拼接方式就是在下载地址后面加了一个&imageMogr2/thumbnail/!20p
(注意这里由于已经有了一个sign参数,所以拼接时用的是$
,不能写成?
,否则不会生效),直接就可以啦,非常易用:
//将图片等比例缩小到原来的20%https://786c-xly-xrlur-1300446086.tcb.qcloud.la/hehe.jpg?sign=b8ac757538940ead8eed4786449b4cd7&t=1591752049&imageMogr2/thumbnail/!20p
后面为了方便,我们将https://786c-xly-xrlur-1300446086.tcb.qcloud.la/hehe.jpg?sign=b8ac757538940ead8eed4786449b4cd7&t=1591752049
简写为download_url:
//缩放宽度,高度不变,下面案例为宽度为原图50%,高度不变download_url&imageMogr2/thumbnail/!50px//缩放高度,宽度不变,下面案例为高度为原图50%,宽度不变download_url&imageMogr2/thumbnail/!x50p//指定目标图片的宽度(单位为px),高度等比压缩,注意下面的是x,不是px,p与x在拼接里代表着不同的意思download_url&imageMogr2/thumbnail/640x//指定目标图片的高度(单位为px),宽度等比压缩:download_url&imageMogr2/thumbnail/x355//限定缩略图的宽度和高度的最大值分别为 Width 和 Height,进行等比缩放download_url&imageMogr2/thumbnail/640x355//限定缩略图的宽度和高度的最小值分别为 Width 和 Height,进行等比缩放download_url&imageMogr2/thumbnail/640x355r//忽略原图宽高比例,指定图片宽度为 Width,高度为 Height ,强行缩放图片,可能导致目标图片变形download_url&imageMogr2/thumbnail/640x355!//等比缩放图片,缩放后的图像,总像素数量不超过 Areadownload_url&imageMogr2/thumbnail/150000@//取半径为300,进行内切圆裁剪download_url&imageMogr2/iradius/300//取半径为100px,进行圆角裁剪download_url&imageMogr2/rradius/100//顺时针旋转90度download_url&imageMogr2/rotate/90//将jpg格式的原图片转换为 png 格式download_url&imageMogr2/format/png//模糊半径取8,sigma 值取5,进行高斯模糊处理download_url&imageMogr2/blur/8x5//获取图片的基础信息,返回的是json格式,我们可以使用https请求来查看图片的format格式,width宽度、height高度,size大小,photo_rgb主色调download_url&imageInfo
当我们希望把缩放、裁剪、旋转、格式变换等图像处理的结果(也就是处理之后的图片)存储到云存储,这个就叫做持久化图像处理,在安装了图像处理能力之后,我们也可以在小程序端做图像处理。
当用户把原始图片上传到小程序端时,我们需要对该图片进行一定的处理,比如图片过大就对图片进行裁剪缩小;比如图片需要进行一定的高斯模糊、旋转等处理,这些虽然在图像处理之前,也是可以使用js来做的,但是小程序端图像处理的效果并没有那么好或者过于复杂,使用图像处理的拓展能力就非常实用了。
在小程序端构建图像拓展依赖
首先在开发者工具小程序根目录(一般为miniprogram),右键“在终端中打开”,然后在终端里输入以下代码,也就是在小程序端安装图像拓展依赖,安装完时,我们就可以在miniprogram文件夹下看到node_modules:
npm install --save @cloudbase/extension-ci-wxmp@latest
然后点击开发者工具工具栏里的工具-构建npm,构建成功之后,就可以在miniprogram文件夹下看到minprogram_npm里有@cloubase文件夹,里面有extension-ci-wxmp,说明图像拓展依赖就构建完成。
在小程序端进行图像处理
使用开发者工具新建一个imgprocess的页面,然后在imgprocess.wmxl里输入如下代码,我们新建一个button按钮:
<button bindtap="imgprocess">处理图片</button>
然后再在imgprocess.js的Page()函数的上面(外面)引入图像处理依赖,代码如下:
const extCi = require("./../../miniprogram_npm/@cloudbase/extension-ci-wxmp");
然后再在imgprocess.js的Page()函数的里面写一个imgprocess的事件处理函数,点击button之后会先执行readFile()函数,也就是获取图片上传到小程序临时文件的结果(是一个对象),然后再调用imageProcess()函数,这个函数会对图片进行处理,图片会保存为tcbdemo.jpg
,而处理之后的图片会保存为image_process文件夹下的tcbdemo.png,相当于保存了两张图片:
async imgprocess(){ const readFile = async function() { let res = await new Promise(resolve=>{ wx.chooseImage({ success: function(res) { let filePath = res.tempFilePaths[0] let fm = wx.getFileSystemManager() fm.readFile({ filePath, success(res){ resolve(res) } }) } }) }) return res } let fileResult = await readFile(); //获取图像的临时文件上传结果 const fileContent = fileResult.data //获取上传到临时文件的图像,为Uint8Array或Buffer格式 async function imageProcess() { extCi.invoke({ action: "ImageProcess", cloudPath: "tcbdemo.jpg", // 图像在云存储中的路径,有点类似于wx.cloud.uploadFile接口里的cloudPath,上传的文件会保存为云存储根目录下的hehe.jpg operations: { rules: [ { fileid: "/image_process/tcbdemo.png", //将图片存储到云存储目录下的image_process文件夹里,也就是我们用image_process存储处理之后的图片 rule: "imageMogr2/format/png", // 处理样式参数,我们可以在这里写图片处理的参数拼接 } ] }, fileContent }).then(res => { console.log(res); }).catch(err => { console.log(err); }) } await imageProcess()}
https://786c-xly-xrlur-1300446086.pic.ap-shanghai.myqcloud.com 不在以下 request 合法域名列表中,请参考文档:https://developers.weixin.qq.com/miniprogram/dev/framework/ability/network.html
,这个要按照参考文档将链接加入到合法域名当中,不然不会生成图片;action
是操作类型,它的值可以为:ImageProcess图像处理,DetectType图片安全审核(后面会介绍),WaterMark图片忙水印、DetectLabel图像标签等。operations
是图像处理参数,尤其是rule和我们之前url的拼接是一致的,比如imageMogr2/blur/8x5
、imageMogr2/rradius/100
等参数仍然有效。上面函数里的fileContent不是必要的,也就是说我们可以不在小程序端上传图片,而是直接修改云存储里面已有的图片,并将图片处理后的照片保存,这种情况代码可以写成如下:
async imgprocess(){ extCi.invoke({ action: "ImageProcess", cloudPath: "tcbdemo.jpg", // 会直接处理这张图片 operations: { rules: [ { fileid: "/image_process/tcbdemo.png", rule: "imageMogr2/format/png", // 处理样式参数,与下载时处理图像在url拼接的参数一致 } ] }, }).then(res => { console.log(res); }).catch(err => { console.log(err); })}
在云函数端的处理和小程序端的处理,使用的方法大体上是一致的,不过云函数的处理图片的场景和小程序端处理图片的场景会有所不同,小程序端主要用于当用于上传图片时就对图片进行处理,云函数则主要用于从第三方下载图片之后进行处理或者对云存储里面的图片进行处理(比如使用定时触发器对云存储里指定文件夹的图片进行处理)。不建议把图片传输到云函数端再来对图片进行处理。
使用开发者工具新建一个imgprocess的云函数,然后在package.json里添加latest最新版的@cloudbase/extension-ci
,并右键云函数目录选择在终端中打开输入命令npm install安装依赖:
"dependencies": { "wx-server-sdk": "latest", "@cloudbase/extension-ci": "latest"}
然后再在index.js里输入以下代码,代码的具体含义可以参考小程序端的内容讲解:
const cloud = require('wx-server-sdk')cloud.init({ env: cloud.DYNAMIC_CURRENT_ENV })const extCi = require('@cloudbase/extension-ci')cloud.registerExtension(extCi)async function process() { try { const opts = { rules: [ { fileid: '/image_process/tcbdemo.jpeg', rule: 'imageMogr2/format/png' } ] } const res = await app.invokeExtension('CloudInfinite', { action: 'ImageProcess', cloudPath: "tcbdemo.jpg", fileContent, operations: opts }) console.log(res) return res } catch (err) { console.log(err) }}
微信小程序的许多业务场景需要通过UGC(用户产生内容)的方式,比如昵称/花名、个人资料签名/日志/聊天/评论、头像/表情/相片、直播等各种场景,其格式内容包括但不限于短文本、长内容、图片或视频等来实现更好的用户体验或更丰富的内容功能和服务场景。但是如果这类功能的使用如果没有做好对用户发布内容的安全审查,可能会产生政治有害等违法违规的内容。一旦被利用进行传播,对小程序用户带来有损的体验,小程序开发者也可能因此承担平台或法律的追责及处罚。因此包含UGC功能的小程序都需要有内容安全检测。
使用开发者工具新建一个云函数,如msgsec,然后在config.json里添加security.msgSecCheck云调用的权限,使用npm install安装依赖之后,上传并部署所有文件(此时也会更新权限)。
{ "permissions": { "openapi": [ "security.imgSecCheck", "security.msgSecCheck" ] }}
然后再在index.js里输入以下代码,
const cloud = require('wx-server-sdk')cloud.init({ env: cloud.DYNAMIC_CURRENT_ENV })exports.main = async (event, context) => { try { const result = await cloud.openapi.security.msgSecCheck({ content:`特3456书yuuo莞6543李zxcz蒜7782法fgnv级 完2347全dfji试3726测asad感3847知qwez到` }) return result } catch (error) { return error }}
调用该云函数,接口errcode返回87014(内容含有违法违规内容):
errMsg: "cloud.callFunction:ok", result: { errCode: 87014 errMsg: "openapi.security.msgSecCheck:fail risky content hint: [bgh98a06644711]"}
而如果返回的result.errCode的值为0,说明内容正常。
errMsg: "cloud.callFunction:ok"result: { errMsg: "openapi.security.msgSecCheck:ok", errCode: 0}
图片内容安全检测和文字内容安全检测最大的不同在于,我们需要考虑图片传输的耗时以及检测的图片不能大于1M这样的一个限制,当图片尺寸比较大时,我们需要对图片进行压缩处理。而且要检测的图片文件的格式为PNG、JPEG、JPG、GIF,图片尺寸不超过 750px x 1334px。通常情况下我们使用小程序端chooseImage上传图片时,我们尽量要求使用compressed压缩图,相册的压缩图一般都不会超过1M。
const cloud = require('wx-server-sdk')cloud.init({ env: cloud.DYNAMIC_CURRENT_ENV })exports.main = async (event, context) => { const fileID = 'cloud://xly-xrlur.786c-xly-xrlur-1300446086/1572315793628-366.png' const res = await cloud.downloadFile({ fileID: fileID, }) const Buffer = res.fileContent try { const result = await cloud.openapi.security.imgSecCheck({ media: { contentType: 'image/png', value: Buffer } }) return result } catch (error) { return error }}
云调用在图像内容安全处理方面,功能有一些不足的地方(比如没有细分涉黄、涉政、涉暴恐以及广告引导类),在限制上有一点严格(对图片的大小有严格的要求),建议大家安装云开发图像安全审核的拓展能力。
它的安装方式在云调用与拓展能力章节有介绍,而使用方式上一节图像处理的拓展能力是一脉相承的,有着相同的使用方法,这是因为图像内容安全就是图像处理的一部分。所以要使用图像内容安全拓展能力建议先阅读之前的内容,这里只给出实际的代码。
首先我们参考上一节内容构建图像处理的npm,然后再在imgprocess.js里引入包以及在Page函数里写一个事件处理函数。图像安全审核只能后置校验,也就是只能对已经上传到云存储的图片进行内容安全审核,方法如下:
const extCi = require("./../../miniprogram_npm/@cloudbase/extension-ci-wxmp");Page({ async imgSec(){ extCi.invoke({ action: "DetectType", cloudPath: "tcbdemo.jpg", operations: { type: 'porn,ads,terrorist,politics' } }).then(res => { console.log(res.data); }).catch(err => { console.log(err); }) }})
这里的type为内容审核的类型,porn(涉黄识别)、terrorist(涉暴恐识别)、politics(涉政识别)、ads(广告识别),我们可以像上面四个一起写,也可以只写其中的几个,用逗号,
隔开即可。
打印res.data,内有RecognitionResult的对象,会显示图片内容审核的结果,下面显示带有商业广告:
RecognitionResult{ PornInfo: {Code: 0, Msg: "OK", HitFlag: 0, Score: 14, Label: ""} TerroristInfo: {Code: 0, Msg: "OK", HitFlag: 0, Score: 0, Label: ""} PoliticsInfo: {Code: 0, Msg: "OK", HitFlag: 0, Score: 26, Label: ""} AdsInfo: {Code: 0, Msg: "OK", HitFlag: 1, Score: 98, Label: "淘宝"}}
在小程序端审核图片,我们可以先上传图片到云存储,然后获取图片在云存储的cloudPath(不是fileID,是相对云存储的绝对路径),再对图片进行审核,审核成功才予以显示,审核失败就删除该图片,让用户重新上传。
使用开发者工具新建一个imgSec的云函数,然后在package.json里添加latest最新版的@cloudbase/extension-ci
,并右键云函数目录选择在终端中打开输入命令npm install安装依赖:
"dependencies": { "wx-server-sdk": "latest", "@cloudbase/extension-ci": "latest"}
然后再在index.js里输入以下代码,代码的具体含义可以参考小程序端的内容讲解:
const cloud = require('wx-server-sdk')cloud.init({ env: cloud.DYNAMIC_CURRENT_ENV })const extCi = require('@cloudbase/extension-ci')cloud.registerExtension(extCi)async function imgSec() { try { const res = await app.invokeExtension('CloudInfinite', { action: 'DetectType', cloudPath: 'tcbdemo.png', operations: { type: 'porn,ads,terrorist,politics' } }) console.log(res) return res } catch (err) { console.log(err) }}
任何可以产生事件,触发云函数执行的均可以被称为触发器,而定时触发器则是可以处理周期性的事情,比如时报、日报、周报等通知提醒,也可以处理倒计时任务,比如节假日、纪念日以及你可以指定一个具体时间的倒计时任务,除此之外,定时触发器还可以用来周期性处理一些定时任务。比如定期清理一些不必要的数据,定期更新集合内的数据。
配置了定时触发器的云函数,会在相应时间点被自动触发,云函数的返回结果不会返回给调用方。在对某个云函数使用定时触发器前,首先要保证该云函数在小程序端可以调用成功,更准确的说是能够在不传入参数的情况下在云开发控制台的云端测试能调试成功(小程序端调用有登录态)。
云函数目录里的config.json文件可以用来配置权限和定时触发器,如果你的云函数目录下面没有这个配置文件,可以自己创建一个,创建的结构目录如下:
test //云函数目录├── config.json //权限和定时触发器等的配置文件├── index.js //云函数├── package.json //云函数的依赖管理
然后再来在配置文件config.json里进行类似如何格式的配置,config.json严格遵循配置文件所要求的格式,比如数组最后一项不能有逗号,
;配置文件里不能有注释等
{ "triggers": [ { "name": "tomylove", "type": "timer", "config": "*/5 * 9-12 * * * *" } ]}
当我们在修改触发器配置文件config.json后,首先鼠标右键config.json选择“云函数增量上传:更新文件”,然后再右键config.json选择“上传触发器”。这里的“云函数增量上传:更新文件”是让云函数端的触发器文件更新;而“上传触发器”则是让触发器开始生效执行。如果在云函数端的触发器没有更新的情况下就“上传触发器”来执行定时触发,文件可能没有更新,执行的还是旧的触发器内容。当我们想暂停或删除触发器时,可以右键选择“删除触发器”。
Cron表达式有七个必填字段,按空格分隔,既不能多写也不能少写,每一个字段都有它的含义对应着不同的时间点,表达式的取值都为整数且为时间制的范围(注意月在星期的前面):
第一位 | 第二位 | 第三位 | 第四位 | 第五位 | 第六位 | 第七位 |
---|---|---|---|---|---|---|
秒(0-59 ) | 分钟(0-59) | 小时(0-23) | 日(1-31) | 月(1-12或三个字母的英文缩写) | 星期(0-6或三个字母的英文缩写) | 年(1970~2099 ) |
下面是cron表达式的案例,以及我们需要了解一下cron表达式里的通配符以及直接写数字的含义:
,
,表示并集,在时间的表述里是“和”的意思,比如在“小时”字段中, 1,2,3
表示1点、2点和3点;-
,指定范围的所有值,在时间的表述里是“到”的意思,比如在“日”字段中,1-15
包含指定月份的1号到15号;*
,表示所有值,在时间的表述里是“每”的意思,比如在“小时”字段中,*
表示每小时;/
,指定步长,在时间的表述里是“隔”的意思,比如在“秒”字段中,*/5
表示每隔5秒;5
表示每月的第5日;//表示每隔5秒触发一次,*/5 * * * * * * //表示在每月的1日的凌晨2点触发0 0 2 1 * * * //表示在周一到周五每天上午10:15触发0 15 10 * * MON-FRI * //表示在每天上午10点,下午2点,4点触发0 0 10,14,16 * * * * //表示在每天上午9点到下午5点内每半小时触发0 */30 9-17 * * * * //表示在每个星期三中午12点触发0 0 12 * * WED *
定时触发器的Cron语法没法实现每隔90秒钟或90分钟发送一次这样的效果,因为90秒超过了秒的时间制上限60,而cron在跨位组合(比如90秒需要结合秒和分)上无法覆盖所有的时间;除此之外,云开发的触发器暂时不支持多个定时触发器的叠加;在 Cron 表达式中的“日”和“星期”字段同时指定值时,两者为“或”的关系,即两者的条件均生效;值得一提的是,尽管云函数的时区为UTC+0 时区,但是定时触发器的时间还是北京时间。
定时触发器的使用非常简单,使用开发者工具新建一个云函数比如trigger,然后在index.js里输入以下代码:
const cloud = require('wx-server-sdk')cloud.init({ env: cloud.DYNAMIC_CURRENT_ENV,})exports.main = async (event, context) => { console.log(event) return event}
再在trigger云函数目录下的config.json(如果没有这个文件,就创建一个),然后输入以下触发器,为了调试方便,我们可以每隔5秒触发一次:
{ "permissions": { "openapi": [ ] }, "triggers": [ { "name": "tomylove", "type": "timer", "config": "*/5 * * * * * *" } ]}
然后分别右键index.js和config.json,选择“云函数增量上传:更新文件”,然后再来右键config.json选择“上传触发器”。云函数就会每隔5秒自动触发,相关的日志我们可以在开发者工具的云开发控制台以及腾讯云云开发网页控制台的云函数的日志里查看。
注意小程序端调用trigger云函数返回的event对象,和使用定时触发器返回的event对象的不同,用定时触发器触发云函数是获取不到openId的,同时这里有一个Time时间是时区为UTC+0 的时间,比北京时间晚8个小时:
//在小程序端调用trigger云函数之后返回的event对象{ "userInfo":{ "appId":"wxda99******7046", "openId":"oUL-m5F******buEDsn8" }}//使用定时触发器触发云函数之后返回的event对象{ "Message":"", "Time":"2020-06-11T11:43:35Z", "TriggerName":"tomylove", "Type":"timer", "userInfo":{ "appId":"wxda99********46" }}
定时触发器的应用非常广泛,以下仅举一些常用案例,并加以说明:
这里的消息推送不仅仅只是指订阅消息,还可以是统一服务消息、公众号的消息(可以用云函数开发微信公众号)、小程序内自己开发的通知(只是用户只有在打开小程序时才能看到)、Email邮件等等。
比如用户订阅了日报、周报、月报等周期性的通知提醒或者我们需要给用户发送一些汇总信息,就可以固定写一个定时触发器,比如我们需要给指定用户发送工作周报,每周五晚上17点30分就定时从数据库获取数据发送消息,cron表达式写法如下:
* 30 17 * * FRI *
还可以用来处理一些倒计时(指定时间点)的任务,比如节假日、纪念日以及一些活动时间节点(定时触发器目前只能一个云函数配一个触发器,但是可以提前管理),比如我们希望在六一儿童节的早上9点调用云函数给指定用户群体发送消息:
0 0 9 1 6 * *
当然这样的具体时间点显得过于的不灵活,但是如果把时间与云开发数据库结合起来,灵活性就会大很多,比如在运营上每天早上11点是你们用户访问最多的时间点,你只需要写一个云函数,把所有的活动都在这个时间点来推送,让定时触发器每天这个时间点都触发,有活动(数据库里有数据)就会发消息,如果没有就不发(云函数调用一次的成本极低)。
如果是实时数据,我们还可以把定时触发器的频率调高,每5秒就触发一次,比如我们的数据库只要有最新的数据,就会发消息给指定用户。尽管不是完全的实时,但是5秒的频率和实时的差别也就不大了。你也可以根据情况,来调整触发器的频率,毕竟5秒和1分钟的频率给用户的体验差异并没有太大,但是成本却是12倍的关系。
可能你还希望在指定的时间段才触发云函数,比如你只希望在工作日、或者在早上9点到晚上18点才触发,在指定的时间段才触发既可以让触发更精准不扰民,也可以节约成本,比如下面的触发器就是工作日早上9点到12点和下午14点到18点这个时间段,每5秒触发一次。
*/5 * 9-12,14-18 * MON,TUE,WED,THU,FRI *
从以上案例我们可以了解到,云函数的定时触发可以来自于cron表达式的配置,我们可以指定时间点时间段和频率来达到我们想要的效果,同时这个时间“也可以来自于数据库的配置”(伪装),意思是我们可以设置触发器的时间段或频率,如果数据库里有数据就发送,没有数据就不发送,这样就可以达到触发器在时间上的灵活性了。
有的时候我们的数据并不是来自于数据库,而是来自于第三方服务,比如前面介绍过的历史上的今天的API,天气的API,知乎日报的API等等,以及一些webhook,这些API和第三方服务提供的是json格式的文件,API的数据也会随时更新,但是它们更新了却并不会主动通知我们,这时我们可以使用定时触发器向这些API发起请求,如果数据出现更新,我们就可以将更新的数据存储到我们的数据库或者进行其他处理,比如企业微信的机器人等机器人通知服务就是如此。
当然定期获取的数据还可以是爬虫,比如我们可以定期抓取指定关键词的新闻或者指定网站的动态,当爬虫获取到了不同的数据的时候,就将最新的动态以机器人消息或者其他方式进行及时的处理。
也就是说,我们无法实时监听到第三方API或者网站数据的变动,但是可以用定时触发器来发起请求或者爬虫抓取数据,通过数据的变化来达到“实时”获取数据的目的。
在数据库的设计里,我们就提到有时候需要对数据库里的数据进行定期的备份与删除等清理维护工作,比如超过一定时间的日志,具有很强时效性的活动数据,以及为了性能考虑而做的虚假删除(数据库性能与优化有介绍)等,毕竟数据库有一定的存储成本而且过多无用数据也会影响数据库的性能,我们可以写一个云函数用定时触发器来执行此类任务。
我们还可以在用户并发比较少的时间段(比如凌晨几点)来处理一些比较耗云函数、数据库性能的任务,比如图片的审核与裁剪、缩略等处理,用户评论是否包含敏感词汇(尽管经过安全处理,但是有时候我们还会设置特别的敏感词),数据的汇总,云存储里废弃文件的删除,用户信息是否完整等等。
也就是说,结合定时触发器,我们可以实现一些任务的自动化处理。
我们知道云函数在处理一些复杂性的任务时是有一些限制的,一是执行时间的限制,建议在设置时执行时间一般不要超过20s,最长不要超过60s;二是并发的限制,云函数最大的并发为1000;三是云函数在查询数据库时一次可以获取最多1000条的数据,面对这三个限制,我们应该如何处理密集型的任务呢,比如发送100万封邮件,导出几百万条数据到Excel,发送十万级的订阅消息或消息等等,这个时候就可以使用到定时触发器来处理了。
借助于定时触发器,我们可以将需要耗时较长、对并发要求较高以及数据库请求等的任务进行分批处理,比如我们要给100万人发邮件:云函数发起数据库请求,一次只请求1000条未发送过邮件的用户(用where条件查询某个字段,比如status:false
),然后将邮件发给1000个人(可以参考前面的邮件发送),发完邮件并对这1000条数据进行标记(比如使用更新指令将status改为true),这样下次查询未发送过邮件的用户时,就不会重复发送了。通过定时触发器,每2秒执行一次发送任务,几十分钟就可以处理完任务。
开通了云开发的小程序可以使用Cloud.CloudID接口返回一个 CloudID(开放数据 ID)特殊对象,将该对象传至云函数就可以获取其对应的开放数据,比如获取微信运动的步数、手机号等开放数据,而这个功能如果是使用非云开发的方式除了需要处理登录的问题,还需要进行加解密,十分繁琐。
获取微信运动步数的小程序接口为wx.getWeRunData,可以获取用户过去三十天微信运动步数。使用可开发者工具新建一个页面页面比如openData,然后在openData.wxml里输入一个button按钮:
<button bindtap="getWeRunData">获取微信步数</button>
然后再在openData.js里输入以下代码,我们用事件处理函数getWeRunData来调用wx.getWeRunData接口,并打印结果。
getWeRunData(){ wx.getWeRunData({ success: (result) => { console.log(result) }, })}
编译之后,点击按钮,我们可以在控制台看到返回的res对象里有encryptedData包括敏感数据在内的完整用户信息的加密数据、iv加密算法的初始向量, cloudID敏感数据对应的云 ID.
{errMsg: "getWeRunData:ok", encryptedData: "ABeBwlCHs....6PvAax", iv: "g8QPFXTLLD3N6Zn3YiuwEQ==", cloudID: "30_jVhZr_Up-8_TV...kgP8yJ8ykN0I"}
这个cloudID只有在开通了云开发的小程序才会返回,我们可以将cloudID传入云函数,通过云调用就可以直接获取开放数据。
使用开发者工具新建云函数比如opendata,再index.js里输入以下代码,并部署上线,在云函数端接收到的 event 将会包含对应开放数据的对象。
const cloud = require('wx-server-sdk')cloud.init({ env: cloud.DYNAMIC_CURRENT_ENV,})exports.main = async (event, context) => { return event}
我们再来在前面的事件处理函数getWeRunData里上传经过cloud.CloudID接口获得的cloudID对象,然后调用opendata云函数,并在success里打印返回来的对象,就可以看到包含微信运动步数的对象啦:
getWeRunData(){ wx.getWeRunData({ success: (result) => { console.log(result.cloudID) wx.cloud.callFunction({ name: 'opendata', data: { weRunData: wx.cloud.CloudID(result.cloudID), }, success:(res)=>{ console.log(res.result.weRunData.cloudID) console.log(res.result.weRunData.data.stepInfoList) } }) } })}
要获取用户的手机号,需要将 button 组件 open-type 的值设置为 getPhoneNumber,当用户点击并同意之后,可以通过 bindgetphonenumber 事件回调获取到微信服务器返回的加密数据,如果开通了云开发,就能在回调对象了获取到cloudID。使用开发者工具在openData.wxml里输入如下代码:
<button open-type="getPhoneNumber" bindgetphonenumber="getPhoneNumber"></button>
然后再在openData.js里输入以下代码,我们打印事件处理函数getPhoneNumber返回的结果。
getPhoneNumber (result) { console.log("result内容",result.detail) },
同样我们也会获得一个类似于微信运动步数的返回结果
{errMsg: "getPhoneNumber:ok", encryptedData: "Aw+W76TSvYAPS.....g==", iv: "9wSepi6qx...=", cloudID: "30_sSext5q.....qmLQ"}
我们仍然只需要将获取到cloudID经过cloud.CloudID()接口处理返回的对象上传并调用云函数:
getPhoneNumber (result) { wx.cloud.callFunction({ name: 'opendata', data: { getPhoneNumber: wx.cloud.CloudID(result.detail.cloudID), }, success:(res)=>{ console.log("云函数返回的对象",res.result.getPhoneNumber) } })},
在getPhoneNumber的data对象里的phoneNumber是用户绑定的手机号(国外手机号会有区号)、purePhoneNumber是没有区号的手机号、countryCode区号。
要获取微信群ID和群名称,需要经过一系列相对比较复杂的处理,需要经过以下步骤,具体的代码和开发方式后面会具体介绍:
withShareTicket: true
,分享也必须分享到微信群里;shareTicket
,wx.getShareInfo
里就会得到微信群敏感数据对应的cloudID,wx.cloud.CloudID(cloudID)
传入到云函数,云函数就可以返回微信群ID,也就是openGId
<open-data type="groupName" open-gid="{{openGId}}"></open-data>
来显示群名通过给 button 组件设置属性open-type="share"
,可以在用户点击按钮后触发页面的生命周期函数Page.onShareAppMessage
事件。首先我们使用开发者工具新建一个页面,比如share,然后再在share.wxml创建一个button组件,比如:
<button open-type="share">转发</button>
要获取群聊的名称以及群的标识openGId,需要带shareTicket的转发才可以,我们在share.js页面生命周期函数onShareAppMessage
里输入如下代码,设置withShareTicket
为true:
onShareAppMessage: function (res) { wx.updateShareMenu({ withShareTicket: true, success(res) { console.log(res) }, fail(err) { console.log(err) } }) if (res.from === 'button') { console.log(res.target) //可以在这里将用户点击button的次数存储到数据库,相当于埋点 } return { title: '云开发技术训练营', path: 'pages/share/share?openid=oUL-m5FuRmuVmxvbYOGuXbuEDsn8', imageUrl:"cloud://xly-xrlur.786c-xly-xrlur-1300446086/share.png"//支持云存储的fileID }},
关于显示右上角菜单的转发按钮可以使用
wx.showShareMenu
接口,而onShareAppMessage
除了可以监听用户点击页面内的button,也可以监听右上角菜单“转发”按钮的行为,无论是哪一种,都可以自定义菜单的title、path、imageUrl等,这里就不具体写代码啦。
值得注意的是,只有转发到微信群聊中,再通过微信群聊里的小程序卡片进入到小程序才可以获取到shareTickets返回值,单聊没有shareTickets;shareTicket仅在当前小程序生命周期内有效。但是在开发时,怎么把小程序转发到微信群里面去呢?开发者工具提供了带shareTickets的调试方法。
在开发者工具的模拟器里点击"转发"button,就会出现一个测试模拟群列表,我们可以将小程序转发到一个群聊里面去,比如测试模拟群4
。调试时,我们要添加自定义编译模式,在进入场景里选择1044: 带 shareTicket 的小程序消息卡片
,选择进入的群为你转发的群,具体可以参考如下图:
获取shareTicket,我们可以使用wx.getLaunchOptionsSync()
来获取小程序启动时的参数,这个参数与App.onLaunch 的回调参数一致,而shareTicket就在这个参数对象里。我们可以在share.js的onLoad生命周期函数里来获取它:
onLoad:function (options) { const res = wx.getLaunchOptionsSync() console.log('小程序启动时的参数',res) const {shareTicket} = res console.log('shareTicket的值',shareTicket)},
如果你直接使用普通编译(不使用上面的调试方法),是获取不到shareTicket的,shareTicket的值会为
undefined
,同时如果小程序直接加载(而不是通过点击群聊里分享的小程序卡片进入),shareTicket的值也是undefined
。
当我们获取到shareTicket之后,就可以调用wx.getShareInfo
接口来获取到关于转发的信息,尤其是cloudID。然后我们可以把获取到的CloudID,传入到云函数,比如share云函数。
使用开发者工具新建一个share云函数,在index.js里输入以下代码(这个其实就是返回event对象,如此简单的云函数我们可以和其他云函数合并到一起使用,比如获取openid等):
const cloud = require('wx-server-sdk')cloud.init({ env: cloud.DYNAMIC_CURRENT_ENV,})const TcbRouter = require('tcb-router');exports.main = async (event, context) => { return event}
然后再在小程序端share.js的生命周期函数里继续写如下代码,先判断shareTicket是否为空(也就是判断是否是通过微信群聊小程序卡片进入的),然后调用wx.getShareInfo来获取CloudID,再将CloudID传入到wx.cloud.CloudID()
接口,并将该对象传至云函数share就可以返回这个CloudID对应的开放数据了(这里的开放数据主要是openGId)。
onLoad:function (options) { const that = this const res = await wx.getLaunchOptionsSync() const {shareTicket} = res if(shareTicket!=null){ //当shareTicket不为空时,调用wx.getShareInfo来获取CloudID wx.getShareInfo({ shareTicket:shareTicket, success:function (res) { const cloudID = res.cloudID wx.cloud.callFunction({ name: 'share', data: { groupData: wx.cloud.CloudID(cloudID) }, success: function (res) { that.setData({ openGId:res.result.groupData.data.openGId }) } }) } }) }},
openGId为当前群的唯一标识,也就是每个微信群都有唯一且不变的这样一个ID,可以用于区分不同的微信群。我们可以把微信群内点击了小程序分享卡片的群成员的用户信息与这个openGId相关联,这样就可以弄群排行榜等一些基于微信群的开发。
不过我们只能获取微信群的群ID,是不能获取微信群的名称的,但是可以通过开放能力来显示微信群的名称,我们只需要把获取到的openGId字符串传入到open-gid
就可以了。
<open-data type="groupName" open-gid="{{openGId}}"></open-data>
可能你在调试的时候会出现,即使你把openGId写入到上面的组件,依然不会显示群名,或者使用真机调试也无法显示,这是因为测试群或者新建的群,可能会无效。
动态消息发出去之后,开发者可以通过后台接口修改部分消息内容,动态消息也有对应的提醒按钮,用户点击提醒按钮可以订阅提醒,开发者可以通过后台修改消息状态并推送一次提醒消息给订阅了提醒的用户。效果如下所示,这种特别适合我们做抢购、拼团等运营活动:
要让转发的小程序卡片里有动态消息,首先需要使用云调用updatableMessage.createActivityId
接口来创建activityId
,然后将activityId和templateInfo传入到wx.updateShareMenu
,而要更新动态消息则需要使用到updatableMessage.setUpdatableMsg
的接口。我们可以把创建动态消息和更新动态消息的云函数使用tcb-router整合到一个云函数里面。
使用开发者工具新建一个云函数,云函数的名称为activity,然后在package.json增加tcb-router最新版latest的依赖并用npm install安装:
"dependencies": { "wx-server-sdk":"latest", "tcb-router": "latest"}
以及在config.json里添加云调用的权限,用于生成ActivityId以及修改被分享的动态消息:
{ "permissions": { "openapi": [ "updatableMessage.createActivityId", "updatableMessage.setUpdatableMsg" ] }}
然后再在index.js里输入以下代码,使用createActivityId
生成ActivityId并返回:
const cloud = require('wx-server-sdk')cloud.init({ env: cloud.DYNAMIC_CURRENT_ENV,})const TcbRouter = require('tcb-router');exports.main = async (event, context) => { const app = new TcbRouter({event}) app.use(async (ctx, next) => { ctx.data = {} await next(); }); app.router('getActivityId',async (ctx, next)=>{ const result = await cloud.openapi.updatableMessage.createActivityId() ctx.data.activityID = result ctx.body = {"activityID":ctx.data.activityID} }) //后面我们会介绍如何更新动态消息,updatableMsg的router可以添加在这里 return app.serve();}
和前面一样,我们可以通过调用wx.updateShareMenu
接口,传入isUpdatableMessage: true
,以及 templateInfo
、activityId
等参数:
async onShareAppMessage(res) { const activityId = (await wx.cloud.callFunction({ name: 'activity', data: { $url: "getActivityId", } })).result.activityID.activityId wx.updateShareMenu({ withShareTicket: true, isUpdatableMessage: true, activityId: activityId, templateInfo: { parameterList: [{ name: 'member_count', value: '4' //这里的数据可以来自数据库 }, { name: 'room_limit', value: '30' //这里的数据可以来自数据库 }] } }) return { title: 'HackWeek技术训练营', path: 'pages/share/share?openid=oUL-m5FuRmuVmxvbYOGuXbuEDsn8', imageUrl:"cloud://xly-xrlur.786c-xly-xrlur-1300446086/1572315793633-633.png" }},
动态消息发出去之后,我们可以通过这个activityId来追踪这个动态消息,当用户进入分享的小程序,报名参与了这个活动时,比如活动为拼团,30人这个团购项目就成功啦,现在已经有4个人参与了(可以从数据库获得),当有新的用户付费参与这个拼团时,我们可以在这个用户付费的回调函数里调用updatableMessage.setUpdatableMsg
这个接口来修改动态消息。比如:
wx.cloud.callFunction({ name: 'activity', data: { $url: "updatableMsg", activityId: activityId, //activityId建议由前端传入,获取的方法如上 }})
我们继续在activity云函数里添加一个updatableMsg的router即可
const {activityID} = eventapp.router('updatableMsg',async (ctx, next)=>{ //我们可以用从数据库拉取现在拼团的人数,以及满团的人数,从而确定targetState的状态 const result = await cloud.openapi.updatableMessage.setUpdatableMsg({ activityID:activityID, targetState:0, templateInfo: { parameterList: [{ name: 'member_count', value: '5' //从数据库拉取 }, { name: 'room_limit', value: '30' //从数据库拉取 }] } })})
在前面的章节,我们已经在小程序端将button 组件open-type 的值设置为 contact ,点击button就可以进入客服消息。不过这个客服消息使用的是官方的后台,没法进行深度的定制,我们可以使用云开发作为后台来自定义客服消息来实现快捷回复、添加常用回答等功能。
如果是使用传统的开发方式,需要填写服务器地址(URL)、令牌(Token) 和 消息加密密钥(EncodingAESKey)等信息,然后结合将token、timestamp、nonce三个参数进行字典序排序、拼接、并进行sha1加密,然后将加密后的字符串与signature对比来验证消息的确来自微信服务器,之后再来进行接收消息和事件的处理,可谓十分繁琐,而使用云开发相对简单很多。
使用开发者工具新建一个云函数,比如customer,在config.json里,设置以下权限后部署上传到服务端。
{ "permissions": { "openapi": [ "customerServiceMessage.send", "customerServiceMessage.getTempMedia", "customerServiceMessage.setTyping", "customerServiceMessage.uploadTempMedia" ] }}
然后再打开云开发控制台,点击右上角的设置,选择全局设置,开启云函数接收消息推送,添加消息推送配置。为了学习方便我们将所有的消息类型都指定推送到customer云函数里。
以上有四种消息类型,但是发送客服消息的customerServiceMessage.send的msgtype属性的合法值有text、image、link(图文链接消息)、miniprogrampage四种,也就是我们还可以发图文链接消息。
使用开发者工具新建一个页面,比如customer,然后在customer.wxml里输入以下按钮,
<button open-type="contact" >进入客服</button>
当用户通过button进入到客服消息之后,在聊天界面回复信息,就能触发设置好的customer云函数,比如下面的例子就是当用户发一条消息(包括表情)到客服消息会话界面,云函数就会给调用customerServiceMessage.send接口给用户回复两条文本消息(一次性可以回复多条),内容分别为等候您多时啦
和欢迎关注云开发技术训练营
,一个云函数里也是可以多次调用接口的:
const cloud = require('wx-server-sdk')cloud.init({ env: cloud.DYNAMIC_CURRENT_ENV,})exports.main = async (event, context) => { const wxContext = cloud.getWXContext() try { const result = await cloud.openapi.customerServiceMessage.send({ touser: wxContext.OPENID, msgtype: 'text', text: { content: '等候您多时啦' } }) const result2 = await cloud.openapi.customerServiceMessage.send({ touser: wxContext.OPENID, msgtype: 'text', text: { content: '欢迎关注云开发技术训练营' } }) return event } catch (err) { console.log(err) return err }}
发送文本消息时,支持插入跳小程序的文字链接的,比如我们把上面的文本消息改为以下代码:
content: '欢迎浏览<a href="http://www.qq.com" rel="external nofollow" target="_blank" data-miniprogram-appid="你的appid" data-miniprogram-path="pages/index/index">点击跳小程序</a>'
我们还可以给用户回复链接,我们可以把customer云函数修改为以下代码,当用户向微信聊天对话界面发送一条消息时,就会回复给用户一个链接,这个链接可以是外部链接哦。
const cloud = require('wx-server-sdk')cloud.init({ env: cloud.DYNAMIC_CURRENT_ENV,})exports.main = async (event, context) => { const wxContext = cloud.getWXContext() try { const result = await cloud.openapi.customerServiceMessage.send({ touser: wxContext.OPENID, msgtype: 'link', link: { title: '快来加入云开发技术训练营', description: '零基础也能在10天内学会开发一个小程序', url: 'https://cloud.tencent.com/', thumbUrl: 'https://tcb-1251009918.cos.ap-guangzhou.myqcloud.com/love.png' } }) return event } catch (err) { console.log(err) return err }}
将上面的云函数部署之后,当用户向客服消息的聊天会话里输入内容时,不管用户发送的是什么内容,云函数都会回给用户相同的内容,这未免有点过于死板,客服消息能否根据用户发送的关键词回复用户不同的内容呢?要做到这一点我们需要能够获取到用户发送的内容。
我们可以留意云开发控制台云函数日志里看到,customer云函数返回的event对象里的Content属性就会记录用户发到聊天会话里的内容:
{ "Content":"请问怎么加入云开发训练营", "CreateTime":1582877109, "FromUserName":"oUL-mu...XbuEDsn8", "MsgId":22661351901594052, "MsgType":"text", "ToUserName":"gh_b2bbe22535e4", "userInfo":{"appId":"wxda99ae4531b57046","openId":"oUL-m5FuRmuVmxvbYOGuXbuEDsn8"}}
由于Content是字符串,那这个关键词既可以是非常精准的,比如“训练营”,或“云开发训练营”,还可以是非常模糊的“请问怎么加入云开发训练营”,我们只需要对字符串进行正则匹配处理即可,比如当用户只要发的内容包含“训练营”,就会收到链接:
const cloud = require('wx-server-sdk')cloud.init({ env: cloud.DYNAMIC_CURRENT_ENV,})exports.main = async (event, context) => { const wxContext = cloud.getWXContext() const keyword = event.Content try { if(keyword.search(/训练营/i)!=-1){ const result = await cloud.openapi.customerServiceMessage.send({ touser: wxContext.OPENID, msgtype: 'link', link: { title: '快来加入云开发技术训练营', description: '零基础也能在10天内学会开发一个小程序', url: 'https://cloud.tencent.com/', thumbUrl: 'https://tcb-1251009918.cos.ap-guangzhou.myqcloud.com/love.png' } }) } return event } catch (err) { console.log(err) return err }}
在前面的案例里,我们都是使用
touser: wxContext.OPENID,
,
要触发event事件,我们可以将customer.wxml的按钮改为如下代码,这里的session-from是用户从该按钮进入客服消息会话界面时,开发者将收到带上本参数的事件推送,可用于区分用户进入客服会话的来源。
<button open-type="contact" bindcontact="onCustomerServiceButtonClick" session-from="文章详情的客服按钮">进入客服</button>
由于我们开启了event类型的客服消息,事件类型的值为user_enter_tempsession,当用户点击button进入客服时,就会触发云函数,不用用户发消息就能触发,同时我们返回event对象.
const cloud = require('wx-server-sdk')cloud.init({ env: cloud.DYNAMIC_CURRENT_ENV,})exports.main = async (event, context) => { const wxContext = cloud.getWXContext() try { const result = await cloud.openapi.customerServiceMessage.send({ touser: wxContext.OPENID, msgtype: 'text', text: { content: '欢迎来到等候您多时啦' } }) return event } catch (err) { console.log(err) return err }}
我们可以去云开发控制台查看返回的event对象
{ "CreateTime":1582876587, "Event":"user_enter_tempsession", "FromUserName":"oUL-m5F...8", "MsgType":"event", "SessionFrom":"文章详情的客服按钮", "ToUserName":"gh_b2bbe22535e4", "userInfo":{"appId":"wxda9...57046", "openId":"oUL-m5FuRmuVmx...sn8"}}
在云函数端,我们是可以通过event.SessionFrom来获取到用户到底是点击了哪个按钮从而进入客服对话的,也可以根据用户进入客服会话的来源不同,给用户推送不同类型,比如我们可以给session-from的值设置为“训练营”,当用户进入客服消息会话就能推送相关的信息给到用户。
还有一点就是,bindcontact是给客服按钮绑定了了一个事件处理函数,这里为onCustomerServiceButtonClick,通过事件处理函数我们可以在小程序端做很多事情,比如记录用户点击了多少次带有标记(比如session-from的值设置为“训练营”)的客服消息的按钮等功能。
要在客服消息里给用户回复图片,这个图片的来源只能是来源于微信服务器,我们需要先使用customerServiceMessage.uploadTempMedia,把图片文件上传到微信服务器,获取到mediaId(有点类似于微信服务器的fileID),然后才能在客服消息里使用。
在customer云函数的index.js里输入以下代码并部署上线,我们将获取到的mediaId使用cloud.openapi.customerServiceMessage.send发给用户:
const cloud = require('wx-server-sdk')cloud.init({ env: cloud.DYNAMIC_CURRENT_ENV,})exports.main = async (event, context) => { const wxContext = cloud.getWXContext() try { //我们通常会将云存储的图片作为客服消息媒体文件的素材 const fileID = 'cloud://xly-xrlur.786c-xly-xrlur-1300446086/1572315793628-366.png' //uploadTempMedia的图片类型为Buffer,而从存储下载的图片格式也是Buffer const res = await cloud.downloadFile({ fileID: fileID, }) const Buffer = res.fileContent const result = await cloud.openapi.customerServiceMessage.uploadTempMedia({ type: 'image', media: { contentType: 'image/png', value: Buffer } }) console.log(result.mediaId) const mediaId = result.mediaId const wxContext = cloud.getWXContext() const result2 = await cloud.openapi.customerServiceMessage.send({ touser: wxContext.OPENID, msgtype: 'image', image: { mediaId: mediaId } }) return event } catch (err) { console.log(err) return err }}
客服消息还能给用户回复小程序消息卡片,以及客服当前的输入状态给用户(使用customerServiceMessage.setTyping接口)。
微信支付云调用(云支付),可以免鉴权快速调用微信支付的开放能力,开发者无需关心证书、签名、也无需依赖第三方模块,免去了泄漏证书,支付等敏感信息的风险;还支持云函数作为微信支付进行支付和退款的回调地址,不再需要定时轮询,更加高效。只需在开发者工具1.02.2005111 (2020年5月11日版)的云开发控制台绑定微信支付商户号,在绑定完成后可在云开发中原生接入微信支付。
要开通微信支付云调用,首先需要小程序已经开通了微信支付,而微信支付是不支持个人小程序的,需要企业账户才行,其次需要小程序已经绑定了商户号。满足这两个条件之后,我们可以在云开发控制台(注意开发者工具的版本)- 设置- 全局设置中开通。
点击添加商户号后进行账号绑定,这时候绑定了微信支付的商户号管理员的微信会收到一条授权确认的模板消息,点击模板消息会弹出服务商助手小程序,确认授权之后就可以在云开发控制台看到绑定状态为“已绑定”,而JS API权限也会显示“已授权”。
jsapi和api退款权限授权,需要前往微信支付商户平台-产品中心-我的授权产品中进行确认授权完成授权后才可以调用微信支付相关接口能力。如果你在你的产品中心看不到我的授权产品,可以点击链接:授权产品
用微信支付云调用来实现完整的支付功能,大体上会经过以下4个步骤(后面在代码的写法上有些步骤会整合到一起):
wx.cloud.callFunction
调用云函数(比如云函数名为pay),并将商品名称、商品价格等信息传递给pay云函数;CloudPay.unifiedOrder()
,参数包括接收的商品信息、云函数环境id,以及需要填写结果通知回调函数(比如函数名为paynotice)用来接收异步支付结果;pay云函数会返回的成功结果对象中会包含payment字段;wx.cloud.callFunction
的success回调函数(也就是拿到云函数返回的对象)里调用wx.requestPayment
接口发起支付,而从pay云函数返回的payment对象(字段)就包含这个接口所需要的所有信息(参数);这时会弹出微信支付的界面;我们可以在小程序的wxml页面比如pay.wxml页面,点击某个button组件时,通过事件处理函数比如callPay,来调用pay云函数,代码如下:
<button bindtap ="callPay">发起支付</button>
然后再在pay.js里输入事件处理函数callPay,调用的支付云函数名称为pay(名称任意),注意成功的回调函数的写法如下,这里把支付流程的第1步和第3步整到了一起:
尤其是
const payment = res.result.payment
和wx.requestPayment({...payment})
不要改(仅对小白用户而言)。因为有不少小白用户啥基础也没有,但是对微信支付比较感兴趣,所以本节内容,会介绍的比较琐碎一些。
callPay(){ wx.cloud.callFunction({ name: 'pay', //云函数的名称,在后面我们会教大家怎么建 success: res => { console.log(res) const payment = res.result.payment wx.requestPayment({ ...payment, success (res) { console.log('支付成功', res) //为方便,只打印结果,如果要写支付成功之后的处理函数,写在这后面 }, fail (err) { console.error('支付失败', err) //支付失败之后的处理函数,写在这后面 } }) }, fail: console.error, })},
然后再在云函数根目录文件夹cloudfunctions右键,选择“新建Nodejs云函数”,新建一个云函数pay,然后再在index.js里输入以下代码,然后进行一些修改(注意参数名称不要改,大小写也要原样写,不懂你就复制):
body
为你的商家名(店名)-销售商品的类名,代码里有参考;outTradeNo
是商户订单号,32个字符内,只能是数字、大小写字母_-,如果你是在调试学习,注意每次都改一下这个,免得重复;subMchId
你的商户ID或子商户ID,填写云开发控制台- 设置- 全局设置- 微信支付配置里的商户号也可以;totalFee
是支付的金额,单位是分,填写100,就是一块钱,注意这个是数值格式,不要写成了字符串格式(不要加单引号或者双引号);envId
是你的结果通知回调云函数所在的环境ID,functionName
结果通知云函数的名称(可以自定义);可以在云开发控制台- 设置- 环境设置里看到,注意是环境ID,不是环境名称,最好直接复制过来;修改完之后,点击pay云函数目录下的index.js,然后右键选择“云函数增量上传:更新文件”或者右键云函数根目录文件夹cloudfunctions,选择“上传并部署:云端安装依赖(不上传Node_modules)”
const cloud = require('wx-server-sdk')cloud.init({ env: cloud.DYNAMIC_CURRENT_ENV})exports.main = async (event, context) => { const res = await cloud.cloudPay.unifiedOrder({ "body": "HackWeek案例-教学费用", "outTradeNo" : "122775224070323234368128", //不能重复,否则报错 "spbillCreateIp" : "127.0.0.1", //就是这个值,不要改 "subMchId" : "1520057521", //你的商户ID或子商户ID "totalFee" : 100, //单位为分 "envId": "xly-xrlur", //你的云开发环境ID "functionName": "paysuc", //支付成功的回调云函数,先可以随便写,比如写paysuc,后面会教你怎么建 "nonceStr":"F8B31E62AD42045DFB4F2", //随便弄的32位字符串,建议自己生成 "tradeType":"JSAPI" //默认是JSAPI }) return res}
然后就可以在开发者工具的模拟器里点击"发起支付"的按钮了,这时会弹出支付的二维码,扫码支付就可以了;也可以使用预览或真机调试。
这里的outTradeNo是自己生成的,我们可以使用时间戳
Date.now().toString()
,或者加随机数Date.now().toString()+Math.floor(Math.random()*1000).toString()
等来处理,而nonceStr是32位以内的字符串,我们可以使用用户的openid和时间戳拼接而成(你也可以使用其他方法),比如下面是用户的openid先替换掉-
字符,然后将字母都大写,最后加上时间戳的字符串"oUL-m5FuRmuVmxvbYOGuXbuEDsn8".replace('-','').toUpperCase()+Date.now().toString()
我们可以在云函数里调用cloudPay.queryOrder()
来查询订单的支付状态,以及调用cloudPay.refund()
来对已经支付成功的订单发起退款。下面的代码只是查询订单与申请退款简单的demo,真正要在实际开发中使用这些接口,都是需要结合云开发数据库的,尤其是申请退款开发时一定要慎重对待。
使用开发者工具新建一个queryorder的云函数,然后在index.js里输入以下代码,将云函数部署到云端之后,调用该云函数就能查询订单信息了:
const cloud = require('wx-server-sdk')cloud.init({ env: cloud.DYNAMIC_CURRENT_ENV,})exports.main = async(event, context) => { const res = await cloud.cloudPay.queryOrder({ "sub_mch_id":"1520057521", //商户订单号,需是云支付成功交易的订单号 "out_trade_no":"122775224070323234368128", //微信订单号可以不必写 // "transaction_id":"4200000530202005179572346100", //任意的32位字符 "nonce_str":"C380BEC2BFD727A4B6845133519F3AD6" }) return res}
使用开发者工具新建一个refundorder的云函数,然后在index.js里输入以下代码,退款的金额少于交易的金额时,可以实现部分退款;注意调用该云函数,退款会直接原路返回给用户,因此一定要有管理员审核或只能管理员来调用该接口:
const cloud = require('wx-server-sdk')cloud.init({ env: cloud.DYNAMIC_CURRENT_ENV,})exports.main = async(event, context) => { const res = await cloud.cloudPay.refund({ "sub_mch_id":"1520057521", "nonce_str":"5K8264ILTKCH16CQ2502SI8ZNMTM67VS", "out_trade_no":"122775224070323234368128",//商户订单号,需是云支付成功交易的订单号 "out_refund_no":"122775224070323234368128001",//退款单号,可以自定义,建议与订单号相关联 "total_fee":100, "refund_fee":20, }) return res}
在前面发起支付的云函数里我们写过一个参数functionName
结果通知云函数paysuc,paysuc云函数在订单支付成功之后才会被调用。我们可以在支付成功的回调函数里处理一些任务,比如把订单支付的重要信息存储到数据库、给用户发送支付成功的订阅消息、以及获取用户的UnionID等等。要处理这些任务,首先需要了解订单支付成功之后,paysuc云函数会接收到哪些数据。我们可以打印paysuc云函数的event对象,可以了解到event对象里包含类似于如下结构的信息,这些都是我们在paysuc云函数处理任务的关键:
"appid": "wxd2********65e", "bankType": "OTHERS","cashFee": 200,"feeType": "CNY","isSubscribe": "N","mchId": "1800008281","nonceStr": "F8B31E62AD42045DFB4F2","openid": "oPoo44....t8gCOUKSncFI","outTradeNo": "1589720989221","resultCode": "SUCCESS","returnCode": "SUCCESS","subAppid": "wxda99a********57046","subIsSubscribe": "N","subMchId": "1520057521","subOpenid": "oUL********GuXbuEDsn8","timeEnd": "20200517211001","totalFee": 2,"tradeType": "JSAPI","transactionId": "42000********178943055343","userInfo": { "appId": "wxd********046", "openId": "oUL-m5F********GuXbuEDsn8"}
要发送订阅消息,首先我们需要去申请订单支付成功的订阅消息模板,比如模板如下,我们需要注意订阅消息里每一个属性对应的具体的格式,以及格式的具体要求,比如支付金额以及支付时间的格式:
商品名称{{thing6.DATA}}支付金额{{amount7.DATA}}订单号{{character_string9.DATA}}支付时间{{date10.DATA}}温馨提示{{thing5.DATA}}
要发订阅消息,需要调用接口wx.requestSubscribeMessage
来获取用户授权以及要有相应的授权次数,在前面我们已经了解到只有用户发生点击行为或者发起支付回调后,才可以调起订阅消息界面,因此我们可以在上面的发起支付的回调函数里直接调用这个接口:
callPay(){ wx.cloud.callFunction({ name: 'pay', //云函数的名称,在后面我们会教大家怎么建 success: res => { console.log(res) const payment = res.result.payment wx.requestPayment({ ...payment, success (res) { console.log('支付成功', res) //为方便,只打印结果,如果要写支付成功之后的处理函数,写在这后面 this.subscribeMessage() //调用subscribeMessage()函数,如果你不是箭头函数,注意this指代的对象 }, }) }, })},subscribeMessage() { wx.requestSubscribeMessage({ tmplIds: [ //订阅消息模板ID,一次可以写三个,可以是同款通知、到货通知、新品上新通知等,通常用户不会拒绝,多写几个就能获取更多授权 "p5ypZiN4TcZrzke4Q_MBB1qri33rb80z-tb16Sg-Kpg", ], success(res) { console.log("订阅消息API调用成功:",res) }, fail(res) { console.log("订阅消息API调用失败:",res) } })},
然后在paysuc云函数的index.js里写如下代码,订阅消息所需的全部参数都是来自于event对象,我们只需要稍加修改格式即可。
const cloud = require('wx-server-sdk')cloud.init({ env: cloud.DYNAMIC_CURRENT_ENV})exports.main = async (event, context) => { const {cashFee,subOpenid,outTradeNo,timeEnd} = event try { const result = await cloud.openapi.subscribeMessage.send({ touser: subOpenid, page: 'index', templateId: "p5ypZiN4TcZrzke4Q_MBB1qri33rb80z-tb16Sg-Kpg", data: { "thing6": { "value": '零基础小程序云开发训练营' }, "amount7": { "value": cashFee/100 +'元' }, "character_string9": { "value": outTradeNo }, "date10": { "value": timeEnd.slice(0,4)+'年'+timeEnd.slice(4,6)+'月'+timeEnd.slice(6,8)+'日'+' '+timeEnd.slice(8,10)+':'+timeEnd.slice(10,12) }, "thing5": { "value": "多谢您的支持哦~爱你哦~" } } }) return result } catch (err) { console.log(err) return err }}
云开发 CMS 内容管理系统是云开发提供的一个扩展程序,可以在云开发控制台一键安装在自己的云开发环境中,方便开发人员和内容运营者随时随地管理小程序 / Web 等多端云开发内容数据。不用编写代码就可以使用,还提供了 PC /移动端浏览器访问支持,支持文本、富文本、图片、文件、关联类型等多种类型的可视化编辑。
我们可以登录腾讯云官网的 云开发后台管理,选择使用微信公众号登录,然后用该小程序管理员的微信扫描二维码,就可以在网页控制台里看到我们使用微信开发者工具创建的小程序云开发资源了。
在这里可以管理小程序云开发的数据库、文件(存储)、云函数、监控告警、日志检索以及环境设置,也可以对云开发资源的服务进行续费,是一个完全可以替代云开发控制台的可视化管理工具。
腾讯云的这个云开发网页控制台相比开发者工具的控制台来说,功能更多更全面,很多新的功能也是会先发布在这里,比如:
如果你开通了多个云开发环境(小程序云开发环境、Web端云开发环境等),也就有了多个云开发资源环境,那我们是否可以在A小程序的云函数里调用B小程序的云开发资源呢?当然可以,通过@cloudbase/node-sdk这个依赖就能很方便的实现。
@cloudbase/node-sdk也就是云开发的服务端SDK,让你可以在服务端(如腾讯云云函数或 CVM 等)使用 Nodejs 服务访问 云开发 的服务,也就是服务端SDK是云开发环境必备的一个依赖。在我们给云函数安装wx-server-sdk时就已经同时安装了该依赖,也就是我们无需再安装就可以直接用它来实现跨云开发环境来调用资源。
比如我们可以在A小程序的云函数里填入B小程序的secretId和secretKey以及环境ID,这里的secretId和secretKey,
比如我们想在A小程序的云函数里对B小程序的数据库进行增删改查,在A小程序的云函数里比如云函数名为cross,在cross云函数的index.js里写如下代码,当我们在小程序调用cross云函数时,就能往B小程序的数据库里添加一条记录了:
const cloud = require('wx-server-sdk')cloud.init({ //任意云开发环境,包含B小程序创建的云开发环境,你一定要找到对应的腾讯云的secretId和secretKey哦 secretId: 'AKIDUmqiIcQUyA...GsDH6frnvcjZ', secretKey: 'iChEVXL7mBKJ...GqRmrgFYZ7', env: 'hac...1279b' })const db = cloud.database()exports.main = async (event, context) => { const {OPENID} = cloud.getWXContext() const result = await db.collection('test').add({ data:{ openid:OPENID, name:"李东bbsky", interest:['爬山','旅游','读书'] } }) return result}
服务端SDK 也就是@cloudbase/node-sdk的用法,与小程序云函数端(服务端)的用法是一致的。该依赖更多信息可以通过阅读在Github上的技术文档来了解。
@cloudbase/node-sdk是云开发Nodejs的服务端SDK,而云开发也在不断支持更多的编程语言,比如php,而tcb-php-sdk则是云开发php语言的服务端SDK。
CloudBase CLI 是一个开源的命令行界面交互工具,用于帮助用户快速、方便的部署项目,管理云开发资源。对于开发人员来说,我们还可以通过cloudbase-cliCLI工具使用命令行对云开发资源进行管理。
如果你想使用Visual Studio Code在电脑本地来开发小程序和进行Web端云开发,可以使用Cloudbase Cli命令行工具来管理云开发的环境。
在电脑本地部署Nodejs环境,结合VS Code编辑器,Cloudbase CLI是一个可以取代微信开发者工具来做跨端云开发的重要工具,当然小程序和网页的一些与账号有关调试还是离不开微信开发者工具。
我们应该如何让市场、运营、产品等来管理云开发的资源(如添加商品、发表文章等),通常我们需要搭建一个后台,便于不懂代码的人员来进行可视化管理,尽管我们可以把这个后台直接搭建在小程序里面,但是PC端的后台可能更加方便一些。
cloudbase-manager-node是云开发的管理端SDK,它支持开发者通过接口形式对云开发提供的云函数、数据库、文件存储等资源进行创建、管理、配置等操作。
相比于云开发的服务端SDK,管理端SDK在管理云开发环境的资源上功能更加丰富;使用管理端SDK可以在本地电脑、Linux、Windows等服务器里搭建云开发环境的管理后台。我们仍然可以把管理端sdk cloudbase-manager-node引入到云函数,取得一些服务端sdk(wx-server-sdk)没有的能力,比如:
tcb-php-sdk是云开发php语言的服务端SDK,那与之相应的,tcb-manager-php是云开发php语言的管理端SDK。
云开发同样适用于网站开发,Web 端是云开发中针对网站应用的统称,包含以下几个场景:普通网站应用(PC 端)、移动页面或者 H5 网页、公众号网页。如果你想将云开发这种免服务器免运维的开发理念和方式贯彻到web端,也可以直接把小程序云开发的环境切换到按量付费,那这个环境就可以成为web端开发的环境,实现一云多端,不需额外购置服务器就能来在网页Web端来管理小程序云开发环境里的资源。在后面我们也会简单介绍一下Web端云开发的相关内容。
云接入是云开发基于云函数之上为开发者提供的HTTP访问服务,开发者可以轻松使用 POST、PUT、GET、DELETE等方法通过 HTTP 请求访问到云开发环境内的全部资源,而不需要使用Web端 SDK,后面也会具体介绍。
开发人员还可以使用HTTP API搭建一个网页后台,HTTP API适用于所有平台所有的编程语言以及所有的平台。云开发官方技术文档有非常详细的 HTTP API技术文档 ,通过HTTP API我们可以实现对数据库的集合、记录、文件等的增删改查以及触发云函数。HTTP API可以完全被云接入以及SDK给取代,各方面的处理更好更合理,所以我们不再单独介绍HTTP API啦。
CloudBase CLI 是一个开源的命令行界面交互工具,用于帮助用户快速、方便的部署项目,管理云开发资源。有了这个工具,我们就能在电脑本地管理小程序云开发、Web端云开发创建的环境,对环境里的云函数、云存储、云数据库进行增删改查等操作。
在本地电脑安装好了Node环境之后,我们可以打开电脑终端(Windows电脑为cmd命令提示符,Mac电脑为终端Terminal),然后逐行输入并按Enter执行下面的代码就可在电脑里安装CloudBase Cli工具:
npm install -g @cloudbase/clicloudbase -v
-g
是全局安装的意思,可能会出现权限不够的提示,如果是Mac电脑可以在前面加一个sudo,而Windows可以通过管理员的方式打开cmd命令提示符。
安装完成后,你可以使用 cloudbase -v
验证是否安装成功,如果输出了版本号,则表明 CloudBase CLI 被成功安装到您的计算机中。
要让Cloudbase Cli工具登录,首先我们打开腾讯云云开发控制台,选择微信公众号的方式来登录,扫描选择你的小程序账号,这样就可以进入管理控制台查看到你在开发者工具创建的云开发环境了。
在终端中输入下面的命令,CLI 工具会自动打开云开发控制台获取授权,在控制台的网页上点击同意授权按钮允许 CloudBase CLI 获取授权。登录成功后,在终端就会有登录成功的提示,并附带一些操作的tips,这些操作的tips我们以后会用到。
cloudbase login
而要登出命令行工具CloudBase CLI的账号则可以使用 cloudbase logout
。
Cloudbase CLI 还有一种获取授权并登陆的方式,就是使用腾讯云的API 密钥授权。首先到腾讯云官网获取云 API 密钥新建密钥,然后输入以下命令并按提示输入密钥的 SecretId 和 SecretKey 就可以完成登录:
cloudbase login --key
通过这种方式可以操作名下的所有腾讯云资源,因此注意要妥善保存和定期更换密钥,旧秘钥要及时删除。这种方式主要用于环境的批量管理。
几乎所有的命令行工具,都支持--help
的方式输出帮助信息来查看命令行主要支持哪些命令,这样我们就没有必要死记硬背命令了,直接通过这种方式来查看即可。
cloudbase --help
打印之后我们就可以看到CloudBase Cli除了我们前面的查看版本、登录腾讯云账号、输出帮助信息等命令外,还有很多命令并予以了一个简单的注解,有了这些命令我们就能对CloudBase Cli工具有了一些非常全面的了解了。比如我们想查看我们的账号下的环境信息,通过帮助信息了解到可以在终端输入以下命令即可:
cloudbase env:list
要如何在本地电脑与云开发的环境建立联系呢?就需要我们在电脑本地初始化环境。在电脑的C盘(Windows)或电脑的下载文件夹(Mac)里新建一个文件夹比如tcbweb,然后在终端里输入以下命令进入到该目录,
#Windows 电脑cd /d C:download cbweb# Mac电脑cd downloads/tcbweb
然后再在终端输入以下命令来创建项目:
cloudbase init
这时会需要选择环境、输入项目名称、开发语言、云开发模板,我们可以通过键盘的上下键来选择,开发语言要选择Nodejs(云开发也支持PHP、Java),模板可以选择Hello World(也可以视情需求选择其他模板),确认之后项目就可以创建好了。
使用 cloudbase init 初始化项目时, CloudBase CLI 根据你输入的项目名称创建一个文件夹,并写入相关的配置和模板文件,创建的项目文件结构如下所示:
.├── _gitignore├── functions // 云函数目录│ └── app│ └── index.js└── cloudbaserc.js // 项目配置文件
所有 CloudBase CLI 命令均在配置文件cloudbaserc.js所在目录执行,也就是说要在终端运行Cloudbase CLI命令,需要先进入cloudbaserc.js所在的目录,cloudbaserc.js在哪里,哪里就是这个项目的根目录。建议编辑代码时使用编辑器比如 Visual Studio Code或IDE工具Webstrom之类的,打开创建好的项目文件夹tcbweb,这样方便运行、调试、编辑。
云开发项目是和云开发环境资源关联的实体,云开发项目聚合了云函数、数据库、文件存储等服务,您可以在云开发项目中编写函数,存储文件,并通过 CloudBase CLI 工具 快速的操作您的云函数、文件存储、数据库等资源。
配置文件可以简化 CloudBase CLI 使用,方便项目开发,当使用命令参数缺省时,CloudBase CLI 会从配置文件中解析相关参数并使用,方便开发者以更简单的方式使用 CloudBase CLI。
默认情况下,使用 cloudbase init 初始化项目时,会生成 cloudbaserc.js 或 cloudbaserc.json 文件作为配置文件,你也可以使用 --config-file 指定其他文件作为配置文件,文件必须满足格式要求。
{ "envId": "dev-xxxx", // 关联环境 ID "functionRoot": "functions", // 云函数文件夹名称,相对路径,可以省略 './ "functions": [ // 云函数配置 { "name": "app", // functions 文件夹下函数文件夹的名称,即函数名 "config": { // 云函数配置 "timeout": 5, // 超时时间,单位:秒 S "envVariables": { // 环境变量 "key": "value" } } } ]}
在 CLI 配置文件cloudbaserc.js中,functions是一个数组,可以包含多个函数配置项,函数配置项包含了函数名称(name),函数运行配置(config),函数调用传入参数(params)等多项与函数相关的信息,影响着函数操作的行为表现,更多具体的配置比如私有网络、触发器等可以去参考相关技术文档。
{ envId: 'dev-xxxx', // 关联环境 ID functions: [ // 函数配置 { name: 'app', // functions 文件夹下函数文件夹的名称,即函数名 config: { // 函数配置 timeout: 5, // 超时时间,单位:秒 S envVariables: { key: 'value' // 环境变量 }, // 私有网络配置,如果不使用私有网络,可不配置 vpc: { // vpc id vpcId: 'vpc-xxx', // 子网 id subnetId: 'subnet-xxx' }, runtime: 'Nodejs10.15',// 运行时,目前可选运行包含:Nodejs8.9, Nodejs10.15,Php7, Java8等,默认为Nodejs 10.15 installDependency: true // 是否云端安装依赖,仅支持 Node.js 项目 }, triggers: [ // 函数触发器 { name: 'myTrigger', // name: 触发器的名字 type: 'timer', // type: 触发器类型,目前仅支持 timer (即定时触发器) config: '0 0 2 1 * * *' // config: 触发器配置,在定时触发器下,config 格式为 cron 表达式 } ], handler: 'index.main', params: {}, // functions:invoke 本地触发云函数时的调用参数 ignore: [ // 部署/更新云函数时忽略的文件 '*.md', // 忽略 markdown 文件 'node_modules', // 忽略 node_modules 文件夹 'node_modules/**/*' ] } ]}
我们可以在终端输入以下命令列出所有云函数(一次只会列出前20条)并查看云函数的基本信息:
cloudbase functions:list
如果你的函数较多,需要列出其他的函数,你可以通过下面的选项指定返回的数据长度以及数据的偏移量,有点类似于通过skip来翻页了。
-l, --limit <limit> 返回数据长度,默认值为 20-o, --offset <offset> 数据偏移量,默认值为 0
#返回前 10 个函数的信息cloudbase functions:list -l 10#返回第 3 - 22 个函数的信息(包含 3 和 22)cloudbase functions:list -l 20 -o 2
如果想查看所有云函数的详情、某个云函数的详情或某个云函数的调用日志,可以在终端输入以下命令来获取:
# 查看具体某个云函数的详情,比如云函数名为logincloudbase functions:detail login# 查看配置文件中的所有云函数的详情,注意是配置文件cloudbaserc.js 中的云函数cloudbase functions:detail#查看云函数的调用日志,比如云函数名为logincloudbase functions:log login
我们可以通过查看云函数的详情了解到云函数的运行环境、网络、触发器、修改时间以及具体的代码等等,这是让我们可以在电脑本地了解一个云函数的部署情况的非常重要的命令了。
你可以在本地通过 Cloudbase CLI 直接触发云函数,也就是直接调用在服务端的云函数,调用成功后,会直接返回和在云开发控制台一样的云函数调用日志。
# 比如触发login函数cloudbase functions:invoke login# 触发配置文件中的全部函数cloudbase functions:invoke
如果我们想要修改我们在小程序云开发里创建的云函数,首先就需要下载云函数的代码以及它的依赖,默认情况下,函数代码会下载到 functionRoot 下(也就是项目根目录下的functions文件夹里),以函数名称作为存储文件夹。
cloudbase functions:download login
当然,你也可以指定函数存放的文件夹地址,函数的所有代码文件会直接下载到指定的文件夹中。这个就和微信开发者工具下载云函数是一样的。
cloudbase functions:download login ./subfile
这里的./subfile
是指项目根目录下的subfile文件夹,如果你要指定的时候,需要先创建这个文件夹,建议最好是直接默认下载到functionRoot下。
当我们修改完本地云函数的代码之后就需要把本地云函数的代码更新或部署上传到服务端,更新代码update只会更新函数的代码以及执行入口,不会修改函数的其他配置,比如下面是更新login云函数
cloudbase functions:code:update login
而使用部署代码命令deploy 时,则会修改函数的代码、配置以及触发器等,比如下面命令部署上传login云函数,在部署上传云函数前建议将login云函数的配置写在cloudbaserc.js里:
cloudbase functions:deploy login
如果我们不指定函数的名称,Cloudbase CLI 会会更新或部署配置文件中的全部函数:
# 更新配置文件中所有函数的配置信息cloudbase functions:config:update# 部署配置文件中所有函数的配置信息cloudbase functions:deploy
如果只是想更新配置文件中云函数的配置信息,可以使用以下命令,目前支持修改的函数配置包含超时时间 timeout、环境变量 envVariables、运行时 runtime,vpc网络以及 installDependency 等选项。
# 更新 login 函数的配置cloudbase functions:config:update login# 更新配置文件中所有函数的配置信息cloudbase functions:config:update
如果存在同名的云函数需要覆盖,可以在命令后附加 --force 选项指定 Cloudbase CLI 覆盖已存在的云函数。
cloudbase functions:deploy dev --force
Cloudbase CLI 支持在本地运行云函数,这个有点类似于微信开发者工具的本地调试云函数,运行云函数时默认以 index.main 作为函数执行入口。在本地运行云函数既可以通过云函数的路径运行,也可以通过函数名来运行。
注意,要运行云函数,Node的依赖wx-server-sdk(核心是服务端SDK @cloudbase/node-sdk)是必不可少的,本地运行云函数,就需要在本地安装wx-server-sdk,在云端运行就要在云端安装这个依赖,由于我们的云函数是从云端下载下来的,都已经带有wx-server-sdk依赖了,所以不需要安装,但是如果是自己创建的云函数就必须要了哦~
你可以使用 --path 选项指定函数入口文件的路径,直接运行云函数,比如下面的命令是执行login云函数
cloudbase functions:run --path ./functions/login
还可以使用 --name 选项指定需要运行的云函数,使用 --name 选项时,可以通过 cloudbaserc.js 配置文件执行函数执行入口
cloudbase functions:run --name login
您可以通过下面的命令来删除指定云函数和配置文件中的所有云函数。
#删除 login 函数cloudbase functions:delete login #删除配置文件中的所有的函数cloudbase functions:delete
云存储是云开发为用户提供的文件存储能力,用户可以通过云开发提供的 CLI 工具、SDK 对存储进行操作,如上传、下载文件。这里需要先了解一下localPath和cloudPath的概念。
Windows 系统中localPath为本地路径形式,是系统可以识别的路径,通常使用分隔符(Mac电脑用
/
分隔符)。cloudPath 是云端文件路径,均需要使用 / 分隔符。
我们可以使用下面的命令来下载文件或文件夹,需要下载文件夹时,需要指定 --dir 参数。
# 下载文件cloudbase storage:download cloudPath localPath# 下载文件夹cloudbase storage:download cloudPath localPath --dir
比如下面这段命令会将云存储根目录下的cloudbase文件夹里的文件下载到本地项目根目录下的download文件夹里(download文件夹需事先创建),只要我们弄清楚了cloudPath和localPath的写法就能熟练使用这个命令了
cloudbase storage:download cloudbase ./download --dir
我们可以使用下面的命令将本地电脑的文件或文件夹上传到云存储,当 CLI 检测到 localPath 为文件夹时,会自动上传文件内的所有文件,如果重复上传会覆盖。
cloudbase storage:upload localPath cloudPath
当不传入 cloudPath,文件会上传到云端的根目录下,同时文件夹的层次结构会被保留,比如下面的命令会把项目根目录的download文件夹里的内容直接上传到云存储的根目录里,download的子文件夹会成为云存储的二级目录。
cloudbase storage:upload ./download
与之相应的,如果你想删除云存储里的文件或文件夹,可以使用下面的命令,需要删除文件夹时,需要指定 --dir 参数。
# 删除文件cloudbase storage:delete cloudPath# 删除文件夹cloudbase storage:delete cloudPath --dir
在不打开云开发控制台或网页控制台,我们也可以通过Cloudbase Cli工具了解云存储里的文件夹或文件的信息,比如在终端里输入以下命令可以列出云存储里的文件夹里的所有文件信息,比如大小、修改时间、key、Etag等信息。
cloudbase storage:list cloudPath
比如我们可以直接使用以下命令打印云存储根目录里的所有文件(平时不要这么做,打印二级目录里的文件即可),其中二级目录里的文件会用路径的方式显示,比如cloudbase/logo.png表示是cloudbase文件夹下的logo.png图片。
cloudbase storage:list
如果我们想通过浏览器打开云存储里的文件,就需要获取文件临时访问链接。我们知道如果公有读的文件获取的链接不会过期,私有的文件获取的链接只有十分钟有效期。注意这里的cloudPath不能是文件夹,只能是文件哦
cloudbase storage:url cloudPath
比如我们想获取云存储cloudbase里的logo.png的临时访问链接,只需要在终端里输入以下命令:
cloudbase storage:url cloudbase/logo.png
我们还可以使用下面的命令获取文件的简单信息:
cloudbase storage:detail cloudPath
云开发为开发者提供静态网页托管的能力,我们可以把静态资源(HTML、CSS、JavaScript、字体等)放到云开发的对象存储 COS 里,这样我们就可以无需域名和备案(云开发提供二级域名,但是建议绑定备案的域名),迅速完成网页应用的部署。静态网页托管,支持 HTTP 与 HTTPS 访问,托管在云开发上的静态网页,均缓存在CDN 服务器中,我们还可以用Cloudbase Cli工具来部署文件到静态托管里。
只有付费方式为按量付费的云开发环境才能开通静态网页托管服务,预付费方式(也就是包月型)环境不可开通。由小程序云开发创建的环境的按量付费,我们可以去微信开发者工具的云开发控制台的设置里去开通,开通按量付费之后就不能再切回预付费了,不过按量付费更值得推荐。
按量付费除了和预付费具有相同的免费额度之外,按量付费还有以下四大优势:
当我们将小程序云开发的环境切换为按量付费之后,我们可以去登录腾讯云网页的云开发控制台,单击对应的环境名称,来开通静态网站托管服务。开通之后就可以看到文件管理与设置两个标签页,当开通了静态网站托管服务之后,会提供一个默认的静态网站域名:
https://你的环境id.tcloudbaseapp.com
这个域名支持 HTTP 与 HTTPS访问,不过静态托管会有默认限速:10K,可以用于少数人访问的管理后台,而如果需要用于对外开放网站,建议绑定已经备案了的域名。当您不再需要静态托管服务后,最好注销静态网站。否则静态网站会持续产生存储费用。我们可以在网页控制台的统计分析页面,查看静态网站服务流量和存储资源的消耗情况。
云开发静态托管服务既可以使用控制台进行文件管理,还能使用 CLI 工具进行文件管理。在网页云开发控制台里可以直接管理静态网站,比如在文件管理标签页,可以上传、删除文件,创建文件夹和批量上传文件夹等操作。
比如我们上传一个图片如tcb.png,
而在设置里,则可以进行静态网站的域名、索引文档的管理。索引文档也就是静态网站的首页,是当用户对网站的根目录或任何子目录发出请求时返回的网页,通常此页面被命名为 index.html。而错误文档指访问静态网站出错后返回的页面。
当添加域名之后,系统会为您自动分配一个以 .cdn.dnsv1.com 为后缀的 CNAME 域名,CNAME 域名不能直接访问,需要在域名服务提供商处完成 CNAME 配置,配置生效后,托管服务方可对自定义域名生效。
在开通静态托管服务,以及CLoudbase CLI工具登录的情况,我们可以用Cloudbase CLI工具来对静态网站里面的文件进行管理。
我们可以在终端输入下面的命令来展示静态网站的状态,访问域名等信息,其中xly-xrlur
要替换成你的云开发环境的id
cloudbase hosting:detail -e xly-xrlur
要查看静态网站存储空间里有哪些文件,比如我们要查看环境ID为xly-xrlur
的文件列表信息:
cloudbase hosting:list -e xly-xrlur
我们可以使用下面的命令将本地电脑(项目根目录的文件或文件夹)上传到静态网站的存储空间中的指定路径,如果不指定cloudPath时,会将项目根目录下的所有文件上传到存储空间的根目录。
cloudbase hosting:deploy localPath cloudPath -e envId
比如我们想将项目根目录下的download文件夹里的内容上传到静态网站存储空间的public文件夹下:
cloudbase hosting:deploy ./download public -e xly-xrlur
或者将static 目录下的 index.js 文件部署到 static/index.js
cloudbase hosting:deploy ./static/index.js static/index.js -e xly-xrlur
我们可以使用下面的命令删除静态网站的存储空间中的文件或文件夹,只要掌握了网络存储空间的路径的写法就很容易掌握啦:
cloudbase hosting:delete cloudPath -e envId
使用静态博客生成器是搭建博客比较流行的一种方式,将博客的静态网页生成之后,也可以部署到云开发的静态网站托管里。静态博客生成器很多,比如有Gatsby、Jekyll、Hugo、Hexo、VuePress等等,这里以Hexo为例。Hexo 是一个快速、简洁且高效的博客框架,可以使用 Markdown(或其他渲染引擎)解析文章,而且还有丰富的博客主题可以选择。
首先我们在终端输入以下命令执行 hexo 的全局安装,
#全局安装hexonpm install hexo-cli -g#检测hexo是否安装成功以及常用命令hexo --versionhexo --help
安装完成之后,在终端中使用 cd 命令进入静态网站托管项目所在的根目录(这个操作在前面有说明),然后生成一个博客
#生成博客项目,这里的tcbblog为博客名称,你可以任意修改hexo init tcbblog#命令行工具进入博客项目cd tcbblog#安装博客项目的依赖npm install
执行完以上命令之后,hexo博客就搭建完成了,那我们要如何才能看到效果呢,执行以下命令之后,在本地电脑的浏览器输入http://localhost:4000
就能看到这个静态博客的效果了:
hexo server#如果你想中断服务可以连续按两次Ctrl+C
我们还可以修改博客的主题,比如Fluid 是基于 Hexo 的一款 Material Design 风格的主题,具体的方法可以查看文档。
怎么往Hexo生成的博客里添加文章内容或者如何进行一些自定义的配置呢,这些可以看Hexo的官方技术文档。
要将静态博客部署到静态存储里,需要先在终端执行以下命令生成静态的网站文件(其他静态博客生成器也是一样,这是静态网站托管的核心),网站文件会被生成在public文件夹里:
#在终端执行这个命令需要注意cd进入tcbblog也就是博客项目的根目录才行hexo generate
接下来我们要做的就是将public文件夹里的网站文件上传到静态网站存储的根目录或二级子目录里。
这里需要注意的是在终端执行hexo命令要在博客项目的根目录,上面的就是tcbblog,而要执行cloudbase命令,则需要到云开发项目的根目录,我们可以使用
cd ..
回退到上级目录
结合上面的知识不难得出,我们可以让终端进入云开发项目的根目录之后执行以下命令,也就是将tcbblog文件夹的二级目录public里面的所有文件都上传到静态存储的blog文件夹里:
cloudbase hosting:deploy ./tcbblog/public blog -e xly-xrlur
然后我们就可以使用你的域名或静态存储域名/blog
打开你的静态博客了。
静态网站托管与云开发本身其实并没有非常直接的联系,我们也可以在其他静态网站托管比如Github Pages里以及在PHP、Java、Python等开发的网页里使用云开发,也就是说云开发的SDK并不依赖静态网站托管服务,基本上只要是网页就可以使用Web端云开发的SDK。静态托管服务只是云开发为了贯彻免服务器而提供的一项服务,在静态托管的网页里使用Web端SDK一样也能让静态的网页动态起来,拥有操作云存储、云数据库等的能力。
云开发的环境还可以开发网站应用,包括普通的 PC 网页或者公众号中的网页等,在开发过程中即便需要后台服务也无需搭建服务器,可以直接使用云开发提供的云端能力。开发者无需关心具体的后台资源及运维,只需使用平台提供的 API 进行核心业务开发,即可实现产品快速上线和迭代。
在前面我们已经用Cloudbase Cli工具在电脑本地用Cloudbase init
创建了一个云开发项目,以及开通了静态网站托管,那我们应该如何在静态的网页里初始化云开发呢?
使用编辑器比如 Visual Studio Code,打开前面创建好的项目文件夹tcbweb,然后使用VS Code来新建一个public文件夹,并在public文件夹里面新建
最终文件夹的目录结构如下,一个清晰的项目文件结构便于我们有序的开发管理:
. ├── _gitignore├── functions // 云函数目录│ └── app│ └── index.js├── public // 用于存放应用程序的静态文件│ └── index.html │ └── js│ └── main.js│ └── css│ └── style.css│ └── assert└── cloudbaserc.js // 项目配置文件
在前面我们已经介绍过电脑的终端,Visual Studio Code也有终端,也可以在这个终端里面进行终端的命令行操作,和电脑的终端略有不同,大家也可以根据习惯自行选择。
然后使用VS Code打开index.html文件,VS Code编辑器内置一套emmet语法,可以大大提高我们书写HTML代码的效率,在index.html里输入一个英文状态下的感叹号!,之后按一下tab键,就会出现以下代码,并修改一下title和body标签里面的内容:
<!DOCTYPE html><html lang="en"><head> <meta charset="UTF-8"> <meta name="viewport" content="width=device-width, initial-scale=1.0"> <title>Web端云开发</title></head><body> Web端云开发页面内容</body></html>
然后将head标签里添加如下内容,把云开发web端SDK也就是tcb.js添加到Web应用中:
<head> <!-- 省略--> <script src="https://imgcache.qq.com/qcloud/tcbjs/1.7.0/tcb.js" rel="external nofollow" ></script> <script src="./js/main.js"></script></head>
注意上面的版本号
1.7.0
,这个不一定是最新的,建议tcb-js-sdk npm包地址获取最新的版本号,尤其是需要使用新功能的时候,要注意修改版本号。我们还可以通过npm install --save tcb-js-sdk@latest
引入npm包tcb-js-sdk的方式来下载最新版的Web端SDK,而在模块化开发时用const tcb = require("tcb-js-sdk");
将Web端SDK引入到项目中,这里就不多介绍啦。
使用VS Code打开js文件夹下main.js文件,在里面输入以下代码就可以完成云开发环境的初始化,和小程序云开发一样,要调用云开发环境里的资源,就需要进行初始化,init 用于设置调用云函数、数据库、文件存储时要访问的环境。
const app = tcb.init({ env: '你的环境ID' // 此处填入你的环境ID });
我们知道在项目根目录下的配置文件cloudbaserc.js或cloudbaserc.json里也需要填入环境ID,这个配置文件主要用于项目管理,这个和初始化Web端环境是有一定的区别的哦,留意一下。
也就是说我们要初始化云开发能力,一是要引入Web端SDK tcb.js文件,二是要初始化Web端的环境,这两个是必不可少的哦,引入这两个之后,云开发能力就算完成初始化了。
要在本地电脑里调试Web端云开发,除了前面已经做过的在电脑里安装Nodejs环境、安装Cloudbase Cli工具、初始化云开发项目之外,还需要执行以下操作。
使用小程序云开发的账号登录到腾讯云云开发控制台,也就是登录时选择微信公众号登录,然后用小程序云开发管理员的微信扫码,在云开发控制台选择其中一个环境,进入到该环境的管理页,点击环境-环境设置菜单,选择登录方式标签页,然后:
在登录方式里可以选择启动匿名登录,匿名登录有点类似于游戏里的游客访问模式,用户不需要注册就可以获取并存储数据,这种方式比较方便我们进行本地调试。
每个设备同时只存在一个匿名用户,并且此用户永不过期(如果用户手动清除了设备或浏览器的本地数据,那么匿名用户的数据便会被同步清除,再次调用匿名登录API会产生一个新的匿名用户)。每个环境的匿名用户数量不超过1000万个。
打开前面创建的main.js文件,在环境初始化后面添加如下代码,实现匿名登录的功能。window.onload类似于小程序生命周期的onLoad生命周期函数,当包括样式、图像和其他资源的页面被全部加载时,window 对象上的 load 事件就会被触发。
window.onload = function(){ app.auth({ persistence: 'session' //在窗口关闭时清除身份验证状态 }) .anonymousAuthProvider() .signIn() //AnonymousAuthProvider.signIn() 匿名登录云开发 .then(() => { console.log("登录成功") //登录成功 }).catch(err => { console.log("登录失败",err) //登录失败 }) env: 'xly-xrlur' // 此处填入你的环境ID });
在WEB安全域名处把 localhost:5000
加入到安全域名的白名单中,这样这个域名下的页面才可以使用 SDK 访问到云开发的服务。如果你希望通过localhost的其他端口或域名地址来访问你的静态托管网站,你需要将这些它们都添加到安全域名的列表里面。
打开终端,我们先全局安装依赖包serve(注意前面说过的安装权限解决方案,Mac电脑命令前加sudo,Windows要用管理员身份打开终端):
npm install -g serve
然后在项目根目录(注意前面说过的cloudbaserc.js所在的目录即为根目录)执行以下命令运行serve,即可打开一个本地静态服务器:
npx serve
成功后就会显示服务开启,以及可以在本地电脑的浏览器访问的localhost端口地址,以及ip地址。在浏览器里输入http://localhost:5000/public/
就能打开index.html了。打开浏览器的开发者工具,在Console标签页看到登录成功的log,就表示匿名登录成功啦
建议使用Chrome浏览器,在index.html页面空白处鼠标右键点击”审查元素”(MacBook 为”检查”),我们就可以打开Chrome的开发者工具,在Console标签页我们就可以调试代码。
为了调试方便,我们可以不要关闭npx serve
的终端窗口,要执行其他任务可以重新开一个终端窗口。当我们更新public文件夹下的index.html、main.js文件时,直接刷新浏览器里的页面即可。
我们再开一个终端窗口,然后通过cd 命令进入到项目根目录,之后在终端输入以下命令,将public文件夹下面的所有文件上传到静态托管网站的文件夹内,比如上传到web文件夹到环境id为xly-xrlur
的环境里:
cloudbase hosting:deploy ./public web -e xly-xrlur
然后我们就可以在浏览器通过打开静态托管网站的二级域名/web
或你的域名/web
访问到这个页面了,同样可以使用浏览器的开发者工具的控制台查看页面的打印日志。
调用Web端SDK并不局限于云开发自带的静态托管服务,我们也可以使用Github Pages等其他静态托管服务,也可以是传统开发的web页,也就是说只要是web也引入sdk,并添加安全域名等之后,也能使用云开发的服务。
Web端云开发不仅可以调用你在小程序云开发里创建的云函数,我们也可以在本地创建云函数并部署到云开发环境里供Web端和小程序端来调用,也就是云开发环境里的云函数是Web端和小程序端共用的,小程序端怎么创建并部署云函数大家应该比较熟悉,web端使用vscode来创建并部署云函数和使用微信开发者工具是一样的道理。
我们可以在web端云开发项目根目录里的functions文件夹下面建和小程序云开发云函数根目录一样的文件夹表示为云函数目录,以及一样新建三个文件并copy里面的内容(每次创建一个云函数的时候都这么做),比如我们新建一个webtest的云函数,它的目录结构如下:
├── _gitignore├── functions // 云函数根目录│ └── app //app云函数目录│ └── index.js│ └── config.json │ └── package.json│ └── webtest //webtest云函数目录│ └── index.js //主体结构和小程序云开发云函数里的index.js一样│ └── config.json //copy小程序云开发云函数里的config.json│ └── package.json //copy小程序云开发云函数里的package.json├── public // 用于存放应用程序的静态文件│ └── index.html │ └── js│ └── main.js│ └── css│ └── style.css│ └── assert└── cloudbaserc.js // 项目配置文件
在index.js里我们可以输入以下代码,引入服务端sdk,初始化服务端的环境,以及在main函数里返回数据:
const tcb = require('@cloudbase/node-sdk')const app = tcb.init({ env: '你的环境ID'})const auth = app.auth()exports.main = async (event, context) => { const ip = auth.getClientIP() //获取客户端ip const { openId, //微信openId,非微信授权登录则空 appId, //微信appId,非微信授权登录则空 uid, //用户唯一ID customUserId //开发者自定义的用户唯一id,非自定义登录则空 } = auth.getUserInfo() return {"event对象":event, "context对象":context, "客户端ip":ip, "openId":openId, "appId":appId, "uid":uid, "customUserId":customUserId }}
web端创建的云函数这里,我们既可以只用 Web端SDK @cloudbase/node-sdk,也可以沿用小程序云开发的写法,使用wx-server-sdk,如果是跨端开发建议后者。
然后使用终端打开云函数目录,使用npm install
来安装依赖package.json的依赖之后,在cloudbaserc.js的配置文件的functions
里添加webtest云函数的配置,比如:
{ name: "webtest", timeout: 5, envVariables: {}, runtime: "Nodejs10.15", handler: "index.main"},
使用Cloudbase Cli工具将云函数部署上传到云端:
cloudbase functions:deploy webtest
这里有几个需要注意一下,小程序端云开发与Web端云开发的相同与不同之处:
@cloudbase/node-sdk
,而如果要在小程序端使用,你引入的wx-server-sdk
,@cloudbase/node-sdk是云开发的服务端sdk,wx-server-sdk是小程序云开发的SDK;所以最好是用变量tcb表示引入@cloudbase/node-sdk,变量cloud表示wx-server-sdk;未找到函数发布配置,是否使用默认配置(仅适用于 Node.js 云函数)
的提示在web端调用云函数,则是使用web端sdk,也就是tcb-js-sdk模块或前面引入tcb.js文件,我们可以在main.js文件里将函数的调用写到app.auth()的回调函数里,当页面加载时匿名用户登录后,就能调用云函数
window.onload= function(){ app.auth({ persistence: 'session' //在窗口关闭时清除身份验证状态 }) .anonymousAuthProvider() .signIn() .then(() => { app.callFunction({ name: 'webtest', data: { "name": "李东bbsky", "title": "杂役"} }) .then((res) => { console.log(res) }); }).catch(err => { console.log("登录失败",err) })
使用浏览器打开(或刷新页面)http://localhost:5000/public/index.html
,然后在浏览器开发者工具的控制台就能看到本地调用云函数的结果啦。我们再执行以下命令将public的静态页面更新到静态存储,再来打开二级域名/web
下的网页,在浏览器开发者工具的控制台也能调试代码啦:
cloudbase hosting:deploy ./public web -e xly-xrlur
通过打印我们可以了解到,web端上传的参数会在event对象里,且event对象会返回用户的userInfo,其中包含微信appId,而context对象则包含关于该云函数的一些信息。
有了web端SDK,我们就能调用云开发环境里的云函数,而云函数也是可以调用云函数、可以对数据库、云存储进行增删改查,还能使用云调用(需要openid的云函数例外),因此我们也可以沿用小程序云开发的云函数的用法。我们也要留意使用CLoudbase Cli触发云函数,以及web端触发云函数和后面使用云接入的方式获取到的云函数的信息会有所不同。
和小程序触发事件处理函数一样,我们既可以使用通过页面的生命周期函数,也可以通过点击的方式,比如在web页面的标签上绑定事件处理函数来触发事件处理函数,来对云函数、数据库、云存储进行一系列的操作。
比如我们可以在public文件夹下的index.html<body>
标签内输入以下代码,一个button标签里,绑定getData()事件处理程序,
<button onclick="getData()">点击获取数据</button>
然后在main.js里添加getData()事件处理函数,保存页面之后,点击button按钮就能触发事件处理程序,对数据发起请求,这里还是获取以前在小程序云开发创建的集合china:
function getData(){ const db = app.database(); const _ = db.command const $ = db.command.aggregate db.collection("china") .where({ gdp: _.gt(3000) }) .field({ _id:false, city: true, province: true, gdp:true }) .orderBy('gdp', 'desc') .skip(0) .limit(10) .get() .then(res => { console.log(res.data) }) .catch(err => { console.error(err) })}
无论是查询数据,还是对数据库的添加记录、删除数据以及指令、聚合等都和小程序云开发保持了很强的一致性,注意哦,数据库的实时推送也是和小程序端的用法一样。
值得注意的是当匿名用户往数据库里添加数据时,云数据库也会默认生成_openid的字段,这个openid是临时的,在前面已经说过它的机制啦。
和小程序云开发一样,要在前端上传文件,首先我们需要建一个上传文件的标签,在public文件夹下的index.html里再输入以下代码,我们只允许上传图片,并绑定
<input type="file" id="file" accept="image/*" onchange="uploadFile()">
然后我们继续在main.js里输入以下代码。被选择的文件以 HTMLInputElement.files 属性返回,它是一个包含一列 File 对象的 FileList 对象。FileList 的行为像一个数组,所以你可以检查 length 属性来获得已选择文件的数量。
function uploadFile() { const filetemp = document.getElementById("file").files[0] console.log("file对象",filetemp) //打印文件对象 const fileName = filetemp.name //从打印对象知道,这就是文件的名称 app.uploadFile({ filePath: filetemp, //本地文件 cloudPath: `tcb/${fileName}`, //云存储的路径 onUploadProgress: (progressEvent) => { //上传进度回调 let percentCompleted = Math.round( (progressEvent.loaded * 100) / progressEvent.total ); console.log('上传进度: ' + percentCompleted, progressEvent); } }, function (err, res) { console.log({err,res}) });}
注意,这个云存储的路径前面不要有
/
,也不能直接是根目录,否则会没有上传权限;尽管云存储可能并没有tcb这个文件夹,但是会自动创建。
在public文件夹下的index.html里再输入以下代码,我们写一个绑定了deleteFile事件的button按钮:
<button onclick="deleteFile()">删除文件</button>
然后我们继续在main.js里输入deleteFile事件处理函数,当点击按钮时删除云存储里指定fileID的文件:
async function deleteFile() { const result = await app.deleteFile({ fileList: ['cloud://xly-xrlur.786c-xly-xrlur-1300446086/tcb/WX20200401-144620@2x.png'] }) result.fileList.forEach(item => { if (item.code === 'SUCCESS') { alert("文件删除成功") } })}
云开发的登录还支持自定义登录、微信公众平台、微信开放平台登录,这个在后面会介绍。
云接入是云开发基于云函数之上为开发者提供的HTTP访问服务,开发者可以轻松使用 POST、PUT、GET、DELETE等方法通过 HTTP 请求访问到云开发环境内的全部资源,而不需要使用Web端 SDK。而且云接入天然支持跨域请求,将域名添加至Web 安全域名中,这个域名里的网页便可以跨域访问云接入。当使用云函数时,系统会自动预配TLS证书,因此我们可以通过安全连接调用云函数。
给云函数启用云接入的方式非常简单,我们可以使用腾讯云云开发网页控制台以及Cloudbase Cli命令两种方式。
打开腾讯云云开发网页控制台的云函数菜单,然后点击HTTP触发标签,开启HTTP触发,云接入就启动啦,云接入的默认域名就是https://{你的环境id}.service.tcloudbase.com/
。
在前面我们已经上传了一个webtest的云函数(其他云函数也都可以的哦),点击进入webtest云函数的管理页,然后点击右上角的编辑,在HTTP 触发路径里输入/webtest
(也可以是其他值),保存之后,在浏览器里打开以下链接(网页控制台直接就有链接)就可以访问云接入了:
https://{你的环境id}.service.tcloudbase.com/webtest
大家可以对比一下event、context对象与调用云函数返回的对象有什么不同。使用云接入调用云函数时,HTTP 请求会被转化为特殊的结构体,称之为集成请求,结构如下:
{ path: 'HTTP请求路径,如 /hello', httpMethod: 'HTTP请求方法,如 GET', headers: {HTTP请求头}, queryStringParameters: {HTTP请求的Query,键值对形式}, requestContext: {云开发相关信息}, body: 'HTTP请求体', isBase64Encoded: 'true or false,表示body是否为Base64编码'}
这里的queryStringParameters
是HTTP请求的Query,我们可以在链接里传入一些参数,比如在访问云接入的链接里加一些参数:
https://xly-xrlur.service.tcloudbase.com/webtest?name=bbsky&location=shenzhen
在前面我们已经了解到通过在终端输入cloudbase --help
可以了解到Cloudbase Cli有哪些命令(尽管cloudbase是全局的,建议大家在云开发项目的根目录执行),我们可以看到关于云接入的命令有:
service:switch [options] 开启/关闭 HTTP Service 服务 service:auth:switch [options] 开启/关闭 HTTP Service 服务访问鉴权 service:create [options] 创建 HTTP Service service:delete [options] 删除 HTTP Service service:list [options] 获取 HTTP Service 列表 service:domain:bind [options] <domain> 绑定自定义 HTTP Service 域名 service:domain:unbind [options] <domain> 解绑自定义 HTTP Service 域名 service:domain:list [options] 查询自定义 HTTP Service 域名
由这些命令,我们可以在终端里输入以下命令来开启和关闭云接入,比如开启云开发环境id为xly-xrlur
的云接入服务:
cloudbase service:switch -e xly-xrlur
而我们可以执行以下命令创建一条云接入路由,路径为 /webtest,指向的云函数为 webtest:
cloudbase service:create -p /webtest -f webtest
除了可以使用浏览器里打开云接入的链接,我们还可以使用cURL命令行来调用云接入,比如我们可以在终端输入以下命令:
curl https://xly-xrlur.service.tcloudbase.com/webtest
我们有必要重新梳理一下一些参数的传入与获取相关的知识,我们往云函数里传入参数的方式有调用云函数时传入参数、访问云接入链接时传入参数以及配置云函数环境时添加配置信息,那这三种方法又是如何获取这些参数呢?(还有一种是通过require模块、文件,这里就不多做介绍了)
调用云函数传入参数
在小程序端、Web端以及云函数端,我们可以通过callFunction的接口(小程序端为wx.callFunction、云函数端为cloud.callFunction、web端为app.callFunction)来调用云函数,并传入参数:
wx.cloud.callFunction({ name: 'webtest', //被调用的云函数的名称 data: { userInfo:{ name:"李东bbsky" }, id:"20200420001" }})
那我们应该如何获取使用调用云函数时传递的参数呢?我们可以从event对象里拷贝:
const {userInfo,id} = event
访问云接入链接时传入参数
我们通过访问云接入链接或axios等进行HTTP 请求的方式也能向云函数里传递参数,这个参数会在queryStringParameters对象里,我们可以通过
const {name,title} = event.queryStringParameters
云函数配置信息传入参数
我们可以在云开发控制台对云函数进行一些参数的配置,也就是新增环境变量的字段,这个参数在云函数的获取方式如下:
const {school,name} = process.env
有了云接入的概念,我们可以把云函数分为两类,在前面的章节云函数主要用于调用云函数、处理数据库、云存储云调用,我们可以称之为后台函数,而HTTP函数则是我们可以通过标准HTTP请求来调用的。
参考上节Web端云开发的内容,在functions文件夹里新建一个云函数比如backfunction,然后在index.js里输入以下代码,注意有一个dbName的参数会由HTTP请求传入:
const tcb = require('@cloudbase/node-sdk')tcb.init({ env: "xly-xrlur"})const db = tcb.database()const _ = db.commandexports.main = (event,context) =>{ const {dbName} = event.queryStringParameters //注意queryStringParameters的来源 return db.collection(dbName) .where({ gdp: _.gt(3000) }) .get()}
然后安装该云函数的依赖,将云函数的代码部署上传到云端,并开启云接入以及设置路由,具体操作前面都有介绍,然后我们在浏览器里打开如下链接,就能看到云函数返回的数据了。
http://xly-xrlur.service.tcloudbase.com/backfunction?dbName=china
在小程序端我们知道获取获取可以使用wx.request()
接口,而在web端我们则可以使用axios,在静态托管的html页面,比如打开之前public文件夹里的index.html,输入以下代码,然后打开链接,然后在浏览器的控制台可以获取到的数据啦:
<script src="https://unpkg.com/axios/dist/axios.min.js" rel="external nofollow" ></script><script> const url ="https://xly-xrlur.service.tcloudbase.com/backfunction?dbName=china" axios.get(url).then(res => { console.log(res) console.log(res.data) }).catch(err => { console.log(err) })</script>
云函数可以返回String、Object、Number等类型的数据,还能返回集成响应,云接入会将返回值转化成正常的HTTP响应,我们接下来使用云接入来返回一些静态资源文件。
使用VS Code在functions文件夹里新建一个云函数,比如sendhtml,以及assets文件夹,里面存放我们要返回的HTML文件,结构如下:
├── sendhtml //sendhtml云函数目录│ └── assets│ └── index.html│ └── index.js│ └── config.json│ └── package.json
然后在index.js里输入以下代码,读取云函数目录assets文件夹里index.html,并返回HTML,这个云函数就和以往的云函数有很大的不同啦:
const tcb = require('@cloudbase/node-sdk')tcb.init({ env: "xly-xrlur"})const fs = require('fs')const path = require('path')exports.main = async (event,context) =>{ //path.resolve() 方法将路径或路径片段的序列解析为绝对路径 const html = fs.readFileSync(path.resolve(__dirname, './assets/index.html'), { encoding: 'utf-8' }) return { statusCode: 200, headers: { 'content-type': 'text/html' }, body: html }}
在assets文件夹里index.html可以输入一些html代码,然后将sendhtml云函数部署上传并开启云接入,以及设置路由如/sendhtml
之后,用浏览器打开云接入的地址,返回的HTML就被浏览器自动解析了。
除了html文件,还能返回其他类型的文件,在响应中,Content-Type 实体头部用于指示资源的MIME媒体类型,告诉Web端返回的内容类型。媒体类型有很多,比如:
类型 | 描述 | 典型示例 |
---|---|---|
text | 普通文本类型 | text/plain , text/html , text/css , text/javascript |
image | 图像类型 | image/gif , image/png , image/jpeg , image/bmp , image/webp , image/x-icon , image/vnd.microsoft.icon |
audio | 音频文件 | audio/midi , audio/mpeg, audio/webm, audio/ogg, audio/wav |
video | 视频文件 | video/webm , video/ogg |
application | 二进制数据 |
将 content-type 分别设置为 application/javascript、text/css,即可在 body 中返回 JavaScript 文件和css文件,html、js、css文件是静态网站的核心文件,都是可以返回的,这样就用集成响应返回一个完整的静态网站啦。注意用集成请求返回的静态网站和静态托管之间的区别哦。
将content-type 设置为 image/png以及将 isBase64Encoded 设置为 true,就能返回图片的二进制文件,这个也可以和云存储结合起来使用。比如我们可以写如下云函数,先下载云存储里面的图片,将Buffer转成base64格式的图片,然后返回,这样我们就能通过云接入的链接查看到图片了:
const cloud = require('wx-server-sdk')cloud.init({ env: "xly-xrlur"})exports.main = async (event,context) =>{ const fileID = 'cloud://xly-xrlur.786c-xly-xrlur-1300446086/1572315793634-953.png' const res = await cloud.downloadFile({ fileID: fileID, }) const buffer = res.fileContent const base64img = await buffer.toString('base64') return { isBase64Encoded: true, statusCode: 200, headers: { 'content-type': 'image/png' }, body: base64img }}
只要在本地或服务器安装了Node环境,使用 require('http')
引入http模块,就能用http.createServer()方法创建一个服务器。比如我们使用VS Code新建一个app.js的文件(保存在电脑的到哪里都行),然后输入以下代码:
const http = require('http'); //引入内置的http模块const hostname = '127.0.0.1';const port = 3000;const requestHandler = (req, res) => { // res.statusCode = 200; res.setHeader('Content-Type', 'text/html;charset=utf-8'); res.end('Node Server创建成功啦'); console.log(`请求链接是:${req.url},请求方法是:${req.method}`)}const server = http.createServer(requestHandler) //使用 http.createServer() 方法创建服务器server.listen(port, hostname, () => { //listen为createServer返回对象的方法,用于指定HTTP服务器监听的端口号 console.log(`通过此链接访问服务器 http://${hostname}:${port}/`);});
保存后,在VS Code里右键该文件选择在终端中打开,然后在VS Code的终端中输入以下代码按Enter执行:
node app.js
在浏览器里输入http://127.0.0.1:3000/
,就能访问我们创建好的服务器啦,页面也会显示Node Server创建成功啦
,可以说使用Nodejs创建一个HTTP服务器非常容易。
注意requestHandler有两个参数,req是request请求对象,调用request对象就可以拿到所有HTTP请求的信息,比如request.url获取请求路径;res是response响应对象,调用response对象的方法,就可以把HTTP响应返回给浏览器了。当用户每访问一次(比如刷新一下页面)就会触发requestHandler函数,我们也能在终端看到打印的日志。
借助于fs 模块: 可以对文件目录进行创建、删除、查询以及文件的读取和写入以及url模块: 可以处理与解析 URL,我们可以把服务器里的文件发送给客户端。比如我们可以修改一下app.js的代码为如下:
var http = require('http');var url = require('url');var fs = require('fs');http.createServer( (req, res) => { //这里把上面的requestHandler给整到一起了,注意一下 const requrl = url.parse(req.url, true); const filename = "." + requrl.pathname; //这里的.表示的是相对路径 fs.readFile(filename, function(err, data) { if (err) { res.writeHead(404, {'Content-Type': 'text/html;charset=utf-8'}); return res.end("404 页面没有找到"); } res.writeHead(200, {'Content-Type': 'text/html;charset=utf-8'}); res.write(data); console.log(`请求链接是:${req.url},请求方法是:${req.method}`); return res.end(); });}).listen(3000);
然后再在终端执行node app.js
(如果你之前的node server还在执行,你可以连续按两次Ctrl+C来终止服务器,再来执行node app.js)。放一个文件比如tcb.jpg到app.js的相同目录里,在浏览器里输入如下地址(也就是ip地址+文件的路径)看看:
http://127.0.0.1:3000/tcb.jpg
本地调试时,服务器和客户端都是同一条电脑,我们使用浏览器打开
http://127.0.0.1:3000/
就能通过浏览器访问到服务器里的文件。
那云函数是否可以搭建一个Nodejs的服务器呢,结合云接入和云函数,可以很轻松地托管Nodejs服务端程序。这里就要使用到serverless-http的模块。我们可以使用serverless-http把集成请求转化为 Node.js Server 能接收的 IncommingMessage,同时把返回的 ServerResponse 转化为集成请求。
使用VS Code在functions文件夹里新建一个云函数,比如server,和小程序云开发的云函数一样新建3个文件,以及assets文件夹,里面存放我们要返回的HTML文件、图片等各种静态资源,结构如下:
├── server //server云函数目录│ └── assets│ └── index.html│ └── demo.jpg│ └── index.js│ └── config.json │ └── package.json
并在package.json里添加serverless-http依赖,
"dependencies": { "wx-server-sdk": "latest", "serverless-http": "latest"}
然后再在index.js里输入以下代码,我们把之前Nodejs Server里的代码Copy过来,注意与普通云函数和集成请求写法的不同。
const cloud = require('wx-server-sdk')const url = require('url')const fs = require('fs')cloud.init({ env: cloud.DYNAMIC_CURRENT_ENV})const serverless = require('serverless-http');const requestHandler = (req, res) => { // const requrl = url.parse(req.url, true); const filename = "." + requrl.pathname; //这里的.表示的是相对路径 fs.readFile(filename, function(err, data) { if (err) { res.writeHead(404, {'Content-Type': 'text/html;charset=utf-8'}); return res.end("404 页面没有找到"); } res.writeHead(200, {'Content-Type': 'text/html;charset=utf-8'}); res.write(data); console.log(`请求链接是:${req.url},请求方法是:${req.method}`); return res.end(); });}exports.main = serverless(requestHandler);
终端进入云函数目录server文件夹,使用npm install
安装依赖,然后再回退到项目根目录使用CLoudbase Cli命令将云函数部署到云端并创建server云函数云接入的路由,再用浏览器或cURL命令访问云接入链接:
cloudbase functions:deploy servercloudbase service:create -p /server -f server
这样我们就能通过云接入的链接来访问托管的服务器里面的资源了,只要是云函数目录里面的资源就都能访问,云函数就”化身“成了一个服务器了。比较一下集成请求返回html与托管Nodejs Server的不同。
https://xly-xrlur.service.tcloudbase.com/server/assets/index.html
Koa和Express都是基于Nodejs平台的web应用开发框架,可以对HTTP Request对象和HTTP Response对象进行封装处理,以及生命周期的维护,路由、视图的处理等等。云接入和云函数可以托管Nodejs Server,它也支持托管Koa和Express,下面就以Koa为例。
我们还是以server云函数为例,首先在server云函数的package.json里添加koa依赖,然后将index.js改为如下代码,仍然读取云函数目录下的assets文件里的index.html文件:
const cloud = require('wx-server-sdk')const fs = require('fs')cloud.init({ env: cloud.DYNAMIC_CURRENT_ENV})const serverless = require('serverless-http');const Koa = require('koa');const app = new Koa();app.use(async ctx => {//ctx是由koa传入的封装了request和response的变量,通过它可以访问request和response ctx.response.type = 'text/html;charset=utf-8'; //ctx.response就是Node的response对象 ctx.response.body = fs.createReadStream('./assets/index.html');})exports.main = serverless(app);
进入云函数目录下载云函数的依赖之后,回退到项目根目录部署上传server云函数,再使用浏览器打开server云函数的云接入地址就能看到解析好的index.html了。
Koa 的Context 上下文将 node 的 request 和 response 对象封装到单个对象中,为编写 Web 应用程序和 API 提供了许多有用的方法。为方便起见许多上下文的访问器和方法直接委托给它们的 ctx.request或 ctx.response。ctx.response就是Node的response对象,ctx.request就是Node的request对象。
使用Koa也能让云函数+云接入作为文件服务器,把云函数目录下的文件都返回给浏览器,我们将server云函数的代码修改为如下,这个功能和前面的托管Nodejs Server类似:
const cloud = require('wx-server-sdk')const fs = require('fs')const url = require('url')cloud.init({ env: cloud.DYNAMIC_CURRENT_ENV})const serverless = require('serverless-http');const Koa = require('koa');const app = new Koa();app.use( async ( ctx ) => { const requrl = url.parse(ctx.request.url, true); const filename = "." + requrl.pathname; ctx.response.type = 'text/html;charset=utf-8'; ctx.body =fs.createReadStream(filename)})exports.main = serverless(app);
将Server云函数部署上传,和前面一样我们可以在浏览器里输入以下地址来访问server云函数目录里的assets文件夹里的index.html页面:
https://xly-xrlur.service.tcloudbase.com/server/assets/index.html
Koa原生路由通过解析request IncomingMessage 的 url 属性, 利用 if...else 来判断路径返回不同的结果,但是如果路由过多, if...else 的分支也会越庞大, 不利于代码的维护,具体的案例这里就不多写了,下面直接用Koa-router解决方案。
尽管我们可以依靠ctx.request.url这种比较原生的方式来手动处理路由,但是这会写很多处理代码,这时候就需要对应的路由中间件对路由进行控制,这里推荐使用Koa-router,以及推荐使用koa-bodyparser中间件。对于POST请求的处理,koa-bodyparser可以把ctx的formData数据解析到ctx.request.body中。
我们仍然以server云函数为例,在package.json添加如下依赖,并进入server云函数目录下载这些依赖:
"dependencies": { "wx-server-sdk": "latest", "serverless-http": "latest", "koa":"latest", "koa-bodyparser":"latest", "koa-router":"latest"}
然后将server云函数修改为如下代码,然后部署上传server云函数,然后访问云接入的地址,注意打开的页面里的首页和关于我们是可以点击的,会跳转到koa-router指定的路由,并返回相应的内容:
const fs = require('fs')const cloud = require('wx-server-sdk')cloud.init({ env: cloud.DYNAMIC_CURRENT_ENV})const serverless = require('serverless-http');const Koa = require('koa');const bodyParser = require('koa-bodyparser')const app = new Koa();const Router = require('koa-router')const router = new Router()app.use(bodyParser())router.get('/',async (ctx) => { //注意这里的路径哈,server为云接入的路由,在前面我们把server云函数的路由设置为server let html = ` <ul> <li><a href="/server/home">首页</a></li> <li><a href="/server/about">关于我们</a></li> </ul> ctx.body = html})router.get('/home',async (ctx) => { ctx.response.type = 'text/html;charset=utf-8'; ctx.response.body = fs.createReadStream('./assets/index.html');})router.get('/about', async ( ctx )=>{ ctx.body = '欢迎您的关注,网页还在建设当中'})app.use(router.routes()) // 添加路由中间件app.use(router.allowedMethods()) // 对请求进行一些限制处理exports.main = serverless(app);
当我们打开云接入地址/home
时,返回的是云函数目录下的assets文件夹里的index.html页面,而事实上云函数目录并没有home这个文件夹,按照之前的方式打开云接入地址/assets/index.html
也打不开了,这个就是路由重定向。
这个案例仅仅只是使用了GET方法来进行注册路由,我们还可以使用POST、DELETE、PUT、DEL、ALL等方法。而koa-router路由也还支持变量等,这里就不展开啦。
有了路由中间件,我们就能把最常见的GET、POST请求都集成在一个云函数里,比如数据库、云存储的增删改查,从而将该云函数作为API服务器,向前端返回所需数据和执行指定的操作。在小程序端我们可以使用wx.request()接口发起HTTPS网络请求(值得注意的是小程序端需要将云接入的域名添加到小程序里的域名白名单内),在Web端则可以通过axios。
获取数据库里的数据
比如我们用Koa router可以添加一个路由getData,用来返回云数据库查询到的数据结果:
router.get('/getData',async (ctx) => { ctx.body = await db.collection('china').where({ gdp: _.gt(3000) }) .field({ _id:false, city: true, province: true, gdp:true }) .orderBy('gdp', 'desc') .skip(0) .limit(10) .get()})
在小程序端获取返回数据:
wx.request({ url: 'https://xly-xrlur.service.tcloudbase.com/server/getData', success (res) { console.log(res.data.data) }})
在web端获取返回数据:
const url ="https://xly-xrlur.service.tcloudbase.com/server/getData"axios.get(url).then(res => { console.log(res.data)}).catch(err => { console.log(err)})
往数据库里添加数据库
我们还可以使用Koa router提供POST接口,对前端传来的参数、数据进行处理,比如我们可以往数据库里添加数据,只需要注意Koa是如何获取参数和数据的即可。
router.post('/addData',async (ctx) => { const {userInfo} = await ctx.request.body const addUser = await db.collection('china').add({ data:userInfo }) ctx.body = addUser })
小程序端发起POST请求的代码如下:
wx.request({ url: 'https://xly-xrlur.service.tcloudbase.com/server/addData', method:"POST", data:{ userInfo:{ Name: '腾讯云云开发', enName: 'CloudBase' } }, success (res) { console.log(res) }})
在web端发起POST请求的代码如下:
async function addData(){ axios.post('https://xly-xrlur.service.tcloudbase.com/server/addData', { userInfo:{ Name: '腾讯云云开发', enName: 'CloudBase' } }) .then(function (response) { console.log(response); }) .catch(function (error) { console.log(error); });}
我们还可以在小程序端或Web端调用一下server云函数,看看返回的数据对象和以往的有什么不同?大致了解一下后台函数与HTTP函数的不同。
匿名登录可以让用户在无需注册登录的情况下在短期内使用数据库、云存储以及调用云函数,但是大多数的应用是需要获取用户的身份才能更长期更安全将数据存储在云端,并且可以获取跨设备跨端的一致性的体验。在Web端我们可以使用自定义登录与匿名登录相结合;在微信小程序端,借助于云开发,无需额外操作便可免鉴权登录(实际上就是openId),要实现跨端一致,就需要考虑免鉴权登录与自定义登录相结合。
微信小程序云开发有一套免鉴权的账号体系openid,我们可以基于这套账号体系结合自定义登录实现Web端和小程序端的跨端登录和权限控制。
为了学习的方便,我们先假定(或者模拟)用户已经使用过我们的小程序并留有userId和openid,打开小程序云开发数据库在数据库里新建一个集合,集合的名称为users,在users里新建一个记录,比如:
{ _openid:"oUL-m5FuRmuVmxvbYOGnXbnEDsn8", userId:"lidongbbsky",}
由于小程序使用的是云开发,用户无需注册登录就可以调用云开发环境里的资源,那当这个用户到Web网页上时,应该怎么样才能登录以前的账号呢?只需要在网页上输入userId即可登录。
直接输入上面这个userId不输入密码就可以登录,这是一个不安全的做法,不过安全的做法也不一定需要密码,我们可以使用云函数每隔十几秒动态刷新userId来取代用户名+密码这种传统方式,比如userId为openid的后三位+只有10几秒生命周期的动态三位数(有点类似于短信的动态验证码),而用户userId的获取只能登录到小程序来获取,这样用户只需要输入6位数,既方便且安全。当然你也可以用其他方式来生成userId。
通过数据库,我们把userId和小程序的唯一openid关联到了一起,那在web网页上又是怎样实现userId的登录呢?又是如何保证登录的安全性的呢?
打开腾讯云云开发网页控制台,在【环境】-【环境设置】-【登录方式】,单击私钥下载,私钥是一份 JSON 格式的数据,里面包含private_key_id
和private_key
。接下来我们会用云函数把openid生成唯一用户ID(称之为customUserId)结合这个私钥文件计算出云开发的自定义登录凭证ticket,最后使用ticket登录。
然后使用VS Code新建一个云函数比如weblogin云函数专门用来处理网页的登录,将私钥json文件的名称自定义一下,比如tcb_custom_login.json保存到与云函数的目录里。
├── weblogin //weblogin云函数目录│ └── index.js│ └── config.json│ └── package.json│ └── tcb_custom_login.json //下载的私钥json文件
然后再在index.js里输入如下代码,创建一个生成ticket的服务,代码的逻辑如下:
const tcb = require('@cloudbase/node-sdk')const app = tcb.init({ env: 'xly-xrlur', credentials: require('./tcb_CustomLoginKeys.json')})const db = tcb.database();exports.main = async (event, context) => { const userId = event.queryStringParameters.userId //从web端传入的userId try{ if( userId != null){ //如果web端传入的userId非空,就从数据库查询是否存在该userId const users = (await db.collection('users').where({ userId:userId }).get()).data if(users.length != 0){ //当数据库存在该userId时,users为一个数组,数组长度不为0 //使用用户的openid为customUserId来生成ticket,因为openid有一个-连接符,把它给替换掉 const customUserId = await (users[0]._openid).replace('-','') const ticket = app.auth().createTicket(customUserId, { refresh: 10 * 60 * 1000 // 每十分钟刷新一次登录态, 默认为一小时 }); return { statusCode: 200, headers: { 'content-type': 'application/json', 'Access-Control-Allow-Origin':'*', 'Access-Control-Allow-Methods':'*',/= 'Access-Control-Allow-Headers':'Content-Type' }, body: ticket } } } }catch(err){ console.log(err) }}
将weblogin云函数部署上传之后,然后开启云接入(HTTP触发)并创建路由比如/weblogin,我们可以在浏览器里输入以下地址(也就是在weblogin云接入里传入参数userId的值为lidongbbsky)获取到生成的ticket:
http://xly-xrlur.service.tcloudbase.com/weblogin?userId=lidongbbsky
我们已经使用云函数生成了一个ticket,那前端又如何根据这个ticket来登录呢?我们还是使用axios进行HTTP请求,所以在我们的前端页面,比如public文件夹下的index.html里先引入axios
<script src="https://imgcache.qq.com/qcloud/tcbjs/1.5.1/tcb.js" rel="external nofollow" ></script><script src="https://unpkg.com/axios/dist/axios.min.js" rel="external nofollow" ></script><script src="./js/main.js"></script>
然后再在main.js里的页面生命周期函数window.onload= function(){//生命周期函数}
里输入以下代码,首先返回用户的登录态LoginState来判断用户是否已经登录,如果用户没有登录,则发起HTTP请求,获取云接入返回的ticket,然后使用auth.customAuthProvider().signIn(ticket)
用自定义登录凭证ticket来登录云开发:
const auth = app.auth({ persistence: 'session' //在窗口关闭时清除身份验证状态})async function login(){ const loginState = app.auth().hasLoginState(); if(!loginState){ const url ="https://xly-xrlur.service.tcloudbase.com/weblogin" axios.get(url,{ userId:"lidongbbsky" }) .then(res => { auth.customAuthProvider() .signIn(res.data) .then(() => { console.log("登录成功") //登录成功后,就可以操作云开发环境里的各种资源啦 }) .catch(err => { console.log("登录失败",err) }); }).catch(err => { console.log(err) }) }else{ console.log("您已经登录啦") }}login()
当我们在web端登录了之后,web端用户也会一个类似于小程序的openid(但是不相同),那我们要如何获取到这个openid呢?和小程序用户一样,当我们往云存储和数据库里添加数据时,就会自动添加一个openid的字段,里面的值就是web端openid(uid)。
那除此之外,我们是否能够像小程序云开发一样在云函数里获取到web端用户的openid呢?这个其实我们已经在前面web端云开发里的webtest云函数就已经写了方法啦,这里再单独拿出来:
const tcb = require('@cloudbase/node-sdk')const app = tcb.init({ env: 'xly-xrlur'})const auth = app.auth()exports.main = async (event, context) => { const {openId, uid, customUserId } = auth.getUserInfo() return {openId, uid, customUserId }}
这里的uid就是web端用户的openid,而openId则是微信用户(小程序)的openid,customUserId就是前面我们用于生成ticket的customUserId。
当用户在web端使用customUserId自定义登录之后也会有一个不同于小程序账户体系的openid,这个openid是用户的uid,customUserId和uid是对应的,只要customUserId不变,web端用户的openid(uid)也不会变更。也就是说由于我们的customUserId是根据小程序的openid生成的唯一且不随设备不随时间变更而变更的,那么web端的openid(uid)也不会因为设备和时间而变更。
尽管用户在web端传入的userId是可以动态刷新的,但是在云函数里我们并没有把这个可以动态刷新的userId作为customUserId,所以不必担心userId的不同,web端用户在云开发的openid会有所变化;ticket也是可以动态刷新的,但是这只是加强账号的安全性,并不会影响web端用户的openid的唯一性。
web端用户的openid(也就是uid)的唯一性,且不随设备和时间的变更而变更的永久性是我们可以进行跨设备操作的基础。不过值得一提的是,即使是相同用户web端的openid和小程序的openid虽然有关联,但是两者之间是不同的账号体系,如果我们要把小程序和web端的账号打通则需要进行一定的处理。
即使是相同的用户,web端和小程序端的openid都是唯一且永久的,而且都还不同,那如果让相同的用户在Web端和小程序端有一致性的体验和相同的权限呢?我们知道云开发的权限是非常依赖openid的,无论是数据库的增删改查,还是云存储的增删改查,都是根据openid来判断用户的权限的。账号体系打通可能比较容易,但是权限又该如何控制呢?
比如用户在小程序端创建了个人资料,发表了一篇文章,我们要打通账号,就要能让该用户在web端可以查看且能修改他的个人资料或文章数据,比如下面是users集合里的一条记录:
{ _openid:"oUL-m5FuRmuVmxvbYOGnXbnEDsn8", userId:"lidongbbsky", userInfo:{ name:"李东bbsky", title:"杂役" }, posts:[{ title:"为什么说云开发值得普及?", content:"<h3>学习门槛特别低</h3><p>可以说云开发是最容易上手且最容易出成果的编程方向了</p>" }]}
当我们把该集合设置为所有人可读,仅创建者可读写
时,用户在小程序端对属于自己的记录可读可写,但是当该用户在web端时,他只能读不能写,除非使用云函数,先在数据库里查询到该用户的openid(如果你把userId设计成动态刷新的话),再进行数据库和存储的增删改查,也就是用户对数据库和云存储的所有操作都需要经过云函数且都需要先查询用户在小程序端的openid,功能虽然可以实现,但是对web端并不是很友好,一是多了一次查询,二是不能在web端直接进行写操作。
如果想要不需要借助于云函数的情况下,让web端的用户能够更加方便的和小程序端的用户权限打通,则需要借助于安全规则,比如仅创建者可读写
的安全规则是:
{ "read": "auth.openid == doc._openid", "write": "auth.openid == doc._openid"}
这个安全规则让小程序用户的openid与记录的_openid字段的值相同时,就有了读写权限。也就是auth.openid
是小程序用户免登录之后的openid。那如何让web端用户也有一样的权限呢?我们可以给user的每一个记录都新增一个webuid的字段,用来记录web端用户的openid(uid)以及一个wxuid的字段,用来记录小程序端的openid。让权限互通,这里会有四种情况:
auth.openid == doc._openid
,那小程序用户对这条记录有读写权限;auth.uid == doc.webuid
,这样webd端用户就能对记录有读写权限;auth.uid == doc._openid
,那web端用户对这条记录有读写权限;auth.openid == doc.wxuid
,这样webd端用户就能对记录有读写权限;所以,我们可以将安全规则设置为如下,无论记录是在小程序端创建还是web端创建,用户都拥有跨端的可读写权限:
{ "read": "auth.openid == doc._openid || auth.uid == doc.webuid || auth.uid == doc._openid || auth.openid == doc.wxuid", "write": "auth.openid == doc._openid || auth.uid == doc.webuid || auth.uid == doc._openid || auth.openid == doc.wxuid",}
之所以这么复杂,是因为web端创建记录时的_openid是用户的uid,小程序端创建记录时的_openid是微信生态的_openid,而要做到两套体系容易,则需要一个字段来做过渡,我们也可以只用一个字段,比如只用一个uid的字段,当记录_openid是小程序的_openid时,uid就记录web端用户的uid;当记录_openid是web端用户的uid时,uid就记录该用户在小程序的openid,安全规则就可以写为:
{ "read": "auth.openid == doc._openid || auth.uid == doc.uid || auth.uid == doc._openid || auth.openid == doc.uid", "write": "auth.openid == doc._openid || auth.uid == doc.uid || auth.uid == doc._openid || auth.openid == doc.uid"}