本系列文章以我的个人博客的搭建为线索(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 }
,或者通过更进一步比较麻烦的一些处理来实现。这样的存储方案,十分不便于查询,比如说我要查询所有人的数学成绩,就显得比较僵硬了。
关系型数据库是现在最常使用的数据库类型,它的特点就是「表」。我们处理一些复杂的数据,正常人都会想到使用表格来处理。最常见的,我们要像刚刚那样记录一个班每个人的各科目成绩,就可以用一张表来记录,表的一行代表一个同学,表的一列代表一门学科,这就是关系型数据库的特点。同时,关系型数据库之所以叫做关系,是因为它其中各个表之间是有联系的。比如说,我可以在每个同学的记录里加上一个“老师”字段,其值存储的是老师的工号,而我另一个表中可以存储老师的工号和对应的姓名、薪资等,那么老师的工号就成了联系这两个表之间的字段,这就是关系型数据库名称的由来。
在后端工程中,最常用的关系型数据库就是MySQL和PostgreSQL了。这两个数据库轻度使用并没有什么区别,我比较喜欢用后者。而在客户端的项目中,由于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作为底层。其框架如图所示:
当我们创建一个使用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内进行数据库存储,一共分为三步:
- 修改数据
- 提交数据(将数据写入内存)
- 将数据写入数据库
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框架与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
}