阅读 179

从零实现一个单词对战游戏 (八) 实战篇4: 云开发架构搭建及首页数据联调

本篇开始进入云开发的重点内容,给大家介绍目前单词天天斗中的实践经验

云开发基础知识

大多数公司和开发者在开发应用时和部署服务时,无论是选择公有云还是自建数据中心,都需要提前考虑服务器、存储和数据库等需求,并且需要花费时间精力在部署应用、依赖。那么是否有一种架构可以帮我们节省这部分的成本呢?这就是 Serverless(无服务器)架构。具体来说,Serverless 架构是指由第三方云计算供应商负责后端基础结构的维护,以服务的方式为开发者提供如数据库、消息、身份验证等所需功能。简言之,这个架构的就是要让开发人员关注代码的运行而不需要管理任何的基础设施。 小程序 · 云开发就是一种 Serverless 架构的实现方式。

更直白一些:个人开发者在小程序访问量不大的阶段(DAU[日活]在0 ~ 500之间),可以免费使用小程序提供的云服务,对个人开发者很友好,具体价格参考

云开发目前提供三大基础能力支持:

  • 云函数:在云端运行的代码,微信私有协议天然鉴权,开发者只需编写自身业务逻辑代码;为小程序前端提供服务端能力,还支持定时自动触发等;
  • 数据库:一个既可在小程序前端操作,也能在云函数中读写的 JSON 数据库;
  • 文件存储:在小程序前端直接上传/下载云端文件,在云开发控制台可视化管理。

更多内容请查阅,云开发详细文档

单词天天斗中的云开发实践

基础架构

由于小程序云开发中,一般不需要使用到wx.request来请求服务端数据了,以前通常把所有服务端api在统一文件中管理,在云开发中就没有必要了。但是在云开发中,也需要获取服务端数据,所以对数据获取进行封装还是有必要的,不然后期代码非常散落,以下介绍单词天天斗中的实践内容

在小程序前端代码目录中,新建model文件夹,其中文件和数据集合(数据表名)保持一致;在很多后端框架,比如thinkPHP、thinkJS、egg中都有类似实践,云开发中参考抽离出数据model层,把所有的数据库操作进行封装(包含云函数对相应的数据库的操作)

├── model
|  ├── base.js # 基类
|  ├── book.js # 单词书数据表
|  ├── index.js # 默认引用的文件 (文件中导入了其他所有数据集合基类)
|  ├── room.js # 房间数据表
|  ├── sign.js # 签到数据表
|  ├── user.js # 用户表
|  ├── userWord.js # 生词表
|  └── word.js # 单词表
复制代码

代码实践

  • 基类
// base基类,所有其他数据集合都继承该类,用来做数据集合初始化
import $ from './../utils/Tool'

const DB_PREFIX = 'pk_' // 数据集合前缀

export default class {
  constructor(collectionName) {
    const env = $.store.get('env') // 获取当前的云开发环境
    const db = wx.cloud.database({ env }) // 初始化数据库操作
    this.model = db.collection(`${DB_PREFIX}${collectionName}`) // 初始化数据集合
    this._ = db.command // 对db.command的引用,可以做数据自加、自减等操作
    this.db = db
    this.env = env
  }

  get date() {
    return wx.cloud.database({ env: this.env }).serverDate() // 获取服务端时间
  }

  /**
   * 取服务器偏移量后的时间
   * @param {Number} offset 时间偏移,单位为ms 可+可-
   */
  serverDate(offset = 0) {
    return wx.cloud.database({ env: this.env }).serverDate({ offset })
  }
}

复制代码
  • room集合

以room集合部分函数做例子,其中包含了房间集合所有的数据操作

import Base from './base' // 引入基类
import $ from './../utils/Tool' // 全局工具对象

const collectionName = 'room' // 数据集合名称

export const ROOM_STATE = {
  IS_OK: 'OK', // 房间状态正常
  IS_PK: 'PK', // 对战中
  IS_READY: 'READY', // 非房主用户已经准备
  IS_FINISH: 'FINISH', // 对战结束
  IS_USER_LEAVE: 'LEAVE' // 对战中有用户离开
}

/**
 * 权限: 所有用户可读写
 */
class RoomModel extends Base { // 继承上述提到的base基类
  constructor() {
    super(collectionName)
  }

  // 用户准备
  userReady(roomId, isNPC = false, openid = $.store.get('openid')) {
    return this.model.where({
      _id: roomId,
      'right.openid': '',
      state: ROOM_STATE.IS_OK
    }).update({
      data: {
        right: { openid },
        state: ROOM_STATE.IS_READY,
        isNPC
      }
    })
  }
  
  // 用户取消准备
  userCancelReady(roomId) {
    return this.model.where({
      _id: roomId,
      'right.openid': this._.neq(''),
      state: ROOM_STATE.IS_READY
    }).update({
      data: {
        right: { openid: '' },
        state: ROOM_STATE.IS_OK
      }
    })
  }
  
  // 开始PK
  startPK(roomId) {
    return this.model.where({
      _id: roomId,
      'right.openid': this._.neq(''),
      state: ROOM_STATE.IS_READY
    }).update({
      data: {
        state: ROOM_STATE.IS_PK
      }
    })
  }

  // 创建房间
  async create(list, isFriend, bookDesc, bookName) {
    try {
      const { _id = '' } = await this.model.add({ data: {
        list,
        isFriend,
        createTime: this.date,
        bookDesc,
        bookName,
        left: {
          openid: '{openid}',
          gradeSum: 0,
          grades: {}
        },
        right: {
          openid: '',
          gradeSum: 0,
          grades: {}
        },
        state: ROOM_STATE.IS_OK,
        nextRoomId: '', // 再来一局的房间id
        isNPC: false // 是否为机器人对战局
      } })
      if (_id !== '') { return _id }
      throw new Error('roomId get fail')
    } catch (error) {
      log.error(error)
      throw error
    }
  }
  
  // 单词选择
  selectOption(roomId, index, score, listIndex, isHouseOwner) {
    const position = isHouseOwner ? 'left' : 'right'
    return this.model.doc(roomId).update({
      data: {
        [position]: {
          gradeSum: this._.inc(score),
          grades: {
            [listIndex]: {
              index,
              score
            }
          }
        }
      }
    })
  }

  /**
   * 结束房间的对战
   */
  finish(roomId) {
    return this.model.where({
      _id: roomId,
      state: ROOM_STATE.IS_PK
    }).update({
      data: {
        state: ROOM_STATE.IS_FINISH
      }
    })
  }

  leave(roomId) {
    return this.model.where({
      _id: roomId,
      state: ROOM_STATE.IS_PK
    }).update({
      data: {
        state: ROOM_STATE.IS_USER_LEAVE
      }
    })
  }

  remove(roomId, state = ROOM_STATE.IS_OK) {
    return this.model.where({
      _id: roomId,
      _openid: '{openid}',
      state
    }).remove()
  }

  /**
   * 搜索随机匹配房间
   * 2mins之内创建的房间
   */
  searchRoom(bookDesc) {
    return this.model.where({
      bookDesc,
      isFriend: false,
      'right.openid': '',
      'left.openid': this._.neq($.store.get('openid')),
      state: ROOM_STATE.IS_OK,
      createTime: this._.gt(this.serverDate(-2 * 60 * 1000)) // 创建时间要>2分钟之前
    }).limit(1).field({ _id: true }).get()
  }

  /**
   * 再来一局
   * @param {String} roomId 当前房间id
   * @param {String} nextRoomId 下一局房间的id
   */
  updateNextRoomId(roomId, nextRoomId) {
    return this.model.where({
      _id: roomId,
      state: ROOM_STATE.IS_FINISH,
      nextRoomId: ''
    }).update({
      data: {
        nextRoomId
      }
    })
  }
}

export default new RoomModel()

复制代码
  • 单词书集合

单词书中,改变当前用户选择的单词书使用了云函数,所有做一个例子解析

import Base from './base'
import $ from './../utils/Tool'
const collectionName = 'book'

/**
 * 权限: 所有用户可读
 */
class BookModel extends Base {
  constructor() {
    super(collectionName)
  }

  async getInfo() {
    const { data } = await this.model.get()
    return data
  }

  async changeBook(bookId, oldBookId, bookName, bookDesc) {
    if (bookId !== oldBookId) {
      const { result: bookList } = await $.callCloud('model_book_changeBook', { bookId, oldBookId, bookName, bookDesc }) // 调用云函数的操作,也封装在对应的数据集合文件中
      return bookList
    }
  }
}

export default new BookModel()

复制代码
  • 整理所有集合依赖

这样导入又导出有一个最大的好处,之后引入集合文件,就只需要importindex.js就行

// index.js
import userModel from './user'
import bookModel from './book'
import wordModel from './word'
import roomModel from './room'
import userWordModel from './userWord'
import signModel from './sign'

export {
  userModel,
  bookModel,
  wordModel,
  roomModel,
  userWordModel,
  signModel
}

复制代码
  • 调用方式
// 引入model类
import { userModel, bookModel, wordModel, roomModel } from './../../model/index'

// 切换用户当前选择的单词书
const bookList = await bookModel.changeBook(bookId, oldBookId, name, desc)
复制代码

首页数据联调

登录获取openid,如果是新用户就先进行注册

// user.js 用户表 类,同上述单词书、房间集合类

import Base from './base'
import $ from './../utils/Tool'
const collectionName = 'user'

/**
 * 权限: 所有用户可读,仅创建者可写
 */
class UserModel extends Base {
  constructor() {
    super(collectionName)
  }

  register() {
    return this.model.add({ data: { ...doc, createTime: this.date } })
  }

  /**
   * 获取自己的用户信息
   */
  async getOwnInfo() {
    const { result: userInfo } = await $.callCloud('model_user_getInfo')
    if (userInfo === null) { // 新用户
      await this.register()
      return (await this.getOwnInfo()) // 注册后,递归
    }
    $.store.set('openid', userInfo._openid)
    return userInfo
  }
}

export default new UserModel()

复制代码

model_user_getInfo云函数如下:

const cloud = require('wx-server-sdk')

cloud.init({
  env: cloud.DYNAMIC_CURRENT_ENV
})

const db = cloud.database()
const userModel = db.collection('pk_user')

exports.main = async () => {
  const { OPENID: openid } = cloud.getWXContext()
  const asBook = 'book'
  const { list } = await userModel
    .aggregate()
    .match({ _openid: openid })
    .limit(1)
    .lookup({
      from: 'pk_book',
      localField: 'bookId',
      foreignField: '_id',
      as: asBook
    })
    .end()
  if (list.length === 0) { return null }
  const userInfo = {
    ...list[0],
    bookName: list[0][asBook][0].name,
    bookDesc: list[0][asBook][0].desc
  }
  delete userInfo[asBook]
  return userInfo
}

复制代码

首页home.js中获取服务端数据,如下调用

  async onLoad() {
    await this.getData() // 登录 + 获取用户数据
  }
  
  /**
   * 获取页面服务端数据
   */
  async getData() {
    $.loading()
    const userInfo = await userModel.getOwnInfo()
    const bookList = await bookModel.getInfo()
    this.setData({ userInfo, bookList })
    $.hideLoading()
  }
复制代码

云开发的其他常见问题

  • 为什么小程序可以直接操作数据库了,还需要云函数?

    • 有一部分数据库操作,不能直接用小程序调用,比如部分场景下的updateremove操作,获取超过记录条数超过20条的get查询操作等
    • 云函数可以实现服务对服务,比如调用第三方非https协议的api、二维码生成、订阅消息等
    • 云函数支持触发器,定时对数据库进行操作
    • 比较隐私的信息,比如APPID、appSecret等需要存在服务端,所以需要云函数来对应一些操作
  • 云开发的基本费用问题

基础版一,有一定的免费额度,查看详细,但除了免费额度需要留意,需要注意的还有以下几点

  • 如下:

    • 免费额度中,数据库并发连接数有20个的限制,推荐在微信后台加一下可用性警报的群,可以收到小程序运行异常的一些错误(收到的信息如下)
    • 云函数(单次运行)运行内存:256M5
    • 云函数数量:50个
    • 云函数并发数:10006
    • 数据库流量:单次出包大小为16M
    • 数据库单集合索引限制:20个
    • 单个小程序的小程序端请求频率限制:100 万次/分钟
小程序名称: 单词天天斗
AppID: wx51a5362ef159dbcd
环境ID: prod-words-pk
说明: 单词天天斗的环境prod-words-pk的资源包数据库并发连接数的使用量为56个>=20个。
可登录微信开发者工具-云开发控制台查看详细的资源使用数据或调整配额。
复制代码

本书是一个实战系列的分享,会持续更新、修正,感谢同学们来一起学习 ~

项目开源

由于本项目参加了大学的比赛,所以暂时不做开源,比赛结束在微信公众号:Join-FE进行开源(代码 + 设计图),关注不要错过哦 ~

公众号

同系列文章,可以到老包同学的掘金主页食用