文章目录
- 项目介绍(开发背景)
- 数据库设计
- 主要使用到的技术点
- 前端
- 后端
- 自定义统一返回对象
- 自定义拦截器
- 加盐加密操作
- 分页功能
- session持久化
- 自定义头像的存储和获取
- 项目编写过程中遇到的困难点
- 困难点一(小)
- 困难点二(小)
- 困难点三(大)
- 上传部署
- 总结
项目介绍(开发背景)
对于一个程序员来说,定期整理总结并写博客是不可或缺的步骤,不管是对近期新掌握的技术或者是遇到bug解决bug过程的记录等,都是非常有必要的。
目前的博客网站有很多,比如CSDN、掘金、博客园等。本人在整个学习的阶段也都会经常在上面发布文章和见解等,最近学了一些开发所需要的主流框架,因此以做项目代学,做出了一个简易版的个人博客系统。
在这个博客系统中不需要担心安全问题(因为用户密码采用了加盐加密处理,破解成本高,且在一些私人界面加入了拦截器等)、短时间内不需要重复登录(因为在redis中存储了用户的session信息并进行了持久化处理)、可以随时随地地更换自己喜欢的头像(因为上传的图片发送到服务器并使用Nginx存储起来,稍等一段时间后台刷新后即可看到换的头像)。
数据库设计
由于这是一个简易版的博客系统,同时也是初代版本,因此在数据库的设计中还是比较简单的,只有两张表:用户表和博客表。后面做下一个版本的时候可以考虑加入文章下评论的表。
用户表主要存的字段是用户id、用户名、密码、头像、创建时间,其中主键是用户id且是自增的,编写出下面sql语句:
create table userinfo(
id int primary key auto_increment,
username varchar(100) not null,
password varchar(100) not null,
photo varchar(500) default '',
createtime timestamp not null default current_timestamp,
`state` int default 1
) default charset 'utf8mb4';
博客表主要存的字段是博客id、标题、内容、发布日期、发布用户、浏览量,其中主键是博客id且是自增的,编写出下面sql语句:
create table articleinfo(
id int primary key auto_increment,
title varchar(100) not null,
content text not null,
createtime timestamp not null default current_timestamp,
uid int not null,
rcount int not null default 1,
`state` int default 1
)default charset 'utf8mb4';
主要使用到的技术点
前端
首先简单说一下本项目中前端代码的基本实现,由于是前后端分离的项目,所以前端需要发送请求给后端,后接收来自后端的响应,这里前端部分我基本使用的都是jQuery来实现的,而且也基本都是使用POST发送请求(相对来说会比较安全),但是也不是说POST就一定好,只是可能会使用的比较多,具体还是需要看实际项目的开发需求来进行制定。这里额外提供出一篇文章:GET和POST的区别。
后端
自定义统一返回对象
在主流的开发中基本上都会制定一套统一的返回数据形式,因为这样既可以方便后端程序员在返回数据的时候不需要考虑前端是如何接收的,又可以方便前端程序员在获取数据的时候不需要再去看后端返回的都有什么。不管传输的数据是何种形式,都可以一一种统一的格式被获取到,大大提高了开发的效率。
在返回对象之前需要先对数据进行一下封装,这一步称为“统一数据返回封装”,对于已经是封装好的对象直接返回即可;对于返回的类型是字符串类型的,则需要额外进行一步操作;对于未封装过的对象,直接对其进行封装后即可返回。而返回这个对象的这一步操作就称为“统一返回对象”。
ResponseAdvice类统一数据返回封装:
@ControllerAdvice
public class ResponseAdvice implements ResponseBodyAdvice {
@Override
public boolean supports(MethodParameter returnType, Class converterType) {
return true;
}
@SneakyThrows
@Override
public Object beforeBodyWrite(Object body, MethodParameter returnType, MediaType selectedContentType, Class selectedConverterType, ServerHttpRequest request, ServerHttpResponse response) {
//本身已经是封装好的对象
if(body instanceof HashMap){
return body;
}
//返回的类型是字符创类型(String)
if(body instanceof String){
ObjectMapper objectMapper = new ObjectMapper();
return objectMapper.writeValueAsString(AjaxResult.success(body));
}
return AjaxResult.success(body);
}
}
AjaxResult类自定义统一返回对象:
public class AjaxResult {
//业务执行成功时返回的方法
public static HashMap<String, Object> success(Object data){
HashMap<String, Object> res = new HashMap<>();
res.put("code", 200);
res.put("msg", "");
res.put("data", data);
return res;
}
public static HashMap<String, Object> success(String msg, Object data){
HashMap<String, Object> res = new HashMap<>();
res.put("code", 200);
res.put("msg", msg);
res.put("data", data);
return res;
}
//业务执行失败时返回的方法
public static HashMap<String, Object> fail(int code, String msg){
HashMap<String, Object> res = new HashMap<>();
res.put("code", code);
res.put("msg", msg);
res.put("data", "");
return res;
}
public static HashMap<String, Object> fail(int code, String msg, Object data){
HashMap<String, Object> res = new HashMap<>();
res.put("code", code);
res.put("msg", msg);
res.put("data", data);
return res;
}
}
自定义拦截器
有一些页面是需要用户登录后才能打开的,如果未登录的用户强制打开就会跳转到登录页面,很多网站其实基本上也都是这样操作的,设置一个拦截器。
以下是设置自定义拦截器的一个模板:
@Component
public class LoginInterceptor implements HandlerInterceptor {
@Override
public boolean preHandle(HttpServletRequest request, HttpServletResponse response, Object handler) throws Exception {
//session中得到用户信息(如果在session中得到userinfo对象,说明用户已经登录,否则用户未登录)
HttpSession session = request.getSession(false);
if(session != null && session.getAttribute(Constant.SESSION_USERINFO_KEY) != null){
//当前用户已经登录
return true;
}
response.setStatus(401);
return false;
}
}
@Configuration
public class AppConfig implements WebMvcConfigurer {
@Autowired
private LoginInterceptor loginInterceptor;
@Override
public void addInterceptors(InterceptorRegistry registry) {
registry.addInterceptor(loginInterceptor)
.addPathPatterns("/**")
.excludePathPatterns(excludes);
}
}
addPathPatterns("/**")
是拦截项目中全部的url,而如果有一部分不需要拦截的话,只需要在excludePathPatterns(excludes)
中的excludes中添加不拦截url的集合即可。
为什么不直接设置拦截的url,而是先全部拦截后再开放一个集合呢?原因是在大部分常见的项目中,拦截的是要比不拦截的多很多,所以先拦截全部后再开放一部分显然是比较合理的。
对于本项目目前来说,设置不拦截的url有以下这些:
注意:前端代码一定是不能拦截的,不然页面会刷新不出来!
加盐加密操作
对于加密操作,可能会有很多人会想到MD5加密(因为学校教的就是MD5加密),MD5加密确实是不可逆的,但是它会存在一个问题,每次加密的结果都是一样的,这样的话黑客是可以通过一张“彩虹表”来暴力枚举破解密码,风险还是比较大的。
而加盐加密则不会,我们这里加的盐其实是随机生成的盐值,以下面这段代码为例:我们制定出一个64位的密码,其中前32位规定为是盐值的存储,后32位规定为是加盐加密后的密码,注册的时候将盐值和加盐加密后的密文拼接在一起存储在数据库中,而登录的时候则是取出密文中前32位的那个盐值和用户登录时输入的那个密码进行同规则的加密后,如果生成的密文与后32位的密文是相等的,那么说明登录成功,否则失败。
public class SecurityUtil {
//加密操作
public static String encrypt(String password){
//每次生成内容不同但长度固定为32位的盐值
String salt = UUID.randomUUID().toString().replace("-", "");
String finalPassword = DigestUtils.md5DigestAsHex((salt + password).getBytes());
return salt + finalPassword;
}
//验证操作
public static boolean decrypt(String password, String finalPassword){
if(!StringUtils.hasLength(password) || !StringUtils.hasLength(finalPassword)){
return false;
}
if(finalPassword.length() != 64){
return false;
}
String salt = finalPassword.substring(0, 32);
String securityPassword = DigestUtils.md5DigestAsHex((salt + password).getBytes());
return (salt + securityPassword).equals(finalPassword);
}
}
分页功能
分页功能实现的前提是需要先知道每一页需要显示多少条数据,也就是说,需要先计算出一个偏移量,以便于在MySQL查询中准确地进行获取数据并显示到页面。
@RequestMapping("list")
public List<ArticleInfo> getList(Integer pindex, Integer psize){
if(pindex == null || psize == null){
return null;
}
int offset = (pindex - 1) * psize; //分页公式计算偏移量
return articleService.getList(psize, offset);
}
对应的MyBatis语句:
<select id="getList" resultType="com.example.demo.model.ArticleInfo">
select * from articleinfo limit #{psize} offset #{offset}
</select>
除此之外,还需要计算总页数,注意这是需要页数进行向上取整操作(数据数不满一页也需要计算成一页):
@RequestMapping("/totalpage")
public Integer getTotalCount(Integer psize){
if(psize != null){
int totalCount = articleService.getTotalCount();
return (totalCount + psize) / psize;
}
return null;
}
session持久化
session持久化的目的是不需要让用户退出后短时间登录不再需要输入密码,因为这样会使用户体验更加好。
对于session的持久化其实不管是mysql还是redis都是可以实现的,但是目前主流的是存储到redis中,所以在此之前需要先提前安装并连接好redis(会在下篇文章中出教程)。
其中,存的话直接在用户登录的时候就可以将用户的session存起来,之后设置一个超时的时间(这部分是在配置文件中完成),由于不管是哪一个页面,都是需要对用户信息进行验证的,所以可以直接在验证的时候在redis中看看是否有存在符合的session即可。
session在redis中是这样存储的(如果过期则会在redis中清除):
自定义头像的存储和获取
在这一步中我想了很多办法,但是最终都是无法正确的实现头像的更换,最后是通过Nginx来搭建一个图床用来存储图片,之后这个图片就存在于公网中,也就可以很容易地被获取到了。
用户上传过来的图片命名格式是用户本地的设置名字,直接将这个文件名存起来的话,显然是不合适的,原因是如果有多个用户上传过来的图片内容不同但是文件名相同,这不就冲突了吗,所以,我们在获取到图片之后还需要就图片进行重命名。
对图片进行重命名有两种操作方式,一种是每次都随机生成一个UUID来代表文件名,这样就不会出现重复的现象,但是由于我们在后台就设置了不能注册相同的用户名,所以,我们也可以使用用户名来用作图片的命名,并且将这个名字+图片格式的后缀加到数据库中,其实这样还有一个好处,就是同一个用户对头像进行更换会对就图片进行覆盖,随机的UUID则不会,减少空间的占用(用户量大的时候)。
之后就是安装Nginx,并设置文件存储的路径。
@RequestMapping("/upload")
public Object upload(HttpServletRequest request, MultipartFile file){
HttpSession session = request.getSession( false);
UserInfo userInfo = null;
if(session != null && session.getAttribute(Constant.SESSION_USERINFO_KEY) != null) {
userInfo = (UserInfo) session.getAttribute(Constant.SESSION_USERINFO_KEY);
}
if(file.isEmpty()){
return AjaxResult.fail(-1, "请选择图片");
}
String originalFileName = file.getOriginalFilename(); //原来图片名
String[] tmp = originalFileName.split("\\.");
String ext = "." + tmp[tmp.length - 1]; //取到后缀
String fileName = userInfo.getUsername() + ext;
//上传图片
String pre = "/www/wwwroot/43.139.71.60/blog_user_img/";
String path = pre + fileName;
try {
//存到外部文件夹中
file.transferTo(new File(path));
userService.updatePhoto(fileName, userInfo.getId());
return AjaxResult.success("更换成功!", 1);
} catch (IOException e) {
e.printStackTrace();
}
return AjaxResult.fail(-1, "更换失败");
}
项目编写过程中遇到的困难点
因为这是我真正意义上做的第一个项目,所以在这个过程中遇到的困难点还是比较多的。
困难点一(小)
由于我一直以来都是以写后端代码为主,所以对于前端页面的制作相对来说会比较困难,很多时候会出现一些未知的错误,调式起来也会不太适应(通常都是会先用Postman先测试后端代码后再看前端如何修改等),但是后面写多了,其实也是有重复性的,慢慢地就适应了。
困难点二(小)
安装redis和远程连接redis客户端需要小心一点。
由于这个项目是临时想到要添加一个session的持久化的,使用也是临时在linux上安装redis的,过程中出现了大意,在开放了6379端口之后没有对redis设置密码,导致服务器被黑客入侵,后通过重装服务器解决。
教训:端口不要随便开放,特别是尽量不要全开,对于一些容易被黑客入侵的端口最好设置一个密码更好一点。
困难点三(大)
用户头像更换这个问题也是困扰比较久的,好在后来通过同学的指点和交流顺利解决了。
第一阶段我的思路是:因为用户那边上传过来的文件是会默认存放到target文件夹下的嘛,所以我使用了几个getParentFile()
方法以及查找的方法来将图片移动到static文件夹下的img文件夹里面,因为这样前端在获取图片的时候就可以直接获取到。当然,这个思路在本地运行是完全没有问题的,但是部署在linux上之后就会出现一个问题了,因为项目部署是需要打包的,而打的jar包在运行的时候执行getParentFile()
方法是会直接跳到整个SpringBoot项目外部的,图片存放位置就会出现错误,后来又查阅了很多相关的文章依旧没有解决这个问题……
第二阶段我的思路是:能不能在项目外找一个文件夹来存储这些图片呢?说干就干,在后端存储图片的时候直接就指定存储的路径,在项目启动之后,这次确实可以将图片成功存储进来,但是无论如何前端一直报错说获取不到这个图片,即使我在img标签下的src属性中使用的是绝对路径,也是不能获取到图片,第二种思路最后也是以失败告终……
第三阶段我的思路是:突然有一次我就想到了,不是还有图床这种东西吗,我能不能直接自己搭建一个图床呢?但是后来看到需要“氪金”的时候,作为“白嫖”的我也就放弃了……
第四阶段我的思路是:正当我想要通过“氪金”来解决这个问题的时候,有个同学(大佬)跟我说了可以使用Nginx来搭建自己的图床,而且还不需要“氪金”的时候,更换头像的难题也就迎刃而解了~
上传部署
当项目在本地能够顺利跑过之后,接下来就是部署到服务器上了。
在部署项目之前需要提前准备好的东西有:jdk1.8、redis、mysql及其库和表都需要建好。这里给出linux安装redis和mysql的教程(CentOS 7 通过 yum 安装 MariaDB、安装redis、记录远程客户端连接不上redis的解决等过程)。
完成上面的准备操作之后,就是部署项目了:将打好的jar包拉到服务器上,输入命令:java -jar [jar包名]
就可以打开SpringBoot项目;当然,这样是不能让项目持久运行的,只能当成一个提前的测试运行,持久运行需要输入命令:nohup java -jar [jar包名] &
。
总结
这是我真正意义上的第一个项目,有很多功能还没有实现,后面版本会持续进行更新……
简单总结一下这个博客系统项目:
优点:使用了主流的SSM+SpringBoot框架进行开发、使用Redis存储用户的session已达到持久化、对密码进行加盐加密处理、合理设置拦截器等。
缺点:页面制作的不够美观、有部分代码写的还是太过于冗余、实现的功能不太够等。
关于本项目的全部代码我都放在了我的个人Gitee账户下,有需要的可以点击查看:个人博客系统项目存放代码。(注:特别需要看其中的配置文件application.yml
以及所需要导入的依赖包pom.xml
,这决定了一个项目是否能够正常启动并运行!)