本帖最后由 dengyu 于 2020-10-24 18:43 编辑

让你的插件使用MySQL存储数据吧!


众所周知,MySQL比用SQLite等存储方式具有体积更小、速度快等优点,下面我就为大家带来插件使用MySQL的教程
本教程所有代码采用GPLv3协议开源
教程目标:实现一个插件,使得创建一个表,包含一个int类型数据和一个最大长度为50的字符串数据
并且可以通过/ms add [int] [data]添加一行数据
通过/ms del [int]移除主键为[int]的一行数据
通过/ms find [int]获取主键为[int]的[data]数据
本教程将实现3个类
Main —— 插件的主类
MySQLManager —— 连接、控制MySQL的核心类
SQLCommand —— 包含一些MySQL指令集(枚举类型)
若此教程有任何错误请大家提出指正!
一、编写config.yml
使用过MySQL的人都知道,一般MySQL需要配置以下的东西:
  • 主机IP
  • 数据库名字
  • 用户名
  • 密码
  • 端口
这些东西可以说是缺一不可,这样的话,我们创建一份config.yml,只需要设置这五个参数,供接下来的类使用。
在插件在plugins目录下生成config.yml的时候,我们应该将其设置为自己的用户名等
上传一份作为参考:
代码点这里

二、编写插件主类
首先,我们需要实现以下功能:
  • 可以连接上MySQL数据库
  • 服务器关闭时断开数据库连接
  • 获取到配置的内容
  • 填写命令执行器
由于Bukkit只有一个线程,如果直接在主线程弄SQL,可能会导致服务器GG,这样的话,咱们就利用BukkitRunnable新建一个线程,重写run方法即可解决这个冲突。
这个类的getConfigString和getConfigInt方法用于获取配置文件里面的相应配置
在关闭服务器的时候别忘记调用shutdown关闭连接

三、编写SQLCommand枚举类型
在处理MySQL的时候,我们将使用大量的数据库指令。然而一个一个写的话会很麻烦,而且在更改的时候也容易出错。
我们创建一个叫做SQLCommand的枚举类型,存储着基本MySQL指令,需要时候随时用。
先给出代码再详细讲解
这个类型我给出了3个指令:
CREATE_TABLE1:查找是否存在一个叫做table1的表,如果没有,创建之,这个表包含一个int类型数据和一个最大长度为50的字符串数据,设置int列为主键,也就是说这个列就是代表,此列的int数据代表了该行其他的数据。
ADD_DATA:添加一行数据;
这个?在SQL语句里面是通配符,下文将通过调用setInt等方法将其替换掉。使用通配符的原因是相对于直接拼接SQL语句来说,使用PrepaerdStatement可以防止SQL注入式攻击。下文将讲述替换的方法,附录3讲述防止攻击的原理。
DELETE_DATA:删除主键为[int]的一行数据;
SELECT_DATA:查找主键为[int]的一行数据,返回该行包含的[String]数据。

四、编写MySQLManager类
这个类处理插件希望传给MySQL的各种命令,配合SQLCommand生成MySQL命令提交给MySQL执行,如果是查询的话同时返回查询结果。
我们需要先写get方法。get方法主要是返回一个MySQLManager类的实例,可以帮助我们静态引用非静态方法。首先我们可以先声明一个叫做instance的字段。设置为null,之后编写get方法。由于如果onCommand方法或者onEnable方法在调用instance的时候是null会抛出空指针异常。那么我们就可以编写方法了。
之后,我们编写一个很重要的方法,即初始化数据库的方法enableMySQL。
首先,声明一下前面提到的“五要素”,而这个方法利用写在主类里面的getConfigString和getConfigInt方法,获取配置文件的设置,通过调用connectMySQL方法连接上数据库,顺便创建一个叫做table1的表,这个创建的过程是这样的。
我不会美工啊别吐槽这图
而connectMySQL方法,主要就是发送连接数据库指令,格式为
jdbc:mysql://IP:端口/数据库?参数1=值1&参数2=值2
用户名和密码单独放在后面
Connection类型的connection是一个连接对象,在通过DriverManager.getConnection方法获取之后就可以代表一个连接。
doCommand方法需要一个PreparedStatement类型的字段为参数,调用ps.executeUpdate();即可发送给MySQL执行了。
至于为什么要新写一个doCommand方法,而不在下面直接executeUpdate,我们马上说。
注意:由于在编写代码时使用了异步(多线程)操作,也就是说connection在执行任务时服务器不需要等待其执行完毕(如果等待了就卡服了),那么会出现一个问题:
在多线程环境中,线程A正在doCommand,还没有结束的时候线程B却意外的申请doCommand,这个情况该怎么办。
换句话说,如果你执行/ms add 12 data1,在没有执行完的时候又以极快的速度执行了/ms add 13 data2,那么就很尴尬。
有一个解决办法是在doCommand加上synchronized关键字,表示给方法上锁(使得执行同步化),这就是为什么我们要新写一个doCommand方法的原因。因为加上之后如果出现这种情况,让第二个操作等待之,直到doCommand执行完之后再执行。当然这个方法在执行大量SQL语句的时候效率降低了很多。
另一个解决办法的使用连接池技术。这个技术将在下文附录1介绍。
之后,我们要写shutdown方法connection.close();指令表示关闭数据库连接对象。

现在,我们进入重点:实现对数据库的增删查
增加:编写insertData方法,用于添加一个int类型的数据和一个String类型的数据
上文提到,?是通配符,使用PreparedStatement可以防止SQL注入式攻击,原理在附录3中讲解。
那么,我们需要替换之。我们可以先这样写:
  1. public void insertData(String data1, String data2, CommandSender sender) {
  2.                 try {
  3.                         PreparedStatement ps;
  4.                         String s = SQLCommand.ADD_DATA.commandToString();
  5.                         ps = connection.prepareStatement(s);
  6.                         ps.setInt(1, Integer.parseInt(data1));
  7.                         ps.setString(2, data2);
  8.                         doCommand(ps, sender);
  9.                 } catch (SQLException e) {
  10.                         e.printStackTrace();
  11.                 } catch (NumberFormatException e) {
  12.                         sender.sendMessage("输入的不是整数,插入失败");
  13.                 }
  14.         }
复制代码

这样的话,我们可以将通配符?替换掉了,而且由于已经预编译了,在一定程度上防止了注入攻击。
删除差不多,由于我们设置了int键为主键,所以我们可以照葫芦画瓢,轻轻松松写出删除的代码
  1. public void deleteData(String data1, CommandSender sender) {
  2.                 try {
  3.                         PreparedStatement ps;
  4.                         String s = SQLCommand.DELETE_DATA.commandToString();
  5.                         ps = connection.prepareStatement(s);
  6.                         ps.setInt(1, Integer.parseInt(data1));
  7.                         doCommand(ps, sender);
  8.                 } catch (SQLException e) {
  9.                         e.printStackTrace();
  10.                 } catch (NumberFormatException e) {
  11.                         sender.sendMessage("输入的不是整数,删除失败");
  12.                 }
  13.         }
复制代码
而查询,这个是数据库操作指令中使用得最频繁最多的指令之一。为此我们先大致介绍一下用法,更高级使用请学习附录2的“实现异步回调,并且获取结果”部分。
由于我们要获取结果,所以我们需要调用executeQuery方法。这个方法返回一个ResultSet类型的结果集,而我们可以遍历这个集合获得结果。
  1. public void findData(String data1, CommandSender sender) {
  2.                 try {
  3.                         String s = SQLCommand.SELECT_DATA.commandToString();
  4.                         PreparedStatement ps = connection.prepareStatement(s);
  5.                         
  6.                         ps.setInt(1, Integer.parseInt(data1));
  7.                         ResultSet rs = ps.executeQuery();
  8.                         while (rs.next())
  9.                         {
  10.                                 String str = rs.getString("string");
  11.                                 sender.sendMessage(str);
  12.                         }
  13.                 } catch (SQLException e) {
  14.                         // TODO 自动生成的 catch 块
  15.                         sender.sendMessage("查询失败");
  16.                 } catch (NumberFormatException e) {
  17.                         sender.sendMessage("输入的不是整数,查询失败");
  18.                 }
  19.         }
复制代码
至此,我们已经学习完毕所有关于MySQL的最基本的内容了,编写一款MySQL插件应该不难了。
不过,我还是推荐大家学习一下附录的内容,这部分内容不是必须掌握的,但是可以帮助理解上文提到的部分问题
这个类的最终代码:

附录1 连接池
连接池,顾名思义,就是一个提供连接的“池子,这个“池子”是干什么用的呢?
一般而言,如果我们要连接数据库,我们应该是这样的:
由于创建(connect)、关闭(close)连接都需要消耗性能,而且如果连接量达到几百几千的话,那么我们频繁创建、销毁连接就会导致有大量性能被消耗,也就是说,实际上此时一个连接就是一种资源
于是有了连接池技术。
如果我们创建若干的已经有了的连接,这些连接就是一个“池子”,此连接不被close掉,当有大量请求过来的时候,那么服务器将会从这“池子”中调出一个连接(我们称之为getConnection),当用完连接的时候,我们不关闭,而是释放使之成为空闲状态(我们称之为releaseConnection),那么,我们可以这样理解:
这样的话,我们在刚刚那份代码里面创建一个Connection类型的数组(链表更好),最好提供Statement,ResultSet的字段,当需要连接时,提供一份Connetion、Statement与ResultSet,不用时回收之即可。
不过对于普通使用MySQL的Bukkit插件而言,一个持久的连接足够了。但是如果需要同步进行大量的访问数据库,使用连接池技术可以避免上文出现的尴尬局面,因为一个连接只能同时处理一个指令,多个并行指令使用连接池的确要好很多。
一般而言,我们不必自己写连接池,因为已经存在很快的连接池系统,叫做HikariCP。这款连接池是目前为止最快的连接池系统了,性能、稳定性都非常好。

附录2 实现异步回调,并且获取结果
一、什么是同步调用和异步调用
众所周知,我们在调用函数的时候,总会出现一个现象:
等待上一个指令执行完了才执行下一个指令。
这个执行方式叫做同步调用。
这一过程大致是这样的:

那么,如果函数1和函数2之间毫无联系,我们为了等待函数1执行完,必定会耗费大量的时间。
举个例子:如果你需要在六点钟给一个人打一个电话(执行函数1),现在时间为五点钟,而你还需要写作业(执行函数2),很明显这两个事件之间毫无联系,那么,如果你采取同步调用就会出现一个问题,在五点钟和六点钟这一段时间里面你需要干等,也不会做作业。为了解决这个问题,我们采取另一个方式,叫做异步调用
异步调用允许你在函数1没有执行完的情况下执行函数2。
那我们如何实现异步调用呢?这就需要新建一个线程了。
如果你编写普通的Java程序,则普遍存在2种方法创建线程:
  • 写一个类,继承Thread类型
  • 实现Runnable接口
而对于Bukkit插件而言,我们可以通过上文提到的方式,使用BukkitRunnable来创建新线程。
大致就像这样:


二、实现异步回调,并且获取结果
回调,最重要的一点就是先“调用”,如果A调用了B,B调用了“调用了B”的A,则称之为回调
实现回调需要一个接口,姑且称之为CallBack。
我们先重写一下findData方法:
findData方法(有问题)
  1. public void findData(String data1, CommandSender sender) {
  2.         try {
  3.                 ConnectServer cs = new ConnectServer();
  4.                 ResultSet rs = cs.getData(connection);
  5.                 //你可以随意使用这个ResultSet
  6.                 //本例先给sender发送消息
  7.                 while (rs.next())
  8.                 {
  9.                         String str = rs.getString("string");
  10.                         sender.sendMessage(str);
  11.                 }
  12.         } catch (SQLException e) {
  13.                 // TODO 自动生成的 catch 块
  14.                 e.printStackTrace();
  15.         }
  16. }
复制代码
这里我们看出,我们没有及时地把整条SQL命令送给服务器执行,而是调用了上级类ConnectServer的getData方法。那么,我们应该写一下这个上级类:
ConnectServer类(有问题)
  1. package com.dengyu.mysql;

  2. import java.sql.Connection;
  3. import java.sql.PreparedStatement;
  4. import java.sql.ResultSet;
  5. import java.sql.SQLException;

  6. public class ConnectServer {
  7.         
  8.         public ResultSet getData(Connection connection) throws SQLException {
  9.                 ResultSet rs;
  10.                 String sql = "SELECT * FROM ";
  11.                 PreparedStatement ps = connection.prepareStatement(sql);
  12.                 rs = ps.executeQuery();
  13.                 return rs;
  14.         }
  15. }
复制代码

这个类就执行了查询数据的指令。
然而,我们发现,这个指令是残缺的,因为上级类并不知道下级类想干啥。
于是,就讲到我们所说的接口回调了。
我们先写一个接口,充当两个类之间反向沟通的桥梁。
  1. package com.dengyu.mysql;

  2. public interface CallBack {
  3.         public String getSQLCommand();
  4. }
复制代码
然后,这个类需要实现这个接口,这个方法应该极其重要,掌握了查询的大权。
  1. @Override
  2.         public String getSQLCommand() {
  3.                 /*
  4.                  * 此处你应该大做文章
  5.                  * 比如写判断什么的
  6.                  * 反正就是把剩下的一半SQL指令补完
  7.                  */
  8.                 return "`TABLE1`";
  9.         }
复制代码

由于我们需要把这个类提供给上级类,那么我们就把findData方法小修一下:
findData方法
  1. public void findData(String data1, CommandSender sender) {
  2.         try {
  3.                 ConnectServer cs = new ConnectServer();
  4.                 ResultSet rs = cs.getData(MySQLManager.this, connection);//修改的是这里
  5.                 //你可以随意使用这个ResultSet
  6.                 //本例先给sender发送消息
  7.                 while (rs.next())
  8.                 {
  9.                         String str = rs.getString("string");
  10.                         sender.sendMessage(str);
  11.                 }
  12.         } catch (SQLException e) {
  13.                 // TODO 自动生成的 catch 块
  14.                 e.printStackTrace();
  15.         }
  16. }
复制代码
之后,我们也将ConnectServer类修一下:
ConnectServer
  1. package com.dengyu.mysql;

  2. import java.sql.Connection;
  3. import java.sql.PreparedStatement;
  4. import java.sql.ResultSet;
  5. import java.sql.SQLException;

  6. public class ConnectServer {
  7.         
  8.         public ResultSet getData(CallBack cb, Connection connection) throws SQLException {
  9.                 ResultSet rs;
  10.                 String sql = "SELECT * FROM ";
  11.                 String sql2 = cb.getSQLCommand();
  12.                 PreparedStatement ps = connection.prepareStatement(sql + sql2);
  13.                 rs = ps.executeQuery();
  14.                 return rs;
  15.         }
  16. }
复制代码
这样的话,我们就实现了回调方法。
整个过程大约是这样的:

至于异步嘛,由于你执行指令的时候用了BukkitRunnable,异步也已经实现了
一般的开发上面,库编写者一般会将上级类封装,只提供一个接口,开发者编写下级类的时候,如果想用调用上级类的方法,就需要实现接口供上级类回调,使得下级类可以方便地控制上级类的运作。

附录3 预防SQL注入式攻击
一、什么是SQL注入式攻击
我们可以先实现一些代码,代码如下:
  1. public void findData(String data1, CommandSender sender) {
  2.         try {
  3.                 Statement s = connection.createStatement();
  4.                 ResultSet rs;
  5.                 String sqlcmd = "SELECT * FROM `TABLE1` WHERE `int` = " + data1;
  6.                 rs = s.executeQuery(sqlcmd);
  7.                 while (rs.next())
  8.                 {
  9.                         String str = rs.getString("string");
  10.                         sender.sendMessage(str);
  11.                 }
  12.         } catch (SQLException e) {
  13.                 // TODO 自动生成的 catch 块
  14.                 sender.sendMessage("查询失败");
  15.         }
  16. }
复制代码
这个和刚刚我们在教程里面写的查询语句功能上是一样的,即查询int = [data1]时候的string数据
那么,这个代码有什么问题呢
实施攻击的人,如果把data1赋值成 0 OR 1=1
那么SQL语句就变成这个了:
SELECT * FROM `TABLE1` WHERE `int` = 0 OR 1=1
由于1恒等于1,故前面的条件它将返回所有的string数据,不会返回特定的与int值有关联的string值。
这种在输入时候输入一些恶意字符串,改变查询等原来的SQL语句的本意,欺骗服务器执行恶意的SQL命令的攻击,就叫做SQL注入式攻击。
二、如何防止注入式攻击
1.我们可以对输入字符串进行检验
上述代码中,如果我们检验一下是不是输入的是数字,不是就catch掉,比如加上这样一段代码:
  1. try {
  2.         int i = Integer.parseInt(data1);
  3. } catch (NumberFormatException e) {
  4.         System.out.println("您输入的不是数字!");
  5. }
复制代码
就可以catch掉0 OR 1=1这样的输入
2.(推荐)我们可以用PreparedStatement代替Statement
我们像教程一样写PreparedStatement,代替掉Statement。
因为Statement是SQL语句的拼接,安全性小
而PreparedStatement,我们只需要预留出占位符?,通过setInt和setString来替换
而且更好的是,PreparedStatement在调用prepareStatement方法的时候,就已经进行预编译了。
也就是说,即使再按老套路注入恶意字符串,那一些字符串也没有特殊含义,不会在SQL命令里面起“其他作用”了
这就是为什么教程代码这样写的原因。

更新日志

[groupid=1330]PluginsCDTribe[/groupid]