此为系列教程的第二部分: 客户端 (前端) + 服务器端 (后端) 模式的在线记账本.

本节我们将在上节的基础上进一步完成服务器端的开发, 从而将我们的记账本变为前、后端贯通的 B/S 软件.

6. 搭建服务器端环境

有很多种技术可以用来实现网站的服务器端, 比如: PHP, ASP, ASP.NET, J2EE ….

这里我们选用 J2EE 技术, 可以把这样的网站称作 Java Web 应用程序.

既然是 Java Web 应用程序, 自然 JDK 的安装是少不了的, 请自行下载并安装 JDK.

此外, 还需要安装一个 Java Web 容器, 我们的网站将运行在 Web 容器的世界里. 同样, Java Web 容器也有很多, 比如: Tomcat, WebLogic, WebSphere, JBOSS….

当然, 你还得安装一个称手的集成开发环境 ( IDE ), 比如: Eclipse, IntelliJ IDEA ….

本教程示例代码在 JDK1.8 + Tomcat 8.5 环境测试通过.

本教程使用的 IDE 是 IntelliJ IDEA 2019.2

本小节内容在另一篇博文中有更详细的介绍, 可同时参看此文: IntelliJ IDEA + Tomcat 搭建 Web 应用程序开发环境

准备就绪, 我们就开始吧 ~

6.1 安装 JDK

这个没什么好说的, Windows 下的 JDK 安装无非就是双击运行安装程序, 一路 “Next”, 直至 “Finish”.

只是安装过程中请关注一下你的 JDK 的安装路径, 待会需要用到.

6.2 安装与配置 Tomcat

建议使用解压版的 Tomcat. 找一个你中意的位置解压 Tomcat 压缩文件, 在系统变量中添加 JAVA_HOME, 值为 JDK 安装路径.

控制台下进入 Tomcat 解压目录中的 bin 目录, 运行 startup.bat, 等待 Tomcat 启动完成.

打开浏览器, 地址栏输入 http://localhost:8080, 回车. 如果一切顺利, 你应该可以看到类似这样的页面:

Tomcat主页

看到上图的页面, Happy 一下之后即可关闭控制台窗口.

记得关闭, 否则在下一步可能导致你创建的动态 Web 项目不能正常启动

当然, 如果你是个勤奋的孩子, 可能你的电脑上还安装了诸如 php 环境之类的东东, 那它们可能会抢先一步占领 Tomcat 默认使用的 8080 端口, 此时可以打开 Tomcat conf 文件夹下的 server.xml 更改其中的端口配置.

关于 JDK, Tomcat 及 开发环境的安装配置真懒得说太多, 打开百度可以搜索到 N 个教程. 如果有问题就问度娘吧~

7. 创建动态 Web 项目

在 IDEA 中, 选择 File > New > Project… 菜单, 在弹出的 “New Project” 窗口中选择 Java Enterprise > Web Application

IDEA 新建 Web 项目

注意上图红框中的内容, 如果是空的, 选一下 或者 “New…” 一下.

然后, “Next”, 填写一个项目名称, “Finish”. 这样一个动态 Web 项目就创建好了.

如果你知道怎么使用 Maven, 强烈建议将此项目建为 Maven web-app

强烈建议自学 Maven ! 关于 Maven 可以看看 这篇文章

接着, 把前一节 ( 在线账本(一) ) 写的代码以及第三方文件依次拷贝到当前新建的项目中, 完成后大概是下图这个结构:

IDEA 新建 Web 项目

如果你是使用 Eclipse 创建的动态 Web 项目, 可能上图中的 web 文件夹名为 webContent

上图中有些文件若没有, 可从上节下载的压缩文件中拷贝(index.js 除外, 这个文件要你自己写).

在 IDEA 主界面右上角的选择 Edit Configurations, 如图-7.1:

图-7.1

Edit Configurations

在出现的 Run/Debug Configurations 窗口中注意关注一下图红框的内容, 这个 URL 将是你网站的根.

图-7.2

Configurations

关闭图-7.2窗口, 点击图-7.1中那个绿色的 “虫” (Debug…), 以调试模式启动项目.

项目启动完成后, 打开浏览器, 地址栏输入 http://localhost:8080, 回车. 你应该可以看到上一节完成的那个记账本的页面.

若图-7.2 中红框内容不是 http://localhost:8080, 则地址栏输入的 URL 相应改变一下.

到此为止, 我们把此前做的 “纯前端的记账本” 移置到了动态 Web 项目里了.

记住, 从现在开始记账本页面的正确打开方式应是 http://localhost:8080 , 而不是直接双击 index.html !

8. 建立服务器端账单的数据模型

又来啦~ 数据模型~ MVC ~

YES, 让我们仍然从数据模型开始…

在项目的 src 目录下创建 example.vo 包 (package), 并在此包中创建一个类: Bill

目录结构大概如下:

目录结构

图中 example 下的其余包 (文件夹) 可以一并创建, 以后会用到

Code-8.1: Bill.java

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
package example.vo;

import java.util.Date;

// 一条账单的数据模型 (VO类)
public class Bill {
private Integer id; // 账单ID
private Date time; // 账单时间
private Long amount; // 金额
private String memo; // 备注

public Integer getId() {
return id;
}

public void setId(Integer id) {
this.id = id;
}

public Date getTime() {
return time;
}

public void setTime(Date time) {
this.time = time;
}

public Long getAmount() {
return amount;
}

public void setAmount(Long amount) {
this.amount = amount;
}

public String getMemo() {
return memo;
}

public void setMemo(String memo) {
this.memo = memo;
}
}

看出来了吗? 这个类的结构和前端账单数据数据结构是一致的.

在服务器我们就要使用这个Bill 类的实例 (对象) 来存储一条账单的信息.

9. 实现账本服务

这个标题好诡异… 什么叫账本服务? 姑且这样吧, 我也不知道取什么标题好.

在这里, 我们在example.service包下创建一个账本服务类AccountBookService, 以提供账本数据存储, 新增账单, 删除账单等服务.

代码如下:

Code-9.1: AccountBookService.java

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
package example.service;

import example.vo.Bill;

import java.util.ArrayList;
import java.util.Date;
import java.util.Iterator;
import java.util.List;

// 账本服务类
public class AccountBookService {

// 账本服务单例
private static AccountBookService instance;

// 账本中的账单列表
private List<Bill> bills;

// 账本服务构造函数, 注意 private 修饰符, 为的是将此服务实现为"单例"
private AccountBookService() {
// 创建 AccountBookService 实例时构建一些测试用的数据
buildTestData();
}

// 静态(static) 的 getInstance() 方法返回账本服务单例
public static AccountBookService getInstance() {
if (instance == null) {
instance = new AccountBookService();
}
return instance;
}

// 返回账本中的账单列表
public List<Bill> getBills() {
return bills;
}

// 添加 1 条账单
public void addBillItem(Bill bill) {
// 将新账单插入到最前面
bills.add(0, bill);
}

// 删除 1 条账单
public void removeBillItem(Bill billToRemove) {
for (Iterator<Bill> itr = bills.iterator(); itr.hasNext(); ) {
Bill bill = itr.next();
if (bill.getId().equals(billToRemove.getId())) {
itr.remove();
break;
}
}
}


// 构建一个 5 行账单记录的账本. 测试用
private void buildTestData() {
bills = new ArrayList<>();
for (int i = 0; i < 5; i++) {
Bill bill = new Bill();
bill.setId(i);
bill.setTime(new Date());
bill.setAmount(Math.round(Math.random() * 10000));
bill.setMemo("收入" + bill.getAmount() / 100.0 + "元");
bills.add(bill);
}
}
}

解释一下:

  • 上面代码的注释中多次提到”单例”这个词, 它来自设计模式中的单例模式这个概念. 简单说, 所谓单例, 就是我们希望在当前的运行环境中, 某个类的实例(对象)只有一个(唯一的).

    显然, 在这里, 我们希望账本服务 (AccountBookService) 只有一个实例, 否则多个服务提供账本, 那这个世界不就乱了吗?

    • 关于单例模式的实现, 可参考上述代码第 14 行 及 19 ~ 31 行

    • 建议抽空自学 “设计模式”

  • 第 17 行使用一个 List<Bill> 来存储账本 (多条账单) 数据, 其中的每一个元素即为一条账单 (Bill 对象, 参见 Code-8.1).

  • 第 39 ~ 42 行, 45 ~ 53 行分别实现了新增 1 条账单和删除 1 条账单的逻辑.

    这些代码的功能和逻辑与第 (一) 节中新增/删除账单的代码类似, 只是这里使用 Java 语言描述. (参见第 (一) 节, Code-4.2.2, Code-5.2.1)

  • 第 57 ~ 67 行的 buildTestData()方法构建了一个 5 行账单记录的账本, 用于测试.

10. 监听并分发前端请求

看完前面第 8, 9 节是不是一头雾水, 到底要干什么呀, 到底要怎么把前后端对接起来呀~

别急, 现在就开始…

大致的思路是: 在服务器使用一个 Servlet 监听前端发来的请求, 在服务器端做相应的处理, 最后将处理结果反馈给前端.

为求简便, 我们来使用本人封装的小工具: Web-lighter, 关于这个东东的详细介绍, 请参看 Web-lighter 简介

安利一下! 用上 web-lighter, 真香!

web-lighter 的底层也是使用的 servlet.

现在先别忙着跑去看 web-lighter 说明书, 继续跟着来, 说不定学完本节你也就会了.

在项目的WEB-INF文件夹下创建一个名为 lib 的文件夹, 将 web-lighter 及其依赖的第三方 jar 文件 拷贝到此文件夹. 如图:

web-lighter及依赖文件

选中所有新拷入的 jar 文件, 右键菜单中选择 Add As Library… , 将这些 jar 文件添加到构建路径.

  • 以上操作的目的在于将 web-lighter 及其依赖的第三方 jar 放入项目构建路径(Build Path). 若使用其它 IDE, 操作方法与上文所述有一定差异, 但目的一致.
  • 如果你使用 Maven 则只需配置 pom.xml, 将 web-lighter 加入项目依赖. 具体方法参看 Web-lighter 简介

准备就绪, 开始写程序啦~

example.actoin包下创建BillAction类, 用于监听前端发来的请求, 进行处理后, 将结果反馈给前端.

Code-10.1: BillAction.java

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
package example.action;

import com.bailey.web.lighter.action.ActionResult;
import com.bailey.web.lighter.action.ActionSupport;
import com.bailey.web.lighter.annotation.Inject;
import com.bailey.web.lighter.annotation.Param;
import com.bailey.web.lighter.annotation.Request;
import example.service.AccountBookService;
import example.vo.Bill;

// 处理前端关于账单的请求的 Action 类
// 注意继承 com.bailey.web.lighter.action.ActionSupport
public class BillAction extends ActionSupport {

// 响应前端 /wl/getBills 请求, 将账本中的所有账单记录返回前端.
// 注意形参表中使用 @Inject 注解, 以告知 Web-lighter 将 AccountBookService 实例作为参数注入
@Request(url = "/getBills")
public ActionResult getBills(@Inject AccountBookService service) {
// service.getBills() 将获得账本中的所有账单数据 ( List<Bill> )
// 参见 Code-9.1 中 34 行
return ActionResult.success(service.getBills());
}

// 响应前端 /wl/saveOrUpdateBill 请求, 实现保存/更新账单功能
// @Inject 注解告知 Web-lighter 将 AccountBookService 实例作为参数注入
// @Param 注解告知 Web-lighter 将前端上行的 action, bill 两个参数分别解析后注入
@Request(url = "/saveOrUpdateBill")
public ActionResult saveOrUpdateBill(
@Inject AccountBookService service,
@Param(name = "action") String action,
@Param(name = "bill") Bill bill) {

// 根据上行参数 action 确定前端请求是要新增账单, 还是删除账单
switch (action) {
case "append":
// 调用 AccountBookService 中的 addBillItem() 方法将新账单数据添入账本
// 参见 Code-9.1 中第 39 行
service.addBillItem(bill);
break;
case "remove":
// 调用 AccountBookService 中的 removeBillItem() 方法将账单从账本中删除
// 参见 Code-9.1 中第 45 行
service.removeBillItem(bill);
break;
}

// 返回保存后的账单信息
return ActionResult.success(bill);
}
}

代码中注释都已经写满了, 就不对具体的代码作更多解释了.

小结:

  • BillAction负责监听前端各种请求, 然后转发给AccountBookService的具体方法来处理.
  • AccountBookService负责管理账本数据, 并提供了 添加/删除 账单功能.
  • Bill 用于封装一条账单的数据.

为保万无一失, 来对比一下我们的 src 文件夹结构是否如下图:

src目录结构

好了, 现在服务器端的事情已经干完了, 重启 Tomcat, 等待前端发来请求吧~

当然我们还得修改前端代码, 以使其能正确地和服务器端对接.

11. 对接服务器端

盼星星, 盼月亮, 终于等到了这一天~

来吧, 以下修改的是前端 index.js 中的内容.

11.1 从服务器端获取账本数据

首先我们在 index.js 中添加一个函数, 来完成向服务器端请求账本数据, 对接 Code-10.1 中第 17 ~ 22 行

Code-11.1.1

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
// 从服务器端获得账本数据
// 注意参数 callback 函数为从服务器端成功取到数据后的回调函数
function getBillsFromServer(callback) {
// 使用 jQuery 向服务器端发送 Ajax 请求
$.ajax({
url: "/wl/getBills", // 对应 Code-10.1 第 17 行, 注意多了 "/wl" 前缀
type: 'post', // 声明以 Post 方式发送请求
dataType: "json", // 告诉 jQuery, 服务器端返回的数据是 JSON 格式
beforeSend: function () { // 发送请求前的回调函数
$("#progress").show(); // 发送请求前显示进度提示(左下角一个绕圈圈的动画)
},
success: function (resp) { // 请求成功时的回调函数
if (resp.code >= 0) { // 若服务器端回应数据中状态码 code >= 0, 说明服务器端一切正常
callback(resp.result);// 回调 callback 函数, 并将服务器端返回的数据传入
}
},
error: function () { // 请求失败的回调函数
$.fail("出错啦!")
},
complete: function () { // 请求完成时的回调函数
$("#progress").hide(); // 隐藏进度提示
}
});
}

修改$(document).ready(function () {...});代码, 变成在初始时从服务器端获取账本数据, 而不再使用前端 bills 数组的账单数据. 修改后的代码如下 (注意和 Code-4.2.1进行对比):

Code-11.1.2

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
$(document).ready(function () {
// 从服务器端获取账本数据
// 此处调用的是 Code-11.1.1 中定义的那个 getBillsFromServer 函数
// 你找到 Code-11.1.1 中 getBillsFromServer 函数所需要的 callback 回调函数了吗?
getBillsFromServer(function (result) {
// 将获取到的数据赋值给 bills 数组
bills = result;

// 显示账单列表
showBillItems();

// 显示结余
refreshBalance();
});

// 注册打开/关闭记账按钮的点击事件监听
$('#btn-add-bill').click(toggleBillEditor);

// 注册 "记一笔" 按钮点击事件监听
$('#btn-confirm-add').click(addBillItem);
});

好了, 现在打开浏览器, 地址栏输入 http://localhost:8080, 是否看到的账单数据变成了服务器端的数据了?

怎么看出是从服务器端来的数据? 呵呵~ 从服务器端来的账单时间都是当前系统时间. (看服务器端代码 Code-9.1 第 57 ~ 67 行就知道了)

注意: 因为我们使用了 web-lighter, 它自动把服务器端待回传数据 (bills) 使用 JSON 格式进行了打包 (Code-10.1 第 21 行).

打开浏览器调试界面 (如: Chrome 中的开发者工具), 可以看到从服务器端回传的数据, 如图:

服务器回传数据

  • 若你未使用 web-lighter, 应注意对应地修改 Code-11.1.1 第 8 行的配置, 以及第 12 ~ 16 行和其它地方的代码逻辑. 总之, 前后端的数据格式要配套.
  • web-lighter 使用 JSON 格式对回传数据进行打包, 数据包中 code 为状态码, 默认情况下 code < 0 表示出错, result 为数据部分. 参看 Web-lighter 简介

11.2 将新增账单保存到服务器端

此前服务器端已经做好了保存账单数据的准备, 此时我们只需修改前端代码既可:

11.2.1 对接服务器端 saveOrUpdateBill

Code-11.2.1

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
// 保存数据更改到服务端
function saveToServer(action, bill, callback) {
$.ajax({
url: "/wl/saveOrUpdateBill", // 对应服务器端 (Code-10.1 第 27 行), 注意前缀"/wl"
type: 'post',
dataType: "json",
data: { // 上行参数
action: action, // 对应服务器端 Action 方法参数 (Code-10.1 第 30 行)
bill: JSON.stringify(bill) // 对应服务器端 Action 方法参数 (Code-10.1 第 31 行)
},
beforeSend: function () {
$("#progress").show();
},
success: function (resp) {
if (resp.code >= 0) {
callback(resp.result);
}
},
error: function () {
$.fail("出错啦!")
},
complete: function () {
$("#progress").hide();
}
});
}

注意: Code-11.2.1 中第 9 行使用 JSON.stringify(bill) 将账单数据按 JSON 格式进行了序列化. 即: 将 bill 对象转为 JSON 格式字符串.

11.2.2 完成新增账单数据功能

修改 index.js 中的 addBillItemData 方法(Code-4.2.2), 以将数据保存到服务器端:

Code-11.2.2

1
2
3
4
5
6
7
8
9
10
11
12
13
// 新增一条账单的数据到账本数据模型
// 参数说明:
// bill - 新账单的数据, 一个对象, 形如 Code-3.3.1 中展示的那样一坨
// callback - 数据模型更新成功后的回调函数
function addBillItemData(bill, callback) {
// 保存数据到服务器端
saveToServer('append', bill, function (newBill) {
// 更新前端账本数据, 以保持和服务器端一致
bills.unshift(newBill);
// 回调刷新界面
callback(newBill);
});
}

OK, 现在刷新一下浏览器, 试一下, 新增账单时数据是不是已经保存到服务器端了.

怎么看出来数据已经保存到服务器端?

呵呵, 若数据保存在服务器端, 新增账单后, 刷新浏览器, 新账单数据并不会丢失.

11.3 与服务器端同步删除账单

修改 index.js 中的 removeBillData()函数:

Code-11.3.1

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
// 删除一条账单的数据
function removeBillData(bill, callback) {
// 保存数据到服务器端, 调用的仍然是 Code-11.2.1 中的 saveToServer 函数, 只是参数变了
saveToServer('remove', bill, function () {
// 删除前端账本数据模型中对应的记录, 与服务器端保存一致
for (var i = 0; i < bills.length; i++) {
if (bills[i].id === bill.id) {
bills.splice(i, 1);
break;
}
}
// 回调刷新界面
callback();
});
}

注意和原先 index.js 中的 removeBillData() 函数 (Code-5.2.1) 对比一下:

上一节中我们只是从前端账本 (bills) 中移除了待删除账单 (bill), 而在这里, 我们先通知服务器端删除账单数据, 成功后再移除前端账本中的数据, 以保持前后端同步.


好了, 就这样, 我们实现了基于客户端 + 服务器模式的在线记账本.

现在, 只要不重启服务器端的 Tomcat 无论如何刷新页面(即使关闭浏览器重新开启), 你的账单数据都不会丢失.

我们来做一下小结:

  • 客户端 + 服务器模式的在线记账本的工作流程:
  1. 浏览器中打开 index.html 页面时, 客户端自动向服务器发起 Ajax 请求 ( getBillsFromServer() ), 取得账本数据, 呈现初始账单列表
  2. 当客户端有动作时(添加/删除账单), 向服务器端发送 Ajax 请求 ( saveToServer() ), 通知服务器端
  3. 服务器端更新账本数据模型, 成功后反馈更新结果
  4. 刷新前端页面
  5. 后续的用户操作, 继续重复上述 2 ~ 4 步
  • 本节使用了 web-lighter, 它只是一个服务器端的轻量级封装, 为的是简化开发过程.

    当领悟了整个开发过程后, 建议尝试一下使用原生的 Servlet 来完成 BillAction 的功能, 为的是更加清楚底层的原理. 当然, 也可以使用 Struts 等其它第三方框架来实现.

  • 服务器端同样有一个 MVC 模型, 相对而言服务器端的账本数据 (bills) 是它的 Model 层, 而 BillAction 属于服务器端的 Controller 层, 其返回的 ActionResult 更像服务器端的 View.

    服务器端的 View 层通过网络通信和客户端的 Model 层对接, 从而形成一个 MVC 链, 上游的 View 层与下游的 Model 层对接.

  • 按照 MVC 模式的低耦合架构, 使得我们即便在前一节的基础上加入了服务器端, 前端代码相较于前一节也没有太大的改动.

    其实前端代码主要变动的只是与前端的 Model 层相关的部分: 构建数据模型 ( getBillsFromServer() ) 和更新数据模型 ( addBillItemData(), removeBillData() )的那部分.

后两点是不是有点看不明白呀~ 细细揣摩一下…

下一节我们将使用数据库来存储账本数据, 这样就实现了真正意义上的在线记账本. 即使服务器端重启 Tomcat, 你的账本仍然不会丢失.

准备好了, 就继续看下一节吧…