简单用socket.io撸个聊天系统(2)——客户端的聊天存储设计

上一章我们设计了一个简单但可靠的聊天系统服务端设计,本章开始做基于uniapp的客户端设计,不过先别急,在正式写逻辑代码之前,我们还是老样子分析需求架构。

作为聊天,需要有一个本地的聊天记录,存储的实现可以有多种,可以用localStorage,可以用文件,但是好用且方便查询最好还是基于数据库形式的,用其他方式纯属给自己找屎吃。

好在uniapp有一套比较残疾(比如不支持preparestatement)但是勉强可用的sqlite系统,本章主要讲如何在uniapp中封装一套可靠且方便(甚至可以在h5上运行的)sqlite轮子。

首先我们看看官方提供的API:

plus.sqlite:


具体到每一个方法里,都是传入一个options对象,而每个对象里都有success()和fail()方法,显然是回调式的写法,而我们整个系统需要大量的sql语句,都用回调式写法可能缩进到外太空去了,于是我们轮子的第一步就是要改造成async/await写法。

接下来我们可以看到,每个options里都要传入一个叫name的字符串,它代表了要操作的数据库,而这个name是我们通过openDatabase()方法产生的,但我们系统只需要一个单例数据库就可以了,也就是应用启动时打开/创建一个数据库,一直用到天荒地老,于是我们的轮子还可以进一步把这个name简化掉,让轮子内部维护。

这还不够轮子!我们还可以在异想天开一点,把plus.sqlite也抽象出来,来一个中间层,因为我们在手机上调试sqlite非常麻烦,要一遍遍把db文件从手机拷贝到电脑上,再者uniapp的APP端调试真的耗费精力,保存→编译→传到手机→手机APP重启就要耗费十秒,但在H5上运行就一秒钟不到时间,不过H5上是不支持plus的,也就是我们如果用上sqlite,就不得不接下来在APP内调试了,但我们可以想想办法,可以做一个网络版sqlite,同时用十几行代码写一个java版sqlite服务端,让sql语句不在本地执行而是传到服务端,这样我们就可以在电脑上随时调试/查看数据库内容了,甚至更变态一点可以关掉手机直接在H5上运行,这样我们的开发时间就可以大幅度缩短,不用在等数十秒的折磨人编译过程了。 而且因为是抽象的,我们随时可以把底层实现改为网络版/本地API版而不用动其他任何代码。

于是我们可以准确的指定轮子的目标了:

  1. plus.sqlite代码async/await化
  2. 单例化数据库
  3. 建立抽象层,允许sqlite在服务器上运行以方便我们开发调试


那么首先我们先封装一个抽象层

export default class sqlite {
   static impl() {
      return typeof plus === "undefined" ? window.sqlite : plus.sqlite
   }

   static openDatabase(options) {
      return this.impl().openDatabase(options)
   }

   static isOpenDatabase(options) {
      return this.impl().isOpenDatabase(options)
   }

   static closeDatabase(options) {
      return this.impl().closeDatabase(options)
   }

   static transaction(options) {
      return this.impl().transaction(options)
   }

   static executeSql(options) {
      return this.impl().executeSql(options)
   }

   static selectSql(options) {
      return this.impl().selectSql(options)
   }
}

这几个方法其实对应的就是plus.sqlite里的方法,而中间有个impl()方法来根据当前运行环境选择使用plus.sqlite(APP上)或者window.sqlite(浏览器内),window.sqlite就是我们稍候要实现的。

所以我们接下来写和sql有关代码的时候不能直接用plus.sqlite.xxx()了,而是使用这个类,语法类似sqlite.xxx()。

回过来我们复习一下plus.sqlite里的方法,具体到执行sql就是executeSqlselectSql两个方法,一个负责增删改,一个负责查,传入sql的string/string[],没了。

就这?

很快我们就发现它缺少了一个数据库里最重要的东西——preparestatement,很明显这个sqlite是残疾的,只能传sql字符串,不支持预编译,没有预编译很多语句执行起来不但性能低,而且自行拼sql也有可能产生注入问题,潜在会影响APP的安全,所以我们还得先解决sql安全问题,好在这种轮子npm里确实有,就是一个平时根本没人用但是非常适合当前架构场景的sqlstring,他可以把sql的参数escape化来防止潜在注入问题,而且也能像是preparestatement那种用“?”当参数占位的写法来方便,用起来也是简单无脑:

import sqlstring from "sqlstring";

let userId = 1;
let sql = sqlstring.format('SELECT * FROM users WHERE id = ?', [userId]);
console.log(sql); // SELECT * FROM users WHERE id = 1


尽管这种写法可能没有拼接sql那样直接无脑,但直接拼接还是有很大安全隐患的,当然也看你具体的项目,如果你认为sql里所有参数都是安全的,那也可以直接拼sql大法,否则有一个escape的过程还是必要的。

好,接下来正式写sql轮子,我们可以创建一个js类,里面都是静态的方法,方便在任何地方都能直接调用,因为我们数据库是单例的,所以我们API也可以设计成静态的而不是new对象形式的。

我们将这个类起名为DB,首先第一步我们创建一个init方法,用来在APP初始化时执行。

import dbString from "raw-loader!./db.sql"
import sqlite from "./sqlite";

export default class DB {

    static async init() {
        const meta = {name: "mydb", path: "_doc/mydb.db"}

        //如果DB已经初始化那init也没什么意义,return之
        if (sqlite.isOpenDatabase(meta))
            return


        //打开/创建数据库,open方法下面创建
        await DB.open(meta)

        //查询当前数据库内全部表的表名,select方法下面创建
        const existsTable = await DB.select("SELECT name FROM sqlite_master WHERE type='table'")

        //如果表名里没有"message"表,则代表数据库还未初始化
        if (existsTable == null || !existsTable.some(n => n.name === "message")) {

            //改变sqlite的journal mode(下面讲)
            await DB.select("pragma journal_mode=wal;")

            //将读取到的dbString用分号隔开转为数组,并批量执行他们。
            for (let sql of dbString.split(";").filter(s => s.trim().length !== 0))
            await DB.execute(sql)
        }
    }

}

上面代码有几个说明,首先我们有一个dbString,他是个字符串,由raw-loader引入,raw-loader是webpack里一种可以直接引入文本文件为字符串的loader,具体可以自行学习,可以看到,当数据库初始化后,先查询sqlite_master表,这个表保存了数据库的上下文信息,其中包含了我们需要的已存在的表名,我们通过是否存在“message”表名来判断是否需要初始化数据库,进而执行dbString里的sql语句,那回到dbString,看看里面有什么东西。

DROP TABLE IF EXISTS "main"."message";
CREATE TABLE "message" (--聊天表
"id"  INTEGER PRIMARY KEY AUTOINCREMENT NOT NULL,--自增ID
"role_id"  INTEGER NOT NULL,--聊天对方UID
"nickname"  TEXT,--聊天对方昵称
"avatar"  TEXT,--聊天对方头像
"time"  INTEGER,--聊天创建时间
CONSTRAINT "role_id" UNIQUE ("role_id" ASC) ON CONFLICT ABORT--约束role_id不允许有重复,防止极端情况
);


DROP TABLE IF EXISTS "main"."message_item";
CREATE TABLE "message_item" (--聊天单条消息表
"id"  INTEGER PRIMARY KEY AUTOINCREMENT NOT NULL,--自增ID
"message_id"  INTEGER,--message表对应ID
"content"  TEXT,--消息内容
"time"  INTEGER,--消息时间
"is_send"  INTEGER,--是否为自己发送是,0否,1是
"is_read"  INTEGER DEFAULT 0--是否已读,0否,1是
);

这里就是一个最弱智精简的一对多表,message存聊天人,message_item存对应聊天记录,没什么好讲解的。

回到刚才的init()代码,还有一个地方需要说明的就是journal_mode这个东西,sqlite里拥有两种日志模式,delete和wal,他们之间的差距很大,在这里不进一步赘述,详细可以看这里有一篇说的比较好的文章,简单来说wal模式下的sqlite用空间换时间,可以大幅度提升sqlite的写能力,相应的会产生1或3个比较大的db文件,但是再大也不过可能只有几MB而已,在1202年的手机里根本就是毛毛雨,我们在创建数据库时要更改journal_mode的模式来提升sqlite性能,这里提另一点提升性能的小技巧,就是尽量把多个sql(尤其是多个写操作)包裹在事务里,这样事务结束后统一写IO,可以大幅度缩短写的时间。

接着我们继续扩充DB类的代码,实现open和close方法,用来开启/关闭DB文件。

static open(meta) {
   return new Promise((success, fail) => {
      sqlite.openDatabase({
         ...meta,
         success,
         fail
      })
   })
}

static close() {
   return new Promise((success, fail) => {
      sqlite.closeDatabase({
         name: "mydb",
         success,
         fail
      })
   })
}


这两段废话代码也没什么好讲的,就是把原始的plus.sqlite.openDatabase和closeDatabase给async/await化。

需要注意的就是close()里我们name是写死的”mydb”,它对应的就是init()方法的数据库名(name),因为我们是单例数据库所以这里写死就好。

写完这两个其实会发现,废话代码还是多,我们可以进一步简化,因为plus.sqlite里的所有方法都是传入参数、success和fail,那我们直接写一个包裹代码,让其他代码调用包裹代码,省去一些废话代码。

static doOperation(operation, options) {

   return new Promise((success, fail) => {
      sqlite[operation]({
         name: "mydb",
         ...options,
         success,
         fail: async e => {

            if(options.stopBubble || !e || !e.message)
               return fail("数据库错误,请重启程序后再试")

            const locked = e.message.toLowerCase().indexOf("locked") >= 0
            const notOpen = e.message.toLowerCase().indexOf("not open") >= 0 || e.code === -1401
            if(!locked && !notOpen)
               return fail("数据库错误,请重启程序后再试")

            try {
               if(locked){
                  await this.close()
               }

               if(locked || notOpen){
                  await this.init()
               }

               await this.doOperation(operation, {...options, stopBubble: true})
            } catch (e) {
               fail("数据库错误,请尝试重启程序")
            }

         },
      })
   })
}


这一大段代码就是“包裹代码”了,比较像是curry函数,在讲这个之前还得讲一些uniapp生产上的问题,uniapp的数据库偶尔总会莫名其妙的关闭,一般发生在APP切到后台太久后再切回来,这些既是bug也算不上bug的事情也可以理解,毕竟安卓不同系统不同厂商混乱到一切皆有可能,作为老码农第一时间想的是“官方肯定懒得管”,因为在uniapp上用sqlite的很少,大多数码农就用来做个破界面混口饭吃,根本涉及不到原生太多东西,所以与其等官方修复不如先把保险做好,回到代码,我们把options的success和fail委托成promise,而在fail里我们写了一段重试代码,当执行失败时我们根据返回结果(数据库是否被锁定/是否未开启)来判断是否自动解锁/开启然后重试,如果重试再次失败,那本次sql执行就彻底失败(不过在开发时根本没碰到重试后仍然失败的事情,一般是数据库莫名其妙挂了但是在这段fail里就恢复成功了)。

那我们怎么用这个doOperation()方法呢?我们先写几个简单的方法试试水。

static async begin() {
   await this.doOperation("transaction", {operation: "begin"})
}

static async commit() {
   await this.doOperation("transaction", {operation: "commit"})
}

static async rollback() {
   await this.doOperation("transaction", {operation: "rollback"})
}


同样没啥可说的,剩下两个plus.sqlite的API我们同样这么造就可以。

static async select(sql, values) {
   sql = this.computeSQL(sql, values)
   return await this.doOperation("selectSql", {sql})
}

static async execute(sql, values) {
   sql = this.computeSQL(sql, values)
   return await this.doOperation("executeSql", {sql})
}


我们把selectSql和executeSql也包裹起来,注意sql是可以字符串/数组两种格式,因为底层的plus.sqlite.selectSql是支持传入sql数组来做到批量执行的,所以这里我们也支持。

这里多了个values变量和computeSQL()方法,将sql+values转化为安全sql字符串,可以让我们写代码时候简化一点,不用费脑筋 escape SQL参数,而是交给方法内部消化,它的实现是

static computeSQL(sql, values) {
   if(Array.isArray(sql)) {
      if(sql.length === 0)
         return

      let computedSQL = []
      for(let obj of sql) {
         if(Array.isArray(obj)) {
            computedSQL.push(sqlstring.format(obj[0], obj[1]))
         } else {
            computedSQL.push(obj)
         }
      }

      return computedSQL

   } else if(typeof sql === "string") {
      if(values)
         return sqlstring.format(sql, values)

      return sql
   }

}


其中sqlstring就是上面说的npm库……代码自行阅读吧懒得流水账了。

到这里SQL实现就结束了,在APP启动时我们执行DB.init(),在其他任何时候想执行sql语句就直接调用DB.select() / DB.execute() 方法即可,比如:

// [{id: 1, nickname: "小张", ...}, {id: 2, nickname: "小李", ...}, ...}
const result = await DB.select("select * from message")

const result = await DB.select("select * from message where id = ?", [1])


await DB.execute("update message set nickname = '直接拼sql' where id = 1")

await DB.execute("update message set nickname = ? where id = ?", ["可以用?方式占位", 1])


await DB.begin()
await DB.execute([
    ["update message set nickname = ? where id = ?", ["也可以", 1]],
    ["update message set nickname = ? where id = ?", ["批量执行", 2]]
])
await DB.commit()


一个精简好用,提升生产力的轮子就出来了,现在我们完成最后一个使命,实现一个网络版sqlite数据库,方便我们远程调试甚至在H5上继续开发我们的APP。

不需要这块的可以结束文章的阅读了,但是我实现了这个网络数据库轮子后,之后的开发都在H5上跑,一秒不到编译完成,刷新,不用一遍遍编译到APP等十多秒,开发时间最起码缩短一整倍,不然枯燥的等待真的让人发狂hhhhh


我们随便建个最精简的spring项目,用gradle引入sqlite实现包。

compile group: 'org.xerial', name: 'sqlite-jdbc', version: '3.28.0'

接着写个controller,因为我们就本地开发本地测,所以直接少写废话直接写个最精简的实现即可,在使用时开着服务即可。

注意服务器语言这里是groovy,一个缩写版java语言,和java语法类似,应该没啥看不懂的,自行转为java语言即可。

import groovy.transform.CompileStatic
import org.springframework.web.bind.annotation.RequestBody
import org.springframework.web.bind.annotation.RequestMapping
import org.springframework.web.bind.annotation.RestController
import team.rpsg.sqliteimpl.pogo.MulSQLBody
import team.rpsg.sqliteimpl.pogo.SQLBody
import team.rpsg.sqliteimpl.util.Response

import java.sql.*

@CompileStatic
@RestController
@RequestMapping(path = "sql")
class SQLController {

   static Connection connection

   @RequestMapping("open")
   void open() {
      if(connection == null) {
         Class.forName("org.sqlite.JDBC")
         connection = DriverManager.getConnection("jdbc:sqlite:mydb.db")
      }

   }

   @RequestMapping("transaction")
   void transaction(String operation) {
      def conn = getOrCreate()

      switch (operation) {
         case "begin":
            conn.autoCommit = false
            break
         case "commit":
            conn.commit()
            conn.autoCommit = true
            break
         case "rollback":
            conn.rollback()
            conn.autoCommit = true
            break

      }

   }

   // MulSQLBody类里仅有一个String[] sqls变量。
   @RequestMapping("execute")
   void transaction(@RequestBody MulSQLBody body) {
      synchronized (SQLController) {
         def conn = getOrCreate()

         for(def sql : body.sqls) {
            def stmt = conn.createStatement()
            stmt.executeUpdate(sql)
         }

      }

   }

   // SQLBody类里仅有一个String sql变量。
   @RequestMapping("select")
   String select(@RequestBody SQLBody body) {
      synchronized (SQLController) {
         def conn = getOrCreate()

         def stmt = conn.createStatement()
         def result = stmt.executeQuery(body.sql)

         //toJSON自行实现,把java对象转为json传给前端
         return toJSON(convertList(result))
      }
   }

   // 获取或创建
   Connection getOrCreate() {
      if(connection == null)
         open()

      return connection
   }

   // 将ResultSet转化为List<Map>,方便转为JSON传给前台
   static List<Map<String, Object>> convertList(ResultSet rs) {
      def list = new ArrayList<Map<String, Object>>()

      try {
         def md = rs.getMetaData()
         def columnCount = md.getColumnCount()
         
         while (rs.next()) {
            def rowData = new HashMap<String, Object>()
            
            for (int i = 1; i <= columnCount; i++) 
               rowData.put(md.getColumnName(i), rs.getObject(i))
            
            list.add(rowData)
         }
      } finally {
            if (rs != null)
               rs.close()
      }
      return list
   }

}

可以看到我们就实现了最简单的4个接口,open()用来开启数据库,如果数据库不存在也会自动创建,transaction()负责支持事物,select()和execute()负责查询和执行,真 · 缩减到不能再缩减了,用来支持我们的网络数据库服务即可。

接下来实现客户端逻辑,我们把一个名为sqlite的变量挂在window对象上,或者export出去给sqlite.impl()(文章最上方实现的)import也都行,代码是死的人是活的。

window.sqlite = {
   openDatabase(options) {
      http.get("sql/open")
         .then(e => options.success(e))
         .catch(e => options.fail(e))
   },
   
   isOpenDatabase(options) {
      return false
   },
   
   closeDatabase(options) {
      options.success()
   },
   
   transaction(options) {
      http.get("sql/transaction", {operation: options.operation})
         .then(e => options.success(e))
         .catch(e => options.fail(e))
   },
   
   executeSql(options) {
      const sqls = Array.isArray(options.sql) ? options.sql : [options.sql]

      http.post(`sql/execute`, {sqls})
         .then(e => options.success(e))
         .catch(e => options.fail(e))

   },
   
   selectSql(options) {
      http.post(`sql/execute`, {sql: options.sql})
         .then(e => options.success(e))
         .catch(e => options.fail(e))

   }
}


可以看到我们甚至直接忽略了isOpenDatabase()和closeDatabase()两个方法的实现,因为没必要,服务器一直开着数据库就是开启状态,我们平时也不太需要读这两个方法。还有”sql/execute”接口需要传入一个数组,我们在这里判断如果是单个字符串就用数组包裹然后http传出去,至于http的实现自行设计即可。

到此为止我们的数据存储环节就告一段落了,仔细看发现其实无论代码还是思路都异常简单,简单到会怀疑“这能运行吗”的程度,这也是我个人编码习惯。

说点题外话,我个人比较讨厌overengineering,仿佛把架构复杂化就能心安理得,认为不会产生bug,有一种“啊啊今天写了这么多代码我真棒”的感觉,但这其实屁用没有,我们“架构”一个系统,最终目标就两点,第一是让系统更加健壮、解耦、可扩充,第二是精简重复代码。但我看过很多代码,都是一层套一层,废话代码二百行,真正的业务逻辑代码就5行,写一堆看起来很可笑的、自认为像是“架构”的克苏鲁屎山,写完后自己都懒得维护,这种魔怔症状算是心理病了,建议辞职看看病,这种东西既不增强系统的健壮性,废话代码反而还变多了,完全不知道意义何在。


下一章开始贴近前端,设计一个uniapp里的聊天界面,同时分析一下uniapp的渲染机制,我们会发现,小小的界面里也有一些不得不去踩的坑。

发表评论