持续学习&持续更新中…
学习态度:守破离
【Java从零到架构师第二季】【The End】项目实战——简历管理项目
项目概述
该项目分为前台页面和后台管理两方面。
项目演示-前台页面
项目演示-后台管理
项目实战前的假设
https://www.bilibili.com/video/BV1R7411f7HZ
开发设计
面向接口编程
开发流程:页面 -> Servlet -> Dao -> 数据库
页面
-
个人项目:作为后端工程师,页面不需要自己写,去网上找模板或者找前端开发工程师来写静态页面(HTML)。
-
公司项目:作为后端工程师,静态页面由设计师、前端工程师提供。
-
JSP页面需要放入WEB-INF,让Servlet访问数据库后携带数据转发访问,外界不能直接访问。
-
css、js、图片等资源文件不能放入WEB-INF目录,应当暴露给浏览器,浏览器在加载渲染页面时需要使用到这些资源。
Servlet
请求规范:http://host:port/context/模块名/xxxx
-
http://host:port/context/模块名/admin
-
http://host:port/context/模块名/front
例:
http://localhost:8080/lp_resume/education/admin
http://localhost:8080/lp_resume/education/front
http://localhost:8080/lp_resume/education/save
http://localhost:8080/lp_resume/education/remove
Dao
Dao、Service命名规范:
数据库
新建一个数据库:
CREATE DATABASE lp_resume CHARACTER SET utf8mb4;
个人信息:user
# user
CREATE TABLE user (
id INT AUTO_INCREMENT,
created_time DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP,
password VARCHAR(32) NOT NULL,
email VARCHAR(50) NOT NULL UNIQUE,
birthday DATE,
photo VARCHAR(100),
intro VARCHAR(1000),
name VARCHAR(20),
address VARCHAR(100),
phone VARCHAR(20),
job VARCHAR(20),
trait VARCHAR(100),
interests VARCHAR(100),
PRIMARY KEY (id)
);
# 初始化
INSERT INTO user(email, name, password,
job, phone, birthday,
address, trait, interests,
intro) VALUES(
'[email protected]', '小码哥MJ', '3f05d332600fa9d9b7837172521ffa60',
'程序员', '9527', '1988-01-02',
'天朝广州', '活泼,可爱', '足球,台球,电玩',
'本人学识渊博、经验丰富,代码风骚、效率恐怖,C/C++ C#、Java、PHP、Android、iOS、Python、JavaScript,无不精通玩转,熟练掌握各种框架,并自写语言,创操作系统,写CPU处理器构架,做指令集成。深山苦练20余年,一天只睡3小时,千里之外定位问题,瞬息之间修复上线。身体强壮、健步如飞,可连续工作100小时不休息,讨论技术方案9小时不喝水,上至研发CPU芯片、带项目、出方案、弄计划,下至盗账号、黑网站、Shell提权挂马、攻击同行、拍片摄影、泡妞把妹纸、开挖掘机、威胁PM,啥都能干。'
);
专业技能:skill
# skill
CREATE TABLE skill (
id INT AUTO_INCREMENT,
created_time DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP,
name VARCHAR(20) NOT NULL,
level INT NOT NULL,
PRIMARY KEY (id)
);
教育信息:education
# education
CREATE TABLE education (
id INT AUTO_INCREMENT,
created_time DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP,
name VARCHAR(20) UNIQUE NOT NULL,
type INT NOT NULL,
intro VARCHAR(1000),
begin_day DATE NOT NULL,
end_day DATE,
PRIMARY KEY (id)
);
公司信息:company
# company
CREATE TABLE company (
id INT AUTO_INCREMENT,
created_time DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP,
name VARCHAR(20) NOT NULL UNIQUE,
logo VARCHAR(100),
website VARCHAR(50),
intro VARCHAR(1000),
PRIMARY KEY (id)
);
工作经验:experience
# experience
CREATE TABLE experience (
id INT AUTO_INCREMENT,
created_time DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP,
job VARCHAR(20) NOT NULL,
intro VARCHAR(1000),
begin_day DATE NOT NULL,
end_day DATE,
company_id INT NOT NULL,
PRIMARY KEY (id),
FOREIGN KEY (company_id) REFERENCES company(id)
);
项目经验:project
# project
CREATE TABLE project (
id INT AUTO_INCREMENT,
created_time DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP,
name VARCHAR(20) NOT NULL,
intro VARCHAR(1000),
website VARCHAR(50),
image VARCHAR(100),
begin_day DATE NOT NULL,
end_day DATE,
company_id INT NOT NULL,
PRIMARY KEY (id),
FOREIGN KEY (company_id) REFERENCES company(id)
);
获奖成就:award
# award
CREATE TABLE award (
id INT AUTO_INCREMENT,
created_time DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP,
name VARCHAR(20) NOT NULL,
image VARCHAR(100),
intro VARCHAR(1000),
PRIMARY KEY (id)
);
留言(联系)信息:contact
# contact
CREATE TABLE contact (
id INT AUTO_INCREMENT,
created_time DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP,
name VARCHAR(20) NOT NULL,
email VARCHAR(50) NOT NULL,
comment VARCHAR(1000) NOT NULL,
subject VARCHAR(20),
already_read INT NOT NULL DEFAULT 0,
PRIMARY KEY (id)
);
网站信息:website
# website
CREATE TABLE website (
id INT AUTO_INCREMENT,
created_time DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP,
footer VARCHAR(1000),
PRIMARY KEY (id)
);
INSERT INTO website(footer) VALUES(
'<a href="https://space.bilibili.com/325538782" target="_blank">小码哥MJ</a> © All Rights Reserved 2020'
);
上述是李明杰老师建的表,个人还想给每个表再添加一个last_update_time
字段,用来保存客户最后一次修改数据的时间。
last_update_time DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP
使用到的第三方库
Java:
<dependency>
<groupId>javax.servlet</groupId>
<artifactId>javax.servlet-api</artifactId>
<version>4.0.1</version>
<scope>provided</scope>
</dependency>
<dependency>
<groupId>org.springframework</groupId>
<artifactId>spring-jdbc</artifactId>
<version>5.2.6.RELEASE</version>
</dependency>
<dependency>
<groupId>jstl</groupId>
<artifactId>jstl</artifactId>
<version>1.2</version>
</dependency>
<dependency>
<groupId>taglibs</groupId>
<artifactId>standard</artifactId>
<version>1.1.2</version>
</dependency>
<dependency>
<groupId>mysql</groupId>
<artifactId>mysql-connector-java</artifactId>
<version>5.1.49</version>
</dependency>
<dependency>
<groupId>junit</groupId>
<artifactId>junit</artifactId>
<version>4.13</version>
<scope>test</scope>
</dependency>
<dependency>
<groupId>com.alibaba</groupId>
<artifactId>druid</artifactId>
<version>1.1.22</version>
</dependency>
<dependency>
<groupId>commons-beanutils</groupId>
<artifactId>commons-beanutils</artifactId>
<version>1.9.4</version>
</dependency>
<!-- commons-io被fileupload包含(依赖)了 -->
<dependency>
<groupId>commons-fileupload</groupId>
<artifactId>commons-fileupload</artifactId>
<version>1.4</version>
</dependency>
<dependency>
<groupId>com.fasterxml.jackson.core</groupId>
<artifactId>jackson-databind</artifactId>
<version>2.11.0</version>
</dependency>
<dependency>
<groupId>com.github.penggle</groupId>
<artifactId>kaptcha</artifactId>
<version>2.3.2</version>
</dependency>
前端:
- jquery
- jquery-validation
- md5
- sweetalert
引入Service
面向接口编程
重构开发流程为:页面 -> Servlet -> Service -> Dao -> 数据库
- Servlet:控制层
- Service:业务层
- Dao:数据访问层
文件上传
form文件上传 — 前端
只使用<input type="file" name="image" accept="image/*">
和普通POST请求,这种方式只会发送图片的文件名给服务器,不会发送该图片真正的二进制文件数据。(accept中是文件的MIME TYPE)
form文件上传 — 后台
form文件上传 — 实时预览
commons-fileupload
-
普通的GET和POST请求,最终请求体都会转化为:
id=XXX&name=XXX&intro=XXX
-
request.getParameter("")
这种方式只能解析上述格式的参数,不能解析enctype="multipart/form-data"
表单的请求体。 -
要想上传文件,必须加上
enctype="multipart/form-data"
,加上enctype之后,该form的请求体就会改变,不会是以&
分隔的字符串,也就是说,form加上enctype之后,就不能使用request.getParameter("")
这种方式解析form的参数了。 -
可以使用第三方库
commons-fileupload
来解析带有enctype的参数:
try {
ServletFileUpload upload = new ServletFileUpload(new DiskFileItemFactory());
upload.setHeaderEncoding("UTF-8");
// 一个FileItem就代表一个请求参数(不要被名字所影响,FileItem既包含文件参数,也包含非文件参数)
List<FileItem> fileItems = upload.parseRequest(req);
for (FileItem item : fileItems) {
// System.out.print(item.getFieldName() + " "); // 请求参数名 fieldName
// System.out.print(item.getName() + " "); // 文件参数的文件名 fileName
// System.out.println(item.getString("UTF-8")); // 非文件参数的值 parameter
String fieldName = item.getFieldName();
if (item.isFormField()) {
// 非文件
System.out.println("fieldName : " + fieldName + " ; " + "parameter : " + item.getString("UTF-8"));
// do something...
} else {
// 文件
System.out.println("fieldName : " + fieldName + " ; " + "fileName : " + item.getName());
// do something...
}
}
} catch (Exception e) {
forwardError(e, req, resp);
}
public static Map<String, String> parseParam(HttpServletRequest req) throws Exception {
final ServletFileUpload upload = new ServletFileUpload(new DiskFileItemFactory());
upload.setHeaderEncoding("UTF-8");
final List<FileItem> items = upload.parseRequest(req);
final Map<String, String> requestParams = new HashMap<>();
for (FileItem fileItem : items) {
final String fieldName = fileItem.getFieldName();
if (fileItem.isFormField()) {
// 非文件
requestParams.put(fieldName, fileItem.getString("UTF-8"));
} else {
// 文件
// 这里只会有一个图片文件
// 这里也可以使用fieldName,之后有时间修改
requestParams.put("finalImage", Uploads.uploadImage(req, fileItem));
}
}
return requestParams;
}
服务器端文件夹和文件名的设置
-
每个项目都应该有属于自己的文件保存路径,我们可以直接在该项目下新建一个upload文件夹来保存客户上传的文件。
-
使用
req.getServletContext().getRealPath("/")
或者req.getServletContext().getRealPath("")
可以得到该项目所在的磁盘路径,对应浏览器地址栏中的context path -
使用
req.getServletContext().getRealPath("upload")
可以得到磁盘上该项目下的upload文件夹,对应浏览器地址栏中的context/upload
目录 -
可以规定使用
req.getServletContext().getRealPath("upload/image")
文件夹来存放客户上传的图片文件。 -
可以规定使用
req.getServletContext().getRealPath("upload/video")
文件夹来存放客户上传的视频文件。 -
可以规定使用
req.getServletContext().getRealPath("upload/json")
文件夹来存放客户上传的json文件。 -
UUID.randomUUID()
可以生成一个唯一的字符串,保证文件名唯一;文件后缀使用客户上传的文件的后缀就好。
生成文件:
// final UUID uuid = UUID.randomUUID(); // 可以生成一个唯一的字符串,用于保证文件名唯一。
// getRealPath("upload/image/")得到的本地目录会和浏览器地址栏的目录对应
// 也就是说getRealPath("/")得到context path,getRealPath("upload/image/")就可以得到context/upload/image目录
String fileDir = req.getServletContext().getRealPath("upload/image/"); // image可以根据需要自行修改
String fileName = UUID.randomUUID() + "." + FilenameUtils.getExtension(item.getName());// commons-io
File file = new File(fileDir, fileName);
System.out.println(file.getAbsolutePath());
private static final String BASE_DIR = "upload";
private static final String IMAGE_DIR = "image";
public static String uploadImage(HttpServletRequest req, FileItem item) throws Exception {
// 是文件参数 用户也不一定上传了文件
// 表单中的属性用户不填时返回值都为空字符串
// 未上传文件的话,item.getName()也为空字符串
// final String paramFileName = item.getName();
// if (null == paramFileName || "".equals(paramFileName)) {
// return null; // 用户未选择文件,就不应该执行保存操作
// }
// 这样判断用户是否上传文件更严谨一点
if (item.getInputStream().available() <= 0) {
return null;
}
final String localRelativeDirStr = BASE_DIR + "/" + IMAGE_DIR + "/";
final String localFileDir = req.getServletContext().getRealPath(localRelativeDirStr);
final String localFileName = UUID.randomUUID() + "." + FilenameUtils.getExtension(paramFileName);
FileUtils.copyInputStreamToFile(item.getInputStream(), new File(localFileDir, localFileName));
return localRelativeDirStr + localFileName;
}
文件上传原理
-
数据库中的
BLOB
数据类型可以存放二进制文件数据,但是不推荐使用该数据类型来保存图片信息,因为这会降低数据库性能。 -
服务器是一台电脑,肯定有自己的硬盘。
-
服务器可以将客户端传过来的图片数据写到自己的硬盘上,然后将该图片在服务器上的相对路径(比如:
upload/image/648df28b-b2f2-4c63-ad06-db03a2989d00.jpg
)保存到数据库。 -
jsp页面使用
<img src="${ctx}/${award.image}" alt="">
即可访问该图片
代码实现(简单demo)
final ServletFileUpload upload = new ServletFileUpload(new DiskFileItemFactory());
upload.setHeaderEncoding("UTF-8");
final List<FileItem> fileItems = upload.parseRequest(req);
final Map<String, String> requestParams = new HashMap<>();
String finalImageName = null;
for (FileItem item : fileItems) {
final String fieldName = item.getFieldName();
if (item.isFormField()) {
// 非文件参数
requestParams.put(fieldName, item.getString("UTF-8"));
} else {
// 文件参数
// 是文件参数 用户也不一定上传了文件
// 未上传文件的话,item.getName()为空字符串
// 表单中的属性用户不填时返回值都为空字符串
final String paramFileName = item.getName();
// 也可以这样判断:item.getInputStream().available() <= 0
if (null == paramFileName || "".equals(paramFileName)) {
continue; // 用户未选择文件,就不应该执行保存操作
}
final String localRelativeDirStr = "upload/image/";
final String localFileDir = req.getServletContext().getRealPath(localRelativeDirStr);
final String localFileName = UUID.randomUUID() + "." + FilenameUtils.getExtension(paramFileName);
FileUtils.copyInputStreamToFile(item.getInputStream(), new File(localFileDir, localFileName));
finalImageName = localRelativeDirStr + localFileName;
}
}
final Award award = new Award();
BeanUtils.populate(award, requestParams);
// 不管之前是添加还是保存,之前有没有图片,<input name="image" type="hidden"> 致使 name为image 的非文件参数值都已经设置给award了
// 现在award.getImage()的值要么为"",要么为之前图片的路径,都是之前的数据。
// 如果用户上传了图片,那么就更新award的image字段
// 如果用户没有上传图片,不会更新award的image字段
if (finalImageName != null) {
award.setImage(finalImageName);
}
if (service.save(award)) {
resp.sendRedirect(req.getContextPath() + "/award/admin");
} else {
forwardError("获奖信息保存失败", req, resp);
}
为了实现图片上传:
<!--保存之前的图片路径-->
<!-- <input name="image" type="hidden"> 不推荐使用 -->
<input style="display: none" type="text" name="image">
<!--提交图片文件-->
<input type="file" name="imageFile" accept="image/*">
function add() {
$addSaveForm[0].reset()
$showImage.attr('src', '${ctx}/asset/admin/img/noimage.png')
$addFormBox.modal()
}
function edit(json) {
add()
for (const k in json) {
const $input = $addSaveForm.find('[name=' + k + ']')
if ($input.attr('type') !== 'file') {
$input.val(json[k])
}
}
if('' !== json.image) $showImage.attr('src', '${ctx}/' + json.image)
}
Tpl(Template)
目前的代码经过抽取封装之后,Dao、Service、Servlet变得都非常类似,都有公共代码,可以考虑自动生成一下代码模板template(tpl)
创建tpl文件
- 自己可以随意定制文件后缀名
- 主要思路是使用
#name#
来替代要生成Bean
的类名
Dao.tpl:
package programmer.lp.resume.dao;
import programmer.lp.resume.base.BaseDao;
import programmer.lp.resume.bean.#name#;
public interface #name#Dao extends BaseDao<#name#> {
}
DaoImpl.tpl:
package programmer.lp.resume.dao.impl;
import org.springframework.jdbc.core.BeanPropertyRowMapper;
import programmer.lp.resume.base.BaseDaoImpl;
import programmer.lp.resume.bean.#name#;
import programmer.lp.resume.dao.#name#Dao;
import java.util.ArrayList;
import java.util.List;
public class #name#DaoImpl extends BaseDaoImpl<#name#> implements #name#Dao {
@Override
public boolean save(#name# bean) {
String sql;
List<Object> args = new ArrayList<>();
final Integer id = bean.getId();
final String tableName = tableName();
if (id == null || id < 1) {
// 插入一条数据
sql = "INSERT INTO " + tableName + "(name, level) VALUES(?, ?)";
} else {
// 更新该条数据
sql = "UPDATE " + tableName + " SET name = ?, level = ? WHERE id = ?";
args.add(id);
}
return JDBCTEMPLATE.update(sql, args.toArray()) == 1;
}
@Override
public List<#name#> list() {
final String sql = "SELECT id, created_time, last_update_time, name, level FROM " + tableName();
return JDBCTEMPLATE.query(sql, new BeanPropertyRowMapper<>(#name#.class));
}
}
Service.tpl:
package programmer.lp.resume.service;
import programmer.lp.resume.base.BaseService;
import programmer.lp.resume.bean.#name#;
public interface #name#Service extends BaseService<#name#> {
}
ServiceImpl.tpl:
package programmer.lp.resume.service.impl;
import programmer.lp.resume.base.BaseServiceImpl;
import programmer.lp.resume.bean.#name#;
import programmer.lp.resume.service.#name#Service;
public class #name#ServiceImpl extends BaseServiceImpl<#name#> implements #name#Service {
}
Servlet.tpl:
package programmer.lp.resume.servlet;
import org.apache.commons.beanutils.BeanUtils;
import programmer.lp.resume.base.BaseServlet;
import programmer.lp.resume.bean.#name#;
import javax.servlet.annotation.WebServlet;
import javax.servlet.http.HttpServletRequest;
import javax.servlet.http.HttpServletResponse;
@WebServlet("/#name#/*")
public class #name#Servlet extends BaseServlet<#name#> {
public void admin(HttpServletRequest req, HttpServletResponse resp) {
try {
req.setAttribute("#name#", service.list());
forward(req, resp, "admin/#name#.jsp");
} catch (Exception e) {
forwardError(e, req, resp);
}
}
public void save(HttpServletRequest req, HttpServletResponse resp) {
try {
#name# #name# = new #name#();
BeanUtils.populate(#name#, req.getParameterMap());
if (service.save(#name#)) {
redirect(req, resp, "/#name#/admin");
} else {
forwardError("#name#保存失败", req, resp);
}
} catch (Exception e) {
forwardError(e, req, resp);
}
}
public void remove(HttpServletRequest req, HttpServletResponse resp) {
try {
if (service.removeAll(intIds(req.getParameterValues("id")))) {
redirect(req, resp, "#name#/admin");
} else {
forwardError("#name#删除失败", req, resp);
}
} catch (Exception e) {
forwardError(e, req, resp);
}
}
}
实现自动生成代码
package programmer.lp.resume.util;
import org.apache.commons.io.FileUtils;
import programmer.lp.resume.bean.Experience;
import programmer.lp.resume.bean.Project;
import java.io.File;
import java.util.HashMap;
import java.util.Map;
import java.util.Set;
public class AutoGenerateCode {
private static final String baseDir = "D:\\code_space\\LearnJava\\IntellijIdea\\LearnJavaEEWithXMGLMJ\\StepTWO\\lp_resume\\src\\main\\java\\programmer\\lp\\resume\\";
private static final String replacement = "#name#";
private static final Class[] classes = {
Experience.class,
Project.class
};
private static final Map<String, String> maps = new HashMap<>();
static {
maps.put("Dao", "dao");
maps.put("DaoImpl", "dao/impl");
maps.put("Service", "service");
maps.put("ServiceImpl", "service/impl");
maps.put("Servlet", "servlet");
}
public static void main(String[] args) {
try {
for (Class aClass : classes) {
final Set<Map.Entry<String, String>> entries = maps.entrySet();
for (Map.Entry<String, String> entry : entries) {
final String key = entry.getKey();
final String value = entry.getValue();
final String classSimpleName = aClass.getSimpleName();
final String tplPath = AutoGenerateCode.class.getClassLoader().getResource("tpl/" + key + ".tpl").getFile();
final String tplSourceStr = FileUtils.readFileToString(new File(tplPath), "UTF-8");
final String tplNewStr = tplSourceStr.replace(replacement, classSimpleName);
final String outFileName = classSimpleName + key + ".java";
final String outFileDir = baseDir + value;
final File finalFile = new File(outFileDir, outFileName);
if (!finalFile.exists()) {
FileUtils.write(finalFile, tplNewStr);
} else {
System.out.println(finalFile.getName() + "文件已经存在");
}
}
}
} catch (Exception e) {
e.printStackTrace();
}
}
}
复杂Bean(复杂模型)
如果两个表之间有关联,比如说,某个表的某个列使用了另外一个表的主键作为外键:
company表:
CREATE TABLE company (
id INT AUTO_INCREMENT,
created_time DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP,
name VARCHAR(20) NOT NULL UNIQUE,
logo VARCHAR(100),
website VARCHAR(50),
intro VARCHAR(1000),
PRIMARY KEY (id)
);
experience表:
CREATE TABLE experience (
id INT AUTO_INCREMENT,
created_time DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP,
job VARCHAR(20) NOT NULL,
intro VARCHAR(1000),
begin_day DATE NOT NULL,
end_day DATE,
company_id INT NOT NULL,
PRIMARY KEY (id),
FOREIGN KEY (company_id) REFERENCES company(id)
);
此时,Bean就会有点复杂,我们可以考虑如下实现:
public class Experience extends BaseBean {
private String job;
private String intro;
private Date beginDay;
private Date endDay;
private Company company;
public String getJob() {
return job;
}
public void setJob(String job) {
this.job = job;
}
public String getIntro() {
return intro;
}
public void setIntro(String intro) {
this.intro = intro;
}
public Date getBeginDay() {
return beginDay;
}
public void setBeginDay(Date beginDay) {
this.beginDay = beginDay;
}
public Date getEndDay() {
return endDay;
}
public void setEndDay(Date endDay) {
this.endDay = endDay;
}
@JsonIgnore
public Company getCompany() {
return company;
}
public void setCompany(Company company) {
this.company = company;
}
public Integer getCompanyId() {
return company.getId();
}
}
DaoImpl:
public class ExperienceDaoImpl extends BaseDaoImpl<Experience> implements ExperienceDao {
public static final String QUERY_STRING;
public static final RowMapper<Experience> ROW_MAPPER;
static {
StringBuilder sb = new StringBuilder();
sb.append("SELECT ")
.append("t1.job, t1.intro, t1.begin_day, t1.end_day, t1.id, ")
.append("t2.name, t2.logo, t2.website, t2.intro, t2.id ")
.append("FROM experience t1 ")
.append("JOIN company t2 ")
.append("ON t1.company_id = t2.id");
QUERY_STRING = sb.toString();
ROW_MAPPER = new RowMapper<Experience>() {
@Override
public Experience mapRow(ResultSet resultSet, int i) throws SQLException {
Experience experience = new Experience();
Company company = new Company();
experience.setCompany(company);
experience.setIntro(resultSet.getString("t1.intro"));
experience.setJob(resultSet.getString("t1.job"));
experience.setId(Integer.valueOf(resultSet.getString("t1.id")));
experience.setBeginDay(resultSet.getDate("t1.begin_day"));
experience.setEndDay(resultSet.getDate("t1.end_day"));
company.setLogo(resultSet.getString("t2.logo"));
company.setIntro(resultSet.getString("t2.intro"));
company.setName(resultSet.getString("t2.name"));
company.setId(Integer.valueOf(resultSet.getString("t2.id")));
company.setWebsite(resultSet.getString("t2.website"));
return experience;
}
};
}
@Override
public boolean save(Experience bean) {
String sql;
List<Object> args = new ArrayList<>();
args.add(bean.getJob());
args.add(bean.getIntro());
args.add(bean.getBeginDay());
args.add(bean.getEndDay());
args.add(bean.getCompany().getId());
final Integer id = bean.getId();
final String tableName = tableName();
if (id == null || id < 1) {
// 插入一条数据
sql = "INSERT INTO " + tableName + "(job, intro, begin_day, end_day, company_id) VALUES(?, ?, ?, ?, ?)";
} else {
// 更新该条数据
sql = "UPDATE " + tableName + " SET job = ?, intro = ?, begin_day = ?, end_day = ?, company_id = ? WHERE id = ?";
args.add(id);
}
return JDBCTEMPLATE.update(sql, args.toArray()) == 1;
}
@Override
public List<Experience> list() {
return JDBCTEMPLATE.query(QUERY_STRING, ROW_MAPPER);
}
}
登录流程
- 绝不能直接将用户密码明文发送到服务器
- 绝不能直接将用户密码明文存储到数据库
- 必须在客户端将明文加密为密文后发送给服务器
常见加密方式:MD5
- https://blueimp.github.io/JavaScript-MD5/
- https://www.cmd5.com/
MD5特点:
- 特点一:明文即使很像,所生成的MD5也不会很像,会有很大的差别。
- 特点二:不可逆
MD5的特点:将明文加密可以得到密文,但是不能使用密文直接逆推出明文。也就是说,这一过程是不可逆的。
如果真的想通过密文得到明文,只能使用暴力枚举法:枚举出所有可能的明文,然后利用MD5进行加密,将枚举得出的密文与要破解的密文进行一一比较。
优化后的登录流程:
<input type="text" style="display: none;" name="password">
<input id="originPassword" type="password" class="form-control" maxlength="20" placeholder="密码" required>
使用md5.min.js
中的md5()
对密码进行加密:
addValidatorRules('.form-validation', function () {
$('[name=password]').val(md5($('#originPassword').val()))
return true
})
验证码
只单单使用MD5加密用户密码这一种安全机制,还是会有很多安全问题存在。
比如说:别人(黑客、恶意软件等)可以拦截获取用户登录时我们通过MD5加密之后的数据,并通过GET/POST方式来登录我们的网站(黑客完全可以自己写一个表单然后填充拦截到的用户和密码数据来实现登录功能)。
那么该怎么办呢?
使用验证码!
# 图片边框
kaptcha.border=yes
# 边框颜色
kaptcha.border.color=105,179,90
# 字体颜色
kaptcha.textproducer.font.color=blue
# 图片宽
kaptcha.image.width=130
# 图片高
kaptcha.image.height=48
# 字体大小
kaptcha.textproducer.font.size=30
# session key
kaptcha.session.key=code
# 验证码长度
kaptcha.textproducer.char.length=4
# 字体
kaptcha.textproducer.font.names=微软雅黑
public void captcha(HttpServletRequest request, HttpServletResponse response) throws Exception {
// 创建Katpcha对象
DefaultKaptcha dk = new DefaultKaptcha();
// 验证码的配置
try (InputStream is = getClass().getClassLoader().getResourceAsStream("kaptcha.properties")) {
Properties properties = new Properties();
properties.load(is);
Config config = new Config(properties);
dk.setConfig(config);
}
// 生成验证码字符串
String code = dk.createText();
// 生成验证码图片
BufferedImage image = dk.createImage(code);
// 设置返回数据的格式
response.setContentType("image/jpeg");
// 将图片数据写会到客户端
ImageIO.write(image, "jpg", response.getOutputStream());
}
<img id="captcha" src="${ctx}/user/captcha" alt="验证码">
实现点击刷新验证码功能
$('#captcha').click(function () {
// 浏览器会有缓存功能,如果浏览器发现请求的URL是已经请求过得,就不会再次重新发送请求给服务器了,因此每次的URL必须不同
$(this).attr('src', '${ctx}/user/captcha?time=' + new Date().getTime())
})
加入动画:
$('#captcha').click(function () {
$(this).hide().attr('src', '${ctx}/user/captcha?time=' + new Date().getTime()).fadeIn()
})
当我们访问一个网页时,如果该网页使用了CSS、image等资源的话,那么绝对不会是一次请求就可以完成的,浏览器会自动向服务器请求所需的这些资源。
因此,无论我们在img标签中写静态图片,还是像上面写的那样(写一个网址(${ctx}/user/captcha)在img标签中),实际上都会由浏览器向服务器请求该资源,只不过静态图片资源是由服务器帮我们写出去的;而当我们自己写网址在img标签时,我们需要自己完成响应图片的操作。
也就是说,不管标签请求的URL是什么,只要能够返回图片数据过去就可以。
多次请求之间的数据共享问题
问题
问题:登录时,服务器如何拿到之前生成验证码时的字符串?
难点:
-
之前的数据共享方式是使用
request.setAttribute("");
和request.getAttribute("");
来实现的(请求转发),原因在于之前的请求都是同一次完整的请求,只有同一次(完整的)请求才可以使用这种方式实现数据共享。 -
而登录和获取验证码是两次不同的请求(分别是user/login和user/captcha)
-
况且HTTP请求又是无状态的,每一次请求都是独立地,请求完毕后服务器和客户端就会断开,不会知道上一次请求的情况。
-
现在想要将“第一次生成验证码请求”生成的字符串数据共享给“第二次登录请求”使用,如何共享数据?
-
生成验证码的请求和登录请求就是两次不同的请求,如何建立两次HTTP请求之间的数据共享?
-
再进一步,不同的请求(两次或多次请求)之间如何共享数据?
再举一个多次请求之间共享数据的例子,购物车的例子:
这是就需要使用到Session技术了。
解决:使用Session
Session特点
-
01 一个客户端(浏览器)对应一个Session对象。
-
02 当服务器第一次明显使用Session(比如
request.getSession()
)时,会创建一个全新的Session对象,之后就一直使用旧的Session对象。
我们利用这个特点就可以实现:同一个客户端(浏览器)中多次请求之间的数据共享。
- 将数据存入Session:
// 将生成的验证码转为小写形式存入Session对象中
String code = dk.createText();
request.getSession().setAttribute("captcha", code.toLowerCase());
- 从Session中获取数据:
String captchaClient = req.getParameter("captcha");
if (Strings.isNotNull(captchaClient)) {
// 将用户输入的验证码转化为小写形式
captchaClient = captchaClient.toLowerCase();
}
if (!captchaClient.equals(req.getSession().getAttribute("captcha"))) {
forwardError("验证码不正确", req, resp);
return;
}
// ......
根据表单内容动态生成SQL
public ContactResult list(Contact.Search search) {
ContactResult result = new ContactResult();
// TODO 在此处更改Search默认值
StringBuilder sb = new StringBuilder(); // 查询Contacts
StringBuilder countSQL = new StringBuilder(); // 查询总条数
StringBuilder condition = new StringBuilder(); // 查询的附加条件
List<Object> args = new ArrayList<>(); // 查询的参数
sb.append("SELECT id, created_time, last_update_time, name, email, comment, subject, already_read FROM ");
sb.append(tableName());
sb.append(" WHERE 1 = 1 "); // 技巧
countSQL.append("SELECT COUNT(*) FROM ");
countSQL.append(tableName());
countSQL.append(" WHERE 1 = 1 "); // 技巧
// 日期查询:左闭右开
Date beginDay = search.beginDay;
if (null != beginDay) {
condition.append("AND created_time >= ? ");
args.add(beginDay);
}
Date endDay = search.endDay;
if (null != endDay) {
condition.append("AND created_time <= ? ");
args.add(endDay);
}
if (Strings.isNotNull(search.keyWord)) {
condition.append("AND (name LIKE ? OR email LIKE ? OR comment LIKE ? OR subject LIKE ?) ");
final String keyWord = "%" + search.keyWord + "%";
args.add(keyWord);
args.add(keyWord);
args.add(keyWord);
args.add(keyWord);
}
if (search.alreadyRead < 2) {
condition.append("AND already_read = ? ");
args.add(search.alreadyRead);
}
countSQL.append(condition);
// 数据总条数
final Integer count = JDBCTEMPLATE.queryForObject(countSQL.toString(), Integer.class, args.toArray());
if (count != null && count > 0) {
result.setTotalSize(count);
final Integer pageSize = search.getPageSize();
/*
总数量:101
每一页显示20条
公式:总页数 = (总数量 + 每页的数量 - 1) / 每页的数量
= ( 101 + 20 - 1) / 20
*/
final int totalPage = (count + pageSize - 1) / pageSize; // 总页数
result.setTotalPage(totalPage);
if (search.pageIndex > totalPage) {
search.pageIndex = totalPage;
}
} else {
// 没有符合要求的结果
/* 不用设置也可以,因为都是默认值
result.setTotalSize(0);
result.setTotalPage(0);
result.setContacts(null);
*/
result.setSearch(search);
return result;
}
sb.append(condition);
sb.append("LIMIT ?, ?");
args.add((search.pageIndex - 1) * search.pageSize);
args.add(search.pageSize);
result.setSearch(search);
result.setContacts(JDBCTEMPLATE.query(sb.toString(), new BeanPropertyRowMapper<>(Contact.class), args.toArray()));
return result;
}
利用AJAX登录
// 先弹框
// 利用该弹框,既能组织用户多次点击登录发送异步请求,又能提升用户体验
swal({
title: '正在登录中...',
text: ' ',
icon: 'info',
button: false,
closeOnClickOutside: false
})
// 利用AJAX发送请求给服务器
$.post('${ctx}/user/login', {
email: $('[name=email]').val(),
password: $('[name=password]').val(),
captcha: $('[name=captcha]').val()
}, function (data) {
if (data.success) {
location.href = '${ctx}/user/admin'
} else {
swal({
title: "提示",
text: data.message,
icon: 'error',
dangerMode: true,
buttons: false,
timer: 1500
})
}
}, 'json')
public void login(HttpServletRequest req, HttpServletResponse resp) {
try {
Map<String, Object> map = new HashMap<>();
String captchaClient = req.getParameter("captcha");
if (Strings.isNotNull(captchaClient)) {
// 将用户输入的验证码转化为小写形式
captchaClient = captchaClient.toLowerCase();
}
if (!captchaClient.equals(req.getSession().getAttribute("captcha"))) {
// 验证码不正确
map.put("success", false);
map.put("message", "验证码不正确");
} else {
// 验证码正确
User user = new User();
BeanUtils.populate(user, req.getParameterMap());
UserService userService = (UserService) service;
user = userService.login(user);
if (null != user) {
// 登录成功
// 将User对象设置到Session中,代表用户已经登录过了
req.getSession().setAttribute("user", user);
map.put("success", true);
} else {
map.put("success", false);
map.put("message", "用户名或密码不正确");
}
}
resp.setContentType("text/json; charset=UTF-8");
resp.getWriter().write(new ObjectMapper().writeValueAsString(map));
} catch (Exception e) {
forwardError(e, req, resp);
}
}
7天免登录
登录成功后:
- 设置Cookie:
Cookie cookie = new Cookie("JSESSIONID", request.getSession().getId());
cookie.setMaxAge(3600 * 24 * 7); // 让该Cookie在本地浏览器存活7天
response.addCookie(cookie);
- 设置Session:
<!-- 配置Seesion的寿命 -->
<session-config>
<!-- 60 * 24 * 7 分钟 -->
<session-timeout>10080</session-timeout>
</session-config>
注意和一些细节
-
编程是一门艺术,如果爱,请深爱!
-
多多学习、多多使用设计模式!
- 首先实现功能代码,然后慢慢抽取封装,之后一直思考如何优化(代码、实现逻辑等)
- 同一次请求之间的数据共享:
request.setAttribute("error", error);
request.getAttribute("error");
- 同一个客户端下的多次请求之间的数据共享:Session:
request.getSession().setAttribute("captcha", code);
request.getSession().getAttribute("captcha")
- 学会巧妙使用表单
使用JS提交数据给服务器的几种方式:
- GET:
window.location.href = '${ctx}/project/remove?id=' + id
- GET/POST:
$removeAllForm.submit()
(jQuery) - GET/POST:
removeAllForm.submit()
(原生DOM)
-
HTML表单提交时,checkbox只会提交已经选中的选项,使用jQuery的
$('#remove_all_form').submit()
即可提交至服务器 -
window.location.href = '${ctx}/education/remove?id=' + id
可以给浏览器发送请求 -
String[] ids = req.getParameterValues("id")
可以获取请求参数中所有的id -
document.getElementById() document.querySelector()
获得的是原生的DOM对象 -
$()
获取的是jQuery包装过的对象,并不是原生的DOM对象 -
$()[]
这样才能获取原生的DOM对象
-
原生的form(表单)对象有一个reset方法
-
<input name="id" type="hidden">
类型为hidden被称作隐藏域,可以用于向服务器提交id或者其他信息。 -
当使用表单的reset方法时(
$addSaveForm[0].reset()
),reset方法并不会重置type为hidden的表单 -
此时使用如下写法即可:
<input style="display: none" name="id" type="text">
<input style="display: none" name="image" type="text">
- web.xml配置404页面
<error-page>
<error-code>404</error-code>
<location>/WEB-INF/404.jsp</location>
</error-page>
private void forward404(HttpServletRequest req, HttpServletResponse resp) {
try {
// 配置好web.xml中的<error-page/>后,在此处随便重定向至一个不存在的页面,最终都会访问配置好的WEB-INF中的404.jsp
resp.sendRedirect(req.getContextPath() + "/404");
} catch (Exception e) {
e.printStackTrace();
}
}
- web.xml配置500页面
<error-page>
<error-code>500</error-code>
<location>/WEB-INF/500.jsp</location>
</error-page>
@Override
protected void doPost(HttpServletRequest req, HttpServletResponse resp) throws ServletException, IOException {
// System.out.println(1 / 0); // 500——服务器内部代码出错
try {
req.setCharacterEncoding("utf-8");
String[] split = req.getRequestURI().split("/");
String methodName = split[split.length - 1];
Method method = getClass().getMethod(methodName, HttpServletRequest.class, HttpServletResponse.class);
method.invoke(this, req, resp);
} catch (Exception e) {
e.printStackTrace();
throw new ServletException("方法找不到!"); // 500状态码 —— 服务器内部出现问题
}
}
- 代码实现error页面
protected void forwardError(Exception error, HttpServletRequest req, HttpServletResponse resp) {
try {
// 找到最根本的异常信息
while (error.getCause() != null) {
error = (Exception) error.getCause();
}
req.setAttribute("error", error);
req.getRequestDispatcher("../WEB-INF/error.jsp").forward(req, resp);
} catch (Exception e) {
e.printStackTrace();
}
}
-
项目中的那些不允许用户直接访问的jsp/html页面应该放在WEB-INF目录下,不能让外部直接访问这些页面,这些页面只能通过Servlet 转发来访问。
-
允许别人直接访问的jsp/html当然可以直接放在webapp下,不用放在WEB-INF中(比如登录等页面)
-
css、js、图片等资源文件不能放入WEB-INF目录,因为浏览器需要通过URL来请求访问,利用这些资源文件,这些资源文件应该是公开给浏览器的。
- JavaBean中的field(成员变量)都应该使用对象类型
-
数据库命名为
field_name
,JavaBean命名为fieldName
,请求参数name命名为fieldName
-
这些都是业界认可的开发规范。
- CSS选择器下标是从1开始的
- 使用
include
指令的时候,为防止乱码,给项目中每个JSP文件都添加上或者替换为:
<%@ page language="java" contentType="text/html; charset=UTF-8" pageEncoding="UTF-8"%>
- JSON
- JSON格式:
{
"name":"lp", "age":20, "sex":"male"}
-
JSON可以直接在JS中当作对象使用
-
书写规范:HTML属性使用双引号,那么此时JSON就应该使用单引号
@JsonIgnore
public String getJson() {
try {
return objectMapper.writeValueAsString(this).replace("\"", "'");
} catch (JsonProcessingException e) {
return null;
}
}
οnclick="edit(${education.json})"
for (const k in json) {
$addSaveForm.find('[name=' + k + ']').val(json[k])
}
- 常见状态码
- 200 OK
- 302 重定向
- 404 找不到资源
- 500 服务器出错
-
使用BeanUtils时,被解析的必须是个JavaBean或者必须有getter、setter方法。
-
使用BeanUtils解析表单提交的数据时,如果表单中没有填写内容的话:
日期类型
会被解析为null
字符串类型
会被解析为空字符串
数字类型
会被解析为0
-
表单中的name存在的情况下,使用
request.getParameter(name);
解析表单提交的数据时,如果表单中没有填写内容的话,都会被解析为空字符串
-
表单无论是什么类型,都可以将其解析为字符串
-
表单中的name不存在的情况下,那么
request.getParameter(name);
会得到null
-
Java中的Boolean可以自动转化为数据库中的INT
- false转化为0
- true转化为1
参考
李明杰: Java从0到架构师②JavaEE技术基石.
本文完,感谢您的关注支持!