Tlias-後端開發

EUNEIR發表於2024-03-14

開發規範

image.png

前後端混合開發

  • 溝通成本高
  • 分工不明確:前端發起請求、資料響應的渲染一般都是後端程式設計師完成的
  • 不便管理
  • 難以維護

前後端分離開發

image.png

產品經理提供介面原型 + 需求,前端/後端分析並設計出介面文件,有了介面文件前端後端就可以並行開發

介面文件中的介面是功能性介面,按照功能劃分介面

RESTful

REST(REpresentational State Transfer),表述性狀態轉換,它是一種軟體架構風格。

前端向後端發起的請求是RESTful風格的

原先的介面風格:

image.png

這種風格的缺點是:不規範,難以維護。

因為不同人的開發風格是不同的,比如刪除使用者不同程式設計師的命名可能是deleteUser、removeUser等,這樣在做大型專案時每一個模組的命名都是不同的,這樣不利於後期維護和擴充套件。

image.png

RESTful的特點:

  • **URL定位資源
  • HTTP動詞描述動作

注意:

  1. RESTful是風格,是約定,但約定並不是規定,約定是可以打破的
  2. 描述功能模組通常使用複數形式(加s),表示此類資源而非單個資源,比如users

環境搭建

  • 前後端並行開發,後端開發完功能後如何對後端介面進行測試呢?
  • 前後端並行開發,前端開發時如何獲取資料渲染頁面呢?

可以使用API fox

Api fox是整合了Api文件、Api除錯、Api Mock、Api測試的一體化協作平臺

作用:介面文件管理、介面請求測試、Mock服務

查詢部門

image.png

@RestController 是@Controller + @ResponseBofy

當前程式碼有兩個問題:

  • RequestMapping未指定請求方式,可以接收所有請求
  • 返回結果不統一
  1. 解決所有請求,讓當前方法只接收GET請求
@RequestMapping("",method=GET)  //麻煩
@GetMapping("")
  1. 解決返回結果不統一

image.png

應該無論執行哪種增刪改查操作,都返回一個同一的結果:

image.png

可能出現的問題

如果後端的介面測試沒有問題,而前端渲染不到資料,就是前端或後端有一方沒有根據介面文件開發,檢查介面文件

Nginx反向代理

前端請求的地址為http://localhost:90/api/depts,如何訪問到後端的http://localhost:8080/depts呢?

其實是使用了Nginx伺服器的[[03-Nginx#Nginx的反向代理|反向代理機制]]

image.png

Nginx反向代理:

  • 安全
  • 靈活
  • 負載均衡

image.png

伺服器監聽90埠的請求,以http://localhost:90/api/depts為例

  • location:用於定義匹配特定URL請求的規則

localtion : ^~ /api/ :精確匹配以/api/開頭的請求路徑,匹配到了/api/depts這個請求

  • rewrite:重寫匹配到的請求路徑

rewrite : ^/api/(.*)$ /$1 break; :精確匹配從/api/到結束的字串,將該字串的/api/之後的內容視為一個分組,捕獲該分組並重寫為/(.*),本例中捕獲的分組是depts,重寫後的路徑是/depts

  • proxy_pass:該指令用於代理轉發,將匹配到的請求轉發給位於後端的指令伺服器

proxy_pass : http://localhost:8080 ,此時完整的請求就是 http://localhost:8080/depts

image.png

三層架構

見[[Spring#分層解耦|分層解耦]]

專案表結構的設計

User表

依據介面原型和需求文件設計員工表的結構

介面原型:

image.png

需求文件:

image.png

image.png

create database if not exists itheima02;  
use itheima02;  
drop table if exists user;  
create table user(  
    id int unsigned primary key auto_increment comment 'id 主鍵 自增',  
    username varchar(20) not null unique comment '使用者名稱',  
    password varchar(50) not null default '123456' comment '密碼',  
    name varchar(10) not null unique comment '姓名',  
    gender tinyint unsigned not null comment '1 男 2 女',  
    phone char(11) not null unique comment '手機號',  
    job tinyint unsigned comment '1 講師 ....',  
    salary int unsigned comment '薪資',  
    image varchar(300) comment '頭像圖片地址',  
    entry_date date comment '入職日期',  
    create_time datetime comment '建立時間',  
    update_time datetime comment '最後更新時間'  
) comment '使用者表';
  • 根據表格分析出大部分資料,根據需求文件2.5描述新增password欄位
  • 注意:
  1. 一般不直接儲存 '男' 或 '女',不同的業務場景下展現的可能是 男性 或 男士,應該進行抽象

Dept表

image.png

-- 部門管理  
create table dept(  
    id int unsigned primary key auto_increment comment '主鍵ID',  
    name varchar(10) not null unique comment '部門名稱',  
    create_time datetime comment '建立時間',  
    update_time datetime comment '修改時間'  
) comment '部門表';  
  • 對於資料庫儲存的表,一般都會新增三個欄位:id、create_time、update_time。id用來標識唯一記錄

在原先SpringBoot專案的基礎上整合MyBatis:

<dependency>

    <groupId>org.mybatis.spring.boot</groupId>

    <artifactId>mybatis-spring-boot-starter</artifactId>

    <version>3.0.3</version>

</dependency>

  

<dependency>

    <groupId>com.mysql</groupId>

    <artifactId>mysql-connector-j</artifactId>

    <scope>runtime</scope>

</dependency>

Emp表

image.png

注意:其中部門和員工是一對多的關係,將部門主鍵加入員工表中

create table emp(  
    id int unsigned primary key auto_increment comment 'ID,主鍵',  
    username varchar(20) not null unique comment '使用者名稱',  
    password varchar(50) default '123456' comment '密碼',  
    name varchar(10) not null comment '姓名',  
    gender tinyint unsigned not null comment '性別, 1:男, 2:女',  
    phone char(11) not null unique comment '手機號',  
    job tinyint unsigned comment '職位, 1 班主任, 2 講師 , 3 學工主管, 4 教研主管, 5 諮詢師',  
    salary int unsigned comment '薪資',  
    image varchar(300) comment '頭像',  
    entry_date date comment '入職日期',  
    dept_id int unsigned comment '部門ID',  
    create_time datetime comment '建立時間',  
    update_time datetime comment '修改時間'  
) comment '員工表';

其中dept_id是邏輯外來鍵。

新增員工:

image.png

其中員工可能有多段工作經歷,員工表和工作經歷表是一對多的關係,應該將一方主鍵加入多方表

create table emp_expr(  
    id int unsigned primary key auto_increment comment '經歷id',  
    begin date comment '開始時間',  
    end date comment '結束時間',  
    company varchar(50) comment '公司',  
    job varchar(50) comment '職位',  
    emp_id int unsigned not null comment '員工ID'  
) comment '工作經歷表';

外來鍵的資料型別要和主鍵保持一致,此表沒有updateTime和createTime,因為工作經歷表實際上是員工表的附屬表,不會單獨的修改工作經歷和建立工作經歷,實際上是修改員工資訊和建立員工資訊,修改時間和建立時間也就包含在員工表中了

image.png

部門的增刪改查

查詢部門

@Select("select * from dept")  
List<Dept> selectAll();

但需要解決MySQL的snake命名和Java的Camel-Case轉換

  1. Results - Result
  2. as 起別名
  3. 自動結果對映

需要注意的是,資料庫中儲存的是datetime型別的欄位,從查詢結果集ResultSet獲得到的是Timestamp,MyBatis是將Timestamp轉化為了LocalDateTime

刪除部門

image.png

點選刪除後,刪除資料庫中對應的記錄。

RESTful風格:前端發起DELETE請求,攜帶要刪除的Dept id DELETE /depts/1

Controller:

@DeleteMapping("/depts/{id}")  
public Result deleteREST(@PathVariable("id") Integer id){  
    Integer i = deptService.removeById(id);  
    if (Integer.valueOf(1).equals(i)){  
        return Result.success();  
    }else {  
        return Result.error("刪除失敗");  
    }  
    
}

需要使用@PathVariable獲取路徑引數

@RequestParam獲取的是請求引數,? 之後的就是請求引數

如果請求路徑是:/depts?id=1,就需要使用@RequestParam進行接收:

@DeleteMapping("/depts")  
public Result delete(@RequestParam Integer id){  
    Integer i = deptService.removeById(id);  
    if (Integer.valueOf(1).equals(i)){  
        return Result.success();  
    }else {  
        return Result.error("刪除失敗");  
    }  
}

如果形參和請求的key不同,需要在註解中指定名稱。

@RequestParam中有require屬性,要求被註解引數必須提供值,預設為true。或者可以指定defaultValue

Service。

Mapper:

需要根據id刪除,SQL是: delete from dept where id = #{id}

此處的id就是Mapper介面中方法的引數:

public interface DeptMapper{
	@Delete("delete from dept where id = #{id}")  
	Integer deleteById(Integer id);
}

如果SQL只有一個引數,方法也只需要一個入參,那麼方法的入參可以隨便命名(不建議)

如果SQL有多個引數,方法就需要多個入參,那麼方法的入參名字就要和佔位符中的名字保持一致。

新增部門

image.png

新增部門需要提供部門名稱,在前端封裝為JSON格式:

{
	"name" : "教研部"
}

前端發起的POST請求需要設定content-type:json

Controller需要接收JSON格式的資料,就要使用@RequestBody註解

@PostMapping("/depts")  
public Result addDept(@RequestBody Dept dept){  
    deptService.addDept(dept);  
    return Result.success();  
}

需要保證JSON的key和Dept類的屬性名是一致的。

資料庫中dept表有四個欄位:

create table dept  
(  
    id          int unsigned auto_increment comment '主鍵ID'  
        primary key,  
    name        varchar(10) not null comment '部門名稱',  
    create_time datetime    null comment '建立時間',  
    update_time datetime    null comment '修改時間',  
    constraint name  
        unique (name)  
)

其中的主鍵ID為null,create_time、update_time需要我們自己設定。

Service:設定兩個時間欄位

Mapper:插入資料

@Insert("insert into dept(name, create_time, update_time) values (#{name},#{createTime},#{updateTime})")  
void insertDept(Dept dept);

如果content-type是表單,這裡就不需要使用@RequestBody了

修改部門

修改部門分為兩步:

  1. 資料回顯:發起GET請求根據ID查詢部門
  2. 修改資料:發起PUT請求根據ID修改部門

資料回顯

Controller:GET請求攜帶ID引數

@GetMapping("/depts/{id}")  
public Result queryDeptById(@PathVariable Integer id){  
    return Result.success(deptService.queryDeptById(id));  
}

Service

Mapper:

@Select("select * from dept where id = #{id}")  
Dept selectById(Integer id);

修改資料

Controller:PUT請求,攜帶application/json

@PutMapping("/depts")  
public Result updateById(@RequestBody Dept dept){  
    return Result.success(deptService.updateDeptNameById(dept));  
}

Service:封裝修改時間

@Override  
public Integer updateDeptNameById(Dept dept) {  
    dept.setUpdateTime(LocalDateTime.now());  
    return deptMapper.updateDept(dept);  
}

Mapper:update from ...

<update id="updateDept">  
    update dept        
	    <set>  
            <if test="name != null and name != ''"> name = #{name},</if>  
            <if test="updateTime != null"> update_time = #{updateTime},</if>  
        </set>  
    where id = #{id};
</update>

結構最佳化

類標註@RequestMapping("depts")

Emp的增刪改查

前置操作:需要先訪問DeptController {/depts} 得到所有的部門資訊,為下拉選單準備值。

設計Emp pojo:

@Data  
public class Emp {  
    private Integer id; //ID,主鍵  
    private String username; //使用者名稱  
    private String password; //密碼  
    private String name; //姓名  
    private Integer gender; //性別, 1:男, 2:女  
    private String phone; //手機號  
    private Integer job; //職位, 1:班主任,2:講師,3:學工主管,4:教研主管,5:諮詢師  
    private Integer salary; //薪資  
    private String image; //頭像  
    private LocalDate entryDate; //入職日期  
    private LocalDateTime createTime; //建立時間  
    private LocalDateTime updateTime; //修改時間  
  
  
    private Integer deptId; //關聯的部門ID  
    //封裝部門名稱數  
    private String deptName; //部門名稱  
  
    //private Dept dept;  
}

設計為單獨的欄位就不必使用resultMap進行結果對映了

分頁查詢資料

image.png

需要顯示:

  1. 分頁查詢到的記錄
  2. 總記錄條數

對應兩條SQL語句

image.png

前後端的資料傳輸:

image.png

在介面文件中指定了前端傳遞的引數格式:

image.png

注意,page和pageSize都是可選的,需要我們指定預設值

和後端響應的資料格式:

image.png

根據介面文件data欄位分析出需要一個PageBean儲存分頁查詢結果的返回值:

@Data  
@NoArgsConstructor  
@AllArgsConstructor  
public class PageBean {  
    private Long total;  
    private List rows;  
}

執行流程:

image.png

Controller:接收pageNo、pageSize,指定預設值,返回Result

@RestController  
@RequestMapping("/emps")  
public class EmpController {  
    @Autowired  
    private EmpService empService;  
  
    @GetMapping  
    public Result pageQuery(@RequestParam(value = "page",defaultValue = "1") Integer pageNo,  
                            @RequestParam(defaultValue = "10") Integer pageSize){  
        PageBean pageBean = empService.pageQueryEmps(pageNo, pageSize);  
        return Result.success(pageBean);  
    }  
}

Service:計算beginIndex,獲取total、rows,封裝pageBean

@Service  
public class EmpServiceImpl implements EmpService {  
  
    @Autowired  
    private EmpMapper empMapper;  
    @Override  
    public PageBean pageQueryEmps(Integer pageNo, Integer pageSize) {  
        Integer beginIndex = (pageNo - 1) * pageSize;  
  
        PageBean pageBean = new PageBean();  
        pageBean.setTotal(empMapper.selectTotalCount());  
        pageBean.setRows(empMapper.selectByPage(beginIndex,pageSize));  
  
        return pageBean;  
    }  
}

Mapper:查詢總記錄條數和分頁對應資料

@Mapper  
public interface EmpMapper {  
    @Select("select emp.*,dept.name deptName from emp join dept on emp.dept_id = dept.id limit #{beginIndex},#{pageSize}")  
    List<Emp> selectByPage(Integer beginIndex, Integer pageSize);  
  
    @Select("select count(*) from emp join dept on emp.dept_id = dept.id")  
    Long selectTotalCount();  
}

image.png

使用pageHelper改善分頁查詢

image.png

可以發現:

  • 分頁查詢都需要查詢總記錄條數,也就是都需要寫兩條SQL語句
  • 分頁查詢都需要根據頁碼查詢起始索引

使用PageHelper可以簡化這個過程。

使用步驟:

  1. 引入PageHelper的依賴
<dependency>
	<groupId>com.github.pagehelper</groupId>
	<artifactId>pagehelper-spring-boot-starter</artifactId>
	<version>1.4.7</version>
</dependency>
  1. 定義Mapper介面的查詢方法(無需考慮分頁)
//查詢員工資料,無需考慮分頁
@Select("select emp.*,d.name deptName from emp join dept d on emp.dept_id = d.id")  
List<Emp> selectByPageHelper();
  1. 在Service方法中實現分頁查詢
@Override  
public PageBean pageQueryEmps(Integer pageNo, Integer pageSize) {  
    PageHelper.startPage(pageNo,pageSize);  
    List<Emp> pages = empMapper.selectByPageHelper();  
    Page<Emp> emps = (Page<Emp>) pages;  
    return new PageBean(emps.getTotal(),emps.getResult());  
}

可能需要的配置檔案:

# pageHelper分頁配置
pagehelper:
  helper-dialect: mysql
  reasonable: true
  support-methods-arguments: true

開啟分頁後,查詢語句得到的結果其實是List<>的子類Page<>,在該類中封裝了分頁查詢相關的資訊。

image.png

上文所說的分頁查詢必定會執行兩個SQL語句:

  1. 查詢總記錄條數
  2. 查詢分頁對應的資料

image.png

實際上是PageHelper對Mapper中未進行分頁的SQL進行了增強。

PageHelper的實現機制:

image.png

注意:

  1. Mapper的SQL語句結尾不要加 ; ,否則拼接limit時會報錯
  2. PageHelper只會對緊跟在其後的第一條SQL語句進行處理

條件分頁查詢

image.png

分頁查詢已經實現了,現在需要傳遞條件查詢的條件

image.png

根據介面文件和正常分析可知,傳遞的這些引數是可選的,對應後端就是動態的SQL。

介面原型:

image.png

之前根據此介面原型設計了基本表的結構

image.png

後端查詢的要求:

image.png

在Controller中接收URL引數,接收的形式:

  • 多個入參接收引數
@GetMapping
public Result page(@RequestParam(defaultValue = "1") Integer page, 
			       @RequestParam(defaultValue = "2") Integer pageSize,
			       String name, 
			       Integer gender,
			       @DateTimeFormat(pattern = "yyyy-MM-dd") LocalDate begin,
			       @DateTimeFormat(pattern = "yyyy-MM-dd") LocalDate end) {  
				       
   log.info("查詢請求引數: {}, {}, {}, {}, {}, {}", page, pageSize, name, gender, begin, end);
   return Result.success();
}

但是引數個數較多時不易維護。

  • 多個入參封裝為查詢物件
@Data  
public class EmpQueryParam {  
    private Integer page = 1;  
    private Integer pageSize = 10;  
  
    private String name;  
    private Integer gender;  
  
    @DateTimeFormat(pattern = "yyyy-MM-dd")  
    private LocalDate begin;  
    @DateTimeFormat(pattern = "yyyy-MM-dd")  
    private LocalDate end;  
  
}

對分頁引數指定的預設值可以使用顯示初始化。

Controller:

@GetMapping  
public Result pageQuery(EmpQueryParam empQueryParam){  
    PageBean pageBean = empService.pageQueryEmps(empQueryParam);  
    return Result.success(pageBean);  
}

Service:開啟分頁

@Override  
public PageBean pageQueryEmps(EmpQueryParam empQueryParam) {  
  
    PageHelper.startPage(empQueryParam.getPage(), empQueryParam.getPageSize());  
  
    Page<Emp> emps = (Page<Emp>) empMapper.selectByPageHelper(empQueryParam);  
  
    return new PageBean(emps.getTotal(),emps.getResult());  
}

Mapper:

<select id="selectByPageHelper" resultType="com.itheima.model.pojo.Emp">  
    select e.*,d.name deptName from emp e join dept d on e.dept_id = d.id        <where>  
            <if test="name != null and name != '' ">e.name like concat('%',#{name},'%')</if>  
            <if test="gender != null">and gender = #{gender}</if>  
            <if test="begin != null and end != null">and entry_date between #{begin} and #{end}</if>  
        </where>  
        order by e.update_time desc</select>

注意介面原型要求的排序

image.png

每名員工可能有多份工作經歷,工作經歷應該定義為一張單獨的表。在多方中維護一方的引用


create table emp_expr  
(  
    id      int unsigned auto_increment comment 'ID, 主鍵'  
        primary key,  
    emp_id  int unsigned null comment '員工ID',  
    begin   date         null comment '開始時間',  
    end     date         null comment '結束時間',  
    company varchar(50)  null comment '公司名稱',  
    job     varchar(50)  null comment '職位'  
)  
    comment '工作經歷';

對應在Java程式碼中,就是在Emp中維護一個List<EmpExpr>的引用。

@Data  
public class Emp {  
    private Integer id; //ID,主鍵  
    private String username; //使用者名稱  
    private String password; //密碼  
    private String name; //姓名  
    private Integer gender; //性別, 1:男, 2:女  
    private String phone; //手機號  
    private Integer job; //職位, 1:班主任,2:講師,3:學工主管,4:教研主管,5:諮詢師  
    private Integer salary; //薪資  
    private String image; //頭像  
    private LocalDate entryDate; //入職日期  
    @DateTimeFormat(pattern = "yyyy-MM-dd")  
    private LocalDateTime createTime; //建立時間  
    @DateTimeFormat(pattern = "yyyy-MM-dd")  
    private LocalDateTime updateTime; //修改時間  
  
  
    private Integer deptId; //關聯的部門ID  
    //封裝部門名稱數  
    private String deptName; //部門名稱  
  
    //private Dept dept;  
  
    private List<EmpExpr> exprList;  
}
@Data  
public class EmpExpr {  
    private Integer id; //ID  
    private Integer empId; //員工ID  
    @DateTimeFormat(pattern = "yyyy-MM-dd")  
    private LocalDate begin; //開始時間  
    @DateTimeFormat(pattern = "yyyy-MM-dd")  
    private LocalDate end; //結束時間  
    private String company; //公司名稱  
    private String job; //職位  
}

工作經歷表沒有create_time、update_time,因為工作經歷表是作為員工表的附屬表,不會單獨的為員工新增一個工作經歷,如果要為員工新增工作經歷實際上就是修改員工資訊,只需要在員工表中記錄create_time和update_time即可。

Controller層:接收Emp型別的引數

@PostMapping  
public Result addEmp(@RequestBody Emp emp) throws Exception {  
    empService.addEmp(emp);  
    return Result.success();  
}

Service層:

  1. 員工資訊插入員工表
  2. 工作經歷資訊插入工作經歷表
@Transactional
@Override  
public void addEmp(Emp emp) throws Exception {  
  
    //1. 新增員工到Emp表  
    emp.setCreateTime(LocalDateTime.now());  
    emp.setUpdateTime(LocalDateTime.now());  
    empMapper.insertEmp(emp);  
    log.info(String.valueOf(emp));  
  
    //2. 新增工作經歷  
    // CollectionUtils.isEmpty:為null或為空返回true  
    List<EmpExpr> exprList = emp.getExprList();  
    if (!CollectionUtils.isEmpty(exprList)) {  
        exprList.forEach(expr -> expr.setEmpId(emp.getId()));  
        empMapper.insertBatch(exprList);  
    }  
}

注意:插入員工表後員工的主鍵id是自增的,第二步插入工作經歷需要使用這個自增的id,也就是插入工作經歷時需要使用第一步插入員工資料後自增的id

獲取自增的id並封裝在指定的欄位中:

@Options(useGeneratedKeys = true,keyProperty = "id")  
@Insert("insert into emp(username, name, gender, phone, job, salary, image, entry_date, dept_id, create_time, update_time) " +  
        "VALUES (#{username},#{name},#{gender},#{phone},#{job},#{salary},#{image},#{entryDate},#{deptId},#{createTime},#{updateTime})")  
void insertEmp(Emp emp);

工作經歷可能有多段,需要使用foreach標籤遍歷插入:

<insert id="insertBatch" parameterType="list">  
    insert into emp_expr(emp_id, begin, end, company, job) values    
	    <foreach collection="exprList" item="expr" separator=",">  
	         (#{expr.empId},#{expr.begin},#{expr.end},#{expr.company},#{expr.job})    
         </foreach>  
</insert>

service層最佳化:

@Transactional  
@Override  
public void addEmp(Emp emp) throws Exception {  
  
    //1. 新增員工到Emp表  
    emp.setCreateTime(LocalDateTime.now());  
    emp.setUpdateTime(LocalDateTime.now());  
    empMapper.insertEmp(emp);  
    log.info(String.valueOf(emp));  
  
    insertBatchEmpExprs(emp);  
}  
  
@Override  
@Transactional(propagation = Propagation.REQUIRED)  
public void insertBatchEmpExprs(Emp emp) {  
    //2. 新增工作經歷  
    // CollectionUtils.isEmpty:為null或為空返回true  
    List<EmpExpr> exprList = emp.getExprList();  
    if (!CollectionUtils.isEmpty(exprList)) {  
        exprList.forEach(expr -> expr.setEmpId(emp.getId()));  
        empExprMapper.insertBatch(exprList);  
    }  
}

insert方法還可能被複用,此處抽取為方法共service的其他方法呼叫,同時需要注意事務傳播為REQUIRED,但是此處該方法在理論上應該是private,而不是public,但是Spring AOP不能為private方法進行增強,這也是Spring AOP的侷限性,Aspectj就可以對私有/final/靜態方法進行增強

檔案上傳

image.png

前端頁面進行檔案上傳的三要素:

  1. 表單標籤使用 <input type="file">
  2. 表單method = post
  3. 表單enctype = "multipart/form-data"
  
<form action="/upload" method="post" enctype="multipart/form-data">  
    姓名: <input type="text" name="username"><br>  
    年齡: <input type="text" name="age"><br>  
    頭像: <input type="file" name="file"><br>  
    <input type="submit" value="提交">  
</form>

傳送的請求報文(Firefox):

image.png

請求報文的請求體被分為三部分,分隔符是----一串數字,分隔符也會一同提交:

image.png

@Slf4j  
@RestController  
public class UploadTest {  
    @PostMapping("/upload")  
    public Result upload(String username, Integer age, MultipartFile file){  
        log.info("[method upload] - username : {},age : {},file : {}",username,age,file);  
        return Result.success();  
    }  
}

測試的時候先在log.info位置打上斷點 debug啟動:

image.png

上傳的檔案就存在此處了:

image.png

這三個.tmp檔案就對應了表單中的三個部分,使用文字編輯器開啟就能看到原先的內容。

以Debug啟動是因為upload方法結束(響應完畢)後會將這三個檔案清空,所以我們需要轉儲檔案

轉儲方案:

  1. 本地儲存
  2. 上傳OSS

本地儲存

@Slf4j  
@RestController  
public class UploadTest {  
    @PostMapping("/upload")  
    public Result upload(String username, Integer age, MultipartFile file) throws IOException {  
        log.info("[method upload] - username : {},age : {},file : {}",username,age,file);  
  
        //獲取原始檔名  
        String filename = file.getOriginalFilename();  
        String parentPath = "D:\\Development\\code\\projects_to_valhalla\\web-pro01-maven\\tlias-web-management\\src\\main\\resources\\uploadfiles";  
        //儲存到本地  
        file.transferTo(new File(parentPath, UUID.randomUUID() 
                                                + filename.substring(filename.lastIndexOf("."))));  
  
        return Result.success();  
    }  
}

SpringBoot檔案上傳時預設單個檔案允許最大大小為1M,如果要上傳大檔案,可以進行設定:

#檔案上傳配置
#配置單個檔案最大上傳大小
spring.servlet.multipart.max-file-size=10MB
#配置單個請求最大上傳大小(一次請求可以上傳多個檔案)
spring.servlet.multipart.max-request-size=100MB

MultipartFile常用方法:

image.png

但是本地儲存是有缺點的:

  1. 無法直接訪問
  2. 磁碟大小限制
  3. 磁碟損壞資料就丟失了

OSS

使用三方服務的通用思路:

  1. 準備
  2. 參照官網SDK寫入門程式
  3. 整合使用

參照官網SDK寫入門程式:

引入依賴:

<dependency>
    <groupId>com.aliyun.oss</groupId>
    <artifactId>aliyun-sdk-oss</artifactId>
    <version>3.15.1</version>
</dependency>
<dependency>
    <groupId>javax.xml.bind</groupId>
    <artifactId>jaxb-api</artifactId>
    <version>2.3.1</version>
</dependency>
<dependency>
    <groupId>javax.activation</groupId>
    <artifactId>activation</artifactId>
    <version>1.1.1</version>
</dependency>
<!-- no more than 2.3.3-->
<dependency>
    <groupId>org.glassfish.jaxb</groupId>
    <artifactId>jaxb-runtime</artifactId>
    <version>2.3.3</version>
</dependency>
import com.aliyun.oss.ClientException;
import com.aliyun.oss.OSS;
import com.aliyun.oss.common.auth.*;
import com.aliyun.oss.OSSClientBuilder;
import com.aliyun.oss.OSSException;
import com.aliyun.oss.model.PutObjectRequest;
import com.aliyun.oss.model.PutObjectResult;
import java.io.FileInputStream;
import java.io.InputStream;

public class Demo {

    public static void main(String[] args) throws Exception {
        // Endpoint以華東1(杭州)為例,其它Region請按實際情況填寫。
        String endpoint = "https://oss-cn-hangzhou.aliyuncs.com";
        // 從環境變數中獲取訪問憑證。執行本程式碼示例之前,請確保已設定環境變數OSS_ACCESS_KEY_ID和OSS_ACCESS_KEY_SECRET。
        EnvironmentVariableCredentialsProvider credentialsProvider = CredentialsProviderFactory.newEnvironmentVariableCredentialsProvider();
        // 填寫Bucket名稱,例如examplebucket。
        String bucketName = "examplebucket";
        // 填寫Object完整路徑,完整路徑中不能包含Bucket名稱,例如exampledir/exampleobject.txt。
        String objectName = "exampledir/exampleobject.txt";
        // 填寫本地檔案的完整路徑,例如D:\\localpath\\examplefile.txt。
        // 如果未指定本地路徑,則預設從示例程式所屬專案對應本地路徑中上傳檔案流。
        String filePath= "D:\\localpath\\examplefile.txt";

        // 建立OSSClient例項。
        OSS ossClient = new OSSClientBuilder().build(endpoint, credentialsProvider);

        try {
            InputStream inputStream = new FileInputStream(filePath);
            // 建立PutObjectRequest物件。
            PutObjectRequest putObjectRequest = new PutObjectRequest(bucketName, objectName, inputStream);
            // 建立PutObject請求。
            PutObjectResult result = ossClient.putObject(putObjectRequest);
        } catch (OSSException oe) {
            System.out.println("Caught an OSSException, which means your request made it to OSS, "
                    + "but was rejected with an error response for some reason.");
            System.out.println("Error Message:" + oe.getErrorMessage());
            System.out.println("Error Code:" + oe.getErrorCode());
            System.out.println("Request ID:" + oe.getRequestId());
            System.out.println("Host ID:" + oe.getHostId());
        } catch (ClientException ce) {
            System.out.println("Caught an ClientException, which means the client encountered "
                    + "a serious internal problem while trying to communicate with OSS, "
                    + "such as not being able to access the network.");
            System.out.println("Error Message:" + ce.getMessage());
        } finally {
            if (ossClient != null) {
                ossClient.shutdown();
            }
        }
    }
} 

封裝OSS工具類

@Slf4j  
public class AliyunOSSUtils {  
  
    /**  
     * 上傳檔案  
     * @param endpoint endpoint域名  
     * @param bucketName 儲存空間的名字  
     * @param content 內容位元組陣列  
     */  
    public static String upload(String endpoint, String bucketName, byte[] content, String extName) throws Exception {  
        // 從環境變數中獲取訪問憑證。執行本程式碼示例之前,請確保已設定環境變數OSS_ACCESS_KEY_ID和OSS_ACCESS_KEY_SECRET。  
        EnvironmentVariableCredentialsProvider credentialsProvider = CredentialsProviderFactory.newEnvironmentVariableCredentialsProvider();  
        // 填寫Object完整路徑,完整路徑中不能包含Bucket名稱,例如exampledir/exampleobject.txt。  
        String objectName = UUID.randomUUID() + extName;  
  
        // 建立OSSClient例項。  
        OSS ossClient = new OSSClientBuilder().build(endpoint, credentialsProvider);  
        try {  
            // 建立PutObjectRequest物件。  
            PutObjectRequest putObjectRequest = new PutObjectRequest(bucketName, objectName, new ByteArrayInputStream(content));  
            // 建立PutObject請求。  
            PutObjectResult result = ossClient.putObject(putObjectRequest);  
        } catch (OSSException oe) {  
            log.error("Caught an OSSException, which means your request made it to OSS, but was rejected with an error response for some reason.");  
            log.error("Error Message:" + oe.getErrorMessage());  
            log.error("Error Code:" + oe.getErrorCode());  
            log.error("Request ID:" + oe.getRequestId());  
            log.error("Host ID:" + oe.getHostId());  
        } catch (ClientException ce) {  
            log.error("Caught an ClientException, which means the client encountered a serious internal problem while trying to communicate with OSS, such as not being able to access the network.");  
            log.error("Error Message:" + ce.getMessage());  
        } finally {  
            if (ossClient != null) {  
                ossClient.shutdown();  
            }  
        }  
  
        return endpoint.split("//")[0] + "//" + bucketName + "." + endpoint.split("//")[1] + "/" + objectName;  
    }  
  
}

最終的檔案上傳流程:

application.yml:

spring:  
  servlet:  
    multipart:  
      max-file-size: 10MB  
      max-request-size: 100MB  
  config:  
    import: classpath:conf/mybatis-conf.yml,classpath:conf/aliyun-oss-config.yml  
  
# 配置spring事務管理器的日誌為true  
logging:  
  level:  
    org.springframework.jdbc.support.JdbcTransactionManager: debug

aliyun-oss-conf.yml:

aliyun:  
  oss:  
   endpoint: https://oss-cn-beijing.aliyuncs.com  
   bucketName: web-tlias-eun

定義屬性類,配置檔案屬性注入該類:

@Data  
@Component  
@ConfigurationProperties(prefix = "aliyun.oss")  
public class AliyunOSSProperties {  
    private String endpoint;  
    private String bucketName;  
}

使用的時候只需要注入就可以了:

@Slf4j  
@RestController  
public class UploadController {  
/*    @Value("${aliyun.oss.endpoint}")  
    private String endPoint;    @Value("${aliyun.oss.bucketName}")    private String bucketName;*/  
    @Autowired  
    private AliyunOSSProperties aliyunOSSProperties;  
  
    @PostMapping("/upload")  
    public Result upload(MultipartFile file) throws Exception {  
        String filename = file.getOriginalFilename();  
        log.info("[檔案上傳] - file : {}", filename);  
        String extName = filename.substring(filename.lastIndexOf("."));  
        String url = AliyunOSSUtils.upload(aliyunOSSProperties.getEndpoint(),
						         aliyunOSSProperties.getBucketName(), file.getBytes(), extName);  
        return Result.success(url);  
    }  
}

屬性類可能有提示:

image.png

只需要再引入一個依賴即可:

image.png

這個依賴就是在先寫屬性類再寫yml檔案時可以有提示。

重新思考本需求

image.png

選擇完頭像後,訪問UploadController {POST /upload} 上傳圖片,並將URL地址返回到此處

但是可能存在的情況:使用者選擇完頭像後點選取消,這條資料沒有儲存在資料庫中而OSS中還有這張圖片;或者使用者選擇完頭像後又選擇了另外一張,第一次選擇的圖片也會存在於OSS之內。

解決辦法:定時任務掃描資料庫中所有URL地址,和OSS的地址做差集,清除OSS中所有差集元素。

image.png

刪除分為:批次刪除和單個刪除,其實單個刪除就是特殊的批次刪除,可以只定義一個介面。

刪除的實現方式:

  • 前端傳遞JSON陣列,每一個元素是要刪除的emp id和OSS的圖片地址, 後端收到請求後,刪除EMP表中id = id的記錄,再刪除EMP_EXPR表中emp_id = id的記錄;非同步刪除OSS中的圖片

但是目前的前端只傳遞了ID,如果要刪除OSS的圖片就要從資料庫裡查出來對應的image url,也就是先查詢完畢才能再刪除

介面文件:

image.png

前端傳遞的資料格式是 ids=1,2,3,應該使用陣列或List集合接收

Controller使用List集合接收:

@DeleteMapping  
public Result delete(@RequestParam List<Integer> ids) throws Exception {  
    //同步查詢URL
    List<String> urls = empService.queryURLByIds(ids);  
    //非同步刪除OSS  
    fileController.delete(urls);  
    //同步刪除資料庫  
    empService.removeByIds(ids);  
    return Result.success();  
}

Service:

@Override  
public void removeByIds(List<Integer> ids) {  
    //刪除emp表資料  
    empMapper.deleteByIds(ids);  
    //刪除emp_expr表資料  
    empExprMapper.deleteByEmpIds(ids);  
}

image.png

修改分為兩部分:

  1. 點選修改按鈕,根據id查詢資料
  2. 點選儲存,儲存修改後的資料

資料回顯

image.png

響應資料示例:

{

    "code": 0,

    "msg": "string",

    "data": {

        "id": 0,

        "username": "string",

        "password": "string",

        "name": "string",

        "gender": 0,

        "job": 0,

        "salary": 0,

        "image": "string",

        "entryDate": "2019-08-24",

        "deptId": 0,

        "createTime": "2019-08-24T14:15:22Z",

        "updateTime": "2019-08-24T14:15:22Z",

        "exprList": [

            {

                "id": 1,

                "begin": "2019-08-24",

                "end": "2019-08-24",

                "company": "string",

                "job": "string",

                "empId": 0

            }

        ]

    }

}

Controller:

@GetMapping("/{id}")  
public Result queryById(@PathVariable Integer id){  
    Emp emp = empService.queryById(id);  
    return Result.success(emp);  
}

Service:

@GetMapping("/{id}")  
public Result queryById(@PathVariable Integer id){  
    Emp emp = empService.queryById(id);  
    return Result.success(emp);  
}

Mapper:

<select id="selectById" resultType="com.itheima.model.pojo.Emp">  
	select  
	    e.*,  
	    ee.id ee_id,  
	    ee.begin ee_begin,  
	    ee.end ee_end,  
	    ee.company ee_company,  
	    ee.job ee_job,  
	    ee.emp_id ee_emp_id  
	from  
	    emp e left join emp_expr ee on e.id = ee.emp_id  
	where e.id = #{id}
</select>

這樣是不行的,因為表連線時,emp表的一條記錄會對應emp_expr的多條記錄,此時就需要使用多對一對映:


<resultMap id="empResultMap" type="com.itheima.model.pojo.Emp" >  
    <id column="id" property="id"/>  
    <result column="username" property="username" />  
    <result column="password" property="password" />  
    <result column="name" property="name" />  
    <result column="gender" property="gender" />  
    <result column="phone" property="phone" />  
    <result column="job" property="job" />  
    <result column="salary" property="salary" />  
    <result column="image" property="image" />  
    <result column="entry_date" property="entryDate" />  
    <result column="dept_id" property="deptId" />  
    <result column="create_time" property="createTime" />  
    <result column="update_time" property="updateTime" />  
    <collection property="exprList" ofType="com.itheima.model.pojo.EmpExpr">  
        <id column="ee_id" property="id" />  
        <result column="ee_begin" property="begin" />  
        <result column="ee_end" property="end" />  
        <result column="ee_company" property="company" />  
        <result column="ee_job" property="job" />  
        <result column="ee_emp_id" property="empId" />  
    </collection>  
</resultMap>

<select id="selectById" resultMap="empResultMap" >  
    select        
	    e.*,        
	    ee.id ee_id,        
	    ee.begin ee_begin,        
	    ee.end ee_end,        
	    ee.company ee_company,        
	    ee.job ee_job,        
	    ee.emp_id ee_emp_id    
    from        
	    emp e left join emp_expr ee on e.id = ee.emp_id    
    where 
	    e.id = #{id}
</select>  

思考:響應update_time是頁面展示的要求,響應create_time是為什麼?

為了在下文提交修改的json資料時攜帶。但似乎這是沒有意義的。

修改

image.png

請求引數:

{

    "id": 0,

    "username": "string",

    "password": "string",

    "name": "string",

    "gender": 0,

    "job": 0,

    "image": "string",

    "entryDate": "2019-08-24",

    "salary": 0,

    "deptId": 0,

    "createTime": "2019-08-24T14:15:22Z",

    "updateTime": "2019-08-24T14:15:22Z",

    "exprList": [

        {

            "id": 1,

            "begin": "2019-08-24",

            "end": "2019-08-24",

            "company": "string",

            "job": "string",

            "empId": 0

        }

    ]

}

Controller:

@PutMapping  
public Result modifyEmp(@RequestBody Emp emp){  
    empService.modifyEmpById(emp);  
    return Result.success();  
}

service:

@Override  
public void modifyEmpById(Emp emp) {  
    emp.setUpdateTime(LocalDateTime.now());  
    //更新員工表資訊  
    empMapper.updateById(emp);  
  
}

mapper:

修改的欄位取決於介面文件:

image.png

<update id="updateById">  
    update emp        
	    <set>  
            <if test="username != null and username != ''">username = #{username},</if>  
            <if test="name != null and name != ''">name = #{name},</if>  
            <if test="gender != null">gender = #{gender},</if>  
            image = #{image},            
            dept_id = #{deptId},            
            entry_date = #{entryDate},            
            job = #{job},            
            salary = #{salary}        
		</set>  
    where id = #{id}
</update>

接下來是service的第二部分:更新工作經歷

但是工作經歷有其特殊性,此處如果要更新有兩種方法:

  1. 前端只傳遞修改的和新增的工作經歷,後端判斷是否有id,有id就update,無id就insert
  2. 點選儲存,傳遞所有工作經歷,點選儲存後先把原先的工作經歷delete,再insert新的工作經歷

對比兩種實現:

第一種方法:如果要刪除工作經歷,還需要一個根據id刪除的介面,點選一個刪除一次,對應了deleteById這條語句,但是刪除員工工作經歷的操作較少;後端儲存員工工作經歷資訊需要 對id有無進行分別處理,這需要兩條SQL語句,也就是兩次資料庫連線。

第二種方法:後端只需要delete和insert,如果刪除的話,只需要執行delete,如果新增 + 修改還是兩條SQL語句

但是對於工作經歷來說,一般情況下修改時只有新增的操作,第一種方法可以只執行一條SQL語句,第二種方法需要執行兩條SQL語句;如果修改時要修改工作經歷內容,第一種方法要執行兩條SQL語句,第二種方法也是兩條SQL語句,但是明顯第二種方法需要處理的資料量更多,第一種方法其實更好。

本例前端傳遞的是所有的工作經歷,只能採用第二種方法

Service:

@Transactional  
@Override  
public void modifyEmpById(Emp emp) {  
    emp.setUpdateTime(LocalDateTime.now());  
    //更新員工表資訊  
    empMapper.updateById(emp);  
    //更新工作經歷  
    // 1. 刪除  
    // 2. 新增  
    empExprMapper.deleteByEmpIds(List.of(emp.getId()));  
  
    List<EmpExpr> exprList = emp.getExprList();  
    if (!CollectionUtils.isEmpty(exprList)){  
        empExprMapper.insertBatch(exprList);  
    }  
}

注意:此處insert工作經歷時沒有遍歷再賦值emp_id,因為前端傳遞的exprList中應當對所有的expr進行賦值,所有的expr都屬於當前的員工,但遺憾的是前端並沒有賦值,我們還需要多一次操作:

@Transactional  
@Override  
public void modifyEmpById(Emp emp) {  
    emp.setUpdateTime(LocalDateTime.now());  
    //更新員工表資訊  
    empMapper.updateById(emp);  
    //更新工作經歷  
    // 1. 刪除  
    // 2. 新增  
    empExprMapper.deleteByEmpIds(List.of(emp.getId()));  
  
    List<EmpExpr> exprList = emp.getExprList();  
    if (!CollectionUtils.isEmpty(exprList)){  
        exprList.forEach(expr -> expr.setEmpId(emp.getId()));  
        empExprMapper.insertBatch(exprList);  
    }  
}

此處的插入工作經歷和新增使用者時插入工作經歷完全相同,可以抽取為方法:

@Transactional  
@Override  
public void modifyEmpById(Emp emp) {  
    emp.setUpdateTime(LocalDateTime.now());  
    //更新員工表資訊  
    empMapper.updateById(emp);  
    //更新工作經歷  
    // 1. 刪除  
    // 2. 新增  
    empExprMapper.deleteByEmpIds(List.of(emp.getId()));  
  
    insertBatchEmpExprs(emp);  
}

@Override  
@Transactional(propagation = Propagation.REQUIRED)  
public void insertBatchEmpExprs(Emp emp) {  
    //2. 新增工作經歷  
    // CollectionUtils.isEmpty:為null或為空返回true  
    List<EmpExpr> exprList = emp.getExprList();  
    if (!CollectionUtils.isEmpty(exprList)) {  
        exprList.forEach(expr -> expr.setEmpId(emp.getId()));  
        empExprMapper.insertBatch(exprList);  
    }  
}

報表資料

性別統計

image.png

響應資料:

引數格式:application/json

引數說明:

引數名 型別 是否必須 備註
code number 必須 響應碼,1 代表成功,0 代表失敗
msg string 非必須 提示資訊
data List 非必須 返回的資料
|- name string 非必須 性別
|- value number 非必須 人數

示例:

{
  "code": 1,
  "msg": "success",
  "data": [
    {"name": "男性員工","value": 5},
    {"name": "女性員工","value": 6}
  ]
}

分析各層職責:

  • Controller:簡單

  • Service:簡單

  • Mapper:根據gender進行分組,統計男性和女性

問題在於Mapper的結果如何封裝?

  1. 常規做法,封裝為pojo類
List<GenderOptions> selectGenderCount();

但是這個pojo類似乎是沒有意義的,查詢的資料過於簡單,如果類似的查詢都封裝為pojo可能會導致類爆炸

  1. 封裝為Map集合
List<Map<String,Object>> selectGenderCount();

Map集合用來代替pojo類,List中Map集合的個數即為查詢出的記錄條數,而Map集合中的鍵值對的個數即為每條記錄中欄位的個數,key為欄位名,value為欄位值。

key一定是string型別的欄位名,而value可能是string/integer的欄位值

image.png

<select id="selectGenderCount" resultType="java.util.Map">  
    select        
	    IF(gender=1,'男性員工','女性員工') name,  
        count(*) value    
	from emp    
	group by gender;
</select>

查詢的結果:

!image.png

注意在Mapper介面中:

@MapKey("id")  
List<Map<String,Object>> selectGenderCount();

需要指定@MapKey,這其實是MybatisX外掛的誤報,指定MapKey是在返回Map<Object,Map<String,Object>>時需要指定外層Map的id

員工職位統計

引數格式:application/json

引數說明:

引數名 型別 是否必須 備註
code number 必須 響應碼,1 代表成功,0 代表失敗
msg string 非必須 提示資訊
data object 非必須 返回的資料
|- jobList string[] 必須 職位列表
|- dataList number[] 必須 人數列表
{
  "code": 1,
  "msg": "success",
  "data": {
    "jobList": ["教研主管","學工主管","其他","班主任","諮詢師","講師"],
    "dataList": [1,1,2,6,8,13]
  }
}

與上例不同的是,上例中的data:

{
  "code": 1,
  "msg": "success",
  "data": [
    {"name": "男性員工","value": 5},
    {"name": "女性員工","value": 6}
  ]
}

這是List<Map<String,Object>>,而本例中返回了一個物件:

@Data  
@NoArgsConstructor  
@AllArgsConstructor  
public class JobOptions {  
    private List<String> jobList;  
    private List<Integer> dataList;  
}

SQL語句查詢的單條結果還是以Map封裝,key為職位資訊,value為該職位對應的員工數量:

<select id="selectJobCount" resultType="java.util.Map">  
    select        
	    case job            
		    when 1 then '班主任'  
            when 2 then '講師'  
            when 3 then '學工主管'  
            when 4 then '教研主管'  
            when 5 then '諮詢師'  
            else '?'            
		end job,
		count(*) jobCount
	from        
		emp    
	where        
		job is not null    
	group by        
		job;
</select>

image.png

不過有兩種封裝結果集:

  • List<Map<String,Object>>:不需要指定@MapKey

  • Map<String,Map<String,Object>>:需要指定@MapKey

最終的結果都是要從結果集中將entry的key作為JobOptions的jobList,value作為dataList,這兩中封裝方式對應了兩種處理方式:

  • List<Map<String,Object>>
@Override  
public JobOptions queryJobCount() {  
    List<Map<String, Object>> listMaps = empMapper.selectJobCount();  
    List<String> jobs = listMaps.stream().map(map -> String.valueOf(map.get("job"))).toList();  
    List<Integer> jobCount = listMaps.stream()
				    .map(map -> Integer.parseInt(map.get("jobCount").toString())).toList();  
    return new JobOptions(jobs,jobCount);  
}
  • Map<String,Map<String,Object>>
@MapKey("job")  
Map<String,Map<String,Object>> selectJobCount();

指定了key為查詢的job欄位值,value為Map,該Map的key為欄位名,value為欄位值

@Override  
public JobOptions queryJobCount() {  
    Map<String,Map<String, Object>> mapMap = empMapper.selectJobCount();  
    List<Integer> jobCount = new ArrayList<>();  
    List<String> jobs = new ArrayList<>();  
    System.out.println(mapMap);  
    mapMap.forEach((key,mapValue) -> {  
        jobs.add(key);  
        jobCount.add(Integer.parseInt(mapValue.get("jobCount").toString()));  
    });  
    return new JobOptions(jobs,jobCount);  
}

異常處理

當前程式執行的流程不可避免的出現各類異常:

但是出現異常的返回結果不符合規範:

image.png

不管是成功還是失敗都應該返回這個結果:

image.png

前端介面也是根據這個格式來解析資料的。

目前我們沒有對異常進行任何處理,SpringBoot遇到異常後自動以該格式返回給瀏覽器:

image.png

處理異常的方式:

  1. 在Controller中try-catch

image.png

  1. 全域性異常處理器:
@Slf4j  
@RestControllerAdvice  //宣告此類為異常處理器  
public class GlobalExceptionHandler {  
    @ExceptionHandler  //宣告此方法為異常處理方法  
    public Result handler(Exception e){  
        log.error("全域性異常處理器 : ",e);  
        return Result.error(String.valueOf(e.getClass()));  
    }  
}

這樣就改變了異常的丟擲過程:

image.png

@RestControllerAdvice = @ControllerAdvice + @ResponseBody

注意:如果定義小範圍和大範圍的異常處理方法,優先被小範圍處理

Clazz的增刪改查

image.png

條件分頁查詢,引數:

image.png

響應資料:

{  
  "code": 1,  
  "msg": "success",  
  "data": {  
    "total": 6,  
    "rows": [  
      {  
        "id": 7,  
        "name": "黃埔四期",  
        "room": "209",  
        "beginDate": "2023-08-01",  
        "endDate": "2024-02-15",  
        "masterId": 7,  
        "createTime": "2023-06-01T17:51:21",  
        "updateTime": "2023-06-01T17:51:21",  
        "masterName": "紀曉芙"  
      },  
      {  
        "id": 6,  
        "name": "JavaEE就業166期",  
        "room": "105",  
        "beginDate": "2023-07-20",  
        "endDate": "2024-02-20",  
        "masterId": 20,  
        "createTime": "2023-06-01T17:46:10",  
        "updateTime": "2023-06-01T17:46:10",  
        "masterName": "陳友諒"  
      }  
    ]  
  }  
}

masterName需要連線emp表查詢,正常情況下,班級狀態應該在前端計算,遺憾的是本例前端沒有計算,只能我們計算

計算方式:

  1. service層查詢結果後,進行計算
  2. SQL語句直接計算
<select id="selectByPage" resultType="com.itheima.model.pojo.Clazz">  
    select        
	    c.*,        
	    e.name master_name,        
	    case            
		    when now() &gt; c.end_date then '結課'  
			when now() &lt; c.begin_date then '未開班'  
			else '開班'  
		end status    
	from 
		clazz c left 
			join emp e on c.master_id = e.id    
	<where>  
        <if test="name != null and name != ''">name like concat('%',#{name},'%')</if>  
        <if test="begin != null and end != null">and end_date between #{begin} and #{end}</if>  
    </where>  
    order by c.update_time desc
</select>

注意:xml檔案中的> <看作標籤的開始或結束,就會匹配到某些標籤導致xml結構混亂,應該使用實體符號代替

很簡單

介面原型:

image.png

班主任是下拉選單,點選新增按鈕之後應該先查出所有的班主任,定義在EmpController中

image.png

再做新增班級的介面:

image.png

image.png

改還是兩部,資料回顯和update

查所有

很簡單

Student的增刪改查

image.png

展示的時候往往都希望最後修改的在最前面,就需要根據update_time倒序排列

登入

請求引數:

image.png

請求引數可以用Emp封裝

響應給使用者的資訊應該包含token,響應資料:

{
	"code": 1,
	"msg": "success",
	"data": {
		"id": 2,
		"username": "songjiang",
		"name": "宋江",
		"token":
		"eyJhbGciOiJIUzI1NiJ9.eyJpZCI6MiwidXNlcm5hbWUiOiJzb25namlhbmciLCJ
		leHAiOjE2OTg3MDE3NjJ9.w06EkRXTep6SrvMns3w5RKe79nxauDe7fdMhBLK-
		MKY"
	}
}

根據id標識使用者唯一身份,應該在token中包含id

響應資料可以封裝在LogInfo中:

@Data  
@AllArgsConstructor  
@NoArgsConstructor  
@Accessors(chain = true)  
public class LogInfo {  
    private Integer id;  
    private String name;  
    private String username;  
    private String token;  
}

Controller:

@PostMapping("/login")  
public Result login(@RequestBody Emp emp){  
    LogInfo logInfo = empService.queryByUsernameAndPwd(emp);  
    //JWT  
    return logInfo == null ? Result.error("使用者名稱或密碼錯誤") : Result.success(logInfo);  
}

Service:

@Override  
public LogInfo queryByUsernameAndPwd(Emp emp) {  
    LogInfo logInfo = empMapper.selectByUsernameAndPwd(emp);  
    if (logInfo == null){  
        return null;  
    }  
    HashMap<String, Object> hashMap = new HashMap<>() {{  
        put("id",logInfo.getId());  
        put("username",logInfo.getUsername());  
        put("name",logInfo.getName());  
    }};  
    return logInfo.setToken(JwtUtils.generateJwt(hashMap));   //chain
}

直接封裝為LogInfo,但是要保證查詢結果列名和Emp的屬性名一致。

Mapper:

@Select("select * from emp where username = #{username} and password = #{password}")  
LogInfo selectByUsernameAndPwd(Emp emp);

工具類:

public class JwtUtils {  
  
    private static String signKey = "SVRIRUlNQQ==";  
    private static Long expire = 43200000L;  
  
    /**  
     * 生成JWT令牌  
     * @return  
     */    public static String generateJwt(Map<String,Object> claims){  
        String jwt = Jwts.builder()  
                .addClaims(claims)  
                .signWith(SignatureAlgorithm.HS256, signKey)  
                .setExpiration(new Date(System.currentTimeMillis() + expire))  
                .compact();  
        return jwt;  
    }  
  
    /**  
     * 解析JWT令牌  
     * @param jwt JWT令牌  
     * @return JWT第二部分負載 payload 中儲存的內容  
     */  
    public static Claims parseJWT(String jwt){  
        Claims claims = Jwts.parser()  
                .setSigningKey(signKey)  
                .parseClaimsJws(jwt)  
                .getBody();  
        return claims;  
    }  
}

至此已經完成了登入功能。

登入校驗

使用者訪問介面時應該對使用者身份進行校驗,如果是登入使用者才能訪問系統介面,未登入的使用者響應401

@Override  
public boolean preHandle(HttpServletRequest request, HttpServletResponse response, Object handler) throws Exception {  
  
    try {  
        String token = request.getHeader("token");  
          
        //未攜帶token  
        if (!StringUtils.hasLength(token)) {  
            response.setStatus(401);
            throw new RuntimeException();  
        }  
        //token被篡改,丟擲異常  
        JwtUtils.parseJWT(token);  
          
        //攜帶了token,並且校驗透過  
        return true;  
    } catch (Exception e) {  
        e.printStackTrace();  
        response.setStatus(401);  
        return false;  
    }  
      
}

如果在Interceptor中出現異常,後續的方法無法執行,目標方法無法執行,異常上拋到全域性異常處理器

記錄操作日誌

create table operate_log  
(  
    id             int unsigned auto_increment comment 'ID'  
        primary key,  
    operate_emp_id int unsigned  null comment '操作人ID',  
    operate_time   datetime      null comment '操作時間',  
    class_name     varchar(100)  null comment '操作的類名',  
    method_name    varchar(100)  null comment '操作的方法名',  
    method_params  varchar(1000) null comment '方法引數',  
    return_value   varchar(2000) null comment '返回值',  
    cost_time      int           null comment '方法執行耗時, 單位:ms'  
)  
    comment '操作日誌表';

ORM:

@Data  
@Accessors(chain = true)  
@NoArgsConstructor  
@AllArgsConstructor  
public class OperateLog {  
    private Integer id; //ID  
    private Integer operateEmpId; //操作人ID  
    private LocalDateTime operateTime; //操作時間  
    private String className; //操作類名  
    private String methodName; //操作方法名  
    private String methodParams; //操作方法引數  
    private String returnValue; //操作方法返回值  
    private Long costTime; //操作耗時  
  
    private String operateEmpName;  
}
@Aspect  
@Component  
public class OperateLogAspectj {  
  
    @Autowired  
    private OperateLogMapper operateLogMapper;  
  
    @Autowired  
    private HttpServletRequest request;  
  
    @Autowired  
    private ObjectMapper objectMapper;  

    @Around("@annotation(com.itheima.anno.Log)")  
    public Object aroundAdvice(ProceedingJoinPoint joinPoint) throws Throwable {  
        String token = request.getHeader("token");  
  
        Integer operateEmpId = JwtUtils.parseParam(token, "id", Integer.class);  
        String operateEmpName = JwtUtils.parseParam(token, "name", String.class);  
        LocalDateTime operateTime = LocalDateTime.now();  
        String className = joinPoint.getTarget().getClass().getName();  
        String methodName = joinPoint.getSignature().getName();  
        String methodParams = objectMapper.writerWithDefaultPrettyPrinter()
        .writeValueAsString(joinPoint.getArgs());  
        long begin = System.currentTimeMillis();  
  
        Object proceed = joinPoint.proceed();  
  
        String returnValue = objectMapper.writerWithDefaultPrettyPrinter().writeValueAsString(proceed);  
        long end = System.currentTimeMillis();  
  
        OperateLog operateLog = new OperateLog().setOperateEmpId(operateEmpId)  
                .setOperateTime(operateTime)  
                .setClassName(className)  
                .setMethodName(methodName)  
                .setMethodParams(methodParams)  
                .setReturnValue(returnValue).setCostTime(end - begin);  
        operateLogMapper.insert(operateLog);  
  
        return proceed;  
    }  
}

如果在切面中出現異常,遞時異常會導致chain無法向下推進,執行不到目標方法。

記錄登入日誌

需求:

記錄當前 tlias 智慧學習輔助系統中所有員工的登入操作 (切入點:execution(....); 通知型別:@Around) ,無論登入成功還是失敗,都需要記錄日誌。日誌資訊包含如下資訊:

  • 使用者名稱 (登入時,輸入的使用者名稱) ----- 【提示:使用者名稱在原始方法執行時的引數中 -- 可以強轉】
  • 密碼 (登入時,輸入的密碼) -------- 【提示:密碼在原始方法執行時的引數中 】
  • 操作時間 (什麼時間,員工登入的)
  • 登入是否成功 ------ 【提示:在原始方法執行後的返回值中,可以透過Result來獲取code從而判斷成功還是失敗 -- 可以強轉】
  • 登入成功後,下發的jwt令牌 ------ 【提示:jwt在原始方法執行後的返回值中 -- 可以強轉】
  • 登入操作耗時
-- 登入日誌表
create table emp_login_log(
    id int unsigned primary key auto_increment comment 'ID',
    username varchar(20) comment '使用者名稱',
    password varchar(32) comment '密碼',
    login_time datetime comment '登入時間',
    is_success tinyint unsigned comment '是否成功, 1:成功, 0:失敗',
    jwt varchar(1000) comment 'JWT令牌',
    cost_time bigint unsigned comment '耗時, 單位:ms'
) comment '登入日誌表';
package com.itheima.pojo;

import lombok.AllArgsConstructor;
import lombok.Data;
import lombok.NoArgsConstructor;

import java.time.LocalDateTime;

@Data
@NoArgsConstructor
@AllArgsConstructor
public class EmpLoginLog {
    private Integer id; //ID
    private String username; //登入使用者名稱
    private String password; //登入密碼
    private LocalDateTime loginTime; //登入時間
    private Short isSuccess; //是否登入成功, 1:成功, 0:失敗
    private String jwt; //成功後, 下發的JWT令牌
    private Long costTime; //登入耗時, 單位:ms
}
@Slf4j  
@Aspect  
@Component  
public class LoginLogAspect {  
  
    @Autowired  
    private EmpLoginLogMapper loginLogMapper;  
  
    @Around("execution(* com.itheima.controller.LoginController.login(com.itheima.model.pojo.entity.Emp))")  
    public Object around(ProceedingJoinPoint joinPoint) throws Throwable {  
        Result result = null;  
        if (joinPoint.getArgs()[0] instanceof Emp emp){  
            String username = emp.getUsername();  
            String password = emp.getPassword();  
            LocalDateTime now = LocalDateTime.now();  
            long begin = System.currentTimeMillis();  
  
            result = (Result) joinPoint.proceed();  
  
            long costTime = System.currentTimeMillis() - begin;  
            short isSuccess = 0;  
            String jwt = null;  
            if (result.getData() instanceof LogInfo logInfo){  
                isSuccess = 1;  
                jwt = logInfo.getToken();  
            }  
            EmpLoginLog empLoginLog = 
					            new EmpLoginLog(null, username, password, now, isSuccess, jwt, costTime);  
            log.info("員工登入資訊:{}",empLoginLog);  
            loginLogMapper.insert(empLoginLog);  
  
        }  
        return result;  
    }  
  
}

總結

image.png

相關文章