准备
有了前期的准备做铺路,就可以开始我们的编码路程了
命名约定
开始前,先对service/dao层的方法名进行约定统一
- 获取当个对象的方法用getOne()
- 获取全部对象用getAll()
- 获取多个对象用get()
- 插入的方法用save做前缀
- 修改用update做前缀
- 删除用remove做前缀
- 通过除主键外获取对象用getXXXByXX()
- 统计方法用count()
配置文件
这里可以创建多两份配置文件application.yml,一份用于开发环境,一份用于生成环境
开发环境dev的配置(application-dev.yml):
spring:
datasource:
driver-class-name: com.mysql.cj.jdbc.Driver
url: jdbc:mysql://127.0.0.1:3306/blog?useUnicode=true&characterEncoding=utf8&autoReconnect=true&useSSL=false&serverTimezone=Asia/Shanghai
username: 你的数据库名称
password: 你的数据库密码
logging:
level:
cn.zpeace.blog.mapper: debug #用于打印sql语句
mybatis-plus:
configuration:
map-underscore-to-camel-case: true #开启驼峰命名匹配
mapper-locations: classpath*:/mapper/**/*.xml #扫描mapper.xml文件路径
生产环境rpo的配置(application-pro.yml):
logging:
file: log/blog-dev.log #主要修改日志配置,生产环境日志输出到文件中,并且不打印sql语句
然后可以在application.yml中指定激活环境:
spring:
profiles:
active: dev #指定激活环境
也可以在运行的时候通过命令行来指定激活环境
java -jar 你的项目包名称 --spring.profiles.active=pro
持久层框架
这里我使用了mybatis-plus作为项目的持久层框架,选择JPA或者mybatis都是一样的,都可以实现这个博客项目,只是具体的开发操作不同而已。
选择mybatis-plus主要是可以简化持久层的开发,mybatis-plus默认已经封装了一些常用的CRUD操作,可以通过继承BaseMapper<T>来使用,不选择JPA的原因呢,是因为JPA生成的SQL语句很难看,不方便观察
public interface BlogMapper extends BaseMapper<Blog>{
}
具体使用可以查看官方文档,MyBatis-Plus
mybatis-plus的配置
@Configuration
@EnableTransactionManagement //代表开启分页
public class MybatisPlusConfig {
@Bean
public PaginationInterceptor paginationInterceptor() {
PaginationInterceptor paginationInterceptor = new PaginationInterceptor();
// 设置请求的页面大于最大页后操作, true调回到首页,false 继续请求 默认false
// paginationInterceptor.setOverflow(false);
// 设置最大单页限制数量,默认 500 条,-1 不受限制
// paginationInterceptor.setLimit(500);
return paginationInterceptor;
}
}
并且在springboot启动类上标注@MapperScan来扫描mapper类的包路径
@SpringBootApplication
@MapperScan("cn.zpeace.blog.mapper")
public class BlogApplication {
public static void main(String[] args) {
SpringApplication.run(BlogApplication.class, args);
}
}
分模块开发
日志记录模块
这里就简单对前台被访问时进行记录
首先,创建一个切面类
@Aspect //表面这是一个切面类
@Component //注入到spring中
@Slf4j //获得日志对象
public class logAspect {
//定义一个切入点,拦截controller包下的类的方法
@Pointcut("execution(* cn.zpeace.blog.controller.*.*(..))")
public void log(){
}
@Before("log()")
public void doBefore(JoinPoint joinPoint){
ServletRequestAttributes attributes = (ServletRequestAttributes) RequestContextHolder.getRequestAttributes();
HttpServletRequest req = attributes.getRequest();
String ip = MyBlogUtil.getRemortIP(req);
String url = req.getRequestURL().toString();
//获得被拦截的方法名
String proxyMethod = joinPoint.getSignature().getDeclaringTypeName() + "." + joinPoint.getSignature().getName();
//获得输入
Object[] args = joinPoint.getArgs();
//记录日志
log.info("访问者IP:{} ,请求的URL:{} ,调用的方法:{} ,方法参数:{}",ip,url,proxyMethod,args);
}
}
异常处理模块
存在异常:资源找不到异常。
定义一个查询结果不存在异常,当查询结果为null时,返回404状态码给客户端
@ResponseStatus(HttpStatus.NOT_FOUND) //表明发生该异常时,将会返回404状态码给客户端
public class NotFoundException extends RuntimeException {
public NotFoundException() {
}
public NotFoundException(String message) {
super(message);
}
public NotFoundException(String message, Throwable cause) {
super(message, cause);
}
public NotFoundException(Throwable cause) {
super(cause);
}
public NotFoundException(String message, Throwable cause, boolean enableSuppression, boolean writableStackTrace) {
super(message, cause, enableSuppression, writableStackTrace);
}
}
同时定义一个异常拦截器
@Slf4j
@ControllerAdvice //所有的异常都通过这个拦截器来进行重新配置
public class ExceptionHandler {
//当抛出Exception类及其子类异常的时候,进行拦截
@org.springframework.web.bind.annotation.ExceptionHandler(Exception.class)
public ModelAndView handlerException(HttpServletRequest request,Exception e) throws Exception {
log.error("Request URL:{} ,exception {}",request.getRequestURL(),e);
if (AnnotationUtils.findAnnotation(e.getClass(),
ResponseStatus.class) != null) {
throw e;
}
ModelAndView mav = new ModelAndView();
mav.addObject("url", request.getRequestURL());
mav.addObject("exception", e);
mav.setViewName("error/error");
return mav;
}
}
分类模块
根据前面的需求,对于分类功能,大概需要提供这几个方法:
public interface CategoryService {
Integer savaOne(Category category); //增加一个分类
Integer updateOne(Category category); //修改一个分类
Integer removeOne(Integer categoryId); //删除一个分类
Category getOne(Integer categoryId); //通过id获取一个分类
//IPage 是mybatis-plus封装的一个分页对象,可以简化分页操作
IPage<Category> getAll(Integer pageNum, Integer pageSize); //分类展示分类
List<Category> getAll(); //获取所有分类
Integer count(); //统计分类个数
}
标签模块
标签跟分类差不多一样,需要注意的是,删除一个标签的时候,应该同时把关联表中该标签的行也删除了
public interface TagService {
Integer saveOne(Tag tag);
Integer update(Tag tag);
Integer removeOne(Integer tagId);
Tag getOne(Integer tagId);
IPage<Tag> getAll(Integer pageNum,Integer pageSize);
List<Tag> getAll();
Integer count();
}
友链模块
public interface LinkService {
Integer saveOne(Link link);
Integer updateOne(Link link);
Integer removeOne(Integer linkId);
IPage<Link> getAll(Integer pageNum, Integer pageSize);
List<Link> getAll();
}
用户模块
public interface UserService {
User findUser(String username,String password) throws Exception;// 管理员登录查询
User getUserById(Integer userId); //通过Id获取管理员信息
Integer updateUserInfo(User user); //更新管理员的信息
User checkPassword(Integer userId,String password); //判断输入的密码与管理员id是否匹配
Integer updatePassword(Integer userId,String password);//更新管理员密码
String getIntroduceAndConvert(Integer userId); //获取关于我
Boolean isUserExist(String username); //通过用户名判断用户存不存在
}
登录与拦截模块
登录逻辑处理
@PostMapping("/login")
public String login(String username, String password, HttpSession session, Model model) throws Exception {
log.info("管理员尝试进行登录,用户名为:{} ,密码为:{}", username, password);
Boolean userExist = userService.isUserExist(username);
if (userExist) {
User user = userService.findUser(username, password);
if (user != null) {
session.setAttribute("user", user);
log.info("登录成功,用户名为:{}", user.getUsername());
return "redirect:/admin/";
} else {
log.info("登录失败,失败原因:用户名或者密码错误");
model.addAttribute("message","用户名或者密码错误");
return "admin/login";
}
} else {
log.info("登录失败,失败原因:用户不存在");
model.addAttribute("message","用户不存在");
return "admin/login";
}
}
配置一个登录拦截器,编写preHandle方法
public class LoginHandler implements HandlerInterceptor {
@Override
public boolean preHandle(HttpServletRequest request, HttpServletResponse response, Object handler) throws Exception {
Object user = request.getSession().getAttribute("user");
if (user == null) {
//未登录
request.setAttribute("message","没有权限,请先登录");
request.getRequestDispatcher("/admin/login").forward(request,response);
return false;
}else {
return true;
}
}
}
将登录拦截器添加到容器中
@Configuration
public class MyWebConfig {
@Bean
public WebMvcConfigurer webMvcConfigurer(){
WebMvcConfigurer configurer = new WebMvcConfigurer() {
@Override
public void addInterceptors(InterceptorRegistry registry) {
registry.addInterceptor(new LoginHandler())
.addPathPatterns("/admin/**") //拦截所有/admin 请求
.excludePathPatterns("/admin/login");//排除掉登录请求
}
};
return configurer;
}
}
博文模块
public interface BlogService {
Integer savaOne(Blog blog); //保存一个博文
Integer updateOne(Blog blog); //修改一个博文
Integer removeOne(Integer blogId); //删除一个博文
Blog getOne(Integer blogId); //获得一个博文详情
Blog getOneAndConvert(Integer blogId); //获得一个博文,并把markdown格式转换为html格式
Integer updateView(Integer blogId,Integer blogView); //更新博文的浏览量
Map<String, List<Blog>> getArchives(); //获得归档
Integer count(); //博文统计
//用于展示在标签单项下的博文
IPage<Blog> getByTag(Integer pageNum,Integer pageSize,Integer tagId);//通过tag获取博文
/**
* @param pageNum 当前页
* @param pageSize 当前的条目
* @param keyword 搜索关键词
* @param categoryId 分类Id
* @param published 是否发布
* @return
*/
MyPage<Blog> getAll(Integer pageNum, Integer pageSize, String keyword , Integer categoryId , Boolean published);
}
MyPage为本人自己用来封装分页查询结果的对象。
为什么不用IPage?因为通过拦截器进行的拦截分页,会自动添加limit在sql语句最后端,这样会造成的结果是
- 普通的分页查询没有什么问题
- 用于多对多,或者多对一的查询,因为映射关系,mybatis会自动帮忙把查询语句的结果封装在对象中,最后就会造成数据库查出5条数据,然后被封装后的数据不到5个的情况。
- 这种问题网上还有另外的解决方案,就是通过嵌套查询,不过这样会造成N+1问题。最好还是自己实现分页,把limit语句写在主查询中就可以完美解决问题
具体实现
前面的分类、标签模块因为实现方法都比较简单,所以没有把实现贴出来。
这里贴一下博文操作的一些实现。
保存博文
@Override
public Integer savaOne(Blog blog) {
/*
将博文简介转化为html格式
把html格式的简介存储到数据库,主要是出于,博文简介是展示在主页上的 ,而主页一般是展示博文列表
如果在取出来的时候在转化,访问首页的时候,会因为后端正在循环转化,页面的响应将会变慢
*/
String blogBrief = MarkdownUtil.markdownToHtmlExtensions(blog.getBlogBrief());
//将html格式的保存进blog对象
blog.setBlogBrief(blogBrief);
blogMapper.insert(blog); //保存到数据库
//保存完博文后,需要同时把关联的标签id 写到数据库的关系表中
return blogMapper.insertAssociation(blog);
}
修改博文
@Override
public Integer updateOne(Blog blog) {
blogMapper.deleteAssociationById(blog.getBlogId()); //删除文章与标签之间的关联
blogMapper.insertAssociation(blog); //插入新的 文章与标签之前的关联
//转化博文简介
blog.setBlogBrief(MarkdownUtil.markdownToHtmlExtensions(blog.getBlogBrief()));
blog.setUpdateTime(new Date()); //设置更新时间,出于博文会更新访问量的考虑,没有把blog表中的update_time 设置为根据当前时间更新,使用这里要自己设置更新时间
return blogMapper.updateById(blog); //更新博文
}
删除博文
@Override
public Integer removeOne(Integer blogId) {
blogMapper.deleteAssociationById(blogId); //删除关联关系
return blogMapper.deleteById(blogId); //删除博文
}
获取一个博文:
主要用在编辑博文上
@Override
public Blog getOne(Integer blogId) {
return blogMapper.getOne(blogId);
}
获取一个博文并且转化格式
这个方法主要用在了前台文章详情页面,把markdown格式转化为html格式,便于浏览
@Override
public Blog getOneAndConvert(Integer blogId) {
Blog blog = blogMapper.getOne(blogId);
if (blog == null) {
throw new NotFoundException("该博文不存在");
}
blog.setBlogContent(MarkdownUtil.markdownToHtmlExtensions(blog.getBlogContent()));
return blog;
}
获取按年份归档的博文
具体思路:
- 可以先查询博客的创建时间,用数据库的year()函数获得年份,然后再去重,就获得了博文所有存在的年份了
- 创建一个map,把年份作为key,遍历这个map ,把key作为查询条件,查询数据库,并把查询结果作为value
@Override
public Map<String, List<Blog>> getArchives() {
List<String> years = blogMapper.getYear();
Map<String, List<Blog>> map = new HashMap<>();
for (String year : years) {
map.put(year,blogMapper.getBlogByYear(year));
}
return map;
}
通过标签获取博文
@Override
public IPage<Blog> getByTag(Integer pageNum, Integer pageSize, Integer tagId) {
//通过关联关系,获得与该标签关联的博文id是哪些
List<Integer> blogIds = blogMapper.getBlogByAssociationTagId(tagId);
//如果没有关联就返回null
if (blogIds == null || blogIds.size() <= 0) {
return null;
}
//通过博文id进行查找 并返回结果
return blogMapper.selectPage(new Page<>(pageNum,pageSize),new QueryWrapper<Blog>() .orderByDesc("create_time").in("blog_id",blogIds).eq("published",true));
}
分页获取博文
@Override
public MyPage<Blog> getAll(Integer pageNum, Integer pageSize, String keyword , Integer categoryId , Boolean published) {
//设置页码,展示数量,总数
MyPage<Blog> page = new MyPage<>(pageNum,pageSize,blogMapper.count(keyword,categoryId,published));
//通过不同的条件查询博文
List<Blog> blogs = blogMapper.getBlogDetail(page, keyword,categoryId,published);
//把博文设置到page对象中
page.setRecords(blogs);
return page;
}
关于DAO层的编写
Dao的编写主要是写sql语句,这里贴一下一些比较复杂的sql语句,主要是BlogMapper中getOne()、getBlogDetail()、count()比较复杂一点
-
首先设置一个用来封装关联查询结果的resultMap
<resultMap id="blogBean" type="cn.zpeace.blog.pojo.Blog"> <id column="blog_id" property="blogId"></id> <result property="blogTitle" column="blog_title"></result> <result property="blogContent" column="blog_content"></result> <result property="blogView" column="blog_view"></result> <result property="blogBrief" column="blog_brief"></result> <result property="blogType" column="blog_type"></result> <result property="appreciation" column="appreciation"></result> <result property="comment" column="comment"></result> <result property="copyright" column="copyright"></result> <result property="published" column="published"></result> <result property="updateTime" column="update_time"></result> <result property="createTime" column="create_time"></result> <association property="category" javaType="cn.zpeace.blog.pojo.Category"> <id property="categoryId" column="category_id"></id> <result property="categoryName" column="category_name"></result> </association> <collection property="tags" ofType="cn.zpeace.blog.pojo.Tag"> <id property="tagId" column="tag_id"></id> <result property="tagName" column="tag_name"></result> </collection> </resultMap>
-
然后定义sql查询语句
-
getOne方法
通过级联查询,获取到t_blog表的所有字段、t_category表中的category_name字段,t_tag表中的tag_name字段
<select id="getOne" resultMap="blogBean" parameterType="integer"> SELECT b.*, t.tag_name FROM ( SELECT b.*, c.category_name, bt.tag_id FROM t_blog b LEFT JOIN t_category c ON b.category_id = c.category_id LEFT JOIN t_blog_tag bt ON b.blog_id = bt.blog_id WHERE b.blog_id = #{blogId} ) b LEFT JOIN t_tag t ON t.tag_id = b.tag_id </select>
-
getBlogDetail()
通过不同的条件查询博文列表
<select id="getBlogDetail" resultMap="blogBean"> SELECT b.*, t.tag_id, t.tag_name FROM ( SELECT b.*, c.category_name FROM t_blog b LEFT JOIN t_category c ON b.category_id = c.category_id <where> <if test="keyword != null"> and blog_title LIKE "%${keyword}%" </if> <if test="published"> and b.published = 1 </if> <if test="categoryId != null"> and c.category_id = #{categoryId} </if> </where> ORDER BY create_time DESC LIMIT #{page.start},#{page.pageSize} ) b LEFT JOIN (SELECT t.tag_id, t.tag_name, bt.blog_id FROM t_tag t LEFT JOIN t_blog_tag bt ON t.tag_id = bt.tag_id) t ON b.blog_id = t.blog_id ORDER BY b.create_time DESC </select>
-
count()
通过不同的条件查询出博文数
<select id="count" resultType="java.lang.Integer"> SELECT COUNT(1) FROM t_blog b LEFT JOIN t_category c ON b.category_id = c.category_id <where> <if test="keyword != null"> and b.blog_title LIKE "%${keyword}%" </if> <if test="categoryId != null"> and c.category_id = 22 </if> <if test="published"> and published = 1 </if> </where> </select>
-
功能完善
markdown图片上传
集成的markdown编辑器,上传图片需要自己在后端进行处理,具体上传的后端处理路径可以在初始化markdown编辑器的时候指定
var contentEditor = editormd("md-content", {
width: "100%",
height: 640,
syncScrolling: "single",
path: "/plugin/editormd/lib/",
toolbarModes: 'full',
saveHTMLToTextarea:true,
/**图片上传配置*/
imageUpload : true,
imageFormats : ["jpg", "jpeg", "gif", "png", "bmp", "webp"], //图片上传格式
imageUploadURL: "/admin/blog/md/uploadimg",
onload: function (obj) { //上传成功之后的回调
}
});
对editor.md编辑器的上传图片后端处理,主要就是把文件保存到服务器,然后回显一个URL给前台,让前台可以通过这个URL访问到这张图片,这个URL可以通过浏览器直接输入地址来访问检验有没有问题。
这里建议再开一个服务器,把图片都保存在这个服务器。
我这里就采用nginx来保存上传的图片,最后回显url的时候把服务器地址写对就可以了,比较简单。
@ResponseBody
@PostMapping("/blog/md/uploadimg")
public Map<String,Object> mdUpload(@RequestParam(value = "editormd-image-file",required = false) MultipartFile file,
HttpServletRequest request,
HttpServletResponse response){
Map<String,Object> result = new HashMap<String, Object>();
String fileName = file.getOriginalFilename();//文件名
String newFileName = MyBlogUtil.getNewFileName(fileName);//设置文件新名称
//用户文件存储的路径
String userPath = MyBlogUtil.getUserDirectory(request.getSession());
//博客图片保存目录
String blogPath = parentDirPath + userPath + "blog" + File.separator + "img";
//获得最终保存的目录
File blogDir = new File(blogPath);
if (!blogDir.exists()){
blogDir.mkdirs();
}
//最终文件上传的路径
File filePath = new File(blogPath + File.separator + newFileName);
String fileUrl = fileSever + userPath.replace("\\","/") + "blog/img/" + newFileName;
try {
request.setCharacterEncoding("utf-8");
response.setHeader("Content-Type", "text/html");
file.transferTo(filePath);
result.put("success",1);
result.put("message","上传成功");
result.put("url",fileUrl);
} catch (UnsupportedEncodingException e) {
e.printStackTrace();
} catch (IOException e) {
result.put("success",0);
e.printStackTrace();
}
return result;
}
上面有两个变量fileSever,parentDirPath,这里可以根据自己的具体情况如何在配置文件配置
这个可以等到后面,我们配置完nginx服务器的时候再来指定
@Value("${fileSever}")
private String fileSever; //代表图片上传到的服务器域名
@Value("${parentDirPath}")
private String parentDirPath; //代表图片上传到的父目录名称 ,也就是图片服务器映射的根目录
修改管理员信息
主要是修改头像的功能实现
- 使用MultipartFile对象来接受前台传过来的图片数据
- 重命名保存到nginx服务器中
- 把URL地址设置到user对象中
- 最后更新管理员信息
@PostMapping("/user")
public String updateInfo(User user, @RequestParam(value = "avatar-img",required = false)MultipartFile file,
HttpSession session){
//判断上传的图片是否为空
if (!file.isEmpty()) {
//用户目录
String userDirectory = MyBlogUtil.getUserDirectory(session);
//文件重命名
String newFileName = MyBlogUtil.getNewFileName(file.getOriginalFilename());
log.info("用户目录为{},文件名为{}",userDirectory,newFileName);
//设置头像的保存位置
String avatarPath = parentDirPath + userDirectory + "avatar" + File.separator;
File avatarDir = new File(avatarPath);
//保存用户头像的目录如果不存在就创建
if (!avatarDir.exists()){
avatarDir.mkdirs();
}
File avatarFile = new File(avatarPath + newFileName);
try {
//保存图片
file.transferTo(avatarFile);
} catch (IOException e) {
e.printStackTrace();
}
String avatorUrl =fileSever + userDirectory.replace("\\","/")+ "avatar/" + newFileName;
user.setAvatar(avatorUrl);
}
userService.updateUserInfo(user);
return "redirect:/admin/user";
}
Nginx
首先先去nginx官网下载: http://nginx.org/
下载解压完后双击nginx.exe ,看到界面一闪就说明已经启动了
也可以通过cmd命令start nginx 来启动(需要先进入到nginx的目录)
修改了nginx的配置文件后,如果nginx正运行着,记得要用nginx -s reload 重载一下。
反向代理
location / {
proxy_pass http://127.0.0.1:8080; #这里写tomcat的服务器地址
proxy_set_header Host $host;
proxy_set_header X-Real-IP $remote_addr;
proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
}
反向代理只要配置proxy_pass 就够了,配置完反向代理后有个问题就是我们通过request.getRemoteAddr()获取
到的访问ip地址都是nginx服务器的地址,无法获得访客的ip地址,因为所有的请求都是经过nginx转发给我们的。
可以通过设置这三行,意思是nginx把请求转发给我们的tomcat的时候,给request请求增加这3个头部。
在我们程序里就可以通过这行代码来获取到访客的ip地址
request.getHeader("X-Real-IP")
动静分离
开发环境nginx的配置
location ~\.(css|js|png|jpg|git|jpeg|bmp|webp)$ {
root D:/nginx-dir/blog/static;
}
表明遇到请求css/js/png等静态资源时,直接去D:/nginx-dir/blog/static目录下查找,这样就不会去我们的tomcat上查找了,只有当访问动态的资源时,nginx无法处理,才会把请求转发给tomcat。
简单配置完nginx后,就可以配置项目中的图片上传的位置
dev.yml
fileSever: http://localhost/ #指定nginx服务器的地址
parentDirPath: D:\nginx-dir\blog\static\ #指定nginx映射的目录,也就是nginx配置文件root配置的目录
生成环境nginx的配置
在CentOS 7 上可以直接通过 yum install nginx 进行安装
Linux上跟windows上的配置大同小异,主要是路径分割符不一样
location / {
proxy_pass http://127.0.0.1:8080;
proxy_set_header Host $host;
proxy_set_header X-Real-IP $remote_addr;
proxy_set_header REMOTE-HOST $remote_addr;
proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
}
location ~\.(css|js|png|jpg|git|jpeg|bmp|webp)$ {
root /root/blog/static;
}
pro.yml文件配置
fileSever: http://127.0.0.1/
parentDirPath: /root/blog/static/
开发大致就介绍这些