文章目录
- 一、项目开发的流程
- 二、项目开发
- 2.1 准备工作
- 2.2 开发公共模块:把能写的先写了
- 什么是公共模块
- model层
- mapper层
- 定义统一返回结果
- 统一异常处理
- 2.2 博客列表页
- 2.3 更改显示的时间
- 2.4 博客详情页
- 2.5 登录
- Session式登录方法分析
- 使用Token来实现登录
- 2.6 强制登录
- 2.7 获取用户信息和作者信息
- 2.8 实现用户退出
- 2.9 实现发布博客
- 2.10 修改返回格式
- 2.11 删除/编辑博客
- 出现删除和编辑按钮
- 编辑操作
- 删除操作
- 三、Token
- 3.1 什么是Token + 与Session的区别 + 优缺点
- 3.2 JWT令牌介绍
- 3.3 如何使用JWT令牌实现Token
- 四、有无状态
- 五、加密/加盐
- 六、部署服务
- 6.1 部署的流程
- 6.2 搭建Java部署环境
- 安装jdk
- 安装mysql
- 6.3 多平台配置
- 6.4 部署
一、项目开发的流程
-
产品经理定下来需求文档:
-
了解需求,确认需求有无问题:如果需求文档有问题,提出来让产品经理去修改
-
方案设计:包括了【接口设计】、【数据库设计】、【架构图】、【流程图】等等
- 文档设计:注意【方案设计】和【接口设计】都是要单独出一个文档的,方案设计里可以放一个【接口设计】的链接
- 区别:方案设计是给后端开发团队内部人员看的,接口设计是给其他团队看的
- 接口文档:告诉别人如何去使用该工具,需不需要引入包,接口是什么,每个字段都是什么含义,相当于一个对外的说明书
- 方案设计:
- 文档设计:注意【方案设计】和【接口设计】都是要单独出一个文档的,方案设计里可以放一个【接口设计】的链接
-
开发:
-
测试:
-
联调:联动其他部门调试,也是一种形式的测试
-
提交测试:将结果提交给测试人员
-
上线:
二、项目开发
2.1 准备工作
- 创建Spring Boot项目:
- 修改Spring Boot配置文件内容:
spring:
datasource:
url: jdbc:mysql://127.0.0.1:3306/mycnblog?characterEncoding=utf8&useSSL=false
username: root
password: root
driver-class-name: com.mysql.cj.jdbc.Driver
mybatis:
mapper-locations: classpath:mapper/*Mapper.xml
configuration: # 配置打印 MyBatis 执行的 SQL
log-impl: org.apache.ibatis.logging.stdout.StdOutImpl
map-underscore-to-camel-case: true #自动驼峰转换
logging:
file:
name: logs/springboot.log
- 创建数据库:
-- 建表SQL
create database if not exists java_blog_spring charset utf8mb4;
-- ⽤⼾表
DROP TABLE IF EXISTS java_blog_spring.user;
CREATE TABLE java_blog_spring.user(
`id` INT NOT NULL AUTO_INCREMENT,
`user_name` VARCHAR ( 128 ) NOT NULL,
`password` VARCHAR ( 128 ) NOT NULL,
`github_url` VARCHAR ( 128 ) NULL,
`delete_flag` TINYINT ( 4 ) NULL DEFAULT 0,
`create_time` DATETIME DEFAULT now(),
`update_time` DATETIME DEFAULT now(),
PRIMARY KEY ( id ));
-- 博客表
drop table if exists java_blog_spring.blog;
CREATE TABLE java_blog_spring.blog (
`id` INT NOT NULL AUTO_INCREMENT,
`title` VARCHAR(200) NULL,
`content` TEXT NULL,
`user_id` INT(11) NULL,
`delete_flag` TINYINT(4) NULL DEFAULT 0,
`create_time` DATETIME DEFAULT now(),
`update_time` DATETIME DEFAULT now(),
PRIMARY KEY (id))
ENGINE = InnoDB DEFAULT CHARSET = utf8mb4 COMMENT = '博客表';
-- 新增⽤⼾信息
insert into java_blog_spring.user (user_name, password,github_url)values("zhangs","123456", "xxx");
insert into java_blog_spring.user (user_name, password,github_url)values("lisi", "123456", "xxx");
insert into java_blog_spring.blog (title,content,user_id) values("第⼀篇博客","11",1);
insert into java_blog_spring.blog (title,content,user_id) values("第⼆篇博客","22",2);
- 添加前端代码:
2.2 开发公共模块:把能写的先写了
什么是公共模块
model层
mapper层
- 写mapper时就需要对功能进行梳理了,看我们的需求需要哪一些数据库操作
- 关于查询用户信息:根据用户名查询用户信息,比对密码是否正确
- 为什么不建议通过用户名和密码同时去查:我们不建议让密码通过数据库的查询,而且密码可能会经过加密之类的处理,出现了SQL注入的情况也不太好处理
- 可以通过单元测试来判断代码有无错误:
- 关于查询用户信息:根据用户名查询用户信息,比对密码是否正确
@Mapper
public interface UserInfoMapper {
@Select("select * from user where user_name = #{userName} and delete_flag = 0")
UserInfo queryByName(String userName);
@Select("select * from user where id = #{id} and delete_flag = 0")
UserInfo queryById(Integer id);
}
@Mapper
public interface BlogInfoMapper {
@Select("select * from blog where delete_flag = 0")
List<BlogInfo> queryBlogList();
@Select("select * from blog where id = #{id} and delete_flag = 0")
BlogInfo queryById(Integer id);
@Update("update blog set title = #{title}, content = #{content} where id = #{id}")
Integer updateBlog(BlogInfo blogInfo);
@Update("update blog set delete_flag = 1 where id = #{id}")
Integer deleteBlog(Integer id);
@Insert("insert into blog(title, content, user_id) values (#{title}, #{content}, #{userId}")
Integer insertBlog(BlogInfo blogInfo);
}
定义统一返回结果
统一异常处理
2.2 博客列表页
- 前端代码:
- 后端代码:
Controller层
@RestController
@RequestMapping("/blog")
public class BlogController {
@Autowired
private BlogService blogService;
@RequestMapping("/queryBlogList")
public List<BlogInfo> queryBlogList(){
List<BlogInfo> res = blogService.queryBlogList();
return res;
}
}
Service层
@Service
public class BlogService {
@Autowired
private BlogInfoMapper blogInfoMapper;
public List<BlogInfo> queryBlogList(){
List<BlogInfo> res = blogInfoMapper.queryBlogList();
return res;
}
}
Mapper层
@Mapper
public interface BlogInfoMapper {
@Select("select * from blog where delete_flag = 0")
List<BlogInfo> queryBlogList();
}
2.3 更改显示的时间
2.4 博客详情页
- 前端代码:
- 后端代码:
Controller层
@RestController
@RequestMapping("/blog")
public class BlogController {
@Autowired
private BlogService blogService;
@RequestMapping("/queryBlogDetail")
public BlogInfo queryBligDetail(Integer blogId){
BlogInfo res = blogService.queryBlogDetail(blogId);
return res;
}
}
Service层
@Service
public class BlogService {
@Autowired
private BlogInfoMapper blogInfoMapper;
public BlogInfo queryBlogDetail(Integer id){
BlogInfo res = blogInfoMapper.queryById(id);
return res;
}
}
Mapper层
@Mapper
public interface BlogInfoMapper {
@Select("select * from blog where id = #{id} and delete_flag = 0")
BlogInfo queryById(Integer id);
}
2.5 登录
Session式登录方法分析
- 传统思路:
- 前端输入账号、密码后,后端进行校验
- 后端校验成功后,存储Session,并返回Cookie
- 前端进行页面跳转,后续访问时,会携带Cookie(里面有sessionId)。后端通过sessionId去服务器里存储的Session里取值,判断用户是否登录
- 前端输入账号、密码后,后端进行校验
- 传统思路存在的问题:
- 修改代码后,需要重新登录:
- 因为Session存储在服务器内存中,所以当服务器重启后,Session就丢失了。用户如果正在使用程序,且遇到了服务器重启,此时如果要再想用,就需要重新登录,这很影响用户体验
- 是单机部署:现在的服务大多不是单机部署,而是多机部署
-
单机部署存在的问题:服务全部部署在一台机器上,当用户流量太大或者其他例如被黑客黑了的原因,导致这台机器挂了,那所有的服务就都无了
-
关于多机部署:
-
登录操作使用多机部署的情况:
-
情境:
(1)用户第一次请求被分配到了服务器1,Session此时存储在了服务器1(2)用户第二次请求,请求被分配到了服务器2,服务器2由于没有用户的Session,被认为没有登录,无法提供后续服务,要求再次登录。
-
解决方法:
(1)Session存储在一个公用机器或者是缓存等地方,如Redis
(2)使用token(带有一定信息的字符串)
-
-
- 修改代码后,需要重新登录:
使用Token来实现登录
- 思路:
- 根据用户名和密码,验证密码是否正确
- 如果密码正确,后端生成Token,并返回给前端(Token由客户端保存)
- 前端存储时,可以把Token放在Cookie里,也可以放在本地存储里(浏览器给每个前端都保留了一个小空间,用来保存数据)
- 后续访问时,Token一般会放在Http请求的header中(也可以作为一个参数),由后端校验Token的合法性
- 根据用户名和密码,验证密码是否正确
- Controller层:
- 思路:
- 检查参数
- 查询用户是否存在,如果不存在,直接返回
- 密码是否正确
- 将用户的信息存储到token里,如用户名、密码等信息
- 思路:
@RequestMapping("/user")
@RestController
public class UserController {
@Autowired
private UserService userService;
@RequestMapping("/login")
public Result login(String userName, String password){
if (!StringUtils.hasLength(userName) || !StringUtils.hasLength(password)){
return Result.fail("用户名或密码为空");
}
UserInfo userInfo = userService.queryByName(userName);
if (userInfo == null || userInfo.getId() < 0){
return Result.fail("用户不存在");
}
if (!password.equals(userInfo.getPassword())){
return Result.fail("密码错误");
}
//生成Token并返回,账户和密码都正确的情况
Map<String, Object> claim = new HashMap<>();
claim.put("id", userInfo.getId());
claim.put("name", userInfo.getUserName());
String token = JwtUtils.genToken(claim);
return Result.success(token);
}
}
- 帮助生成token的代码:
public class JwtUtils { //在utils包里
private static final long expiration = 30 * 60 * 1000;
private static final String secreString = "abJPV1XoZl11HrSHF0eSIfonkgMYwAk++RYhR5+i6RU=";
private static final Key key = Keys.hmacShaKeyFor(Decoders.BASE64.decode(secreString));
public static String genToken(Map<String ,Object> claim){
String token = Jwts.builder()
.setClaims(claim)
.setExpiration(new Date(System.currentTimeMillis() + expiration))
.signWith(key)
.compact();
return token;
}
}
- Service层:
@Service
public class UserService {
@Autowired
private UserInfoMapper userInfoMapper;
public UserInfo queryByName(String username){
return userInfoMapper.queryByName(username);
}
}
- mapper层:
@Mapper
public interface UserInfoMapper {
@Select("select * from user where user_name = #{userName} and delete_flag = 0")
UserInfo queryByName(String userName);
}
- 效果:
- 前端代码:
- localStorage.setItem():存储到内存里
- localStorage.setItem():存储到内存里
2.6 强制登录
-
关于重复登录的问题:
- 后端只是生成和验证Token,存储是交给前端的。此时,哪怕后端重启了,因为存储方不在后端,除非我们把前端存储的token删掉,否则用户不需要重复登录
- 在多机环境下,Token可以正常工作
-
流程:
-
登录的拦截器:
-
让拦截器生效:
-
让前端在Header里面发送token:
-
前端未登录时的逻辑:
2.7 获取用户信息和作者信息
- 实现方式:
- 方式一:如果页面需要的信息较少,且是固定不变的,就可以把这些信息存储到token里,直接从token获取
- 不建议这种方式,因为这点无法保证,比如用户信息就是会发生变化的,虽然一旦发生变化,我们只需要更改Token即可,但这里其实涉及到了服务边界的问题
- 方式二:从token中获取用户ID,根据用户ID,获取用户信息
- 如果用户信息较少,可以把信息存储在token里。此处我们在Token里面存储了id,可以通过id从数据库中查到用户的对应信息
- 方式一:如果页面需要的信息较少,且是固定不变的,就可以把这些信息存储到token里,直接从token获取
- 后端代码:
Controller层
@RequestMapping("/user")
@RestController
@Slf4j
public class UserController {
@Autowired
private UserService userService;
@RequestMapping("/getUserInfo")
public UserInfo getUserInfo(HttpServletRequest request){
String token = request.getHeader("user_token");
Integer userId = JwtUtils.getUserIdFromToken(token);
if (userId == null){
return null;
}
return userService.queryById(userId);
}
@RequestMapping("/getAuthorInfo")
public UserInfo getAuthorInfo(Integer blogId){
return userService.getAuthorInfo(blogId);
}
}
Service层
@Service
public class UserService {
@Autowired
private UserInfoMapper userInfoMapper;
public UserInfo queryById(Integer userId) {
return userInfoMapper.queryById(userId);
}
public UserInfo getAuthorInfo(Integer blogId) {
BlogInfo blogInfo = blogInfoMapper.queryById(blogId);
if (blogInfo == null || blogInfo.getUserId() < 0){
return null;
}
return userInfoMapper.queryById(blogInfo.getUserId());
}
}
Mapper层
@Mapper
public interface UserInfoMapper {
@Select("select * from user where id = #{id} and delete_flag = 0")
UserInfo queryById(Integer id);
}
Utils层
解析传来的token(从里面获取id)
@Slf4j
public class JwtUtils {
public static Claims parseToken(String token){
JwtParser build = Jwts.parserBuilder().setSigningKey(key).build();
Claims claims = null;
try{
claims = build.parseClaimsJws(token).getBody();
}catch (Exception e){
log.info("解析token失败,token:" + token);
}
return claims;
}
public static Integer getUserIdFromToken(String token){
Claims claims = parseToken(token);
if (claims == null){
return null;
}
return (Integer) claims.get("id");
}
}
测试后端
- 前端代码:
- 拿到blogId:
- 直接从query string里拿,location.href
- 设置一个隐藏框,把blogId放进去
- 拿到blogId:
getUserInfo();
function getUserInfo() {
$.ajax({
type: "get",
url: "/user/getUserInfo
success: function(result){
if (result.code == 200 && result.data != null){
var user = result.data;
$(".left .card h3").text(user.userName);
$(".left .card a").after("href", user.githubUrl);
}else{
location.href = blog_login.html;
}
}
});
}
getUserInfo();
function getUserInfo() {
$.ajax({
type: "get",
url: "/user/getAuthorInfo" + location.search,
success: function(result){
if (result.code == 200 && result.data != null){
var user = result.data;
$(".left .card h3").text(user.userName);
$(".left .card a").after("href", user.githubUrl);
}else{
location.href = blog_login.html;
}
}
});
}
因为两个代码只改动了url,所以我们可以把这段代码提到 common.js 即可,后续直接传入 url 即可
2.8 实现用户退出
- 实现方式:把前端保存的token删除即可,注意因为很多页面都需要该功能,故直接放在common.js里,到时候直接引用即可
function logout(){
localStorage.removeItem("user_token");
alert("注销成功");
location.href = "blog_login.html";
}
2.9 实现发布博客
- 实现方式:
- editor.md:editor.md 是一个开源的页面 markdown 编辑器组件,使用时引入对应依赖就行了
- 后端代码:
Controller层
@Slf4j
@RestController
@RequestMapping("/blog")
public class BlogController {
@Autowired
private BlogService blogService;
@RequestMapping("/publishBlog")
public Result publishBlog(String title, String content, HttpServletRequest request){
String token = request.getHeader("user_token");
Integer userId = JwtUtils.getUserIdFromToken(token);
if (userId == null || userId < 0){
log.info("传来的用户id有误,为: " + userId);
return Result.fail("用户未登录", false);
}
BlogInfo blogInfo = new BlogInfo();
blogInfo.setUserId(userId);
blogInfo.setTitle(title);
blogInfo.setContent(content);
blogService.insertBlog(blogInfo);
return Result.success(true);
}
}
Service层
@Service
public class BlogService {
@Autowired
private BlogInfoMapper blogInfoMapper;
public Integer insertBlog(BlogInfo blogInfo) {
if (blogInfo == null){
return 0;
}
return blogInfoMapper.insertBlog(blogInfo);
}
}
Mapper层
@Insert("insert into blog(title, content, user_id) values (#{title}, #{content}, #{userId})")
Integer insertBlog(BlogInfo blogInfo);
- 前端代码:
function submit() {
$.ajax({
type:"post",
url: "/blog/publishBlog",
data:{
title:$("#title").val(),
content:$("#content").val()
},
success:function(result){
if(result.code==200 && result.data==true){
location.href = "blog_list.html";
}else {
alert(result.error);
}
}
});
}
2.10 修改返回格式
2.11 删除/编辑博客
出现删除和编辑按钮
-
实现效果:只有是作者本人(登录用户是作者)才能进行【编辑】和【删除】
- 实现方式:
- 方式一:单独写一个用来判断 “当前登录用户是否等于作者” 的接口,返回true/false
推荐,因为这符合接口的单一原则,让一个接口功能的维度更细一点。 - 方式二:用一个变量标记当前登录用户是否为作者
该方法不推荐,但实现起来比较简单
- 方式一:单独写一个用来判断 “当前登录用户是否等于作者” 的接口,返回true/false
- 实现方式:
-
后端代码:
-
前端代码:
编辑操作
- 更新的时候,获取当前帖子的内容,方便修改:
- 编辑操作的后端代码:
- 编辑操作的前端代码:
删除操作
三、Token
3.1 什么是Token + 与Session的区别 + 优缺点
- 什么是Token:
- 是客户端进行访问时携带的身份标识,就像人的身份证一样,不能伪造(但是其他人能看到),不是加密
- 令牌和token可以认为是一个东西
- Token与Session的区别:
-
Session:
(1)走到哪都会用户都会携带身份证,酒店不会携带身份证,Session相当于把身份证放在了酒店,然后用户一直在该酒店里住(2)第一次入住时,用户把身份信息登记在酒店上,酒店会存储下来,往后用户再住的时候,酒店因为已经保存了我们的身份信息,可以直接入住,无需再登记
-
Token:
(1)用户不会在一个酒店上吊死,会去多个酒店入住,也因此不可能每个酒店都存有用户的身份信息(2)此时用户每到一个酒店,就会展示身份证,酒店看到身份信息后就知道我们是谁了
-
- 令牌的优缺点:
- 优点:解决了集群环境下的的认证问题 + 令牌是在用户这边存储的,减轻了服务器的存储压力
- 缺点:需要自己生成、传递、校验
3.2 JWT令牌介绍
- 服务器 VS Token:服务器具备的功能就是校验身份信息,因为Token无法伪造,所以服务器需要有判断Token是否为真的能力
- 关于JWT令牌:Token的实现方式有很多,此处我们用开源的【JWT令牌】网址
- 存储位置:Cookie也是存储在客户端的一个信息,里面除了存Token,还存了些别的信息
- JWT介绍:
3.3 如何使用JWT令牌实现Token
- 引入依赖:这个依赖jdk8和17都能支持
<!-- jwt依赖-->
<!-- https://mvnrepository.com/artifact/io.jsonwebtoken/jjwt-api -->
<dependency>
<groupId>io.jsonwebtoken</groupId>
<artifactId>jjwt-api</artifactId>
<version>0.11.5</version>
</dependency>
<!-- https://mvnrepository.com/artifact/io.jsonwebtoken/jjwt-impl -->
<dependency>
<groupId>io.jsonwebtoken</groupId>
<artifactId>jjwt-impl</artifactId>
<version>0.11.5</version>
<scope>runtime</scope>
</dependency>
<dependency>
<groupId>io.jsonwebtoken</groupId>
<artifactId>jjwt-jackson</artifactId> <!-- or jjwt-gson if Gson is preferred -->
<version>0.11.5</version>
<scope>runtime</scope>
</dependency>
-
生成Token:
-
解析Token:
-
其他:
四、有无状态
- 有状态 VS 无状态:和HTTP的有无状态是一样的
- 有状态:当前状态与上一次的进度有关,是衔接过来的。如接力赛,下一棒的人要接过上一棒才能行动。
- 无状态:当前状态与上一次的进度无关,从中间的任何一个状态上去都可以无缝衔接,如自由跑,可以选择从任何一个起点开始跑,不用管上一次是在哪里起跑的。
- 关于服务的有无状态:我们去做服务时,要尽可能保证服务是【无状态】的
- 服务有状态:服务器重启后有记忆一些东西
- 服务无状态:服务器重启后没有记忆一些东西
- 关于Session 和 Token:Session存储在服务器,服务器有记忆功能,是有状态的; Token存储在客户端,是无状态的
五、加密/加盐
- 什么是加密:我们需要对诸如“密码”、“身份证号”……等敏感信息加密,这样即使数据库被入侵了,别人也没办法知道数据是什么
- 如何加密:需要使用密码算法对保存的明文进行加密,使其变成密文
- 对称密码算法:加密是 y = f(x),解密就是 x = f(y),加密和解密是同一个,靠这一个算法就可以完成加密解密
- 非对称密码算法:加密是 y = f(x),解密就是 x = m(y),加密和解密是不同的
-
关于公钥和私钥:公钥类似于锁,通常存储在服务器上。私钥类似于钥匙,通常存储在客户端,一个锁会有很多把钥匙,钥匙可以不同。所以通常情况下,公钥是公开的,大家都可以拿到,但是没有正确
一个文件通过锁进行了加密,别人是无法直接查看该文件的内容的,钥匙是在用户手中,且可以不一样,如小红和小王都拿了把钥匙,钥匙可以不同,但都能打开这把锁。
-
关于安全性:没有非对称加密比对称加密更安全的说法,两者都是安全的,取决于实现方式
-
- 摘要算法:不是字符串的编码方式,如果两个字符串加密后的密文一样,我们就认为明文是一样的
- Https用了对称加密和非对称加密两种手段
- 关于摘要算法之MD5加密:
- 使用MD5加密解密:
- DigestUtils:import org.springframework.util.DigestUtils Spring中用来实现加密的一个工具
- DigestUtils:import org.springframework.util.DigestUtils Spring中用来实现加密的一个工具
六、部署服务
6.1 部署的流程
- 原理:把项目打包成一个Jar包,让它在远程的云服务器上运行
- 流程:
- 确保当前能运行Java程序:有jdk和mysql
- 修改配置文件:修改 application.yml 配置文件,如修改数据库账号密码和日志保存位置
- 使用多平台配置:不需要我们来回改application.yml,而是能根据【不同的环境/平台】去采用不同的配置文件
- 不同的环境:当前是【开发环境】,部署到服务器上后为【线上环境/生产环境】,不同的环境上需要的配置是不同的,比如【开发环境】里需要打印SQL,但到【线上环境】后,就不需要打印了
- 使用多平台配置:不需要我们来回改application.yml,而是能根据【不同的环境/平台】去采用不同的配置文件
- 打Jar包:IDEA上面可以用Maven来帮忙打Jar包
- 在Linux服务器上运行该项目:
6.2 搭建Java部署环境
安装jdk
- centos 环境下
- unbuntu 环境下
安装mysql
- unbuntu 环境下安装
- mariaDB VS mysql
- 相同点:两者相似,端口都是3306
- 不同点:mariaDB会影响建表时间 以及 不支持数据库的DateTime类型
6.3 多平台配置
- 使用场景:让每个平台都有自己专属的配置文件,省得我们在一个application.yml里改来改去
- 如何使用多平台配置: