这节我们来学习一下数据库。哦,不会很难的,各位!
数据库存储了许多数据。(这简直是废话)
更详细地说,一个数据库中有许多不同名字的表。(MySQL 中)
一台数据库服务器上通常有许多不同名字的数据库。
相比文件而言,数据库有更好的管理方式,虽然速度不如内存(除了寄存器和缓存也就没哪个能超越内存好吧),但比磁盘更快。除此之外,数据库提供了数据引擎用于有效率地在一大堆数据中找出需要的数据。
简单说,数据库比硬盘强。
SQL 用于控制、管理、访问数据库。
SQL 是另一种语言(不是 Java 的一部分),用于在不同的数据库间获得一个尽可能统一的控制方法。
虽然 SQL 语言的格式是固定的,但目前为止,SQL 的平台差异性仍然很强。一个能在 MySQL 中正常工作的 SQL 指令(很)可能无法在 SQL Server 或者 Access 中工作。
我们介绍的是 MySQL 数据库,所有的内容都是针对它编写的。
SQL 是指令格式。
所谓指令格式,就是这样:
做这件事;
做这件事;
SQL 指令组成的也不能成为程序,因为终端向数据库发送信息时也是一条一条发送的,因此 SQL 没有「程序」的概念,一条指令就叫做一条 SQL 语句。
SQL 长得像这样:
INSERT INTO myTable (a, b, c) VALUES (1, 2, "String");
SQL 中,不同的操作的写法也不一样。幸亏我们在插件开发中只需要用到一小部分,全部介绍也不难。
选用 MySQL 的原因很简单:大多数面板服都默认搭载了这种数据库。如果我们不进行相应的调整,我们的插件就没办法很好地进行兼容。此外,MySQL (社区版)是自由的(GPL 许可证),这是她相比其它价格高昂的数据库软件的优点之一。
我们假定读者是 Windows 操作系统,实际上在 GNU/Linux 上的安装方法也很相似。
前往 MySQL 下载页面 获取安装程序。安装程序只有 32 位版本,但在安装时你可以选择安装 32 位或 64 位。下载页面提供两个版本,在线版和离线版,由于 MySQL 的速度还比较快,建议使用在线版。
下载后运行安装。所有的设置都默认就可以了。在安装时可能会出现下载失败的情况,单击「Try Again」让它再试一次就可以了。
到达「Accounts and Roles」页面时,你需要为 MySQL 的 root 用户设置一个密码,这对你而言应该很简单吧?
接着继续,到达「Connect To Server」时,你需要输入之前的密码登录一次。
最后几个「Next」,安装就完成了。「MySQL Workbench」和「MySQL Shell」会打开,但我们不需要,将它们关闭就好。
打开「开始」菜单,选择「MySQL 8.0 Command Line Client - Unicode」,单击打开。
输入之前设置的密码,即可进入 MySQL 终端。
现在,MySQL 就准备好了。
如果你刚刚登录成功了,现在你就可以在终端中输入语句了。
我们先来学习第一条语句。
我们说,MySQL 中有许多数据库,一个数据库中有许多表。一般而言,一个应用程序使用一个数据库。
现在 MySQL 中还没有数据库,我们来创建一个,这是创建数据库的语句:
CREATE DATABASE <数据库名>;
每一条 SQL 语句的最后都有分号。
请注意这是一条管理语句,也就是说,只有我们这种登录到 MySQL 终端后的管理用户才有资格使用,一般的应用程序不可以(也没必要)使用。
例如,创建名为 test
的数据库:
CREATE DATABASE test;
SQL 语句不区分大小写,上面的语句也可以写成 create database test
。一般而言,我们用大写书写关键字,用小写和 _
表示标识符(各种名字)。
USE <数据库名>;
这也是一条管理语句,用于切换当前使用的数据库。当你进入 MySQL 终端时是没有选定数据库的,这条语句用于选定一个数据库。
CREATE TABLE <表名> (
<标识符 1> <类型 1> PRIMARY KEY NOT NULL ,
<标识符 2> <类型 2> NOT NULL,
<更多标识符> <更多类型>,
<最后一个标识符> <类型后面不要加逗号>
);
MySQL 中的表必须在创建时规划好,也就是说,每个表的「列」在创建后就无法修改。
PRIMARY KEY
表示主键,主键被 MySQL 索引,查询时会有大幅的性能提升,并且可以判断是否重复。一个表只能有一个主键。
NOT NULL
表示非空,插入数据时不允许插入 NULL
或者不插入。
这两个都是可选的。
SQL 语句可以跨行,只有分号才表示结束,不换行也是可以的,表名和左括号之间的空格也不是必需的。
比如,我们要创建一个存储玩家信息的数据表:
CREATE TABLE player_data (
uuid VARCHAR(255) PRIMARY KEY NOT NULL,
name TEXT NOT NULL,
isGirl BOOLEAN NOT NULL,
money INTEGER NOT NULL
);
指定 NOT NULL
可以提供更高的安全性,不指定则可以在插入数据时只插入必要的部分。
由于创建已经存在的表会触发错误,通常我们使用更聪明的:
CREATE TABLE IF NOT EXISTS <数据表名> (
(中间的内容)
) ;
只有当表不存在时才会创建。
这就不会出现错误了。
另外,这是一条应用语句,这条语句可以被应用程序使用。(一个应用程序使用一个单独的数据库,该应用程序可以管理属于自己的多张表)
INSERT INTO <表名> (<标签 1>, <标签 2>, <标签 3>) VALUES (<值 1>, <值 2>, <值 3>)
这是插入「一行」数据到指定的表中。所有注明了 NOT NULL
的标签都必须在 VALUES
前的括号中出现,VALUES
后面的括号中的值与 VALUES
一一对应。没有 NOT NULL
修饰的可以不提供,它将被设为空。
MySQL 在插入数据时不覆盖!也就是说,在 MySQL 中可以有完全相同的两行。
INSERT INTO
有一个变种:
INSERT INTO <表名>
(<标签 1>, <标签 2>, <标签 3>)
VALUES
(<值 1>, <值 2>, <值 3>)
ON DUPLICATE KEY UPDATE <标签>=<值>, <标签>=<值>;
这个版本会在主键(PRIMARY KEY
)重复时执行后面的 UPDATE
操作,也就是设定指定的标签。这可以避免创建重复的行。
那有没有插入列的方法呢?很遗憾,没有。由于我们在创建表时就指定了列,因此无法修改。
UPDATE <表名> SET <标签 1>=<值 1>, <标签 2>=<值 2> WHERE <条件 1><操作符><值 1>
这用来更新指定的一行。
WHERE
是 MySQL 中的大杀器,它相当于 if
,后面可以跟一些条件。
WHERE isGirl=true;
最基本的 WHERE
,查找 isGirl
为真的行。
WHERE (money>=3000) OR (isGirl=true);
改进后的 WHERE
,查找 money
大于等于 3000
或者 isGirl
的行。这里也可以用 AND
(与)。不过遗憾的是,MySQL 不支持 XOR
(亦或)。除了 =
和 >=
,还有 >
,<
,!=
可以使用。
WHERE (isGirl=true) AND (name LIKE "%FHC");
LIKE
用于执行一次相似查找,%
表示「任意」。如果不使用 %
,LIKE
和 =
是一样的。上面这个方法用于查找任何以 FHC
结尾的小姐姐。
SQL 语句中所有的字符串都要打引号,与 Java 相同。
WHERE
在查找主键时非常迅速,而在查找其它值则会有性能损耗。并且对于较大的数据类型(LONGTEXT
、LONGBLOB
等)不一定能够返回正确的结果。
SELECT (<标签 1>, <标签 2>) FROM <表名> WHERE <条件 1>, <条件 2>;
SELECT
从整个表中「复制」一部分。标签是选定的列,WHERE
和上面的用法一样,查找指定的行。
以上面创建的数据表为例,这一次我想查询服务器中所有的平民玩家和小姐姐的 uuid
,就应该这样写:
SELECT uuid FROM player_data WHERE (isGirl=true) OR (money<3000);
仅选择一列时,括号可以省略。
在终端中,MySQL 会将返回值直接以表格的显示展示出来。如果要使用 Java 来读取呢?那我们就要用到 ResultSet
了。这一点马上我们就会说明。
DELETE FROM <表名> WHERE <条件>;
用于删除一行或几行。如果不使用 WHERE
将删除所有的记录!
另外 DELETE
不支持删除一「格」,要这样做应该使用 UPDATE WHERE
。
DROP TABLE <表名>;
这会删除一个表的所有数据。不可撤销!
(学着点,将来删库跑路时会用到的!)
DROP DATABASE <数据库名>;
这也是一条管理语句。
MySQL 中的文本有好几种。
CHAR(大小)
,定长字符串,大小不能超过 255,可用作主键VARCHAR(大小)
,弹性字符串,大小不能超过 16383,大小不大于 768 的可用作主键TINYTEXT
,弹性短文本,能够存储最大达 255 的字符串,不能用作主键TEXT
,弹性文本,能够存储最大达 65535 的字符串,不能用作主键MEDIUMTEXT
,弹性扩展文本,能够存储最大达 16777215 的字符串,不能用作主键LONGTEXT
,弹性长文本,能够存储最大达 4294967295 的字符串,不能用作主键
文本不适合用 WHERE
来查找,WHERE
适合查找下面这种类型……
所有的数学类型都可以用作主键。
BOOLEAN
,逻辑值,仅有true
和false
TINYINT
,极短整数,从 -128 到 127SMALLINT
,短整数,从 -32768 到 32767MEDIUMINT
,弹性短整数,从 -8388608 到 8388607INTEGER
,整数,从 -2147483648 到 2147483647BIGINT
,大整数,从 -9223372036854775808 到 9223372036854775807FLOAT
,单精度小数,从 -3.402823466 E+38 到 -1.175494351 E-38,0 以及从 1.175494351 E-38 到 3.402823466351 E+38DOUBLE
,双精度小数,能够存储极大和极小的数据(这里就不列出了,CPU 放得下的DOUBLE
都放得下)
除此之外,MySQL 中还有 BLOB
家族用于保存二进制数据,DATE
家族用于保存日期,但它们在插件开发中都不是很常用,就不介绍了。
要驾驭更强大的力量,就必须有更强大的能力。
Bukkit 没有为我们提供连接 MySQL 的方法,因此我们不得不自己连接。
数据库的各种操作都是异步(Asynchronous)的。因为虽然服务端的动作很快,但数据库软件的响应并没有那么快,如果服务端采用同步(Synchronous)方式处理,和数据库进行的交互就要浪费大量时间。我们还是举例子说明:
你在等一个快递(数据库的结果),它大概会在一个小时之后到(需要时间,相比服务端,数据库花的时间更多),而你需要写插件(后面有操作在等待)。
如果采用同步方法,那就是这样:
-
等一个小时,等到快递过来(等待数据库返回)
-
收取快递,然后写插件(继续操作)
如果只是浪费时间还好,问题是,等待数据库返回时,服务端阻塞在 1 这个位置。也许就在这个时候,有玩家的移动数据被发送过来,而服务端却不能进行处理,它在等待数据库的响应。
而如果采用异步方法,就会是这样:
- 先设置一个提醒事项,告诉自己,一会快递来的时候,如此这般……(不等待数据库的返回)
- 继续写插件(后面的处理得以及时运行)
- 当快递来了(数据库返回),暂停手中的插件开发(中断),进行快递的签收(回调函数)
- 签收完毕后切换回插件开发,继续完成其它任务
从这里不难看出,异步是在等待一个很慢的外部操作(读取文件,访问数据库等)时,不中断主程序的操作。
在 Bukkit 中,要实现异步需要多线程,幸运的是 Bukkit 已经为我们实现了这个功能。只需要使用 BukkitRunnable
就行了:
new BukkitRunnable() {
@Override
public void run() {
// 要做之事……
}
}.runTaskAsynchronously(this);
其中的 this
是插件实例,如果这些代码不是在插件主类中运行的,可以参考 EX-1-2 中的小技巧:在插件主类中暴露出 instance
。
按道理讲,MySQL 的 CP 是 PHP,她俩才是最合适的组合~
然而我们的插件使用的是 Java,钢铁直男一般的角色,显然不能直接和娇滴滴的小姑娘进行交♂流……这当然又只是玩笑,但 Java 确实无法直接和 MySQL 交互。
这时候 JDBC 出场了。JDBC 是一个驱动程序,能够帮助 Java 和 MySQL 进行连接。
JDBC 是一个库,我们需要像引入 spigot-1.16.5.jar
一样引入它。
MySQL 在安装时已经为我们准备了对应的 JDBC,但笔者还是决定手动下载,因为 JDBC 有时候会更新,而我们不可能每次都重新安装数据库。
前往 MySQL JDBC 下载地址 下载 JDBC。在下载页面中需要选择「Platform Independent」。
Windows 下载下面那个(ZIP),GNU/Linux 下载上面那个(TAR)。
单击「Download」后不会直接进入下载地址,你需要在新的页面单击「No thanks, just start my download.」
下载的文件是一个压缩包,解压它,你可以在解压的文件中找到 mysql-connector-java-8.0.23.jar
。当你阅读本教程时可能已经有了更新的版本,尽管使用就行。
这个库要怎么使用呢?我们先卖个关子,到了本章的演习(「HarmonyAuth SMART」),我们再来说它的引入方法。
出于安全原因,MySQL(和大多数其它的数据库)都使用账号密码进行身份验证。这些信息我们无法读取,因此应当让服主在配置文件中指定。除此之外,因为一个应用程序使用一个数据库,因此这个数据库名由服主为我们分配,我们也要取得这一信息。
连接 MySQL 时用到了 java.sql
包。很有趣的是,为了使用 JDBC,我们需要使用反射:
final String JDBC_DRIVER = "com.mysql.cj.jdbc.Driver"; // 驱动名称
final String PORT = "3306"; // 由服主指定
final String DB_NAME = "plugin_db"; // 由服主指定
final String DB_URL = "jdbc:mysql://localhost:" + PORT + "/" + DB_NAME + "?useSSL=false&allowPublicKeyRetrieval=true&serverTimezone=UTC";
// 连接参数的固定格式
final String USERNAME = "root"; // 由服主指定
final String PASSWORD = "******"; // 由服主指定
Connection connection;
Statement statement;
try {
Class.forName(JDBC_DRIVER); // forName 又来了!
connection = DriverManager.getConnection(DB_URL, USERNAME, PASSWORD);
} catch (ClassNotFoundException | SQLException e) {
e.printStackTrace();
}
!> 当心!
上述代码片段必须写在 BukkitRunnable
中,并进行异步调用,所有涉及数据库的操作都要这样!
这里的反射加载是一个特例,因为 SQL 的实现多种多样,jav.sql
这个包不可能考虑到所有的 SQL 驱动程序,因此这个包只使用了一个接口(一张未实现的协议),具体的实现由驱动程序完成。
我们不需要清楚内部的原理,只要知道执行一次 Class.forName
设定就完成了。如果有错,会触发 ClassNotFoundException
。
DriverManager.getConnection
方法获得一个连接。如果连不上,不要自作聪明进行处理,直接使用 printStackTrace
(或者稍微转换以下)将错误报告给服主吧。
?> 到底怎么回事?
虽然对于插件自己的错误,我们应当全力进行捕获并且处理,但对于像数据库这种外部错误,不要擅自处理。
数据库中可能包含其它插件的重要数据,如果擅自进行处理可能导致意想不到的后果,这时候我们最好的做法就是报告错误,让服务器的技术人员来处理。
获取到连接后,就可以进行操作了。
我们直接使用 PreparedStatement
类就可以创建 SQL 语句并进行执行。
注意:任何有关 SQL 的操作都可能引发 SQLException
,请进行捕获!
使用 Connection
对象的 prepareStatement
可以创建 SQL 语句,并且用 ?
以备将来插值。如果没有需要插值的地方呢?那就不用 ?
嘛……
PreparedStatement ps = connection.prepareStatement("UPDATE MYTABLE SET MYKEY = ? WHERE ID = ?");
ps.setString(1, "someKey"); // 如果用了 ?,这里就要进行插值
ps.setString(2, "someId");
// SQL 中索引从 1 开始!
我们先创建了语句,再通过 setXXX
方法设置各个 ?
处的值。这里和 Java 不一样:SQL 的第一项就是 1(Java 数组中是 0)!
上面创建好了语句,那么下面我们看看怎么执行。
// 普通的执行
ps.execute();
// 用于更新数据
int affected = ps.executeUpdate();
// 用于查询数据
ResultSet rs = ps.executeQuery(ResultSet.TYPE_SCROLL_SENSITIVE, ResultSet.CONCUR_READ_ONLY);
第一个是一般的执行,不关心结果。
第二个返回受影响的行数。
第三个用于获得 SELECT
的结果。
那 ResultSet
是什么捏?
ResultSet
是 SELECT
返回的一个「切片」,也就是一小张表。它的用法是:「先选行,再选列」。
调用它的 first
方法选择第一行,next
滚动到下一行。
ResultSet
有一系列的 getXXX
方法(和 YAML 很像)。getXXX
获得已选行中的指定列上的值。一定要选择正确的类型!如果 getXXX
与那里实际存储的值无法相互转换,就会出错。
例如,getString("UUID")
用于获取当前行中名为 UUID
的列。除了可以使用列的名字,还可以使用编号来获得,例如 getString(1)
获取当前行中第一列的内容。这里同样从 1 开始。
括号中为什么有两个参数呢?这是 executeQuery
的固定写法,如果不这样做,ResultSet.first
方法就不能正常运行。其中的原理已经超出了本教程涵盖的内容,总之,要使用这些功能,就需要这样写。
我们在行动调试中(AC-1-3)中会见到这个问题的。
处理结束后一定要关闭连接!保持连接很容易消耗 MySQL 的性能,如果一直不关闭,不知道会有什么后果!
st.close(); // 先关闭语句
connection.close(); // 再断开连接
有关 MySQL 的应用我们暂且只说到这里,在一般的插件开发中使用到的 SQL 都仅限于增删查改,因此更高级的内容我们就不介绍了。在本章的演习中我们会再次介绍 SQL。