社交网站后端项目开发日记(二)

本项目目标是开发一个社区网站,拥有发帖、讨论、搜索、登录等一个正常社区拥有的功能。涉及到的版本参数为:

  • JDK1.8
  • Maven3.8.1(直接集成到IDEA)
  • Springboot 2.5.1
  • tomcat 9.0.45
  • Mybatis
  • Mysql 8.0.15

参考网站(在使用框架过程中可能会看的开发文档):

//mvnrepository.com/ 查找maven依赖

//mybatis.org/mybatis-3/zh/index.html mybatis的官方文档,配置等都有说明

项目代码已发布到github //github.com/GaoYuan-1/web-project

关于数据库文件,该篇博客中已有提到,可去文中github获取数据 MySQL基础篇(一)

本文介绍如何实现注册,发送激活邮件等内容。本系列下一篇博客将会开发登录功能或发布帖子功能等,最终将会把完整项目经历发布出来。

本系列主要介绍的是实战内容,对于理论知识介绍较少,适合有一定基础的人。

接上次开发日记(一)说明:

spring.datasource.url=jdbc:mysql://localhost:3306/community?characterEncoding=utf-8&useSSL=false&serverTimezone=Hongkong&allowPublicKeyRetrieval=true

在项目application.properties中添加一句allowPublicKeyRetrieval=true。否则每次打开项目需要将数据库启动,不然的话会出现公钥不识别的错误。

1. 开发网站首页

开发流程实质上就是一次请求的执行过程。

image-20210715231657983

Controlloer(视图层)依赖于Service(表现层)依赖于DAO(数据访问层),所以开发过程中可以从DAO开始,依次进行开发。

首页会有许多个功能,首先我们需要实现一个简单的demo,之后对功能进行丰富即可。

首先计划开发页面显示10个帖子,进行分页。

数据库中的TABLE如下所示:

image-20210715234333573

其中,comment_count意义为评论数量。

1.1 DAO层开发

首先在项目.entity文件中,建立DisscussPost实体(帖子信息),然后建立DiscussPostMapper。

@Mapper
public interface DiscussPostMapper {
    //userId传参是为了将来显示个人首页,可以认为userId==0时为网站首页
    List<DiscussPost> selectDiscussPosts(int userId, int offset, int limit);  //因为首页要分页显示,每页十条,所以直接使用集合
    //如果在<if>里使用时,比如显示首页时不需要判断userId,而显示个人首页需要,如果只有一个参数,需要加上@Param,否则会报错
    int selectDiscussPostRows(@Param("userId") int userId); //该注解可以给参数取别名
}

这个接口只需要写两个方法。第一个负责返回一个集合,比如我们要分页显示,每页10条,返回这10条记录的集合

第二个方法,负责返回总的行数。

接下来写Mybatis的.xml文件

<mapper namespace="com.nowcoder.community.dao.DiscussPostMapper">  <!-- 这里写服务接口的全限定名 -->
    <sql id="selectFields">
        id, user_id, title, content, type, status, create_time, comment_count, score
    </sql>
    <select id="selectDiscussPosts" resultType="DiscussPost">
        select <include refid="selectFields"></include>
        from discuss_post
        where status != 2
        <if test="userId != 0">
            and user_id = #{userId}
        </if>
        order by type desc, create_time desc
        limit #{offset}, #{limit}
    </select>

    <select id="selectDiscussPostRows" resultType="int">
        select count(id)
        from discuss_post
        where status != 2
        <if test="userId != 0">
            and user_id = #{userId}
        </if>
    </select>

</mapper>

配置和之前的user-mapper配置相同,只是namespace需要更改为当前的。注意这个<if>语句是为了判断是显示首页,还是显示用户个人首页(这个功能将来实现),配置完成之后进行测试。

如果测试对数据库的操作无误,DAO层部分至此结束。

1.2 Service层开发

@Service
public class DiscussPostService {
    @Autowired
    private DiscussPostMapper discussPostMapper;

    public List<DiscussPost> findDiscussPosts(int userId, int offset, int limit){
        return discussPostMapper.selectDiscussPosts(userId, offset, limit);
    }

    public int findDiscussPostRows(int userId) {
        return discussPostMapper.selectDiscussPostRows(userId);
    }
}

首先在Service层对上述两个功能进行实现,这时候需要考虑一个问题,DisscusPost 对象中的userId意味着用户的ID,但是在以后调取信息时候肯定不能直接使用这个数字而是使用用户名,所以这时候有两种实现方式:一是在SQL查询时直接关联查询,二是针对每一个DisscusPost查询相应的用户。这里采用第二种方式,是为了将来采用redis缓存数据时候有一定好处。

这个功能是User相关的(用户相关),所以在UserService中添加方法:

@Service
public class UserService {
    @Autowired
    private UserMapper userMapper;

    public User findUserById(int id) {
        return userMapper.selectById(id);
    }
}

这两个功能相对简单,Service层至此结束。

1.3 Controller层开发

@Controller
public class HomeController {
    @Autowired
    private DiscussPostService discussPostService;

    @Autowired
    private UserService userService;

    @RequestMapping(path = "/index", method = RequestMethod.GET)
    public String getIndexPage(Model model) {
        List<DiscussPost> list = discussPostService.findDiscussPosts(0,0,10);
        List<Map<String, Object>> discussPosts = new ArrayList<>();
        if(list != null) {
            //list中每个元素装的是一个map,map中含有两个元素,一个帖子信息,一个用户,方便thymeleaf操作
            for(DiscussPost post : list) {
                Map<String, Object> map = new HashMap<>();
                map.put("post", post);
                User user = userService.findUserById(post.getId());
                map.put("user", user);
                discussPosts.add(map);
            }
        }
        model.addAttribute("discussPosts",discussPosts);
        return "/index";
    }
}

这里没有写@ResponseBody因为我们返回的是一个html。有两种实现方式,可回顾上篇博客。

其中前端文件html,css,js等均已给出,本篇不对前端知识进行总结描述。

1.4 index.html相关

其中,在首页index.html中,我们利用thymeleaf引擎对帖子列表进行循环,后面需要加上th:,这是和静态页面不同的地方。

<li class="media pb-3 pt-3 mb-3 border-bottom" th:each="map:${discussPosts}">
<!-- 帖子列表 -->
<ul class="list-unstyled">
   <!-- th:each="" 循环方式,这里引用map对象,即list中的map -->
   <li class="media pb-3 pt-3 mb-3 border-bottom" th:each="map:${discussPosts}">
      <a href="site/profile.html">
         <!-- 用户头像是动态的,map.user其实是map.get("user"),后面也是get操作,会自动识别 -->
         <img th:src="${map.user.headerUrl}" class="mr-4 rounded-circle" alt="用户头像" style="width:50px;height:50px;">
      </a>
      <div class="media-body">
         <h6 class="mt-0 mb-3">
            <!-- 帖子标题动态,其中utext可以直接将转义字符呈现出来,text则不可以 -->
            <a href="#" th:utext="${map.post.title}">备战春招,面试刷题跟他复习,一个月全搞定!</a>
            <!-- if标签 -->
            <span class="badge badge-secondary bg-primary" th:if="${map.post.type==1}">置顶</span>
            <span class="badge badge-secondary bg-danger" th:if="${map.post.status==1}">精华</span>
         </h6>
         <div class="text-muted font-size-12">
            <!-- 时间转义 -->
            <u class="mr-3" th:utext="${map.user.username}">寒江雪</u> 发布于 <b th:text="${#dates.format(map.post.createTime,'yyyy-MM-dd HH:mm:ss')}">2019-04-15 15:32:18</b>
            <ul class="d-inline float-right">
               <!-- 目前暂不处理 -->
               <li class="d-inline ml-2">赞 11</li>
               <li class="d-inline ml-2">|</li>
               <li class="d-inline ml-2">回帖 7</li>
            </ul>
         </div>
      </div>                
   </li>
</ul>

呈现效果如下:(此项目的前端部分都是根据已有的,仿牛客网设计)

image-20210717084225057

image-20210717084239579

第一页共10条帖子,当然此时第二页还没有设计。

注意:可能出现的bug有:引入的bootstrap和jQuery失效,这样会造成页面显示有问题。如果遇到这种问题,可在html中更换链接。

demo完成之后,需要思考的是:这时候点击帖子实际上是没有信息返回的,包括页码,都没有返回信息,我们接下来需要做的就是这一步。

1.5 分页

接下来我们需要实现的是分页,真正把页码部分给利用起来。首先在entity文件中,建立Page对象。建立一系列需要的方法,方便在index.html中使用。

//封装分页相关信息
public class Page {
    //当前页码
    private int current = 1;
    //显示上限
    private int limit = 10;
    //记录数量(计算总页数)
    private int rows;
    //查询路径
    private String path;
    //get和set省略了,注意判断set,比如setRows,rows要大于等于0,current要大于等于1

    /*
    获取当前页的起始行
     */
    public int getOffset() {
        return (current-1) * limit;
    }

    //获取总页数
    public int getTotal() {
        if(rows % limit == 0)
            return rows/limit;
        else
            return rows/limit + 1;
    }

    //获取起始页码以及结束页码
    public int getFrom() {
        int from = current - 2;
        return from < 1 ? 1 : from;
    }

    public int getTo() {
        int to = current + 2;
        int total = getTotal();
        return to > total ? total : to;
    }
}

Controller中只需要添加两行代码:

//方法调用前,SpringMVC会自动实例化model和page,并将page注入model
//所以在thymeleaf中可以直接访问page对象中的数据
page.setRows(discussPostService.findDiscussPostRows(0));
page.setPath("/index");

接下来介绍index.html中关于分页部分的代码,其中有些thymeleaf相关代码需要注意,已添加注释。

<!-- 分页 -->
<nav class="mt-5" th:if="${page.rows>0}">
   <ul class="pagination justify-content-center">
      <li class="page-item">
         <!-- 小括号的意义 /index?current=1 -->
         <a class="page-link" th:href="@{${page.path}(current=1)}">首页</a>
      </li>
      <!-- disabled指点击无效,比如第一页点上一页无效 -->
      <li th:class="|page-item ${page.current==1?'disabled':''}|">
         <a class="page-link" th:href="@{${page.path}(current=${page.current-1})}">上一页</a>
      </li>
      <!-- 这里是调用numbers中建立两个数为起点和终点的数组 -->
      <!-- active这里是点亮 -->
      <li th:class="|page-item ${i==page.current?'active':''}|" th:each="i:${#numbers.sequence(page.from,page.to)}">
         <a class="page-link" href="#" th:text="${i}">1</a>
      </li>
      <li th:class="|page-item ${page.current==page.total?'disabled':''}|">
         <a class="page-link" th:href="@{${page.path}(current=${page.current+1})}">下一页</a>
      </li>
      <li class="page-item">
         <a class="page-link" th:href="@{${page.path}(current=${page.total})}">末页</a>
      </li>
   </ul>
</nav>

这里的跳转链接:/index?current=x,这个current实际是根据请求改变的,进而current改变之后再次请求,页面发生改变。注意理解一下程序流程。

至此,部分分页组件(直接点击页码还没有完成)开发完成,效果如下:

image-20210717101700077

image-20210717101852153

2. 登录注册功能

注册功能首先需要服务器向用户发送激活邮件进行验证。

2.1 发送邮件

Spring Email参考文档://docs.spring.io/spring-framework/docs/current/reference/html/integration.html#spring-integration

maven仓库中找到依赖进行声明:

<dependency>
   <groupId>org.springframework.boot</groupId>
   <artifactId>spring-boot-starter-mail</artifactId>
   <version>2.5.2</version>
</dependency>

大致思路:

image-20210723021235201

过程:

首先需要在application.properties作以下配置:

# MailProperties
spring.mail.host=smtp.qq.com
spring.mail.port=465
[email protected]
spring.mail.password=QQ邮箱的话需要激活码,其他邮箱的话需要密码
#表示启用的安全的协议
spring.mail.protocol=smtps
#采用SSL安全连接
spring.mail.properties.mail.smtp.ssl.enable=true

这时候邮件发送类放在DAO,Service等以上提到的包中显然不合适,建立util工具包,建立如下类:

@Component
public class MailClient {
    private static final Logger logger = LoggerFactory.getLogger(MailClient.class);

    @Autowired
    private JavaMailSender mailSender;

    @Value("${spring.mail.username}")
    private String from;

    public void sendMail(String to, String subject, String content) {
        try {
            MimeMessage mimeMessage = mailSender.createMimeMessage();
            MimeMessageHelper helper = new MimeMessageHelper(mimeMessage);
            helper.setFrom(from);
            helper.setTo(to);
            helper.setSubject(subject);
            helper.setText(content,true); //加true,会认为内容支持html文本
            mailSender.send(helper.getMimeMessage());
        } catch (MessagingException e) {
            logger.error("发送邮件失败" + e.getMessage());
        }
    }
}

因为这个不属于controller,dao,service这三层框架中任何一层,所以用的注解为@Component,声明Bean

以上的接口等如果是自学,且想深入了解,可以查找博客,不过最全的还是官方文档,上文已给出链接。

测试类进行测试:

@Test
public void testTextMail() {
    mailClient.sendMail("[email protected]","TEST","Welcome");
}

效果如图:

image-20210723031536094

发送HTML邮件,利用thymeleaf建立动态模板,以下进行示例:

<!DOCTYPE html>
<html lang="en" xmlns:th="//www.thymeleaf.org">
<head>
    <meta charset="UTF-8">
    <title>邮件示例</title>
</head>
<body>
<p>hello,world.<span style="color:red;" th:text="${username}"></span></p>
</body>
</html>

测试代码如下:

@Test
public void testHtmlMail() {
    Context context = new Context();
    context.setVariable("username","gaoyuan");

    String content = templateEngine.process("/mail/demo",context);   //templates文件下的路径
    System.out.println(content);

    mailClient.sendMail("[email protected]","TEST1",content);
}

这里面调用了TemplateEngine类(SpringMVC中的核心类),会将HTML邮件的内容转为字符串。

此外,context是org.thymeleaf.context.Context。在这里的作用是声明了动态模板中的变量。

image-20210725013326701

image-20210725013104826

2.2 开发注册功能

首先思考注册功能的具体流程:

image-20210725015253222

开发日记(一)中公布的源码已有前端代码,templates/site/register.html

对该代码进行thymeleaf声明,以及相对路径更改。

对Index.html进行一定更改:

<header class="bg-dark sticky-top" th:fragment="header">

这里的意思是对header代码块进行声明,同时在register.html进行声明:

<header class="bg-dark sticky-top" th:replace="index::header">

这样的话,/register页面会复用/index页面的header。

建议读者对thymeleaf的相关知识进行一定的了解,本篇博客注重于实战。

首先对访问注册页面进行实现,非常简单,建立LoginController.class:

@RequestMapping(path = "/register", method = RequestMethod.GET)
public String getRegisterPage() {
    return "/site/register";
}

在提交注册数据的过程中,需要对字符串进行一定的处理,接下来插入一个新的包:

<dependency>
   <groupId>org.apache.commons</groupId>
   <artifactId>commons-lang3</artifactId>
   <version>3.12.0</version>
</dependency>

在application.properties中配置域名:

# community
community.path.domain=//localhost:8080

目前项目没上线,直接配置为tomcat主机名。

在工具类目录下新建CommunityUtil.class,建立项目中需要用到的一些方法。

public class CommunityUtil {
    //生成随机字符串(用于激活码)
    public static String generateUUID() {
        return UUID.randomUUID().toString().replaceAll("-","");
    }

    //MD5加密
    public static String md5(String key) {
        if(StringUtils.isBlank(key)) {
            return null;  //即使是空格,也会认为空
        }
        return DigestUtils.md5DigestAsHex(key.getBytes()); //将传入结果加密成一个十六进制的字符串返回,要求参数为byte
    }
}

以上为注册功能中涉及到的字符串处理方法。

密码我们采用MD5加密,该类加密方式只能加密,不能解密:

假如说 hello加密为avblkjafdlkja,是不能有后者解密为前者的。但是只有这样还不够安全,因为简单字符串的加密结果都是固定的。

因此我们对密码采用 password + salt(加一个随机字符串),这样的话即使密码设置为简单字符串,也会较为安全。

这是涉及到的字符串处理逻辑。


接下来介绍Service层如何编码,进行注册用户,发送激活邮件:

这个属于用户服务,在UserSevice中进行添加:

public Map<String, Object> register(User user) {
    Map<String, Object> map = new HashMap<>();
    //空值处理
    if(user==null) {
        throw new IllegalArgumentException("参数不能为空!");
    }
    if(StringUtils.isBlank(user.getUsername())) {
        map.put("usernameMsg", "账号不能为空!");
        return map;
    }
    if(StringUtils.isBlank(user.getPassword())) {
        map.put("passwordMsg", "密码不能为空!");
        return map;
    }
    if(StringUtils.isBlank(user.getEmail())) {
        map.put("emailMsg", "邮箱不能为空!");
        return map;
    }

    //验证账号
    User u = userMapper.selectByName(user.getUsername());
    if(u != null) {
        map.put("usernameMsg", "该账号已存在");
        return map;
    }

    //验证邮箱
    u = userMapper.selectByEmail(user.getEmail());
    if(u != null) {
        map.put("emailMsg", "该邮箱已被注册");
        return map;
    }

    //注册用户
    user.setSalt(CommunityUtil.generateUUID().substring(0,5)); //设置5位salt
    user.setPassword(CommunityUtil.md5(user.getPassword() + user.getSalt())); //对密码进行加密
    user.setType(0);
    user.setStatus(0);
    user.setActivationCode(CommunityUtil.generateUUID()); //激活码
    user.setHeaderUrl(String.format("//images.nowcoder.com/head/%dt.png", new Random().nextInt(1000)));  //设置随机头像,该Url对应的0到1000均为头像文件
    user.setCreateTime(new Date());
    userMapper.insertUser(user);


    //激活邮件
    Context context = new Context();  //利用该对象携带变量
    context.setVariable("email",user.getEmail());
    // //localhost:8080/community/activation/101(user_id)/code(ActivationCode)  激活路径
    String url = domain + contextPath + "/activation/" + user.getId() + "/" + user.getActivationCode();
    context.setVariable("url", url);
    String content = templateEngine.process("/mail/activation", context);
    mailClient.sendMail(user.getEmail(), "激活账号", content);

    return map;  //如果map为空,说明没有问题
}

注意:该代码块只是部分代码,省略了注入对象等简单代码。

激活邮件的动态模板为:templates/site/activation.html,改为thymeleaf适用即可

<!doctype html>
<html lang="en" xmlns:th="//www.thymeleaf.org">
<head>
    <meta charset="utf-8">
    <link rel="icon" href="//static.nowcoder.com/images/logo_87_87.png"/>
    <title>牛客网-激活账号</title>
</head>
<body>
   <div>
      <p>
         <b th:text="${email}">[email protected]</b>, 您好!
      </p>
      <p>
         您正在注册牛客网, 这是一封激活邮件, 请点击
         <!-- 这里类似于markdown的  []() -->
         <a th:href="${url}">此链接</a>,
         激活您的牛客账号!
      </p>
   </div>
</body>
</html>

接下来,处理Controller层逻辑,LoginController.class:

@RequestMapping(path = "/register", method = RequestMethod.POST)
public String register(Model model, User user) {
    Map<String, Object> map = userService.register(user);
    if(map == null || map.isEmpty()) {
        model.addAttribute("msg","注册成功,我们已经向您的邮箱发送了一封激活邮件,请尽快激活");
        model.addAttribute("target", "/community/index");
        return "/site/operate-result";
    }else {
        model.addAttribute("usernameMsg", map.get("usernameMsg"));
        model.addAttribute("passwordMsg", map.get("passwordMsg"));
        model.addAttribute("emailMsg", map.get("emailMsg"));
        return "/site/register";
    }
}

该请求为POST请求,因为要向服务器提交注册信息。/site/operate-result地址为注册成功的html文件,公布源码中可以查看。

与此同时,我们需要考虑,如果注册过程中,发生错误信息了,继续返回register,前端部分需要作以下处理(部分代码):

<div class="form-group row">
   <label for="username" class="col-sm-2 col-form-label text-right">账号:</label>
   <div class="col-sm-10">
      <input type="text" class="form-control"
            th:value="${user!=null?user.username:''}"
            id="username" name="username" placeholder="请输入您的账号!" required>
      <div class="invalid-feedback">
         该账号已存在!
      </div>
   </div>
</div>
user!=null?user.username:'' 这句话是进行赋默认值,如果错误之后返回该页面,保存上次输入的信息,if判断上次是否输入user信息

接下来对代码进行测试,开启debug模式:

查找了一个数据库中已存在的username进行注册

image-20210725035708443

成功情况:

image-20210725035820472

自动跳转回首页:

image-20210725041321209

邮箱已接收到邮件:

image-20210725040220034


但是在到目前为止,激活链接是无效的,因为我们还没进行这一步骤,接下来进行激活链接相关设计:

首先需要考虑的是激活的时候可能会有三种情况:

  • 激活成功
  • 已经激活过了,再次激活重复操作无效
  • 激活失败,激活路径错误

首先在util目录下建立一个常量接口:

//定义常量
public interface CommunityConstant {
    //激活成功
    int ACTIVATION_SUCCESS = 0;

    //重复激活
    int ACTIVATION_REPEAT = 1;

    //激活失败
    int ACTIVATION_FAILURE = 2;
}

实际上,激活链接只需要我们向数据库进行访问,当HTTP请求路径中的激活码部分和数据库中相等,将数据库中用户的状态改为已激活即可。

在UserService中添加该方法。

// //localhost:8080/community/activation/101(user_id)/code(ActivationCode)
@RequestMapping(path = "/activation/{userId}/{code}", method = RequestMethod.GET)
public String activation(Model model, @PathVariable("userId") int userId, @PathVariable("code") String code) {
    int result = userService.activation(userId, code);
    if(result == ACTIVATION_SUCCESS){
        model.addAttribute("msg","激活成功,您的账号已经可以正常使用!");
        model.addAttribute("target", "/community/login");
    }else if(result == ACTIVATION_REPEAT){
        model.addAttribute("msg","无效操作,该账号已经激活过了!");
        model.addAttribute("target", "/community/index");
    }else{
        model.addAttribute("msg","激活失败,您提供的激活码不正确!");
        model.addAttribute("target", "/community/index");
    }
    return "/site/operate-result";
}

这里我们不需要提交数据,采用GET请求即可,但是我们需要复用operate-result动态模板,所以需要利用model添加变量。

至于/login.html已给出前端源码,目前只完成了注册功能,暂时只响应一下界面,下个博客再继续开发登录功能。

点击邮件中的链接,效果如下:

image-20210725225513808

成功之后跳转到登录页面。

image-20210725225442362

3. 总结

本篇博客关注于社交网站的首页实现和注册功能实现。需要理解MVC这三层概念,以及软件设计的三层架构。通常来说,首先实现数据层,再设计服务层,最终实现视图层,但是有些是不需要数据层的,比如注册功能,我们已经在设计首页的时候建立了用户实体,所以在开发注册功能时,直接添加UserService即可。另外,发送邮件调用了Springmail,以及注册过程中处理字符串调用了Commonslang。总的来说,在开发过程中,需要借助成熟的包,熟悉它们的API,参考官方文档,这是非常重要的。