# 开发指南
# 关于 Aca.ts
Aca.ts 是一个只在前端开发的全栈开发工具,它能根据一个 Typescript ORM 文档,自动同步数据库、生成数据库查询的 API;根据后端函数及数据库查询的 API,自动生成前端访问后端的 API,以实现后端代码在前端开发、测试。
# 特点
- 支持数据库事务
- 支持数据库表和列的映射(map)
- 支持多个数据库
- 支持表和函数命名空间
- 前后端数据交互接口自动生成(像 RPC,不需定义路由)
- 和框架无关,适配现有任何前后端 JS 框架
- 后端代码在前端开发、测试,免去和后端沟通
- TS 代码提示完善,无需看文档即可开发
# 快速开始
先决条件:请先安装(如果没有)node.js(v18.0.0^) 和 ts-node。
在终端按顺序运行如下的命令(如果提示权限拒绝访问,请加 sudo 前缀):
$ npm install -g aca.ts
$ aca create acaProject && cd acaProject
$ aca server
$ aca client
$ aca up
然后,分别在两个终端启动 server 和 client:
server 目录下运行:
$ npm run dev
client 目录下运行:
$ npm start
示例内置了一个 Better-Sqlite3 数据库,点击打开的页面上的按钮,会创建一条数据库 user 表记录,并返回其 id。
具体代码查看 src/App.tsx 页面,展示了如何在前端使用数据库 API 和 RPC 函数。
# 配置
config.json 位于.aca 目录下,具体字段说明如下:
ORM: 你自己编写的 typescript ORM 文件的名字,该 ORM 文件必须存放在.aca 目录下
databases: 数据库相关配置(支持多数据库),字段名为 ORM 文件中定义数据库模型时指定的变量名,默认为:default,可以指定的数据库配置有:
- tableNameWithNamespace:默认为 true,定义 ORM 时,支持使用命名空间分类各个表定义,aca 在创建数据库时,是否也要加上命名空间的前缀,如果为 false,则数据库表的名字就只是你定义的 class 的名字
- onlyApi: 默认为 false, 当你项目中需要使用如第三方提供的数据库时,你不能去修改这样的数据库架构,只生成 API,不同步数据库架构
- idDefaultType:默认为"cuid",当 id 类型不指定时,默认使用此类型
- foreignKeyConstraint: 默认为 true,外键表使用外键约束
- onUpdate onDelete:默认为"cascade" 当主键修改或删除时,外键的动作
- connectOption:数据库连接选项,参考 Knex.js 的连接选项
serverApps;为一个字典对象,项目下所有的后端应用均登记于此,也可以将自己创建的后端应用相关信息填于此(与通过 aca add 命名添加效果一样);
- 字段名:为 app 的名字,
- apiDir:存放生成的 API 的路径名(相对于该 App),默认为:"src/aca.server"
clientApps:为一个字典对象,项目下所有的前端应用均登记于此,也可以将自己创建的前端应用相关信息填于此(与通过 aca add 命名添加效果一样)
- 字段名:为 app 的名字,
- apiDir:存放生成的 API 的路径名(相对于该 App),默认为:"src/aca.client"
- allowRPCs:为一个数组,指明该应用只能访问后端的 RPC 服务器列表,如果没有该字段,则默认为该项目下所有的后端应用的 RPC 都可访问
# CLI
Aca.ts 有如下命令:
- 创建一个新项目:
$ aca create [projectName]
projectName: 你自己取的项目名
运行该命令会在当前目录创建一个 projectName 文件夹,该文件夹下含有一个名为.aca 的文件夹,其中配置文件 config.json 就位于.aca 文件夹下
以下命令需在 projectName 目录或创建的 app 目录下运行。
- 创建一个后端服务:
$ aca server <appName>
appName 默认为:server
创建一个基于 koa 框架的简单后端服务
- 创建一个前端服务:
$ aca client <appName>
appName 默认为:client
调用 create-react-app 创建一个 react app 应用
- 将自己创建的应用添加到项目中(需保证该应用在 aca 项目目录下):
$ aca add <appName> --server --client --apiDir <path>
appName:为你自己创建的这个 app 的名字
--server(-s):指明是后端 App
--client(-c):指明是前端 App
--apiDir(-a):生成的 API 存放的路径
path:相对于该 App 的路径,默认为:src/aca.server 或 src/aca.client,如果同时指定-s 和-c,则-a 使用默认路径
- 生成 API:
$ aca up
生成前后端的 API, 并自动同步数据库架构(如果有改变的话)。API 存放于项目中各个 App 指定的目录下
- 数据库架构回滚:
$ aca rollback
会将数据库架构恢复到上一次改变,注意:被删除了的列的数据不能被恢复
# ORM
ORM 的定义可以参考 example-blog.ts 编写,具体说明如下:
支持的标量数据类型有:id、boolean、string、int、float、object(json 对象)、Date、enum(枚举类型)
id:每张表只有一个 id,支持 5 种类型:cuid uuid autoincrement int string, 写法如:id['autoincrement'],如果只写 id,则根据.aca/config.json/idDefaultType 指定,如果也不存在,则默认为:cuid
boolean:布尔类型(true false)
string:字符串类型
int:整型
float:浮点型
object:json 对象类型
Date:日期时间类型
enum:自定义枚举类型
标量数组:目前只支持 pg
定义一个数据库:用一个顶级命名空间代表一个数据库,命名空间的名字指向你的一个数据库,包含一个可选的常量定义,和多个类定义,类定义也可以使用命名空间进行组织
可选的常量定义:类型为字符串,其值为你在.aca/config.json/database 中定义的一个字段名,如果没有这个定义,则默认为:default
定义一个表:用类定义语法:
- 数据库表的名字:类名
- 数据库表的列名:字段名
- 列的数据类型:字段类型
- 表之间的关系:字段名,类型为表的名字
装饰器:
- 类装饰器:集成在$装饰器中,有 map、id、unique、index、deprecated
- @$.map(name);数据库表的名字为:name
- @$.id(fields):指定 id,fields: 列名组成的数组
- @$.unique(fields):指定为 unique 约束,fields: 列名组成的数组
- @$.index(fields):指定建表索引,fields: 列名组成的数组
- @$.deprecated;指明该表已被废弃
- 类成员装饰器:集成在$_装饰器中,有:unique、index、dbType、createdAt、updatedAt、foreign、deprecated
- @$_.unique:表明该字段为 unique 字段
- @$_.index:表明该字段为 index 字段
- @$_.dbType(name):定义该字段的数据库类型,name 必须为该数据库支持的类型的名字,如:varchar(23)
- @$_.createdAt、@$_.updatedAt:数据记录创建和修改的时间戳,由系统自动填充
- @$_.foreign(obj):见表之间的关系
- @$_.deprecated;指明该列已被废弃
- 类装饰器:集成在$装饰器中,有 map、id、unique、index、deprecated
表之间的关系: 两个关系表中都有一个类字段指向对向表,该字段的类型为对向表的表名,如果含有命名空间,也需要命名空间前缀
表之间的关系有一对一、一对多、多对多关系
一对一、一对多关系需要在相应的外键字段指定@$_.foreign(obj),外键字段也会建立在该表中
obj 写法:- 如果 obj 是一个字符串或字符串数组,则 obj 为外键名,默认引用主键表 id 类型的字段
- 如果 obj 是一个对象,则有以下字段:
- keys: 是一个字符串或字符串数组,其成员为外键名(aca.ts 会自动添加到数据库表中
- references:是一个字符串或字符串数组,引用的主键表的 id 或 unique 字段名
- onUpdate、onDelete:当主键被修改或删除时,外键表的动作,如果缺省,默认采用:.aca/config.json/databases 对应字段下的相应设置
- 一对一关系:主键字段或外键字段至少有一个是可选字段(在字段名后面加问号(?))
- 一对多关系:在主键表类型后面加数组类型(类型后面加方括号([])),此时外键字段自动为可选字段
- 多对多关系:两个表的类型都为数组类型,此时,aca.ts 会自动创建一个多对多关系的映射表,对外不可见,如果需要可见,则你需要显式定义这个多对多关系映射表
注意:当两个表之间有多个关系时,最多只有一组关系可以不用在类型中指定对象字段名,其余关系必须在类型中指定对向表的字段名
# 数据库 API
ts 类型:$Enum $TB $TbOper $ApiBridge
$Enum:所有自定义的枚举类型都集中放在这里,在代码中引用如下:$Enum['枚举名']
$TB:所有表字段的类型定义集中在这里,如果是关系类型,则为:$Relation<'关系表名', '关系表字段名', '关系类型'> 关系类型:toOne(对一关系)、toMany(对多关系)、M2M(多对多关系) 在代码中引用某个表的类型如下:$TB['顶级命名空间名_表名']
$TbOper:对表的操作类型定义,有:unique where insert update updateMany select selectScalar,使用如下: $TbOper<'顶级命名空间名_表名'>['where']
$ApiBridge:连接前端和后端的数据格式定义:
export type $ApiBridge = | { kind: 'rpc' method: string[] args: any } | { kind: 'orm' query: string dbVar: string method: string[] args: any } | { kind: 'raw' dbVar: string args: any }
数据库 API:对定义的所有 ORM,分别生成一个实例化的类对象,对象名为顶级命名空间名,为每个表生成处理方法,有:
findOne
findFirst
findMany
insert
upsert
update
updateMany
delete
deleteMany
聚合函数:count countDistinct sum sumDistinct avg avgDistinct max mix,集中放在每个表的$命名空间中
raw:对每个数据库生成一个 raw 函数,用$.raw 表示
访问方式:<顶级命名空间名>.<表名>.<方法>,每个方法的传参如下:// 这些方法和类型前端和后端一致 type findOne<Tb extends keyof $TB> = { where: $TbOper<Tb>['unique'] select?: $TbOper<Tb>['select'] sql?: true } findFirst<Tb extends keyof $TB> = { where?: $TbOper<Tb>['where'] select?: $TbOper<Tb>['select'] sql?: true orderBy?: $Enumerable<{ [K in $ScalarColumns<Tb>]?: $Order }> } findMany<Tb extends keyof $TB> ={ where?: $TbOper<Tb>['where'] select?: $TbOper<Tb>['select'] distinct?: '*' | $Enumerable<$ScalarColumns<Tb>> limit?: number offset?: number sql?: boolean orderBy?: $Enumerable<{ [K in $ScalarColumns<Tb>]?: $Order }> } insert<Tb extends keyof $TB> = { data: $TbOper<Tb>['insert'] | $TbOper<Tb>['insert'][] select?: $TbOper<Tb>['select'] sql?: true } upsert<Tb extends keyof $TB> = { where: $UN[Tb] insert: $TbOper<Tb>['insert'] update?: $TbOper<Tb>['update'] select?: $TbOper<Tb>['select'] sql?: true } update<Tb extends keyof $TB> = { where: $UN[Tb] data: $TbOper<Tb>['update'] select?: $TbOper<Tb>['select'] sql?: true } updateMany<Tb extends keyof $TB> = { where?: $TbOper<Tb>['where'] data: $TbOper<Tb>['updateMany'] select?: $TbOper<Tb>['selectScalar'] sql?: true } delete<Tb extends keyof $TB> ={ where: $UN[Tb] select?: $TbOper<Tb>['selectScalar'] sql?: true } deleteMany<Tb extends keyof $TB> = { where: $TbOper<Tb>['where'] select?: $TbOper<Tb>['selectScalar'] sql?: true } // 聚合函数 aggregate<Tb extends keyof $TB> = { select: { [K in keyof Required<$TB[Tb]>]?: number extends $TB[Tb][K] ? K : never }[keyof $TB[Tb]] where?: $TbOper<Tb>['where'] sql?: true }
事务:数据库事务使用如下:
const trx = <顶级命名空间名>.transaction()
// 注意: 一定要使用commit()和rollback()
try {
await trx.<表名>.<方法>
await trx.commit()
} catch(e){
await trx.rollback()
}
注意:前端也有事务,但并不能实现真正的事务,仅仅是为了使用事务的方式书写代码,便于以后将代码迁移到后端时,减少代码改动!
- $ApiBridge:该方法是桥接前端传到后端的数据,会解析成前端相同的方法名和参数,再调用后端相同的方法,并将结果返回
# RPC 函数
RPC 函数定义在后端,需存放在 API 目录下的 RPC 文件夹(如:src/aca.server/RPC),其写法如:
export async function fn(ctx: any, param: string): stirng {
return param
}
特别注意:
- 函数的第一个参数为上下文,在生成前端的同名函数时,会把这个参数省去
- 如果在 RPC 目录下有多个文件,则 aca.ts 会自动把 RPC 目录下的 index.ts 文件重写为其他页面的引用导出,此时不能将 RPC 函数写在 index.ts 中
- 支持命名空间
# 安全性
- 前端数据库 API 只建议在开发阶段使用,在生产环境禁止使用
- 生产环境访问后端的数据都必须经过严格的数据校验
- 生产环境 web 访问使用 https
- 带 token 访问后端
- 生产环境禁止使用 raw 函数
# 开发建议
- 在做 web 开发时,尽量将访问后端的方法集中在一个文件夹,不要直接在前端逻辑中嵌入调用数据库方法
- 如果在一个逻辑需要多次访问后端数据库,尽量写成事务的方式,待代码稳定后,把代码移到后端,写成一个 RPC 函数,供前端调用
- 前端调用后端的函数建议如下写法,迁移到后端时,代码改动最小:
// ctx为后端的上下文,前端可以定义一个变量模拟上下文
async function fn(param) {
// 当这个函数迁移到后端成为RPC函数时
// 取消下面这行注释,调用后端函数
// return $RPC.fn(param)
let ctx
const trx = <数据库>.transaction()
try {
trx.<表名>.<方法名>()
await trx.commit()
} catch (e) {
await trx.rollback()
}
}
// 以上代码迁移到后端,做成RPC函数,改动如下(将ctx作为函数的入参):
async function fn(ctx, param) {
const trx = <数据库>.transaction()
try {
trx.<表名>.<方法名>()
await trx.commit()
} catch (e) {
await trx.rollback()
}
}
# 交流群
代码及文档还处于内测阶段,欢迎提出宝贵意见,qq 群:638713641