优化数据查询: Cloud SQL与Prisma整合处理复杂数据关系
在过去的两年里,我们的应用一直使用Google Firebase
这个NoSQL
数据库,而对于小型应用来说,Firebase
确实是一个很不错的选择。然而,随着项目功能的不断扩展,开始遇到了一些局限性。最主要的问题是:
- Firebase的索引数量有限,最多只能有500个索引。
- 每当新增一个Filter或查询条件时,就需要额外新增一个索引,导致索引数量逐渐达到上限。
- Firebase无法进行连表查询
这对于日益复杂的查询需求成为了最大的痛点。考虑到整个项目都是建立在Google Cloud Platform (GCP)
之上,决定在不脱离GCP的前提下寻找解决方案。
最终,选择了Cloud SQL作为解决方案。对于复杂的功能和查询需求,将数据处理转移到了Cloud SQL
,并使用ORM
框架Prisma
来处理数据的映射和操作。而对于一些简单的GET请求,仍然保留在Firebase数据库中。
为了实现与Cloud SQL的通信,前端通过调用Cloud Functions Http onCall
来触发后台接口,实现与Cloud SQL的数据交互。
1. 数据库选型
Cloud SQL 有3种类型的database可供选择,最初我们优先考虑的是MySQL,相对其他database,MySQL读写速度更快、市场使用率高。
但是最终我们选择了PostgreSQL,处于以下几点
- 项目最初使用的是Firebase database,业务模型中有大量的List类型,MySQL不支持List类型,虽然可以使用JSON类型,但是在查询上会相当繁琐
- PostgreSQL在社区的使用率上已经逐渐超过MySQL,社区的活跃度也是高于MySQL
- PostgreSQL内置List类型,可以和我们项目原有Firebase List类型完美契合,不需要额外工作
2. 本地连接CloudSQL数据库
本地连接Cloud SQL我们就可以更方便的操作数据库(如果已经做了配置可以忽略这一小节)
-
根据Google Cloud Document 完成以下步骤
-
Install and initialize the gcloud(安装和初始化gcloud)
-
Create your credential file (创建证书文件)
arduinogcloud auth application-default login
-
接下来到Cloud SQL Overview 查看实例连接名
-
启动 Cloud SQL Auth 代理,
INSTANCE_CONNECTION_NAME
就是上图中的Connection name
-
对于 Linux (Mac)环境,使用以下命令启动 Cloud SQL Auth 代理:
bash./cloud-sql-proxy INSTANCE_CONNECTION_NAME
-
在 Windows 中的 PowerShell 中,使用以下命令启动 Cloud SQL Auth 代理:
sql.\cloud-sql-proxy.exe INSTANCE_CONNECTION_NAME
-
系统会显示类似以下内容的消息:
csharpListening on 127.0.0.1:5432 forINSTANCE_CONNECTION_NAME Ready for new connections
-
如果是MySQL数据库则是3306端口,这时我们已经连接上Cloud SQL
想要看在图形界面上查看数据库中的数据,可以中Navicat/ DateGrip进行连接,在当前页面下载完对应的驱动然后进行Test Connection
即可
3. 通过代码连接数据库
当我们在初始化数据库,可以直接使用PostgreSQL 语句进行插入数据,从时间和内存占用率上来看,纯PostgreSQL 语句比使用ORM 批量插入时间和内存占有都更少
js
const connectionString = 'postgresql://USER:PASSWORD@HOST:PORT/DATABASE';
const client = new Client({ connectionString })
async insertDataBase() {
await client.connect();
let count = 0;
await Promise.all(data.map(async (item) => {
var id = item['id'];
var logoURL = item['logoURL'];
const query = {
text: `INSERT INTO "Companies" ("id", "logoURL") VALUES($1, $2)`,
values: [
id ?? null,
logoURL ?? null,
]
}
await client.query(
query,
);
}));
client.end()
}
需要注意的是,PostgresSQL推荐使用下划线命名列名foo_bar
如果我们使用的是驼峰命名则在查询时需要对字段加上双引号INSERT INTO "Companies" ("id", "logoURL") VALUES($1, $2)
4. 使用Prisma ORM框架
为了使用Prisma框架与数据库进行连接,我们需要在目录结构下创建prisma文件夹,同时在该文件夹下创建schema.prisma文件
js
datasource db {
url = env("DATABASE_URL")
provider = "postgresql"
}
generator client {
provider = "prisma-client-js"
}
model User {
id Int @id @default(autoincrement(uuid()))
createdAt DateTime @default(now())
email String @unique
name String?
twitterLink String[]
}
4.1 基础配置
上面的代码中可以看到定义了一个datasource
-
url: 数据库的链接,格式为:
postgresql://USER:PASSWORD@HOST:PORT/DATABASE
-
项目中使用到Cloud SQL,所以在链接尾部需要添加
?host=/cloudsql/INSTANCE_CONNECTION_NAME
INSTANCE_CONNECTION_NAME
是上文中第二点提到的Connection Name
-
-
client: 是固定格式
-
model: 我们可以在该文件定义多个model,model就是数据库的表,User为User表
-
id为Int类型,同时是默认自增且唯一
-
name为String类型,同时可为空
-
twitterLink为String[], 字符串数组
- 在做新增/更新时需要注意如果是字符串数组类型的字段,一定不能为null/undefined,需要赋一个空数组[]
-
4.2 初始化客户端
做完基础配置后,我们运行以下指令,让我们的客户端代码可以使用Prisma进行CRUD
yarn risma generate
通过运行后的提示,可以看到在客户端中我们可以使用new PrismaClient()
来获取Prisma实例,后续通过Prisma实例内置方法进行CRUD
4.3 同步Model到数据库生成对应的表
当写完Model后,我们想同步到数据库中,可以运行以下命令来初始化Prisma
csharp
yarn prisma migrate dev -- name init --create-only
运行后我们会看到数据库中自动生成了一张_prisma_migrations
表
然后我们接着执行以下指令,将我们定义好的UserModel 生成一张User表
yanr prisma deploy
运行后,就可以看到数据库多出了一张User表
5. 数据库操作封装
Prisma的文档可以说是对开发者相当友好,具体的CRUD文档中写的很清晰这里就不多赘述,接下来根据实际的应用场景对CRUD进行函数封装,这样团队的其他成员可以方便的调用
5.1获取页码函数
- pageNumber 传入的页面
- pageSize 当前页的大小
- take 从第几页开始获取
- skip 数据间隔多少
js
/**
* @param {number} pageNumber
* @param {number} pageSize
* @returns
*/
getPagination(pageNumber, pageSize) {
const page = pageNumber || 1;
const limit = pageSize || 20;
const skip = (page - 1) * limit;
const take = limit;
return { take, skip };
}
5.2 处理请求入参数
该函数更具入参的类型,判断合适的prisma查询条件
-
condition 请求的参数
jsconditions: { priority: 10, tags: ['B2B', 'Education'], address: [ { "country": null, "province": null, "city": "Nanjing" } { city: "San Francisco", state: "CA" }, { "city": "Xiamen", "country": "China", "province": "Fujian" }, { 'country': 'India' }, { "city": "Los Angeles", "province": "California", "country": "United States", } // ... 可以添加更多 JSON 对象 ] },
-
rangeField 类型为长度为2的数组,例如金额区间、时间区间。
-
ListField 数据库中定义的数组类型
String[]
int []
-
addressField Json类型,这里主要对地址做了处理
js
/**
*
* @param {{}} conditions
* @param {[]} rangeField
* @param {string []} ListField
* @param {string []} addressField
* @returns
*/
async handleConditions(conditions, rangeField, ListField, addressField) {
const where = {};
for (const key in conditions) {
if (Object.prototype.hasOwnProperty.call(conditions, key)) {
const value = conditions[key];
if (addressField.includes(key)) {
let addressFilter = [];
// The key must be add quotes
// {"city": "San Jose", "country": "United States", "province": "California"}
for (let i = 0; i < value.length; i++) {
const address = value[i];
if (address['city']) {
addressFilter.push({
[key]: {
path: ['city'],
equals: address.city
}
})
}
if (address['country']) {
addressFilter.push({
[key]: {
path: ['country'],
equals: address.country
}
})
}
if (address['province']) {
addressFilter.push({
[key]: {
path: ['province'],
equals: address.province
}
})
}
}
// Prisma sentence OR/AND, OR like ||, AND like &&
where['OR'] = addressFilter
} else if (Array.isArray(value)) {
// if rangeField is DateTime, value's type is string and must be ISO8601String e.g.: 1969-07-20T20:18:04.000Z
if (rangeField.includes(key)) {
where[key] = {
gte: value[0],
lte: value[1],
};
}
else if (ListField.includes(key)) {
where[key] = { hasSome: value };
}
else {
// for database field not array type, but use array search
where[key] = { in: value };
}
} else {
where[key] = { equals: value };
}
}
}
return where;
}
5.3 获取分页数据
通过上面的基础函数,我们就可以进行查询分页数据
- onDataProcess,对特殊字段处理
js
/**
* @param {num} pageNumber
* @param {num} pageSize
* @param {object} conditions
* @param {Prisma.CompaniesDelegate<ExtArgs>} collection
* @param {object} orderByField
* @param {[]} rangeField
* @param {string []} ListField
* @param {string []} addressField
* @param {function} onDataProcess
* @returns
*/
async fetchDataWithPagination(pageNumber, pageSize, conditions, collection, orderByField, rangeField, ListField, addressField, onDataProcess) {
const { take, skip } = this.getPagination(pageNumber, pageSize);
const query = await this.handleConditions(conditions, rangeField, ListField, addressField);
const data = await collection.findMany({
where: query,
orderBy: orderByField,
take,
skip,
});
const totalCount = await collection.count({ where: query });
const totalPages = Math.ceil(totalCount / pageSize);
const result = onDataProcess?.(data) ?? data;
return {
data: result,
total: totalCount,
totalPages,
currentPage: pageNumber,
};
}
完整代码请查看github
总结
希望可以帮助到使用Firebase 以及Cloud SQL的开发者,欢迎交流