整合营销服务商

电脑端+手机端+微信端=数据同步管理

免费咨询热线:

Python(4):秒抢脚本(火车票、秒杀、红包)

Python(4):秒抢脚本(火车票、秒杀、红包)

者:面包君

链接:https://zhuanlan.zhihu.com/p/25214682

来源:知乎

著作权归作者所有。商业转载请联系作者获得授权,非商业转载请注明出处。

最近在写风控方面的内容,涉及到一些怎么设置网站的用户登陆安全、投资安全、运营安全方面的内容时,正好想起来去年的“月饼门”事件。对于码农来说,写个程序实现脚本抢标,这样的事情其实很简单。正好借着最近Python折腾代码的机会,整理下怎么通过Python来实现抢火车票、红包这些。

需要的工具和组件有:

  • 浏览器驱动ChromeDriver http://chromedriver.storage.googleapis.com/index.html?path=2.20/

  • Python 3.5

  • Splinter 执行:pip install splinter安装Splinter即可

重点介绍splinter怎么使用?

>>> from splinter.browser import Browser

>>> xx=Browser(driver_name="chrome")

>>> xx.visit("http://www.zhihu.com/")

介绍几个常用功能:

1. 输入:xx.fill("wd", "dataman")即可在搜索栏搜索dataman。

2. 输入:button=xx.find_by_value(u"提问")

button=xx.find_by_id(u"zu-top-add-questionSBSBSBSBSBSBSB")寻找该按钮

(通过快捷键F12查询)

3. 输入:button.click() 点击该按键

下面用12306抢火车票/京东抢手机来示例下:

#12306秒抢Python代码from splinter.browser import Browserx=Browser(driver_name="chrome")url=“https://kyfw.12306.cn/otn/leftTicket/init”x=Browser(driver_name="chrome")x.visit(url)#填写登陆账户、密码x.find_by_text(u"登录").click()x.fill("loginUserDTO.user_name","your login name")x.fill("userDTO.password","your password")#填写出发点目的地x.cookies.add({"_jc_save_fromStation":"%u4E0A%u6D77%2CSHH"})x.cookies.add({"_jc_save_fromDate":"2016-01-20"})x.cookies.add({u'_jc_save_toStation':'%u6C38%u5DDE%2CAOQ'})#加载查询x.reload()x.find_by_text(u"查询").click()#预定x.find_by_text(u"预订")[1].click()#选择乘客x.find_by_text(u"数据分析侠")[1].click()
#-*- coding:utf-8 -*-#京东抢手机脚本from splinter.browser import Browserimport time#登录页def login(b): #登录京东 b.click_link_by_text("你好,请登录") time.sleep(3) b.fill("loginname","account*****") #填写账户密码 b.fill("nloginpwd","passport*****") b.find_by_id("loginsubmit").click() time.sleep(3) return b#订单页def loop(b): #循环点击 try: if b.title=="订单结算页 -京东商城": b.find_by_text("保存收货人信息").click() b.find_by_text("保存支付及配送方式").click() b.find_by_id("order-submit").click() return b else: #多次抢购操作后,有可能会被转到京东首页,所以要再打开手机主页 b.visit("http://item.jd.com/2707976.html") b.find_by_id("choose-btn-qiang").click() time.sleep(10) loop(b) #递归操作 except Exception as e: #异常情况处理,以免中断程序 b.reload() #重新刷新当前页面,此页面为订单提交页 time.sleep(2) loop(b) #重新调用自己b=Browser(driver_name="chrome") #打开浏览器b.visit("http://item.jd.com/2707976.html")login(b)b.find_by_id("choose-btn-qiang").click() #找到抢购按钮,点击time.sleep(10) #等待10secwhile True: loop(b) if b.is_element_present_by_id("tryBtn"): #订单提交后显示“再次抢购”的话 b.find_by_id("tryBtn").click() #点击再次抢购,进入读秒5,跳转订单页 time.sleep(6.5) elif b.title=="订单结算页 -京东商城": #如果还在订单结算页 b.find_by_id("order-submit").click() else: print('恭喜你,抢购成功') break
系列文章旨在记录和总结自己在Java Web开发之路上的知识点、经验、问题和思考,希望能帮助更多(Java)码农和想成为(Java)码农的人。

目录

  1. 介绍
  2. JDBC规范
  3. 添加JDBC驱动的JAR包
  4. 注册JDBC驱动器类
  5. 连接数据库
  6. 数据库运维
  7. 执行SQL
  8. 总结

介绍

上篇文章介绍了数据库应用的开发步骤,一般包括数据库选型、数据库设计、编写数据库访问代码三个步骤。而数据库选型又因开发环境、测试环境和生产环境的不同而不同。

所以,我们在编写数据库访问代码时,要尽量使用符合SQL标准的语法。关于SQL和SQL标准知识,大家可以自行搜索。

因为我们的租房网应用只是用作演示,所以它的数据库选型和数据库设计就从简而行,我就直接使用H2Database(简称H2,可以参考这篇文章)这个数据库作为JDBC的驱动。

JDBC规范

要想了解JDBC如何使用,那最权威的资料莫过于JDBC规范了。

JDBC规范的下载与Servlet等规范类似,都可以在JCP官网(https://jcp.org/en/home/index)找到,具体如何下载可以参考这篇文章。

截止到本文成稿之日,JDBC规范的最新版本好像是4.3,对应JSR221,是在2017年完成的。

JDBC规范的内容也不算少,足足有二百多页,包括:

  1. 介绍
  2. 目标
  3. 新特性
  4. 概述
  5. 类和接口
  6. 兼容性
  7. 数据库元数据
  8. 异常
  9. 数据库连接(Connections)
  10. 事务
  11. 连接池
  12. 分布式事务
  13. SQL声明(Statements)
  14. 批量更新
  15. 结果集
  16. 高级数据类型
  17. 自定义类型映射
  18. 与连接器的关系
  19. 包装器(Wrapper)接口

由于涉及内容太多,我们只能介绍JDBC最基本的用法。

添加JDBC驱动的JAR包

上篇文章的结尾我们提到过,JDBC仅仅是一套接口,它的使用模式与接口的普遍性使用模式是类似的。

租房网应用作为使用JDBC的外部程序,它也需要配置一个具体的JDBC实现才行,这就是JDBC驱动。基本上,市面上各个数据库(特别是关系数据库)厂商都会提供自己的JDBC驱动,H2也不会例外。

那如何在租房网应用中添加H2的JDBC驱动的JAR包呢?我们当然可以到H2官网(http://www.h2database.com/html/main.html)去下载它,然后在IDE中配置第三方依赖库(可以参考这篇文章)。

不对,我们不是学习过Maven了吗?可以让Maven来帮助我们管理依赖啊。我们在这篇文章中也已经把租房网应用改造成使用Maven来管理依赖了,比如Spring等依赖。我们当然也可以直接使用Maven来添加H2的JDBC驱动这个依赖啊。

H2的所有东西都在一个JAR包里,当然也包括了它的JDBC驱动了,我们在这篇文章中下载的JAR包是 h2-1.4.200.jar 。

现在,我们像添加Spring依赖那样先到Maven仓库中(https://mvnrepository.com/ )搜索H2这个依赖,后面的在POM文件中添加依赖等具体步骤就不再赘述了,可以参考这篇文章。

H2的依赖在Maven中的描述是这样的:

<!-- https://mvnrepository.com/artifact/com.h2database/h2 -->
<dependency>
 <groupId>com.h2database</groupId>
 <artifactId>h2</artifactId>
 <version>1.4.200</version>
 <scope>test</scope>
</dependency>

将它复制到我们租房网应用的POM文件中,这样,添加H2的JDBC驱动的JAR包就一切大功告成了,当然,你必须确保你的机器能够连接上互联网,因为Maven会自动到Maven仓库中下载。

其他数据库的JDBC驱动的JAR包也可以同样方式添加到我们的租房网应用中。

注册JDBC驱动器类

在使用JDBC的外部程序(这里就是我们的租房网应用)配置一个JDBC驱动,首先要有该驱动,上面已经解决这个问题了。

然后需要在外部程序中注册该驱动的驱动器类。如何注册呢?事实上,满足 JDBC 4 规范的驱动会自动注册到外部程序中,而我们添加的H2的JDBC驱动是符合 JDBC 4 规范的。因此,这一步就可以跳过了。

事实上,JDBC驱动的这种自动注册机制是使用了JAR规范的一个特性,包含 META-INF/services/java.sql.Driver 文件的JAR文件可以自动注册驱动器类。我们可以用压缩/解压缩工具打开 h2.1.4.200.jar 看看是否有这么个文件,当然,在Eclipse中可以直接看到:

不过,还是介绍一下如何解决JDBC驱动不能自动注册的话该如何手动注册的问题。有两个方法:

  • 在使用JDBC的外部程序中编写:
Class.forName("org.h2.Driver");
  • 是在启动外部程序的命令行参数中指定 jdbc.drivers 这个属性:
Java -Djdbc.drivers=org.h2.Driver 外部程序的名字

当然,这种方式的变种是在外部程序中编写API接口来设置这个属性:

System.setProperty("jdbc.drivers", "org.h2.Driver");

假设我们的H2的JDBC驱动是不能自动注册的,那么我们应该手动注册它(当然,手动注册也是没有任何问题的,只不过是多此一举而已)。

那么,应该在哪个地方添加手动注册的代码呢?因为目前我们只有在房源服务(HouseService类)里使用到了模拟的房源数据,而未来我们是要将房源数据持久化到数据库中的,所以我们暂且把它加到这个类中吧。

与模拟的房源数据一样,把注册JDBC驱动器类的代码放到HouseService类的构造函数中:

	public HouseService() throws ClassNotFoundException {
		Class.forName("org.h2.Driver");
 
		mockHouses=new ArrayList<House>();
		mockHouses.add(new House("1", "金科嘉苑3-2-1201", "详细信息"));
		mockHouses.add(new House("2", "万科橙9-1-501", "详细信息"));
	}

当添加 Class.forName() 这一句代码后,可以看到Eclipse自动提示有编译错误:

Unhandled exception type ClassNotFoundException

这是因为Class类的forName()方法会抛出必须要我们处理的异常,如果不处理它,那就继续抛给调用者,因此,我们在构造方法的声明中添加了 throws ClassNotFoundException 这一部分,关于Java异常,我们以后详细介绍。

好,现在让我们在Eclipse中发布租房网应用到Tomcat,并启动Tomcat(可以参考这篇文章),然而在Eclipse的Console(控制台)窗口中却看到众多异常,这些异常形成一条异常链(即最底层的异常抛到外部调用者后,外部调用者捕获后包装成另一个异常再抛给它的外部调用者,如此重复下去,直到最顶层异常被处理后不再往外抛出):

可以看到,最底层的异常就是 Class.forName() 因为找不到 org.h2.Driver 这个类而抛出的异常,随后导致 HouseService 的构造函数抛出异常,因此不能实例化 HouseService ,再导致Spring IoC框架找不到 HouseService 实例来注入到 HouseRenterController 这个Bean,最后导致 dispatcher 这个Servlet初始化失败。

那到底是何原因找不到H2的JDBC驱动器类 org.h2.Driver 呢?原来是POM文件中的H2依赖的配置在作怪,我们必须把 <scope> 标签的内容设置为 compile ,或直接去掉该标签

<!-- https://mvnrepository.com/artifact/com.h2database/h2 -->
<dependency>
 <groupId>com.h2database</groupId>
 <artifactId>h2</artifactId>
 <version>1.4.200</version>
 <!-- <scope>test</scope> 必须去掉此标签-->
</dependency>

从标签的名字可以得知它是设置某种范围的,配置为 test ,则表示该依赖只在执行测试时有效;配置为 compile (此为默认值),则表示该依赖在执行编译时有效(实际上是任何时候都有效,是程度最强的依赖)。

关于Maven的依赖的相关知识,以后再详细介绍。

现在,我们重新发布应用,启动Tomcat,可以发现一切正常矣!

连接数据库

接下来,我们就可以使用JDBC API来建立与数据库的连接(数据库往往单独部署在独立的数据库服务器上,因此,需要通过网络来访问数据库)。

JDBC API主要被包含在 java.sql 和 javax.sql 这两个包中,在JDK的 rt.jar 中可以找到它们,属于Java的标准(运行时)库。

使用JDBC API中的 DriverManager 类的 getConnection() 方法即可建立与数据库的连接。

getConnection() 方法有多个重载版本(关于重载,可以参考这篇文章),其中一个版本就是:

Connection getConnection(String url, String user, String password) throws SQLException

它返回一个数据库连接,但需要传入一些参数,一般情况下,连接数据库最基本的要求是要知道如下信息:

  • 数据库URL,它包括:数据库服务器的种类(对应具体的数据库厂商)、IP地址或域名、端口、数据库名字等等;
  • 能够访问该数据库的用户名、密码。

用户名和密码都有对应的参数,是字符串类型的,显然是由DBA为我们分配的。那这个数据库URL该怎么写呢?

既然我们用的是H2,那我们到它的官网上肯定能找到答案吧。否则的话,我们得到它却不知如何使用,那H2的作者图个啥!费过一番九牛二虎之力后,终于在其官网(http://www.h2database.com/html/main.html)的左侧导航栏中的 Features 中找到了相关内容:

它支持的URL远比我们想象的要复杂的多,但基本格式都是 jdbc:h2: 开头,前缀 jdbc: 实际上是JDBC规范规定的格式,后面的 h2: 当然是指H2Database这个数据库了。

可以看到,H2为我们提供了多种模式,我们可以选择适合我们的模式。但对我们租房网应用来说,或者说对于在开发阶段的应用来说,使用嵌入式模式或者内存模式是最简单的,因为我们无需单独部署数据库服务器。我们这里就直接使用嵌入式模式吧:jdbc:h2:~/h2db/houserenter 。这里没有数据库服务器的IP地址和端口,有的只是一个路径,最后的houserenter就是一个文件,它就代表一个数据库,实际的数据就存在该文件中。当然,文件的格式我们是不知道的也不用知道。

那用户名和密码是什么呢?实际上,这里可以随便填写。但在测试环境和生产环境中,或者数据库是独立部署的环境中,用户名和密码是由DBA负责分配的。

现在,我们的HouseService类的构造函数变成:

	public HouseService() throws ClassNotFoundException, SQLException {
		Class.forName("org.h2.Driver");
		String url="jdbc:h2:~/h2db/houserenter";
		String user="sa";
		String password="";
		Connection conn=DriverManager.getConnection(url, user, password);
 
		mockHouses=new ArrayList<House>();
		mockHouses.add(new House("1", "金科嘉苑3-2-1201", "详细信息"));
		mockHouses.add(new House("2", "万科橙9-1-501", "详细信息"));
	}

注意,getConnection()方法也会抛出必须处理的异常 SQLException ,我这选择直接抛给HouseService类的构造函数的调用者。

现在,让我们重新发布应用并启动Tomcat,验证一下是否有问题,结果正常,我们也可以到 ~/h2db (波浪线实际是指你登录操作系统的用户的家目录,在Windows系统中,一般是 C:\Users\用户名\)这个目录下看看是否存在 houserenter 这个文件:

数据库运维

数据库的运维,即数据库服务器的部署和搭建,数据库、表、索引等的创建、授权,数据的备份和迁移,数据的分库分表分区等工作。这些工作往往有专门人员来负责,就是数据库管理员,即DBA。

我们做好数据库选型和设计之后,在测试环境和生产环境中(有的公司开发环境也需要),一般是向DBA申请服务器资源,让他们部署和搭建数据库服务器,建库建表等。

当然,你也可以自己一人包揽所有这些工作。

最后,一定是启动数据库服务器,这样我们的数据库应用才能建立数据库连接,当然必须保证数据库URL是正确的。即数据库连接能够建立的条件必须包括:

  • 数据库服务器在运行中;
  • URL必须正确,即数据库服务器的IP、端口必须正确,数据库的名字所指定的数据库必须已经存在;
  • 访问该数据库的用户名、密码必须正确。

而H2支持嵌入式模式,则没有诸多限制,所以在开发环境中使用是最合适不过了!

执行SQL

现在,连接已经建立好了,我们就可以用它来执行SQL语句了。不过,Connection接口并没有执行SQL的方法,而是提供了一个创建Statement对象的方法:

Statement createStatement() throws SQLException

而Statement接口拥有众多执行SQL的方法,最常用的有两个:

int java.sql.Statement.executeUpdate(String sql) throws SQLException

ResultSet java.sql.Statement.executeQuery(String sql) throws SQLException


正如方法名字所示,executeUpdate()方法主要是用来执行insert、update或者delete等数据更新(写)操作的SQL语句的(这些语句叫数据操纵语言,即Data Manipulation Language,简称DML)。当然,它还可以执行建立、删除数据表等操作的SQL语句(这些语句叫数据定义语言,即Data Definition Language,简称DDL)。

而executeQuery()方法是用来执行select等数据查询操作的SQL语句的(也属于DML)。它返回查询结果集,我们就可以使用ResultSet接口来访问查询结果了。

大家可以通过IDE或者其他方式查看它们的JavaDoc。

我们之前建立的数据库连接中使用的是 jdbc:h2:~/h2db/houserenter (简称houserenter数据库),这个库中也是H2刚刚建立的,因此该库中还没有任何数据表,所以我们要先使用 create table 语句建立数据表。

根据上篇文章我们对租房网应用的数据库设计,房源表包含三个字段,于是执行SQL的代码如下:

statement.executeUpdate("create table house(id varchar(20) primary key, name varchar(100), detail varchar(500))");

但是,如果我们把它放到HouseService类的构造函数的话,岂不是每次启动应用时都要建立一次house表,大家可以试试,这样的话第二次启动应用时会抛出house表已经存在的异常。

那该怎么办呢?我们可以在每次启动应用时都使用 drop table 语句将原来的house表删除,然后再执行建表语句:

statement.executeUpdate("drop table if exists house");

接着,我们再使用 insert 语句插入模拟的房源数据(因为我们的租房网应用还没有提供发布房源的功能):

statement.executeUpdate("insert into house values('1', '金科嘉苑3-2-1201', '详细信息') ");
statement.executeUpdate("insert into house values('2', '万科橙9-1-501', '详细信息') ");

现在,我们的HouseService类的构造方法变成这样:

	public HouseService() throws ClassNotFoundException, SQLException {
		Class.forName("org.h2.Driver");
		String url="jdbc:h2:~/h2db/houserenter";
		String user="sa";
		String password="";
		Connection conn=DriverManager.getConnection(url, user, password);
		Statement statement=conn.createStatement();
 statement.executeUpdate("drop table if exists house");
 statement.executeUpdate("create table house(id varchar(20) primary key, name varchar(100), detail varchar(500))");
 statement.executeUpdate("insert into house values('1', '金科嘉苑3-2-1201', '详细信息') ");
 statement.executeUpdate("insert into house values('2', '万科橙9-1-501', '详细信息') ");
 
		mockHouses=new ArrayList<House>();
		mockHouses.add(new House("1", "金科嘉苑3-2-1201", "详细信息"));
		mockHouses.add(new House("2", "万科橙9-1-501", "详细信息"));
	}

executeUpdate()方法的返回值是一个整型值,表示执行该SQL语句后受影响的行数,大家可以在上述代码的基础上打印每一次executeUpdate()方法的返回值,看看返回值是多少。

这样,我们的数据准备已经完成,下面应该使用executeQuery()方法来改造查询房源的方法了,原来的是这样的:

	public House findHouseById(String houseId) {
		for (House house : mockHouses) {
			if (houseId.equals(house.getId())) {
				return house;
			}
		}
		return null;
	}

首先,我们仍然需要建立数据库连接,创建Statement对象,因为上面使用的都是局部变量。这不就有代码重复了吗?没错,我们需要消除这种重复,不过,我们暂且把这个任务放一边,先重点看看如何查询数据库,如何访问查询结果集。

根据房源ID查询房源的SQL语句很简单:

select id,name,detail from house where id='这里是房源ID'

查询条件房源ID是通过方法的参数传入的,我们可以利用Java的字符串拼接功能,拼成上述SQL语句

ResultSet rs=statement.executeQuery("select id,name,detail from house where id='" + houseId + "'");

要特别注意拼接SQL语句时用到的单引号和双引号(使用PreparedStatement可以避免此现象,还可以提高性能,以后讨论),很容易出错,而且容易让黑客执行SQL注入攻击,这里就不介绍了。

当然,你必须确保house表存在,而且它有 id、name、detail 三列,这也是非常容易出错的地方,很明显,我们的代码与数据库设计紧耦合了,因此,我们需要思考一下怎么样才能解耦!

言归正传,得到结果集之后,我们就可以访问结果集中的数据了。因为查询结果可能是多条记录,或者零条记录(当然,我们这里house表是以房源ID为主键的,而查询条件就是房源ID,因此查询结果要么该房源ID的房源不存在,要么就只能有一条房源),所以必须遍历结果集,ResultSet接口提供的是 next()方法:

 while (rs.next()) {
			return new House(rs.getString("id"), rs.getString("name"), rs.getString("detail"));
 }

当然,一旦访问到一条房源记录,那就可以直接返回了。

使用next()方法指向结果集的下一条记录后(若无下一条则返回false),就可以用ResultSet接口提供的getXXX()方法来访问每一列的值了,这里的 XXX 可以是各种数据类型,比如Byte、Short、Int、Long、Float、Double、Date、String等等,传入的参数既可以是该列在select语句中的位置,也可以是selcet语句中出现的列名。

于是,我们的查询房源的方法就变为了:

	public House findHouseById(String houseId) {
		try {
			String url="jdbc:h2:~/h2db/houserenter";
			String user="sa";
			String password="";
			Connection conn=DriverManager.getConnection(url, user, password);
			Statement statement=conn.createStatement();
			ResultSet rs=statement.executeQuery("select id,name,detail from house where id='" + houseId + "'");
			
			while (rs.next()) {
				return new House(rs.getString("id"), rs.getString("name"), rs.getString("detail"));
	 }
		} catch (SQLException e) {
			System.out.println("HouseService findHouseById SQLException: " + e.getMessage() + "! houseId=" + houseId);
		}
		
		return null;
	}

需要注意的是,我这里采用了另外一种处理异常的方式,即使用 try-catch 语句捕获抛出的必须处理的异常,并处理它(仅仅打印日志而已,往往要把异常的上下文信息打印出来,便于查找问题)。

另外一个查询用户感兴趣房源的方法,也可以做相应的修改,HouseService类的构造方法中原来的添加模拟房源数据的代码也可以删除了,最后的HouseService变成了这样:

package houserenter.service;
import java.sql.Connection;
import java.sql.DriverManager;
import java.sql.ResultSet;
import java.sql.SQLException;
import java.sql.Statement;
import java.util.ArrayList;
import java.util.List;
import org.springframework.stereotype.Service;
import houserenter.entity.House;
@Service
public class HouseService {
	public HouseService() throws ClassNotFoundException, SQLException {
		Class.forName("org.h2.Driver");
		String url="jdbc:h2:~/h2db/houserenter";
		String user="sa";
		String password="";
		Connection conn=DriverManager.getConnection(url, user, password);
		Statement statement=conn.createStatement();
 statement.executeUpdate("drop table if exists house");
 statement.executeUpdate("create table house(id varchar(20) primary key, name varchar(100), detail varchar(500))");
 statement.executeUpdate("insert into house values('1', '金科嘉苑3-2-1201', '详细信息') ");
 statement.executeUpdate("insert into house values('2', '万科橙9-1-501', '详细信息') ");
	}
	public List<House> findHousesInterested(String userName) {
		// 这里查找该用户感兴趣的房源,省略,改为用模拟数据
		List<House> houses=new ArrayList<>();
		try {
			String url="jdbc:h2:~/h2db/houserenter";
			String user="sa";
			String password="";
			Connection conn=DriverManager.getConnection(url, user, password);
			Statement statement=conn.createStatement();
			ResultSet rs=statement.executeQuery("select id,name,detail from house");
			
			
			while (rs.next()) {
				houses.add(new House(rs.getString("id"), rs.getString("name"), rs.getString("detail")));
	 }
			return houses;
		} catch (SQLException e) {
			System.out.println("HouseService findHousesInterested SQLException: " + e.getMessage() + "! userName=" + userName);

		}
		
		return houses;
	}
	public House findHouseById(String houseId) {
		try {
			String url="jdbc:h2:~/h2db/houserenter";
			String user="sa";
			String password="";
			Connection conn=DriverManager.getConnection(url, user, password);
			Statement statement=conn.createStatement();
			ResultSet rs=statement.executeQuery("select id,name,detail from house where id='" + houseId + "'");
			
			while (rs.next()) {
				return new House(rs.getString("id"), rs.getString("name"), rs.getString("detail"));
	 }
		} catch (SQLException e) {
			System.out.println("HouseService findHouseById SQLException: " + e.getMessage() + "! houseId=" + houseId);
		}
		
		return null;
	}
}

我们可以重新发布应用并启动Tomcat,然后用浏览器访问租房网应用进行验证,基本上没有问题,唯一存在功能上的问题就是,我们编辑的房源没有存到数据库中,所以我们需要为HouseService类增加一个更新房源的方法,具体实现如下:

	public void updateHouseById(House house) {
		try {
			String url="jdbc:h2:~/h2db/houserenter";
			String user="sa";
			String password="";
			Connection conn=DriverManager.getConnection(url, user, password);
			Statement statement=conn.createStatement();
			statement.executeUpdate("update house set id='" + house.getId()
					+ "', name='" + house.getName()
					+ "', detail='" + house.getDetail()
					+ "' where id='" + house.getId() + "'");
		} catch (SQLException e) {
			System.out.println("HouseService updateHouseById SQLException: " + e.getMessage() + "! house=" + house.toString());
		}
	}

要非常注意各列的类型是字符串,因此SQL语句中各列的值要加上单引号!

而我们的HouseRenterController控制器类的处理房源编辑表单的Handler方法由原来的:

	@PostMapping("/house-form.action")
	public ModelAndView postHouseForm(String userName, House house) throws UnsupportedEncodingException {
		// 这里需要验证用户是否登录,省略
		//更新指定房源的详情
		House target=houseService.findHouseById(house.getId());
		target.setName(house.getName());
		target.setDetail(house.getDetail());
		//将请求转发到查找房源详情的动作
		ModelAndView mv=new ModelAndView();
		mv.setViewName("redirect:house-details.action?userName=" + userName + "&houseId=" + house.getId());
		return mv;
	}

修改为:

	@PostMapping("/house-form.action")
	public ModelAndView postHouseForm(String userName, House house) throws UnsupportedEncodingException {
		// 这里需要验证用户是否登录,省略
		//更新指定房源的详情
		houseService.updateHouseById(house);
		//将请求转发到查找房源详情的动作
		ModelAndView mv=new ModelAndView();
		mv.setViewName("redirect:house-details.action?userName=" + userName + "&houseId=" + house.getId());
		return mv;
	}

现在,再重新验证一下,编辑房源也没有问题了!

总结

  • JDBC仅仅是一套接口,实际使用要配置JDBC驱动;
  • JDBC API的使用步骤依次是:注册驱动器类、连接数据库、获取Statement对象、执行SQL语句;
  • 使用Statement接口的executeUpdate()方法执行更新操作的SQL语句(insert、update、delete和DDL等);executeQuery()方法执行查询操作的SQL语句(select);
  • 遍历查询结果集使用ResultSet接口的next()方法和getXXX()方法;

但是,我们使用JDBC的代码还有很多不足:

  • 有很多代码重复的地方;
  • 访问数据库的代码没有独立出来(事实上,应该从Service层独立出来形成DAO层);
  • 访问数据库的一些资源没有释放,比如连接、Statement、结果集;
  • 每次访问都要建立数据库连接,性能低下;
  • 与数据库设计耦合严重;
  • 正文代码中仍然有用于测试的添加模拟数据的代码;
  • 数据库访问的异常处理不够;
  • 等等

不管怎样,我们向数据持久化迈出了一步!

pring在2018年9月发布了Spring-Data-JDBC子项目的1.0.0.RELEASE版本(目前版本为1.0.6-RELEASE),Spring-Data-JDBC设计借鉴了DDD,提供了对DDD的支持,包括:

  • 聚合与聚合根
  • 仓储
  • 领域事件

在前面领域设计:聚合与聚合根一文中,通过列子介绍了聚合与聚合根;而在领域设计:领域事件一文中,通过例子介绍了领域事件。

本文结合Spring-Data-JDBC来重写这两个例子,来看一下Spring-Data-JDBC如何对DDD进行支持。

环境搭建

Spring-Data-JDBC项目还较新,文档并不齐全(Spring-Data-JDBC的文档还是以Spring-Data-JPA为基础编写的,依赖还是Spring-Data-JPA,实际不需要Spring-Data-JPA依赖),所以这里给出搭建过程中的注意点。

新建一个maven项目,pom.xml中配置

<!--这里需要引入spring-boot 2.1.0以上,2.0的boot还没有spring-data-jdbc--><parent>
 <groupId>org.springframework.boot</groupId>
 <artifactId>spring-boot-starter-parent</artifactId>
 <version>2.1.4.RELEASE</version></parent><dependencies>
 <!--引入spring-data-jdbc-->
 <dependency>
 <groupId>org.springframework.boot</groupId>
 <artifactId>spring-boot-starter-data-jdbc</artifactId>
 </dependency>
</dependencies>

开启jdbc支持

@SpringBootApplication
@EnableAutoConfiguration
@EnableJdbcRepositories // 主要是这个注解,来启动spring-data-jdbc支持@EnableTransactionManagement
public class TestConfig {
}

聚合与聚合根

领域设计:聚合与聚合根中举了两个列子:

  • Order与OrderDetail之间的关系
  • Product与ProductComment之间的关系
  • 我们通过Spring-Data-JDBC来实现这两个例子,来看一下Spring-Data-JDBC对聚合和聚合根的支持。

我们先看Order与OrderDetail。

订单与详情

Order与OrderDetail组成了一个聚合,其中Order是聚合根,聚合中的操作都是通过聚合根来完成的。

在Spring-Data-JDBC中如何表示这一层关系呢?

@Getter // 1
@Table("order_info") // 2
public class Order {
 @Id // 3
 private Long recId;
 private String name;
 private Set<OrderDetail> orderDetailList=new HashSet<>(); // 4
 public Order(String name) { // 5
 this.name=name;
 }
 // 其它字段略
 public void addDetail(String prodName) { // 6
 orderDetailList.add(new OrderDetail(prodName));
 }
}

@Getter // 1
public class OrderDetail {
 @Id // 3
 private Long recId;
 private String prodName;
 // 其它字段略
 OrderDetail(String prodName) { // 7
 this.prodName=prodName;
 }
}
  1. lombok注解,这里只提供了get方法,封装操作
  2. 默认情况下,类名与表名的映射关系是
  • 类名的首字母小写
  • 驼峰式转下划线
  • 这里order在数据库中是关键字,所以使用Table注解进行映射,映射到order_info表
  1. 通过@Id注解,标明这个类是个实体
  2. Order中持有一个OrderDetail的Set集合,标明Order与OrderDetail组成了一个聚合,且Order是聚合根
  • 聚合关系由spring-data-jdbc默认维护
  • 如果是Set集合,则order_detail表中,需要有个order_info字段,保存订单主键
  • 如果是List集合,则order_detail表中,需要有两个字段:order_info保存订单主键,order_info_key保存顺序
  1. 两个类都没有提供set方法,通过构造方法来赋值
  2. Order是聚合根,所有操作通过聚合根来操作,这里提供addDetail方法来新增订单详情
  3. 因为OrderDetail的操作都是通过Order来进行的,所以设置OrderDetail构造方法包级可见,限制了外部对OrderDetail的构建

根据上面的说明,我们的sql结构如下:

DROP TABLE IF EXISTS `order_info`;
CREATE TABLE `order_info` (
 `rec_id` BIGINT(11) NOT NULL AUTO_INCREMENT COMMENT '主键',
 `name` varchar(11) NOT NULL COMMENT '订单名称',
 PRIMARY KEY (`rec_id`)
) ENGINE=InnoDB DEFAULT CHARSET=utf8 ;

DROP TABLE IF EXISTS order_detail;
CREATE TABLE `order_detail` (
 `rec_id` BIGINT(11) NOT NULL AUTO_INCREMENT COMMENT '主键',
 `order_info` BIGINT(11) NOT NULL COMMENT '订单主键,由spring-data-jdbc自动维护',
 `prod_name` varchar(11) NOT NULL COMMENT '产品名称',
 PRIMARY KEY (`rec_id`)
) ENGINE=InnoDB DEFAULT CHARSET=utf8 ;

对聚合的操作,Spring-Data-JDBC提供了Repository接口,直接实现即可,提供了类似RubyOnRails那样的动态查询方法,不过需要通过Query注解自行编写sql,详见下文。

@Repository
public interface OrderRepository extends CrudRepository<Order, Long> {
}
  • 这里编写一个接口,继承CrudRepository接口,里面提供了基本的查询,直接使用即可

这就搞定了,我们编写一个测试,来测试一下:

@RunWith(SpringRunner.class)
@SpringBootTest(classes=TestConfig.class)
public class OrderTest {
 @Autowired
 private OrderRepository orderRepository;
 @Test
 public void testInit() {
 Order order=new Order("测试订单");
 order.addDetail("产品1");
 order.addDetail("产品2");
 Order info=orderRepository.save(order); // 1
 Optional<Order> orderInfoOptional=orderRepository.findById(info.getRecId()); // 2
 assertEquals(2, orderInfoOptional.get().getOrderDetailList().size()); // 3
 }
}
  1. 直接使用提供的save方法进行保存操作,自动处理聚合关系,也就是说这里自动保存了order及里面的两个order_detail
  2. 通过提供的findById查询出Order,这里返回的是个Optional类型
  3. 返回的Order中,自动组装了其中的order_detail。对应的删除操作,也会自动删除其关联的order_detail

产品与评论

产品与产品评论的关系如下:

  • 产品和产品评论没有业务上的一致性需求,所以是两个「聚合」
  • 产品评论通过productId与「产品聚合」进行关联

代码表示就是简单的通过id进行关联。代码如下:

@Getter
public class Product { // 1
 @Id
 private Long recId;
 private String name;
 public Product(String name) {
 this.name=name;
 }
 // 其它字段略
}

@Getter
public class ProductComment {
 @Id
 private Long recId;
 private Long productId; // 2
 private String content;
 // 其它字段略
 public ProductComment(Long productId, String content) {
 this.productId=productId;
 this.content=content;
 }
}
  1. Product中不再持有对应的集合
  2. 相应的,ProductComment中持有了产品主键字段

对应的sql如下:

DROP TABLE IF EXISTS `product`;
CREATE TABLE `product` (
 `rec_id` BIGINT(11) NOT NULL AUTO_INCREMENT COMMENT '主键',
 `name` varchar(11) NOT NULL COMMENT '产品名称',
 PRIMARY KEY (`rec_id`)
) ENGINE=InnoDB DEFAULT CHARSET=utf8 ;

DROP TABLE IF EXISTS product_comment;
CREATE TABLE `product_comment` (
 `rec_id` BIGINT(11) NOT NULL AUTO_INCREMENT COMMENT '主键',
 `product_id` BIGINT(11) NOT NULL COMMENT '产品主键,手动赋值',
 `content` varchar(11) NOT NULL COMMENT '评论内容',
 PRIMARY KEY (`rec_id`)
) ENGINE=InnoDB DEFAULT CHARSET=utf8 ;

产品和评论都是聚合根,所以都有各自的仓储类:

@Repository
public interface ProductRepository extends CrudRepository<Product, Long> {
}

@Repository
public interface ProductCommentRepository extends CrudRepository<ProductComment, Long> {

 @Query("select count(1) from product_comment where product_id=:productId") // 1
 int countByProductId(@Param("productId") Long productId); // 2

}
  1. 通过Query注解来绑定sql与方法的关系,参数以:开头。(Spring-Data-JDBC目前还不支持自动sql绑定)
  2. Param注解来标明参数名,或者使用jdk8的-parameters编译方式,来根据参数名自动绑定

熟悉Mybatis的朋友对这段代码应该很眼熟吧!

测试如下:

@RunWith(SpringRunner.class)
@SpringBootTest(classes=TestConfig.class)
public class ProductTest {
 @Autowired
 private ProductRepository productRepository;
 @Autowired
 private ProductCommentRepository productCommentRepository;

 @Test
 public void testInit() {
 Product prod=new Product("产品名称");
 Product info=productRepository.save(prod);
 ProductComment comment1=new ProductComment(info.getRecId(), "评论1"); // 1
 ProductComment comment2=new ProductComment(info.getRecId(), "评论2");
 productCommentRepository.save(comment1);
 int num=productCommentRepository.countByProductId(info.getRecId());
 assertEquals(1, num);
 productCommentRepository.save(comment2);
 num=productCommentRepository.countByProductId(info.getRecId());
 assertEquals(2, num);
 productRepository.delete(info); // 2
 num=productCommentRepository.countByProductId(info.getRecId());
 assertEquals(2, num);
 }
}
  1. 产品和评论各自保存
  2. 删除产品后,评论并不会跟着一起删除。如果需要一并删除,需要手动处理。

聚合小节

从上面的两个例子可以看出:

  • 对于同一个聚合中的多个实体,可以通过在聚合根中引用对应的实体对象,来实现聚合操作。Spring-Data-JDBC会自动处理这层关系
  • 对于不同的聚合,通过id的方式进行引用,手动处理两者的关系。这也是领域设计里推荐的做法
  • 如果实体中需要引用其他实体,但是并不想保持一致的操作,那么使用Transient注解
  • 被聚合根引用的实体对象,对应的数据库表中需要一个与聚合根同名的字段,用于保存聚合根的id。这就可以用来区分数据表之间是聚合根与实体的关系,还是聚合根与聚合根之间的关系
  • 如果表中有一个字段,字段名与另一张数据表的表名相同,其中保存的是对应的id,那么这张表是对应字段表的实体,对应字段表是聚合根
  • 如果表中的字段是「表名+id」形式,那么两张表都是聚合根,分属于不同的聚合
  • 如果两个实体之间是多对多的关系,则可以引入一个「关系值对象」,引用方持有这个「关系值对象」来维护关系。对应数据库设计,就是引入一个mapping表,代码如下:
// 来自spring示例
class Book {
 ......
 private Set<AuthorRef> authors=new HashSet<>();
}

@Table("book_author")
class AuthorRef {
 Long authorId;
}

class Author {
 ......
 String name;
}

领域事件

在领域设计:领域事件一文中使用Spring提供的ApplicationEvent演示了领域事件,这里通过对Order聚合根的扩展,来看看Spring-Data-JDBC对领域事件的支持。

假设上面的Order创建后,需要发送一个领域事件,该如何处理呢?

Spring-Data-JDBC默认提供了5个事件:

  • BeforeDeleteEvent:聚合根在被删除之前触发
  • AfterDeleteEvent:聚合根在被删除之后触发
  • BeforeSaveEvent:聚合根在被保存之前触发
  • AfterSaveEvent:聚合根在被保存之后触发
  • AfterLoadEvent:聚合根在被从仓储恢复后触发

那么对于上面的需求,我们不需要创建什么事件,只需要创建一个监听器,来监听AfterSaveEvent事件就可以了。

@Bean
public ApplicationListener<AfterSaveEvent> afterSaveEventListener() {
 return event -> {
 Object entity=event.getEntity();
 if (entity instanceof Order) {
 Order order=(Order) entity;
 System.out.println("订单[" + order.getName() + "]保存成功");
 }
 };
}

重新执行上面的OrderTest的测试方法,会得到如下输出:

订单[测试订单]保存成功

如果我们需要自定义事件,该如何处理呢?Spring-Data-JDBC提供了DomainEvents和AfterDomainEventPublication注解:

  • 被DomainEvents注解的无参方法,可以返回一个或多个事件
  • 被AfterDomainEventPublication注解的方法,可以用于事件发布后的后续处理工作
  • 这两个方法在repository.save方法执行时被调用
@Getter
public class OrderCreateEvent extends ApplicationEvent { // 1
 private String name;
 public OrderCreateEvent(Object source, String name) {
 super(source);
 this.name=name;
 }
}

@Getter
@Table("order_info")
public class Order {
 ......
 @DomainEvents
 public ApplicationEvent domainEvent() { // 2
 return new OrderCreateEvent(this, this.name);
 }
 @AfterDomainEventPublication
 public void postPublish() { // 3
 System.out.println("Event published");
 }
}

public class TestConfig {
 ......
 @Bean
 public ApplicationListener<OrderCreateEvent> orderCreateEventListener() { // 4
 return event -> {
 System.out.println("订单[" + event.getName() + "]保存成功");
 };
 }
}
  1. 自定义一个事件,具体可见领域设计:领域事件
  2. DomainEvents注解的方法,会在repository.save方法调用时创建一个OrderCreateEvent事件,传入订单名称作为参数
  3. AfterDomainEventPublication注解的方法在事件发布完成后,进行回调,可以处理事件发布后的一些处理,这里只是简单的打印
  4. OrderCreateEvent事件监听对象,监听事件进行处理

再次执行上面的OrderTest的测试方法,会得到如下输出:

订单[测试订单]保存成功 // 这是AfterSaveEvent事件触发的
订单[测试订单]保存成功 // 这是自定义事件触发的
Event published

事件小节

Spring-Data-JDBC在原来Spring事件的基础上进行了增强:

  • 新增了5个聚合根操作相关的事件
  • 通过DomainEvents注解简化了事件的发布(只在repository.save时触发)
  • 通过AfterDomainEventPublication注解处理事件发布后的回调(只在repository.save时触发)
  • 提供了AbstractAggregateRoot抽象类来进一步简化事件处理

总结

Spring-Data-JDBC的设计借鉴了DDD。本文演示了Spring-Data-JDBC如何对DDD进行支持:

  • 自动处理聚合根与实体之间的关系
  • 默认仓储接口,简化聚合存储
  • 通过注解来简化领域事件的发布

Spring-Data-JDBC还提供了如下功能:

  • MyBatis support
  • Id generation
  • Auditing
  • CustomConversions

有兴趣可自行参考文档。

参考资料

  • spring-data-jdbc文档:https://docs.spring.io/spring-data/jdbc/docs/1.0.6.RELEASE/reference/html/
  • Spring Data JDBC, References, and Aggregates:https://spring.io/blog/2018/09/24/spring-data-jdbc-references-and-aggregates