社区/学习指南/小程序云开发学习指南

存储、数组、对象

在云开发能力章节我们了解到小程序端和服务端都可以上传文件到云存储,不过在实际开发中云存储里的文件链接需要被记录在数据库里才方便调用。接下来我们就来介绍云存储文件的增删改查是如何与数据库的增删改查结合在一起的。在云数据库入门章节我们所涉及到的数据库里数据类型还非常简单,在这一章里我们会来介绍如何操作数据库的数组和对象等复杂数据类型的增删改查。

云存储与数据库的关系

不经过数据库直接把文件上传到云存储里,这样文件的上传、删除、修改、查询是无法和具体的业务对应的,比如文章商品的配图、表单图片附件的添加与删除,都需要图片等资源能够与文章、商品、表单的 ID 能够一一对应才能进行管理(在数据库里才能对应),而这些文章、商品、表单又可以通过数据库与用户的 ID、其他业务联系起来,可见数据库在云存储的管理上扮演着极其重要的角色。

数据库的设计与结构

和 Excel 表、关系型数据库(如 MySQL)以行和列、多表关系来设计表结构不同的是,云开发的数据库是基于文档的。我们可以在一个记录里嵌套多层数组和对象,把每个文档所需要的数据都嵌入到一个文档里,而不是分散到多个不同的集合。

比如我们想做一个网盘小程序,用来记录用户信息,以及创建的相册、文件夹,这里相册和文件夹因为可以创建很多个,所以它是一个数组;而每一个相册对象和文件夹对象里都可以存储一个照片列表和文件列表,我们发现在云开发数据库里一个元素的值是数组,数组里又嵌套对象,对象里又有元素是数组是非常常见的事情。

以下是网盘小程序的数据库设计,包含了一个用户的信息,上传的所有文件和照片等信息:

{
  "_id": "自动生成的ID",
  "_openid": "用户在当前小程序的openid",
  "nickName": "用户的昵称",
  "avatarUrl": "用户的头像链接",
  "albums": [
    {
      "albumName": "相册名称",
      "coverURL": "相册封面地址",
      "photos": [
        {
          "comments": "照片备注",
          "fileID": "照片的地址"
        }
      ]
    }
  ],
  "folders": [
    {
      "folderName": "文件夹名称",
      "files": [
        {
          "name": "文件名称",
          "fileID": "文件的地址",
          "comments": "文件备注"
        }
      ]
    }
  ]
}

如果是用关系型数据库,就会建 user 表来存储用户信息,albums 表存储相册信息,folders 表存储文件夹信息,photos 表存储照片信息,files 表存储文件信息,相信大家可以通过这个案例对云数据库是面向文档的有一个大致的了解。

当然云开发的数据库也是可以把数据分散到不同集合的,需要视不同的情况而定,在后面章节我们会介绍。这种将每个文档所需的数据都嵌入到一个文档内部的做法,我们称之为反范式化(denormalization),将数据分散到多个不同的集合,不同集合之间相互引用称之为范式化(normalization),也就是说反范式化文档里包含子文档,而范式化呢,文档的子文档则是存储在另一个集合之中。

fileID 是存储与数据库的纽带

从上面可以看出,云存储与数据库就是通过 fileID 来取得联系的,数据库只记录文件在云存储的 fileID,我们可以访问数据库相应的 fileID 属性进行记录的增删改查操作,与此同时调用云存储的上传文件、下载文件、删除文件等 API,这样云存储就被数据库给管理起来了。

打开云开发技术文档里云存储的所有 API,如上传文件 uploadFile、下载文件 downloadFile、删除文件 deleteFile、用云文件 ID 换取真实链接 getTempFileURL,我们发现这些 API 始终是围绕 fileID 来展开的,要么 fileID 是 success 回调返回的对象,要么 fileID 是 API 必备的属性。

建立用户与数据的关系

openid 与云开发

在前面我们已经了解到,用户在小程序里有着独一无二的 openid,用 openid 完全可以区分用户;使用云开发时用户在小程序端上传文件到云存储,这个 openid 会被记录在文件信息里;添加数据到数据库这个 openid 会被保存在_openid 的字段里(也就是说我们除了可以用云函数如前面的 login 来获取用户的 openid,还可以通过数据库的_openid 字段来获取 openid);而且我们在小程序端查询数据时(查询时改、删、更新等的前提),都会默认有一个 where({_openid:当前用户的 openid})的条件,限制了用户 write 写(改、删、更新)的权限。

_id 与云开发

当用户在小程序端往数据库用 Collection.add 添加记录 document 时,会自动给该记录生成_id,同时也会创建一个_openid,_id 和_openid 由于都是独一无二的,只要我们获取每个用户创建的记录_id,也就能同时确定这个用户的 openid。

判断用户是否存在并创建记录

打开云开发控制台的数据库标签,新建一个 clouddisk 的集合,并修改它的权限为为“所有人可读,仅创建者可读写”(或使用安全规则)。使用开发者工具新建一个 folder 的页面,然后在 folder.js 的页面生命周期函数 onLoad 里输入以下代码:

this.checkUser()

this 调用自定义函数,开发者可以添加任意的函数或数据到 Object 参数中,在页面的函数中用 this 可以访问

然后再在 Page()对象里输入以下代码,代码的意思是如果 clouddisk 里没有用户创建的数据,那就在 clouddisk 里新增一条记录;如果有数据,就返回数据:

  async checkUser() {
    //获取clouddisk是否有当前用户的数据,注意这里默认带了一个where({_openid:"当前用户的openid"})的条件
    const userData = await db.collection('clouddisk').get()
    console.log("当前用户的数据对象",userData)

    //如果当前用户的数据data数组的长度为0,说明数据库里没有当前用户的数据
    if(userData.data.length === 0){
      //没有当前用户的数据,那就新建一个数据框架,其中_id和_openid会自动生成
      return await db.collection('clouddisk').add({
        data:{
          //nickName和avatarUrl可以通过getUserInfo来获取,这里不多介绍
          "nickName": "",
          "avatarUrl": "",
          "albums": [ ],
          "folders": [ ]
        }
      })
    }else{
      this.setData({
        userData
      })
      console.log('用户数据',userData)
    }
  },

一个用户只能创建一条记录,如果是开一个用户可以创建多条记录…

预先搭好文档的数据框架方便我们在后面以 update 的方式来更新数据。

async/await 的使用说明

async 是“异步”的简写,async 用于申明一个 function 是异步的,而 await 用于等待一个异步方法执行完成,await 只能出现在 async 函数中。await 在 async 函数中才会有效。假设一个业务需要分步完成,每个步骤都是异步的,而且依赖上一步的执行结果,甚至依赖之前每一步的结果,就可以使用 Async Await 来完成

小程序端现在完全支持 async/await 的写法,不过需要在开发者工具-详情-本地设置,勾选增强编译才行,否则会报以下错误。

Uncaught ReferenceError: regeneratorRuntime is not defined

async 函数返回值是 Promise 对象, async 函数内部 return 返回的值。会成为 then 方法回调函数的参数。如果 async 函数内部抛出异常,则会导致返回的 Promise 对象状态变为 reject 状态。抛出的错误而会被 catch 方法回调函数接收到。async 函数返回的 Promise 对象,必须等到内部所有的 await 命令的 Promise 对象执行完,才会发生状态改变。也就是说,只有当 async 函数内部的异步操作都执行完,才会执行 then 方法的回调。

在 async 函数中使用 await,那么 await 这里的代码就会变成同步的了,意思就是说只有等 await 后面的 Promise 执行完成得到结果才会继续下去,await 就是等待,这样虽然避免了异步,但是它也会阻塞代码,所以使用的时候要考虑周全。await 会阻塞代码,每个 await 都必须等后面的 fn()执行完成才会执行下一行代码

云存储文件夹管理

在小程序端创建一个文件夹,需要考虑三个方面,一是文件夹在云存储里是怎么创建的;二是文件夹在数据库里的表现形式;三是小程序端页面应该怎么交互才算是创建了一个文件夹;

文件夹在云存储里是怎么创建的

在云开发能力章节我们了解到,要上传 demo.jpg 到云存储的 cloudbase 文件夹里,只需要指明 cloudPath 云存储的路径为 cloudbase/demo.jpg 即可,这里的 cloudbase 文件夹,在我们上传文件时代码会自动创建,也就是说我们在小程序端创建文件夹不需要对云存储做任何事情,因为在云存储这里,文件夹是只有在文件上传时才会创建。

文件夹在数据库里的表现形式

尽管文件夹在小程序端的页面交互看来非常复杂,但是它在数据库的形式看起来却非常简单,我们创建文件夹只是在操作(增删改查)数组和对象而已,以下的 folders 数组是文件夹列表,而一个文件夹只是数组里的一个对象而已。

"folders": [
    {
      "folderName": "文件夹名称",
      "files": [ ]
    }
  ]

文件夹的创建与页面交互

通过前面的分析可知,在小程序端创建文件夹,只会操作数据库的数据,而不会操作云存储,我们来看具体的代码实现。使用开发者工具新建一个 folder 的页面,然后在 folder.wxml 里输入以下代码:

<form bindsubmit="formSubmit">
   <input name="name" placeholder='请输入文件夹名' auto-focus value='{{inputValue}}' bindinput='keyInput'></input>
    <button type="primary" formType="submit">新建文件夹</button>
</form>

方法一:使用 push 和

在 folder.js 里输入以下代码:

  async createFolder(e) {
    let foldersName = e.detail.value.foldersName
    const folders = this.data.userData.data[0].folders
    folders.push({ foldersName: foldersName, files: [] })
    const _id= this.data.userData.data[0]._id
    return await db.collection('clouddisk').doc(_id).update({
      data: {
        folders: _.set(folders)
      }
    })
  },

技术文档:字段更新操作符 set

方法二:

在 folder.js 里输入以下代码:

  async createFolder(e) {
    let foldersName = e.detail.value.foldersName
    const _id= this.data.userData.data[0]._id
    return await db.collection('clouddisk').doc(_id).update({
      data: {
        folders: _.push([{ foldersName: foldersName, files: [] }])
      }
    })
  },

技术文档:数组更新操作符 push

先读后写与先写后读

上传单个文件到文件夹

相信大家都应该在其他小程序体验过文件上传的功能,在交互上这个功能虽然看起来简单,但是在代码的逻辑上却包含着四个关键步骤:

  • 首先把文件上传到小程序的临时文件,并获取临时文件地址以及文件的名称;
  • 将临时文件上传到云存储指定云文件里,并 q 取到文件的 FileID;
  • 将文件在云存储的 FileID 和文件的名称上传到数据库;
  • 获取文件夹内所有文件的信息。

上传文件到小程序的临时文件

使用开发者工具在 folder.wxml 里输入以下代码:

<form bindsubmit="uploadFiles">
   <button type="primary" bindtap="chooseMessageFile">选择文件</button>
   <button type="primary" formType="submit">上传文件</button>
</form>

然后在 folder.js 里输入以下代码:

  chooseMessageFile(){
    const files = this.data.files
    wx.chooseMessageFile({
      count: 5,
      success: res => {
        console.log('选择文件之后的res',res)
        let tempFilePaths = res.tempFiles
        for (const tempFilePath of tempFilePaths) {
          files.push({
            src: tempFilePath.path,
            name: tempFilePath.name
          })
        }
        this.setData({ files: files })
        console.log('选择文件之后的files', this.data.files)
      }
    })
  },

将临时文件上传到云存储

技术文档:wx.cloud.uploadFile

  uploadFiles(e) {
    const filePath = this.data.files[0].src
    const cloudPath = `cloudbase/${Date.now()}-${Math.floor(Math.random(0, 1) * 1000)}` + filePath.match(/\.[^.]+?$/)
    wx.cloud.uploadFile({
      cloudPath,filePath
    }).then(res => {
      this.setData({
        fileID:res.fileID
      })
    }).catch(error => {
      console.log("文件上传失败",error)
    })
  },

上传成功后会获得文件唯一标识符,即文件 ID,后续操作都基于文件 ID 而不是 URL。

将文件信息存储到数据库

  addFiles(fileID) {
    const name = this.data.files[0].name
    const _id= this.data.userData.data[0]._id
    db.collection('clouddisk').doc(_id).update({
      data: {
        'folders.0.files': _.push({
          "name":name,
          "fileID":fileID
        })
      }
    }).then(result => {
      console.log("写入成功", result)
      wx.navigateBack()
    }
    )
  }

匹配数组第 n 项元素
如果想找出数组字段中数组的第 n 个元素等于某个值的记录,那在 <key, value> 匹配中可以以 字段.下标 为 key,目标值为 value 来做匹配。如对上面的例子,如果想找出 number 字段第二项的值为 20 的记录,可以如下查询(注意:数组下标从 0 开始)

获取文件夹内文件列表

在 onload 生命周期函数里输入

this.getFiles()

然后再在 Page 对象里添加 getFiles()方法,获取该用户的数据

  getFiles(){
    const _id= this.data.userData.data[0]._id
    db.collection("clouddisk").doc(_id).get()
    .then(res => {
      console.log('用户数据',res.data)
    })
    .catch(err => {
      console.error(err)
    })
  }

要实际开发一个具体的功能,一定要先思考这个功能的页面交互是怎样的,而页面交互的背后都只不过是简单的数据,但正是这些简单的数据经过页面交互处理之后却“蒙蔽”了用户的双眼,让用户觉得复杂,觉得这个功能真实存在。

嵌套数组和对象的查询

我们可以对对象、对象中的元素、数组、数组中的元素进行匹配查询,甚至还可以对数组和对象相互嵌套的字段进行匹配查询/更新

匹配记录中的嵌套字段

// 方式一
db.collection('todos').where({
  style: {
    color: 'red'
  }
}).get()

// 方式二
db.collection('todos').where({
  'style.color': 'red'
}).get()

匹配并更新数组中的元素

上传多个文件到文件夹

查询所有数据

const cloud = require('wx-server-sdk')
cloud.init({
  env: cloud.DYNAMIC_CURRENT_ENV
})
const db = cloud.database()
const MAX_LIMIT = 100
exports.main = async (event, context) => {
  // 先取出集合记录总数
  const countResult = await db.collection('china').count()
  const total = countResult.total
  // 计算需分几次取
  const batchTimes = Math.ceil(total / 100)
  // 承载所有读操作的 promise 的数组
  const tasks = []
  for (let i = 0; i < batchTimes; i++) {
    const promise = db.collection('china').skip(i * MAX_LIMIT).limit(MAX_LIMIT).get()
    tasks.push(promise)
  }
  // 等待所有
  return (await Promise.all(tasks)).reduce((acc, cur) => {
    return {
      data: acc.data.concat(cur.data),
      errMsg: acc.errMsg,
    }
  })
}

小程序端下载并预览文件

技术文档:wx.openDocument()wx.cloud.downloadFile

使用云开发来下载云存储里面的文件,就不会有域名校验备案的问题

  previewFile(){
    wx.cloud.downloadFile({
      fileID: 'cloud://xly-xrlur.786c-xly-xrlur-1300446086/cloudbase/技术工坊预备手册.pdf'
    }).then(res => {
        const filePath = res.tempFilePath
        wx.openDocument({
          filePath: filePath
        })
    }).catch(error => {
      console.log(error)
    })
  }

删除记录与删除字段

技术文档:deleteFile

可以根据文件 ID 下载文件,用户仅可下载其有访问权限的文件:

const cloud = require('wx-server-sdk')
cloud.init({
  env: cloud.DYNAMIC_CURRENT_ENV
})

exports.main = async (event, context) => {
  const fileIDs = ['xxx', 'xxx']
  const result = await cloud.deleteFile({
    fileList: fileIDs,
  })
  return result.fileList
}

嵌套删除字段

  return await db.collection("clouddisk").doc("_id").update({
   data:{
    "folders.0.files.1": _.remove()
   }
  })

获取临时链接并分享文件

技术文档:getTempFileURL

将服务端的文件传到小程序端

技术文档:downloadFile

本文出自 李东bbsky