社交網站後端專案開發日記(二)

GaoYuan206發表於2021-07-25

本專案目標是開發一個社群網站,擁有發帖、討論、搜尋、登入等一個正常社群擁有的功能。涉及到的版本引數為:

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

參考網站(在使用框架過程中可能會看的開發文件):

https://mvnrepository.com/ 查詢maven依賴

https://mybatis.org/mybatis-3/zh/index.html mybatis的官方文件,配置等都有說明

專案程式碼已釋出到github https://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參考文件:https://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
spring.mail.username=422374979@qq.com
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("gaoyuan206@gmail.com","TEST","Welcome");
}

效果如圖:

image-20210723031536094

傳送HTML郵件,利用thymeleaf建立動態模板,以下進行示例:

<!DOCTYPE html>
<html lang="en" xmlns:th="http://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("gaoyuan206@gmail.com","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=http://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("https://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());
    // http://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="http://www.thymeleaf.org">
<head>
    <meta charset="utf-8">
    <link rel="icon" href="https://static.nowcoder.com/images/logo_87_87.png"/>
    <title>牛客網-啟用賬號</title>
</head>
<body>
   <div>
      <p>
         <b th:text="${email}">xxx@xxx.com</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中新增該方法。

// http://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,參考官方文件,這是非常重要的。

相關文章