现代化程序开发笔记(15)——各端数据库框架

本系列文章以我的个人博客的搭建为线索(GitHub 仓库:Evian-Zhang/evian-blog),记录我在现代化程序设计中的一些笔记。在这篇文章中,我将介绍的是数据库在编程中的一些技术,以及iOS端和Android端的数据库框架。

常见数据库

众所周知,数据库就是我们在日常软件开发的时候用来存储、查询、修改持久性数据的技术。我通过之前自己做的几个小项目,积累了一些各个常见数据库的经验。

键值对数据库

要想记录数据,最最最直接的想法,就是使用键值对。我们可以把键值对数据库想象成一个字典,比如说,我要记录软件的开发者为Evian,那么就向字典中插入一个键值对{ developer: "Evian" }。我们又想记录这个软件的版本是1.2.1,那么就向字典中插入一个键值对{ version: "1.2.1" }。对于这种简单的数据,我们用键值对数据库用的很爽。

从实现上来说,键值对数据库一般都会用哈希表的形式来实现,这种实现方案带来的好处就是,插入、查询、修改都特别特别快,因为不需要什么辅助的数据结构,直接求一个哈希值就完事了。

最常见的键值对数据库就是Redis了。Redis在哈希表的基础上更进一步,实现为内存型数据库,也就是将数据都存储在内存上,这下对数据的访问读取的速度就更快了。但是,Redis的键值特性决定了它只能存储简单的数据。

因此,虽然键值对型和内存型的特性使Redis的速度很快,但是也给它带来了以下两个缺点:

  • 只能存储简单的数据
  • 数据具有易失性

针对第一个缺点,Redis常见的用途就是作为缓存。我们可以将对复杂数据的持久化数据库查询中插入一层Redis缓存,也就是如果我们查询到了数据,把它写在Redis数据库里,那么下次再查询相同的数据,我们就可以直接通过Redis的缓存来高速获得结果。

第二个缺点,虽然Redis也有把数据同步在硬盘上的功能,但是内存型数据库的特性仍然会让数据具有易失性。因此,我们可以用Redis存储一些不太重要的信息,比如说用户的Session,如果丢失,那么让用户重新登陆就行了。而用Redis存储Session的另一个重要原因在于,Redis支持分布式存储,那么我们如果使用分布式架构来做服务器,就可以保证多站点的登录信息的同步性了。

关系型数据库

之前提到,键值对数据库只能处理较简单的数据。比如说,我想存储一个班每个同学的各科成绩,如果采用键值对型数据库,我们只能这样:{ EvianMath: 90, EvianEnglish: 60, WisphaMath: 100 },或者通过更进一步比较麻烦的一些处理来实现。这样的存储方案,十分不便于查询,比如说我要查询所有人的数学成绩,就显得比较僵硬了。

关系型数据库是现在最常使用的数据库类型,它的特点就是「表」。我们处理一些复杂的数据,正常人都会想到使用表格来处理。最常见的,我们要像刚刚那样记录一个班每个人的各科目成绩,就可以用一张表来记录,表的一行代表一个同学,表的一列代表一门学科,这就是关系型数据库的特点。同时,关系型数据库之所以叫做关系,是因为它其中各个表之间是有联系的。比如说,我可以在每个同学的记录里加上一个“老师”字段,其值存储的是老师的工号,而我另一个表中可以存储老师的工号和对应的姓名、薪资等,那么老师的工号就成了联系这两个表之间的字段,这就是关系型数据库名称的由来。

在后端工程中,最常用的关系型数据库就是MySQLPostgreSQL了。这两个数据库轻度使用并没有什么区别,我比较喜欢用后者。而在客户端的项目中,由于Android的Room框架和iOS的Core Data框架,SQLite是最广泛使用的数据库。正如这些名字所暗示的,关系型数据库都支持SQL查询语句,而SQL查询语句也是为关系型数据库而量身定制的。比如说,我们要查询这个班每个同学的数学成绩,我们只需要

SELECT id, math FROM thisclass;

都不需要额外的解释,就可以直接从这个SQL语句中看出它的含义。

图数据库

Graph Databases一书中,有一个很有趣的话:

Relational Databases Lack Relationships

翻译成中文,就是关系型数据库不关系。我们刚刚提到,关系型数据库处理两个表之间的关系,是使用两个表共有的字段。这看上去很直观,而且使用同样是这本书中的例子,我们需要维护一个朋友关系网。也就是说,Alice把Bob和Zach看作朋友,Bob把Zach看作朋友,Zach把Alice和Bob看作朋友。这就是我们关系型数据库中最难处理的多对多关系。常见的处理关系就是用两张表,一张Person表中存储的是每个人的ID和姓名,一张PersonFriend表,存储的是一个关系双方的ID。比方说,我们上面这个关系中的Person表就是:

ID	Person
1	Alice
2	Bob
3	Zach

PersonFriend表就是:

PersonID	FriendID
1			2
1			3
2			3
3			1
3			2

看上去还是很清晰的。

我们如果要查询Bob的朋友:

SELECT p1.Person
FROM Person p1 JOIN PersonFriend
	ON PersonFriend.FriendID = p1.ID
JOIN Person p2
	ON PersonFriend.PersonID = p2.ID
WHERE p2.Person = 'Bob';

看上去虽然有点麻烦,但还是可以理解的:我们首先在Person表中找到名字叫Bob的人的ID,然后在PersonFriend表中找到这个ID的所有朋友的ID,然后回到Person表中找到这些ID代表的人。

那么,我们如果要查询所有Alice的朋友的朋友,会是怎样的过程呢?

SELECT p1.Person AS PERSON, p2.Person AS FRIEND_OF_FRIEND
FROM PersonFriend pf1 JOIN Person p1
	ON pf1.PersonID = p1.ID
JOIN PersonFriend pf2
	ON pf2.PersonID = pf1.FriendID
JOIN Person p2
	ON pf2.FriendID = p2.ID
WHERE p1.Person = 'Alice' AND pf2.FRIENDID <> p1.ID;

BOOM!查找某人朋友的朋友,一个很简单的需求,为什么会弄的这么复杂?用编程语言做类比的话,我们可以把关系型数据库看作C语言,我们所有的需求,都能用C语言来实现,但是,有些东西实现起来就会非常复杂,这是因为C语言本身的特性决定的。

我们处理这些多对多关系是很常见的,比如说,在我的博客中,一篇文章有多个标签,一个标签下也有多个文章。这些多对多的关系,实际上就是一个图。图是由顶点集和边集组成的,我们的一个顶点就可以看作上述朋友网中的一个人,那么一个顶点向另一个顶点有边,就代表一个人把另一个人看作朋友。我们寻找朋友的朋友,就可以看作,从这个顶点发出边相连的顶点的发出边相连的顶点,学过简单算法的人都知道,我们只需要一个特别特别简单的深度优先搜索就能实现了。

图数据库就是使用图来建模数据。我们可以把数据存储为一个一个的顶点,而数据之间的联系则存储在边上。如果同样拿编程语言做类比的话,图数据库就是使用LLVM作后端的高级编程语言。它采用与C语言不同的底层设计,同时隐藏了很多细节,并且抽象了许多更高级的特性,可以轻松地做一些用C语言做很复杂的事,但有些语言做一些底层C语言做的事却因为层次太高导致效率较低,这也是图数据库的现状。

最广泛使用的图数据库当属Neo4J,它采用Cypher语言作为查询语言。我的博客目前也是使用Neo4J作为数据库的。但是,我最关注的图数据库实际上是Dgraph。这个数据库采用Go语言编写,支持分布式存储,同时原生支持GraphQL查询语句。但目前其GraphQL的特性仍处于建设阶段,甚至连collect, max, min这种aggregation特性也没能实现。等其一旦成熟,我会将我的博客的数据库迁移至Dgraph中。

数据库相关技术

SQL注入

在现在这个时代,能被SQL注入攻击的后端都是由水平极低的一些程序员编写的。拿PHP官方的一个例子为例,我们要把用户传入的数据作为页数,查找相应的数据,也就是实现一个分页功能:

$offset = $argv[0];
$query  = "SELECT id, name FROM products ORDER BY name LIMIT 20 OFFSET $offset;";
$result = pg_query($conn, $query);

这看上去很平常,正常人都会这么写来实现这个功能呀?然而,如果用户输入了这个字符串:

0;
insert into pg_shadow(usename,usesysid,usesuper,usecatupd,passwd)
    select 'crack', usesysid, 't','t','crack'
    from pg_shadow where usename='postgres';
--

那么,我们的SQL语句就变成了:

SELECT id, name FROM products ORDER BY name LIMIT 20 OFFSET 0;
insert into pg_shadow(usename,usesysid,usesuper,usecatupd,passwd)
    select 'crack', usesysid, 't','t','crack'
    from pg_shadow where usename='postgres';
--;

通过在0后加入;,结束了当前的SQL语句。同时,在之后执行了一个新的SQL语句,也就是加入了一个新用户。然后在最后,使用--这个注释符号,将原本SQL语句之后的所有内容注释掉,防止语法错误。

Apple在今年WWDC活动中的一个演讲Secure your app: threat modeling and anti-patterns就讲得很好,我们应该在编程的时候,应该考虑的第一件事,就是各个数据的来源是否是可信的。我们再看我们之前辣鸡程序员写的PHP代码,我们使用的唯一的数据,就是$argv[0]。然而它的来源则来自于用户,而用户输入的数据一定是不可信的。因此,从开发者的角度来讲,我们对这些数据就应该进行过滤,比方说,我们不允许用户输入任何包含;--的语句等。但是无论开发者怎么绞尽脑汁,都会有各种绕过方法,CTF中SQL注入题经常就是,过滤了这个,没过滤那个,所以我们就能用那个来进行SQL注入。

对于关系型数据库的SQL语句来说,防止注入最好的方法就是使用预编译语句,这是数据库提供的功能。比方说,我们可以这样写之前的查询语句:

SELECT id, name FROM products ORDER BY name LIMIT 20 OFFSET ?;

这里的?就代表一个占位符。对于支持预编译的数据库来说,可以将这个语句预编译,然后无论接下来传入什么来替代?,都会当作OFFSET的参数,而不会参与SQL语句的编译之中。对于不支持预编译的数据库,别用。

在别的非关系型数据库中,也有类似的做法,其核心就是将传入的数据真正参数化。也就是说,我们告诉数据库,哪些是查询语句,哪些是作为参数传入的数据,而不像我们一开始的垃圾程序员一样,单纯地进行字符串的拼接。

ORM

预防SQL注入更高级的手段是ORM,而ORM的目的也不仅仅是预防SQL注入,它能更高效地帮助程序员完成自己的工作。在面向对象的语言中,我们可以把数据库的每张表看作一个类,一条记录就是这个类的一个对象,而每个字段都是它的一个属性。

Rust的Diesel框架就是一个著名的ORM库。对于一个由下面语句创建的表Post:

CREATE TABLE posts (
  id SERIAL PRIMARY KEY,
  title VARCHAR NOT NULL,
  body TEXT NOT NULL,
  published BOOLEAN NOT NULL DEFAULT 'f'
);

我们可以通过一些手段,把它与一个类绑定起来:

#[derive(Queryable)]
pub struct Post {
    pub id: i32,
    pub title: String,
    pub body: String,
    pub published: bool,
}

这是一个很自然的想法。那么,我们平时需要写的复杂查询字符串,就可以用这样的方法实现:

let results = posts.filter(published.eq(true))
        .limit(5)
        .load::<Post>(&connection)
        .expect("Error loading posts");

也就是说,我们将原本应该作为字符串的WHERE, LIMIT, SELECT等SQL语句,都变成了它在相应编程语言中的函数filter, limit, load等。而编程语言的函数,其接受的参数是带类型的,这样我们就可以把在运行SQL语句时产生的一些类型错误,在编译期就解决。同时,其底层依然使用SQL的预编译功能,所以也能很好地解决SQL注入问题。

总之,ORM最大的优点就是,我们不用再用字符串写SQL查询了,而是可以利用编程语言自己的特性,来保证SQL查询的高效性与安全性。

版本管理

代码需要版本管理,数据库也需要版本管理。这里的版本管理,并不是指那种快速回滚的功能,而是指管理对数据库本身的更改。比方说,创建一个新的表,或者增加一个新的外键等等。这一点说实话我没见过多少文章讨论,但是我觉得还是很有必要的。

一些ORM库会带数据库版本管理的功能,比如说刚刚提到的Diesel,其CLI工具就提供了这种功能。要实现这种版本管理,我们需要提供的,就是三个东西:

  • 改变的时间
  • 改变的方法
  • 复原的方法

比方说,刚刚创建Post表的SQL语句,就是一个改变的方法,而它复原的方法就是

DROP TABLE posts;

那么,当我们想要复原之前的更改,只需要用CLI工具,然后就会按时间逆顺序调用复原的方法,从而复原我们之前的操作。

各端数据库框架

iOS端

iOS端的数据库框架叫Core Data,其采用SQLite作为底层。其框架如图所示:

coredata_architecture

当我们创建一个使用Core Data的app时,整个App会自带一个NSPersistentContainer,其代表一个SQLite数据库本身。当我们需要对数据库整体进行操作,比如打开、迁移数据库时,会用到这个类。Xcode会为我们自动创建相应的模板,其中与这个类有关的就是

lazy var persistentContainer: NSPersistentContainer = {        
    let container = NSPersistentContainer(name: "DataModel")
    container.loadPersistentStores { description, error in
        if let error = error {
            fatalError("Unable to load persistent stores: \(error)")
        }
    }
    return container
}()

在应用启动时载入数据库。

而接下来的三个组成部分,则是分工明确。我们知道,在一个app内进行数据库存储,一共分为三步:

  1. 修改数据
  2. 提交数据(将数据写入内存)
  3. 将数据写入数据库

NSManagedObjectModel代表一个抽象层面的数据库,它包含我们应用需要的表,其为ORM一层。在这一层中,每一张表都会生成一个继承自NSManagedObject的类,我们通过对类进行操作,来实现第一步:描述修改数据的过程。这就是ORM层的功能。在Xcode中,一个继承NSManagedObject的类是自动生成的,如:

@objc(Quake)
public class Quake: NSManagedObject {

}

然后在其CoreDataProperties的延伸中,会增加其字段和自定义的方法:

extension Quake {

    @nonobjc public class func fetchRequest() -> NSFetchRequest<Quake> {
        return NSFetchRequest<Quake>(entityName: "Quake")
    }

    @NSManaged public var magnitude: Float
    @NSManaged public var place: String?
    @NSManaged public var time: Date?
    @NSManaged public var countries: NSSet?

}

// MARK: Generated accessors for countries
extension Quake {

    @objc(addCountriesObject:)
    @NSManaged public func addToCountries(_ value: Country)

    @objc(removeCountriesObject:)
    @NSManaged public func removeFromCountries(_ value: Country)

    @objc(addCountries:)
    @NSManaged public func addToCountries(_ values: NSSet)

    @objc(removeCountries:)
    @NSManaged public func removeFromCountries(_ values: NSSet)

}

接着,我们只需要将相应的操作提交到内存中去执行就行,这就是NSManagedObjectContext对象所做的事,它将我们之前用NSManagedObject子类描述的对数据的修改进行执行。

最后第三步,将数据写入数据库,这是NSPersistentStoreCoordinator的功能,

Android

Android的数据库框架为Room,其底层同样使用的是SQLite。Room类似一个ORM框架,但其也与传统的ORM框架有不同。

Room的基本框架如图所示:

room_architecture

其也分为三个部分:数据库,数据访问对象与实体。

Room框架与Core Data框架看待这个问题的角度不同,它将我们在App中使用数据库看作三个部分:

  • 数据库
  • 对数据的操作
  • 数据本身

用来描述数据本身的方法,是使用@Entity这个annotation。我们使用这个来将我们的普通的类变为一个ORM类:

@Entity
data class User(
    @PrimaryKey val uid: Int,
    @ColumnInfo(name = "first_name") val firstName: String?,
    @ColumnInfo(name = "last_name") val lastName: String?
)

这和我们Core Data框架中的继承NSManagedObject的思路一致,都是用一个对象来表示实际的类,也就是我们的ORM层。

而用来表示对数据的操作,则使用的是@DAO这个annotation。它与我们传统的ORM不同,传统的ORM是将SQL语句中的每个指令都对应一个函数,而将整个SQL语句变成多个函数的级联。但是,Room框架中,则是直接将原始的类SQL语句转化为单一的函数:

@Dao
interface UserDao {
    @Query("SELECT * FROM user")
    fun getAll(): List<User>

    @Query("SELECT * FROM user WHERE uid IN (:userIds)")
    fun loadAllByIds(userIds: IntArray): List<User>

    @Query("SELECT * FROM user WHERE first_name LIKE :first AND last_name LIKE :last LIMIT 1")
    fun findByName(first: String, last: String): User

    @Insert
    fun insertAll(vararg users: User)

    @Delete
    fun delete(user: User)
}

当然,在@Query@Insert等annotation中传入的并不是真正的SQL语句,其中以:开头的就代表一个对象对应的字段,比如说在loadAllByIds函数中,:userIds就代表User类的userIds字段。我们的框架会在编译期间就检查这些传入的字符串,查看字段名、类型等是否符合,也就是做了我们之前用多个函数叠加起来做出的效果。

最后用来表示整个数据库的,就是使用@Database,如:

@Database(entities = arrayOf(User::class), version = 1)
abstract class AppDatabase : RoomDatabase() {
    abstract fun userDao(): UserDao
}
注意,此版本不包含源代码,只是一个绿色的可以随时打开的版本,开发工具为:delphi7+sqlserver2000我的PDF(Perfect Developer Frame),想要 简单,快速,健壮,因此我用的还是C/S结构,因为客户可不管你用的是什么最高新的技术!经过一两年的完善,以及几个小的项目的使用,基本上还能拿得出客户正式使用!现在拿出来征求大伙的意见,看看在哪方面还要做改进,以达到更好...如果您的意见很好,被我们实现在PDF上,那么您就可以得到一份PDF 插件版的源代码.诚心希望与您的交流:EMAIL:kingjit@163.comMSN:kingjit@163.comQQ:112876992=================================================本系统使用了绿色SQLSERVER 4.0,只供下载学习,版权归微软公司所有请下载后24小时内删除系统使用方法(纯绿色版本):1.下载并解压2.打开目录下的SERVERadmin.exe,并确保数据库已经启动3.打开CLIENTXiandi.exe即可使用,密码为空这个版本是没有完成的版本,但材料的进销存还是可以用的,请帮助测试==============================2005-10-12 jacky 第一次做开发记录以前所有的都没有做开发记录,所以很多功能都写了而无法查证,所以希望以后能坚持写下去,保留一份最完整的开发记录供后人查阅.*BUG:在单据审核后使用反审核及新增单据都会报已被审核不能修改.*NEW:单据在审核或反审核,结单或反结单时都会被记录在UserLog记录中.*NEW:新增公共部分的部门和人员表,以便在其他的功能中使用.*NEW:如果在编辑状态在GRID中移动,并且定义了Button的OnClick事件,则会自动触发 但要注意,只能在Col中增加Button并写事件或者在Col的OnButtonClick中写事件才行,在Grid的onButtonClick事件中写代码,则不会触发,要注意2005-10-14 jacky*NEW:把系统改为简繁自动切换界面的软件,界面用SILANG处理,报表用RMLOCAL.LOADDLL处理,UNIT里面用CS函数转换字符串*NEW:新增一个工具,LangTrans,来自动转换SIL语言文件,默认第一个语言是简体,第二个语言是繁体的转换2005-10-15 jacky*NEW:新增单据,会自动根据BILLTYPE及TABLE等自动生成BILLNO的生成格式信息,前缀默认为B开头*BUG:通用查询,过滤,报表窗口的DFM文件都有错误,不是OBJECT开头而是inherted*NEW:LangTrans完成*BUG:TFrmSysLog中,给尚未登出人员设置颜色时,没有检测FIELD是否存在,所以出错*NEW:在模版中创建FORM时就查询Dataset中是否有被LOOKUP的字段,如果有,则其FIELD的默认值填0,函数放在FUNCTION中*BUG:在审核时,会跳到编辑界面*NEW:把单据的录入窗口中,子表的录入新增按钮等放到GRID的右边*NEW:主界面的状态栏改为9号字,看起来好看多了*NEW:单据中的字段现在也可以是常用的固定FIELDNAME自动转换成常用的中文标签*BUG:BILL模版中,结单的状态文字,错误的打成审核了*BUG:系统经常出现错误,及不稳定现象,应该是内存泄露,正在查...2005-10-16 jacky*BUG:内存泄露的问题,稍稍解决了一些,是否经得住考验还得测试*BUG:我觉得主从表好象有问题,查询从表的时候,总是不带条件的,这样从表记录多了,该如何是好啊!所以想改一下其打开的方式*NEW:OK搞定主从表重新的方式取得数据,从表不会出现没有条件的select * from detail table*NEW:以前是在DATASET的FIELD中设置只读,则表格标题为红色,必须有值,则标题显示蓝色,现在如果GRID的COLUMN也是只读,则也显示红色.*NEW:上面说的功能,把它写在了一个函数中,SetGridTitleColor,而且只在Dataset的AfterOpen中执行一次,这样程式的性能应该会有提高.2005-10-16 jacky*BUG:主窗口的状态栏上显示文字的格子不够宽,加宽一些2005-10-18 jacky*TODO:在主窗口的功能列上,在功能项上按右键,应该有一个加入收藏夹的功能*记忆:在FIELD的DEFAULTEXPRESS中填DATE表示默认当前日期*NEW:把KU中SELECT相关的过程中,设置表格宽度的参数部分,如果为负数,则不改变默认宽度*NEW:在功能项目上按右键,即有菜单把项目加入到收藏夹中*NEW:AccountManager已经被改成双语版的了2005-10-23 jacky*TODO:新增一张单据的具体操作步骤如下: 1.从uBaseBill模版中继承建立一个FORM 2.建立好主从表,并且主从表的关键字都是固定名称:BillNO 3.把类名称改名,并保存单元,然后从自动建立列表中移除 4.在REGUNIT中注册此类 5.在MenuMoudle中建立一个菜单项,其中HINT要写上刚刚注册的类名称即可 6.ADSMain中commandtext,在FROM 后面加上主表名称 7.ADSDETAIL的commandtext中一般只要写上"select * from 子表名称",即可 8.调整各dataset中字段显示的名称,及表格中的位置等等,后面做的工作就可以很灵活了,如添加LOOKUP字段及做统计等等工作了2005-10-25 jacky*NEW:旧的万能过滤感觉不太好使,所以换成了EHLIB自带的万能过滤,不知道好不好用*BUG:没有登录的情况下是不可以点收藏夹右键的*BUG:没有登录的情况下是不可以点修改密码的2005-10-26 jacky*BUG:修改导航功能,让其能正常显示与隐藏,边界大小等,哈完美解决*NEW:导航显示时,让主界面也不出现灰色标题栏*WHY:系统参数设置FORM上没有光标出现,这种现象在其他FORM上好象偶尔也会出现==好象是解决了*NEW:在FrmMain中增加处理子窗口打开的消息,完全解决只要有子FORM打开,则功能列消失的功能*NEW:在系统管理中设置系统更新路径后,就可以在程序起动期间或是手动点工具栏按钮更新程序2005-10-29 jacky*NEW:如果进入GRID后,有写代码,按右键弹出,是不是会好一些呢?*NEW:在界面上按回车跳转的功能,遇到TDBMEMO等就不应该再跳了*NEW:在SELECT_FORM中,搜索栏位选择中,不再出现隐藏的字段了*NEW:SELECT_FORM中的MEMO字段要显示出文字*TODO:如果有把DataSet中的字段拉出来,变成文本框的录入,可以在其OnEnter和OnExit事件中选择PubEnter和PubExit事件,以达到进入编辑时,改变颜色,以醒目*NEW:如果是模版建立的窗口,则自动建立OnEnter和OnExit事件,以改变控件颜色*NEW:KU.GetParam函数有了重载的版本,即是可以有预设值的,而且也不会因为没有这个参数而出错*OK:先迪的材料进销存部分,已经完成库存数量之间的触发关系,所有单据在审核后数量才会改变2005-10-30 jacky
评论
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

当前余额3.43前往充值 >
需支付:10.00
成就一亿技术人!
领取后你会自动成为博主和红包主的粉丝 规则
hope_wisdom
发出的红包
实付
使用余额支付
点击重新获取
扫码支付
钱包余额 0

抵扣说明:

1.余额是钱包充值的虚拟货币,按照1:1的比例进行支付金额的抵扣。
2.余额无法直接购买下载,可以购买VIP、付费专栏及课程。

余额充值