MyBatis 進階,MyBatis-Plus!(基於 Springboot 演示)

BWH_Steven發表於2020-10-03

這一篇從一個入門的基本體驗介紹,再到對於 CRUD 的一個詳細介紹,在介紹過程中將涉及到的一些問題,例如逐漸策略,自動填充,樂觀鎖等內容說了一下,只選了一些重要的內容,還有一些沒提及到,具體可以參考官網,簡單的看完,其實會發現,如果遇到單表的 CRUD ,直接用 MP 肯定舒服,如果寫多表,還是用 Mybatis 多點,畢竟直接寫 SQL 會直觀一點,MP 給我的感覺,就是方法封裝了很多,還有一些算比較是用的外掛,但是可讀性會稍微差一點,不過個人有個人的看法哇,祝大家國慶快樂 ~

一 引言

最初的 JDBC,我們需要寫大量的程式碼來完成與基本的 CRUD ,或許會在一定程度上使用 Spring 的 JdbcTemplate 或者 Apache 的 DBUtils ,這樣一些對 JDBC 的簡單封裝的工具類。

再到後再使用 Mybatis 等一些優秀的持久層框架,大大的簡化了開發,我們只需要使用一定的 XML 或者註解就可以完成原來的工作

JDBC --> Mybatis 無疑簡化了開發者的工作,而今天我們所講額 MyBatis-Plus 就是在 MyBatis 的基礎上,更加的簡化開發,來一起看看吧!

二 初識 MyBatis-Plus

下列介紹來自官網:

(一) 概述

MyBatis-Plus(簡稱 MP)是一個 MyBatis 的增強工具,在 MyBatis 的基礎上只做增強不做改變,為簡化開發、提高效率而生。

我們的願景是成為 MyBatis 最好的搭檔,就像魂鬥羅中的 1P、2P,基友搭配,效率翻倍。

總之一句話:MyBatis-Plus —— 為簡化開發而生

(二) 特性

  • 無侵入:只做增強不做改變,引入它 不會 對現有工程產生影響,如絲般順滑
  • 損耗小:啟動即會自動注入基本 CRUD,效能基本無損耗,直接物件導向操作
  • 強大的 CRUD 操作:內建通用 Mapper、通用 Service,僅僅通過少量配置即可實現單表大部分 CRUD 操作,更有強大的條件構造器,滿足各類使用需求
  • 支援 Lambda 形式呼叫:通過 Lambda 表示式,方便的編寫各類查詢條件,無需再擔心欄位寫錯
  • 支援主鍵自動生成:支援多達 4 種主鍵策略(內含分散式唯一 ID 生成器 - Sequence),可自由配置,完美解決主鍵問題
  • 支援 ActiveRecord 模式:支援 ActiveRecord 形式呼叫,實體類只需繼承 Model 類即可進行強大的 CRUD 操作
  • 支援自定義全域性通用操作:支援全域性通用方法注入( Write once, use anywhere )
  • 內建程式碼生成器:採用程式碼或者 Maven 外掛可快速生成 Mapper 、 Model 、 Service 、 Controller 層程式碼,支援模板引擎,更有超多自定義配置等您來使用
  • 內建分頁外掛:基於 MyBatis 物理分頁,開發者無需關心具體操作,配置好外掛之後,寫分頁等同於普通 List 查詢
  • 分頁外掛支援多種資料庫:支援 MySQL、MariaDB、Oracle、DB2、H2、HSQL、SQLite、Postgre、SQLServer 等多種資料庫
  • 內建效能分析外掛:可輸出 Sql 語句以及其執行時間,建議開發測試時啟用該功能,能快速揪出慢查詢
  • 內建全域性攔截外掛:提供全表 delete 、 update 操作智慧分析阻斷,也可自定義攔截規則,預防誤操作

(三) 支援資料庫

  • mysql 、mariadb 、oracle 、db2 、h2 、hsql 、sqlite 、postgresql 、sqlserver 、presto 、Gauss 、Firebird
  • Phoenix 、clickhouse 、Sybase ASE 、 OceanBase 、達夢資料庫 、虛谷資料庫 、人大金倉資料庫 、南大通用資料庫

三 入門初體驗

按照官網的案例簡單試一下 ,注:官網是基於 Springboot 的示例

@Repository
public interface UserMapper extends BaseMapper<User> {

}

(一) 建立入門案例表

@Repository
public interface UserMapper extends BaseMapper<User> {

}

自行建立一個資料庫即可,然後匯入官網給出的案例表,然後插入如下資料

-- ----------------------------
-- Table structure for user
-- ----------------------------
DROP TABLE IF EXISTS `user`;
CREATE TABLE `user`  (
  `id` bigint(20) NOT NULL AUTO_INCREMENT COMMENT '主鍵ID',
  `name` varchar(30) CHARACTER SET utf8 COLLATE utf8_general_ci NULL DEFAULT NULL COMMENT '姓名',
  `age` int(11) NULL DEFAULT NULL COMMENT '年齡',
  `email` varchar(50) CHARACTER SET utf8 COLLATE utf8_general_ci NULL DEFAULT NULL COMMENT '郵箱',
  PRIMARY KEY (`id`) USING BTREE
);

-- ----------------------------
-- Records of user
-- ----------------------------
INSERT INTO `user` VALUES (1, 'Jone', 18, 'test1@baomidou.com');
INSERT INTO `user` VALUES (2, 'Jack', 20, 'test2@baomidou.com');
INSERT INTO `user` VALUES (3, 'Tom', 28, 'test3@baomidou.com');
INSERT INTO `user` VALUES (4, 'Sandy', 21, 'test4@baomidou.com');
INSERT INTO `user` VALUES (5, 'Billie', 24, 'test5@baomidou.com');

(二) 初始化 SpringBoot 專案

如果沒有接觸過 SpringBoot,使用常規的 SSM 也是可以的,為了演示方便,這裡還是使用了SpringBoot,如果想在 SSM 中使用,一個注意依賴的修改,還一個就需要修改 xml 中的一些配置

(1) 引入依賴

引入 MyBatis-Plus-boot-starter 肯定是沒什麼疑問的,同樣我們還需要引入,資料庫連線的驅動依賴,還可以看需要引入 lombok,這裡為了簡便所以使用了它,如果不想使用,手動生成構造方法和 get set 即可

<!-- 資料庫驅動-->
<dependency>
	<groupId>mysql</groupId>
	<artifactId>mysql-connector-java</artifactId>
</dependency>

<!-- MyBatis-Plus -->
<dependency>
	<groupId>com.baomidou</groupId>
    <artifactId>mybatis-plus-boot-starter</artifactId>
    <version>3.4.0</version>
</dependency>

<!-- lombok -->
<dependency>
	<groupId>org.projectlombok</groupId>
    <artifactId>lombok</artifactId>
</dependency>

(2) 配置資料庫資訊

如何進行資料庫相關的資訊在以前的SpringBoot文章已經說過了,這裡強調一下:

mysql 5 驅動:com.mysql.jdbc.Driver

mysql 8 驅動:com.mysql.cj.jdbc.Driver 、還需要增加時區的配置

serverTimezone=GMT%2B8
spring.datasource.username=root
spring.datasource.password=root99
spring.datasource.url=jdbc:mysql://localhost:3306/mybatis_plus?useSSL=false&useUnicode=true&characterEncoding=utf-8&serverTimezone=GMT%2B8
spring.datasource.driver-class-name=com.mysql.cj.jdbc.Driver

(3) 建立實體類

根據資料庫欄位建立出對應實體屬性就行了,還是提一下:上方三個註解,主要是使用了 lombok 自動的生成那些 get set 等方法,不想用的同學直接自己按原來的方法顯式的寫出來就可以了~

@Data
@AllArgsConstructor
@NoArgsConstructor
public class User {
    @TableId(type = IdType.AUTO)
    private Long id;
    private String name;
    private Integer age;
    private String email;
}

(4) 建立Mapper介面

程式碼如下,可以看到,我們額外的繼承了 BaseMapper,同時指定了泛型為 User

@Repository
public interface UserMapper extends BaseMapper<User> {

}

其實點進去 BaseMapper 看一下,你會發現,在其中已經定義了關於 CRUD 一些基本方法還有一些涉及到配合條件實現更復雜的操作,同時泛型中指定的實體,會在增刪改查的方法中被呼叫

照這樣說的話,好像啥東西都被寫好了,如果現在想要進行一個簡單的增刪改查,是不是直接使用就行了

(5) 測試

首先在測試類中注入 UserMapper,這裡演示一個查詢所有的方法,所以使用了 selectList ,其引數是一個條件,這裡先置為空。

如果有哪些方法的使用不明確,我們可以先點到 BaseMapper 中去看一下,down 下原始碼以後,會有一些註釋說明

/**
 * 根據 entity 條件,查詢全部記錄
 *
 * @param queryWrapper 實體物件封裝操作類(可以為 null)
 */
List<T> selectList(@Param(Constants.WRAPPER) Wrapper<T> queryWrapper);

下面是測試查詢所有的全程式碼

@SpringBootTest
class MybatisPlusApplicationTests {

    @Autowired
    private UserMapper userMapper;

    @Test
    void contextLoads() {
        List<User> userList = userMapper.selectList(null);
        for (User user : userList) {
            System.out.println(user);
        }
    }
}

(5) 結果

控制檯輸出如下

User(id=1, name=Jone, age=18, email=test1@baomidou.com)
User(id=2, name=Jack, age=20, email=test2@baomidou.com)
User(id=3, name=Tom, age=28, email=test3@baomidou.com)
User(id=4, name=Sandy, age=21, email=test4@baomidou.com)
User(id=5, name=Billie, age=24, email=test5@baomidou.com)

經過一個簡單的測試,感覺還是很香的,而以前在 Mybatis 中我們執行sql語句時,是可以看到控制檯列印的日誌的,而這裡顯然沒有,其實通過一行簡單的配置就可以了

(三) 開啟日誌

其實只需要在配置檔案中加入短短的一行就可以了

MyBatis-Plus.configuration.log-impl=org.apache.ibatis.logging.stdout.StdOutImpl

列印如下:

當然你還可以通過一些 MyBatis Log 的外掛,來快速的檢視自己所執行的 sql

四 CRUD

(一) 插入操作

(1) 可用方法

首先,先試試插入一個實體的操作,我們選擇使用了 insert 這個方法,下面是其定義:

/**
 * 插入一條記錄
 *
 * @param entity 實體物件
 */
int insert(T entity);

(2) 演示

@Test
public void testInsert() {
    // 擬一個物件
    User user = new User();
    user.setName("理想二旬不止");
    user.setAge(30);
    user.setEmail("ideal_bwh@163.com");
    // 插入操作
    int count = userMapper.insert(user); 
    System.out.println(count);
    System.out.println(user);
}

結果:

根據結果看到,插入確實成功了,但是一個發矇的問題出現了,為啥 id 變成了一個 long 型別的值

(3) 主鍵生成策略

對於主鍵的生成,官網有如下的一句話:

自3.3.0開始,預設使用雪花演算法+UUID(不含中劃線)

也就是說,因為上面我們沒有做任何的處理,所以它使用了預設的演算法來當做主鍵 id

A:雪花演算法(snowflake)

snowflake是Twitter開源的分散式ID生成演算法,結果是一個long型的ID。其核心思想是:使用41bit作為毫秒數,10bit作為機器的ID(5個bit是資料中心,5個bit的機器ID),12bit作為毫秒內的流水號(意味著每個節點在每毫秒可以產生 4096 個 ID),最後還有一個符號位,永遠是0。

雪花演算法 + UUID 所以基本是可以保證唯一的

當然除了雪花演算法為,我們還有一些別的主鍵生成的策略,例如 Redis、資料庫自增

對於我們之前常用的一種主鍵生成方式,一般都會用到資料庫id自增

(4) 設定主鍵自增

  • 資料庫主鍵欄位設定自增!!!
  • 主鍵實體類欄位註解 @TableId(type = IdType.AUTO)

再次插入,發現 id 已經實現了自增

(5) 欄位註解解釋

@TableId 註解中的屬性 Type 的值來自於 IdType 這個列舉類,其中我把每一項簡單解釋一下

  • AUTO(0) :資料庫 ID 自增(MySQL 正常,Oracle 未測試)

    • 如果你想要全域性都使用 AUTO 這樣的資料庫自增方式,可以直接在 application.properties 中新增
    • MyBatis-Plus.global-config.db-config.id-type=auto
  • NONE(1) :該型別為未設定主鍵型別(註解裡等於跟隨全域性,全域性裡約等於 INPUT)

  • INPUT(2) :使用者輸入 ID,也可以自定義輸入策略,內建策略如下

    • DB2KeyGenerator
    • H2KeyGenerator
    • KingbaseKeyGenerator
    • OracleKeyGenerator
    • PostgreKeyGenerator

    使用時:

    先新增 @Bean,然後實體類配置主鍵 Sequence,指定主鍵策略為 IdType.INPUT 即可,重點不說這個,有需要可以直接扒官網

@Bean
public IKeyGenerator keyGenerator() {
    return new H2KeyGenerator();
}
  • ASSIGN_ID(3) :雪花演算法

  • ASSIGN_UUID(4):不含中劃線的UUID

3.3.0 後,ID_WORKER(3)、ID_WORKER_STR(3)、UUID(4) 就已經被棄用了,前兩個可以使用 ASSIGN_ID(3)代替,最後一個使用 ASSIGN_UUID(4)代替

(二) 更新操作

(1) 可用方法

// 根據 whereEntity 條件,更新記錄
int update(@Param(Constants.ENTITY) T entity, @Param(Constants.WRAPPER) Wrapper<T> updateWrapper);
// 根據 ID 修改
int updateById(@Param(Constants.ENTITY) T entity);

(2) 演示

MyBatis-Plus 中的更新操作也是非常方便

舉一種比較常見的一種情況,通過 id 值修改某些欄位

傳統做法會傳一個修改後的物件,然後通過 #{} 設定具體更新的值和 id

<update id="updateById">
	UPDATE user SET name=#{name}, portrait=#{portrait}, gender=#{gender}, telephone=#{telephone}, email=#{email} WHERE id=#{id}
</update>

MyBatis-Plus 方式:

@Test
public void testUpdate() {
    // 擬一個物件
    User user = new User();
    user.setId(1L);
    user.setName("理想二旬不止");
    user.setAge(20);
    int i = userMapper.updateById(user);
    System.out.println(i);
}

首先我們給定了 id 值,同時又修改了姓名和年齡這兩個欄位,但是並不是全部欄位,來看一下執行效果

神奇的發現,我們不需要在 sql 中進行設定了,所有的配置都被自動做好了,更新的內容和 id 都被自動填充好了

(3) 自動填充

自動填充是填充什麼內容呢?首先我們需要知道,一般來說表中的建立時間修改時間,我們總是希望能夠給根據插入或者修改的時間自動填充,而不需要我們手動的去更新

可能以前的專案不是特別綜合或需要等原因,有時候也不會去設定建立時間等欄位,寫這部分是因為,在阿里巴巴的Java開發手冊(第5章 MySQL 資料庫 - 5.1 建表規約 - 第 9 條 )有明確指出:

【強制】表必備三欄位:id, create_time, update_time。

說明:其中 id 必為主鍵,型別為 bigint unsigned、單表時自增、步長為 1。create_time, update_time

的型別均為 datetime 型別,前者現在時表示主動式建立,後者過去分詞表示被動式更新

A:資料庫級別(不常用)

我們可以通過直接修改資料庫中對應欄位的預設值,來實現資料庫級別的自動新增語句

例如上圖中我首先新增了 create_time, update_time 兩個欄位,然後將型別選擇為 datetime,又設定其預設值為 CURRENT_TIMESTAMP

注:更新時間欄位中要勾選 On Update Current_Timestamp ,插入不用,使用 SQLYog 沒問題,在 Navicat 某個版本下直接通過視覺化操作可能會報錯,沒有此預設值,這種情況就把表先匯出來,然後修改SQL,在SQL 中修改語句

create_time` datetime(0)  DEFAULT CURRENT_TIMESTAMP(0) COMMENT '建立時間',
update_time` datetime(0)  DEFAULT CURRENT_TIMESTAMP(0) COMMENT '修改時間',

B:程式碼級別

根據官網的自動填充功能的說明,其實我們需要做的只有兩點:

  • 為 create_time, update_time 兩個欄位配置註解
  • 自定義實現類 MyMetaObjectHandler

注:開始前,別忘了刪除剛才資料庫級別測試時的欄位預設值等喔

首先填充欄位註解:

@TableField(fill = FieldFill.INSERT)

@TableField(fill = FieldFill.INSERT_UPDATE)

@TableField(fill = FieldFill.INSERT)
private Date createTime;

@TableField(fill = FieldFill.INSERT_UPDATE)
private Date updateTime;

FieldFill 說明:

public enum FieldFill {
	/**
     * 預設不處理
     */
    DEFAULT,
    /**
     * 插入時填充欄位
     */
    INSERT,
    /**
     * 更新時填充欄位
     */
    UPDATE,
    /**
     * 插入和更新時填充欄位
     */
    INSERT_UPDATE
}

接著建立自定義實現類 MyMetaObjectHandler,讓其實現MetaObjectHandler,重寫其 insertFill 和 updateFill 方法,列印日誌就不說了,通過 setFieldValByName 就可以對欄位進行賦值,原始碼中這個方法有三個引數

/**
 * 通用填充
 *
 * @param fieldName  java bean property name
 * @param fieldVal   java bean property value
 * @param metaObject meta object parameter
 */
default MetaObjectHandler setFieldValByName(String fieldName, Object fieldVal, MetaObject metaObject) {...}
  • fieldName:欄位名
  • fieldVal:該欄位賦予的值
  • metaObject:操作哪個資料
@Slf4j
@Component
public class MyMetaObjectHandler implements MetaObjectHandler {
    @Override
    public void insertFill(MetaObject metaObject) {
        log.info("start insert fill ....");
        setFieldValByName("createTime", new Date(), metaObject);
        setFieldValByName("updateTime", new Date(), metaObject);

    }

    @Override
    public void updateFill(MetaObject metaObject) {
        log.info("start update fill ....");
        setFieldValByName("updateTime", new Date(), metaObject);
    }
}

檢視一下效果:

下面還有一些注意事項:

注意事項:

  • 填充原理是直接給entity的屬性設定值!!!
  • 註解則是指定該屬性在對應情況下必有值,如果無值則入庫會是null
  • MetaObjectHandler提供的預設方法的策略均為:如果屬性有值則不覆蓋,如果填充值為null則不填充
  • 欄位必須宣告TableField註解,屬性fill選擇對應策略,該宣告告知MyBatis-Plus需要預留注入SQL欄位
  • 填充處理器MyMetaObjectHandler在 Spring Boot 中需要宣告@Component@Bean注入
  • 要想根據註解FieldFill.xxx欄位名以及欄位型別來區分必須使用父類的strictInsertFill或者strictUpdateFill方法
  • 不需要根據任何來區分可以使用父類的fillStrategy方法

(4) 樂觀鎖外掛

演示樂觀鎖外掛前,首先補充一些基礎概念:

A:沒有鎖會怎麼樣

打個比方,一張電影票價格為 30,老闆告訴員工 A ,把價格上調到 50,員工 A 因為有事耽擱了兩個小時,但是老闆想了一會覺得提價太高了,就想著定價 40 好了,正好碰到員工 B,就讓員工 B 將價格降低 10 塊

當正好兩個員工都在操作後臺系統時,兩人同時取出當前價格,即 30 元,員工A 先操作後 價格變成了 50元,但是員工 B 又將30 - 10 ,即 變成20塊,執行了更新操作,此時員工 B 的更新操作就會把前面的 50 元覆蓋掉,即最終成為了 20元,雖然我內心毫無波瀾,但老闆卻虧的一匹

B:樂觀鎖和悲觀鎖

  • 樂觀鎖:故名思意十分樂觀,它總是認為不會出現問題,無論幹什麼不去上鎖!如果出現了問題,
    再次更新值測試
    • 樂觀鎖下,員工 B 更新時會檢查是否這個價格已經被別人修改過了,如果是就會取出新的值,再操作
  • 悲觀鎖:故名思意十分悲觀,它總是認為總是出現問題,無論幹什麼都會上鎖!再去操作!
    • 悲觀鎖下,員工 B 只能在 員工 A 操作完以後才能操作,這樣能保證資料只有一個人在操作

C:MP 中的樂觀鎖外掛

意圖:

當要更新一條記錄的時候,希望這條記錄沒有被別人更新

樂觀鎖實現方式:

  • 取出記錄時,獲取當前version
  • 更新時,帶上這個version
  • 執行更新時, set version = newVersion where version = oldVersion
  • 如果version不對,就更新失敗

實現這個功能,只需要兩步:

  • 新增資料庫version欄位和實體欄位以及註解
@Version // 樂觀鎖的Version註解
private Integer version;
  • 建立配置類,引入樂觀鎖外掛
// 掃描 mapper 資料夾
@MapperScan("cn.ideal.mapper")
@EnableTransactionManagement
// 代表配置類
@Configuration 
public class MyBatisPlusConfig {
    // 樂觀鎖外掛
    @Bean
    public OptimisticLockerInterceptor optimisticLockerInterceptor() {
        return new OptimisticLockerInterceptor();
    }
}

說明:剛開始例如掃描 mapper 這樣的註解就放在了啟動類中,現在有了配置類,所以把它也移過來了

測試一下:

首先1號和2號獲取到的資料是一樣的,但是在1號還沒有執行到更新的時候,2號搶先提交了更新操作,也就是說,當前真實資料已經是被2號修改過的了,與1號前面獲取到的不一致了

如果沒有樂觀鎖,那麼2號提交的更新會被1號的更新資料覆蓋

// 測試更新
@Test
public void testUpdate() {
	// 1號取得了資料
    User user1 = userMapper.selectById(1L);
    user1.setName("樂觀鎖1號");
    user1.setAge(20);
    user1.setEmail("ideal_bwh@xxx.com");
    // 2號取得了資料
    User user2 = userMapper.selectById(1L);
    user2.setName("樂觀鎖2號");
    user2.setAge(30);
    user2.setEmail("ideal@xxx.com");
    // 2號提交更新
     userMapper.updateById(user2);
    // 1號提交更新
    userMapper.updateById(user1);
}

可以看到,在2號搶先執行後,1號就沒有成功執行了

同樣資料庫中表的其 version 也從1變成了1

(三) 查詢操作

(1) 可用方法

// 根據 ID 查詢
T selectById(Serializable id);
// 根據 entity 條件,查詢一條記錄
T selectOne(@Param(Constants.WRAPPER) Wrapper<T> queryWrapper);

// 查詢(根據ID 批量查詢)
List<T> selectBatchIds(@Param(Constants.COLLECTION) Collection<? extends Serializable> idList);
// 根據 entity 條件,查詢全部記錄
List<T> selectList(@Param(Constants.WRAPPER) Wrapper<T> queryWrapper);
// 查詢(根據 columnMap 條件)
List<T> selectByMap(@Param(Constants.COLUMN_MAP) Map<String, Object> columnMap);
// 根據 Wrapper 條件,查詢全部記錄
List<Map<String, Object>> selectMaps(@Param(Constants.WRAPPER) Wrapper<T> queryWrapper);
// 根據 Wrapper 條件,查詢全部記錄。注意: 只返回第一個欄位的值
List<Object> selectObjs(@Param(Constants.WRAPPER) Wrapper<T> queryWrapper);

// 根據 entity 條件,查詢全部記錄(並翻頁)
IPage<T> selectPage(IPage<T> page, @Param(Constants.WRAPPER) Wrapper<T> queryWrapper);
// 根據 Wrapper 條件,查詢全部記錄(並翻頁)
IPage<Map<String, Object>> selectMapsPage(IPage<T> page, @Param(Constants.WRAPPER) Wrapper<T> queryWrapper);
// 根據 Wrapper 條件,查詢總記錄數
Integer selectCount(@Param(Constants.WRAPPER) Wrapper<T> queryWrapper);

(2) 簡單查詢

A:根據 id 查詢

@Test
public void testSelectById(){
	User user = userMapper.selectById(1L);
    System.out.println(user);
}

B:根據 id 集合查詢

說明:我這裡使用的還是最基本的寫法,例如 List 可以用工具類建立 如:Arrays.asList(1, 2, 3)

遍歷也完全可以這樣 users.forEach(System.out::println);

@Test
public void testSelectByBatchId(){
    List list = new ArrayList();
    list.add(1);
    list.add(2);
    list.add(3);
    List<User> users = userMapper.selectBatchIds(list);
    for (User user : users){
    	System.out.println(user);
    }
}

C:根據 map 查詢

@Test
public void testSelectByMap(){
    HashMap<String, Object> map = new HashMap<>();
    // 自定義要查詢的欄位和值
    map.put("name","理想二旬不止");
    map.put("age",30);

    List<User> users = userMapper.selectByMap(map);
    for (User user : users){
        System.out.println(user);
    }
}

通過日誌的列印可以看到,它根據我們的選擇自動拼出了 SQL 的條件

==>  Preparing: SELECT id,name,age,email,version,create_time,update_time FROM user WHERE name = ? AND age = ?
==> Parameters: 理想二旬不止(String), 30(Integer)

(3) 分頁查詢

JavaWeb 階段,大家都應該有手寫過分頁,配合 SQL 的 limit 進行分頁,後面在 Mybatis 就會用一些例如 pageHelper 的外掛,而 MyBatis-Plus 中也有一個內建的分頁外掛

使用前只需要進行一個小小的配置,在剛才配置類中,加入分頁外掛的配置程式碼

// 掃描我們的mapper 資料夾
@MapperScan("cn.ideal.mapper")
@EnableTransactionManagement
@Configuration // 配置類

public class MyBatisPlusConfig {

    // 樂觀鎖外掛
    @Bean
    public OptimisticLockerInterceptor optimisticLockerInterceptor() {
        return new OptimisticLockerInterceptor();
    }

    // 分頁外掛
    @Bean
    public PaginationInterceptor paginationInterceptor() {
        return new PaginationInterceptor();
    }

}

接著就可以測試分頁了

@Test
public void testPage(){
    // Page 引數: 引數1:當前頁 ,引數1:頁面大小
    Page<User> page = new Page<>(2,3);
    userMapper.selectPage(page,null);

    List<User> users = page.getRecords();
    for (User user : users){
        System.out.println(user);
    }
    System.out.println(page.getTotal());
}

執行結果日誌:

==>  Preparing: SELECT id,name,age,email,version,create_time,update_time FROM user LIMIT ?,?
==> Parameters: 3(Long), 3(Long)
<==    Columns: id, name, age, email, version, create_time, update_time
<==        Row: 4, Sandy, 21, test4@baomidou.com, 1, null, null
<==        Row: 5, Billie, 24, test5@baomidou.com, 1, null, null
<==        Row: 1308952901602811906, 理想二旬不止, 30, ideal_bwh@163.com, 1, null, null
<==      Total: 3

(4) 條件查詢 ※

如何實現一些條件相對複雜的查詢呢?MyBatis-Plus 也給我們提供了一些用法,幫助我們方便的構造各種條件

其實前面大家應該就注意到了,在查詢操作的可用方法中,引數中往往帶有一個名叫 Wrapper<T> queryWrapper 的內容,這就是我們要構造條件的重點

查詢中最常用的就是 QueryWrapper

說明:

繼承自 AbstractWrapper ,自身的內部屬性 entity 也用於生成 where 條件
及 LambdaQueryWrapper, 可以通過 new QueryWrapper().lambda() 方法獲取

例項化一個 QueryWrapper 後,通過呼叫一些內建的方法,就可以實現條件構造

A:如何使用

例如我們想要構造這樣一個條件:查詢郵箱不為空,且年齡小於 25 歲的使用者

@Test
void contextLoads() {
	// 例項化一個 QueryWrapper 物件
    QueryWrapper<User> wrapper = new QueryWrapper<>();
    // 進行具體條件構造
    wrapper
            .isNotNull("email")
            .lt("age", 25);
    // 執行具體的查詢方法,同時將 wrapper 條件作為引數傳入
    List<User> users = userMapper.selectList(wrapper);
    for (User user : users){
        System.out.println(user);
    }
}

看一下列印的日誌:

==>  Preparing: SELECT id,name,age,email,version,deleted,create_time,update_time FROM user WHERE deleted=0 AND (email IS NOT NULL AND age < ?)
==> Parameters: 25(Integer)
<==    Columns: id, name, age, email, version, deleted, create_time, update_time
<==        Row: 2, 理想, 22, ideal_bwh@xxx.com, 1, 0, 2020-09-26 15:06:09, 2020-09-26 21:21:52
<==        Row: 4, Sandy, 21, test4@baomidou.com, 1, 0, null, null
<==        Row: 5, Billie, 24, test5@baomidou.com, 1, 0, null, null
<==      Total: 3

可以看到條件都被自動在 SQL 中構造出來了

使用的方式就這麼簡單,通過各種巧妙的構造就好了

B:構造方式

下面是從官網摘取的各種構造方式:


allEq
allEq(Map<R, V> params)
allEq(Map<R, V> params, boolean null2IsNull)
allEq(boolean condition, Map<R, V> params, boolean null2IsNull)

個別引數說明:

params : key為資料庫欄位名,value為欄位值
null2IsNull : 為true則在mapvaluenull時呼叫 isNull 方法,為false時則忽略valuenull

  • 例1: allEq({id:1,name:"老王",age:null})--->id = 1 and name = '老王' and age is null
  • 例2: allEq({id:1,name:"老王",age:null}, false)--->id = 1 and name = '老王'
allEq(BiPredicate<R, V> filter, Map<R, V> params)
allEq(BiPredicate<R, V> filter, Map<R, V> params, boolean null2IsNull)
allEq(boolean condition, BiPredicate<R, V> filter, Map<R, V> params, boolean null2IsNull) 

個別引數說明:

filter : 過濾函式,是否允許欄位傳入比對條件中
paramsnull2IsNull : 同上

  • 例1: allEq((k,v) -> k.indexOf("a") >= 0, {id:1,name:"老王",age:null})--->name = '老王' and age is null
  • 例2: allEq((k,v) -> k.indexOf("a") >= 0, {id:1,name:"老王",age:null}, false)--->name = '老王'
eq
eq(R column, Object val)
eq(boolean condition, R column, Object val)
  • 等於 =
  • 例: eq("name", "老王")--->name = '老王'
ne
ne(R column, Object val)
ne(boolean condition, R column, Object val)
  • 不等於 <>
  • 例: ne("name", "老王")--->name <> '老王'
gt
gt(R column, Object val)
gt(boolean condition, R column, Object val)
  • 大於 >
  • 例: gt("age", 18)--->age > 18
ge
ge(R column, Object val)
ge(boolean condition, R column, Object val)
  • 大於等於 >=
  • 例: ge("age", 18)--->age >= 18
lt
lt(R column, Object val)
lt(boolean condition, R column, Object val)
  • 小於 <
  • 例: lt("age", 18)--->age < 18
le
le(R column, Object val)
le(boolean condition, R column, Object val)
  • 小於等於 <=
  • 例: le("age", 18)--->age <= 18
between
between(R column, Object val1, Object val2)
between(boolean condition, R column, Object val1, Object val2)
  • BETWEEN 值1 AND 值2
  • 例: between("age", 18, 30)--->age between 18 and 30
notBetween
notBetween(R column, Object val1, Object val2)
notBetween(boolean condition, R column, Object val1, Object val2)
  • NOT BETWEEN 值1 AND 值2
  • 例: notBetween("age", 18, 30)--->age not between 18 and 30
like
like(R column, Object val)
like(boolean condition, R column, Object val)
  • LIKE '%值%'
  • 例: like("name", "王")--->name like '%王%'
notLike
notLike(R column, Object val)
notLike(boolean condition, R column, Object val)
  • NOT LIKE '%值%'
  • 例: notLike("name", "王")--->name not like '%王%'
likeLeft
likeLeft(R column, Object val)
likeLeft(boolean condition, R column, Object val)
  • LIKE '%值'
  • 例: likeLeft("name", "王")--->name like '%王'
likeRight
likeRight(R column, Object val)
likeRight(boolean condition, R column, Object val)
  • LIKE '值%'
  • 例: likeRight("name", "王")--->name like '王%'
isNull
isNull(R column)
isNull(boolean condition, R column)
  • 欄位 IS NULL
  • 例: isNull("name")--->name is null
isNotNull
isNotNull(R column)
isNotNull(boolean condition, R column)
  • 欄位 IS NOT NULL
  • 例: isNotNull("name")--->name is not null
in
in(R column, Collection<?> value)
in(boolean condition, R column, Collection<?> value)
  • 欄位 IN (value.get(0), value.get(1), ...)
  • 例: in("age",{1,2,3})--->age in (1,2,3)
in(R column, Object... values)
in(boolean condition, R column, Object... values)
  • 欄位 IN (v0, v1, ...)
  • 例: in("age", 1, 2, 3)--->age in (1,2,3)
notIn
notIn(R column, Collection<?> value)
notIn(boolean condition, R column, Collection<?> value)
  • 欄位 NOT IN (value.get(0), value.get(1), ...)
  • 例: notIn("age",{1,2,3})--->age not in (1,2,3)
notIn(R column, Object... values)
notIn(boolean condition, R column, Object... values)
  • 欄位 NOT IN (v0, v1, ...)
  • 例: notIn("age", 1, 2, 3)--->age not in (1,2,3)
inSql
inSql(R column, String inValue)
inSql(boolean condition, R column, String inValue)
  • 欄位 IN ( sql語句 )
  • 例: inSql("age", "1,2,3,4,5,6")--->age in (1,2,3,4,5,6)
  • 例: inSql("id", "select id from table where id < 3")--->id in (select id from table where id < 3)
notInSql
notInSql(R column, String inValue)
notInSql(boolean condition, R column, String inValue)
  • 欄位 NOT IN ( sql語句 )
  • 例: notInSql("age", "1,2,3,4,5,6")--->age not in (1,2,3,4,5,6)
  • 例: notInSql("id", "select id from table where id < 3")--->id not in (select id from table where id < 3)
groupBy
groupBy(R... columns)
groupBy(boolean condition, R... columns)
  • 分組:GROUP BY 欄位, ...
  • 例: groupBy("id", "name")--->group by id,name
orderByAsc
orderByAsc(R... columns)
orderByAsc(boolean condition, R... columns)
  • 排序:ORDER BY 欄位, ... ASC
  • 例: orderByAsc("id", "name")--->order by id ASC,name ASC
orderByDesc
orderByDesc(R... columns)
orderByDesc(boolean condition, R... columns)
  • 排序:ORDER BY 欄位, ... DESC
  • 例: orderByDesc("id", "name")--->order by id DESC,name DESC
orderBy
orderBy(boolean condition, boolean isAsc, R... columns)
  • 排序:ORDER BY 欄位, ...
  • 例: orderBy(true, true, "id", "name")--->order by id ASC,name ASC
having
having(String sqlHaving, Object... params)
having(boolean condition, String sqlHaving, Object... params)
  • HAVING ( sql語句 )
  • 例: having("sum(age) > 10")--->having sum(age) > 10
  • 例: having("sum(age) > {0}", 11)--->having sum(age) > 11

func

func(Consumer<Children> consumer)
func(boolean condition, Consumer<Children> consumer)
  • func 方法(主要方便在出現if...else下呼叫不同方法能不斷鏈)
  • 例: func(i -> if(true) {i.eq("id", 1)} else {i.ne("id", 1)})
or
or()
or(boolean condition)
  • 拼接 OR

注意事項:

主動呼叫or表示緊接著下一個方法不是用and連線!(不呼叫or則預設為使用and連線)

  • 例: eq("id",1).or().eq("name","老王")--->id = 1 or name = '老王'
or(Consumer<Param> consumer)
or(boolean condition, Consumer<Param> consumer)
  • OR 巢狀
  • 例: or(i -> i.eq("name", "李白").ne("status", "活著"))--->or (name = '李白' and status <> '活著')
and
and(Consumer<Param> consumer)
and(boolean condition, Consumer<Param> consumer)
  • AND 巢狀
  • 例: and(i -> i.eq("name", "李白").ne("status", "活著"))--->and (name = '李白' and status <> '活著')
nested
nested(Consumer<Param> consumer)
nested(boolean condition, Consumer<Param> consumer)
  • 正常巢狀 不帶 AND 或者 OR
  • 例: nested(i -> i.eq("name", "李白").ne("status", "活著"))--->(name = '李白' and status <> '活著')
apply
apply(String applySql, Object... params)
apply(boolean condition, String applySql, Object... params)
  • 拼接 sql

注意事項:

該方法可用於資料庫函式 動態入參的params對應前面applySql內部的{index}部分.這樣是不會有sql注入風險的,反之會有!

  • 例: apply("id = 1")--->id = 1
  • 例: apply("date_format(dateColumn,'%Y-%m-%d') = '2008-08-08'")--->date_format(dateColumn,'%Y-%m-%d') = '2008-08-08'")
  • 例: apply("date_format(dateColumn,'%Y-%m-%d') = {0}", "2008-08-08")--->date_format(dateColumn,'%Y-%m-%d') = '2008-08-08'")
last
last(String lastSql)
last(boolean condition, String lastSql)
  • 無視優化規則直接拼接到 sql 的最後

注意事項:

只能呼叫一次,多次呼叫以最後一次為準 有sql注入的風險,請謹慎使用

  • 例: last("limit 1")
exists
exists(String existsSql)
exists(boolean condition, String existsSql)
  • 拼接 EXISTS ( sql語句 )
  • 例: exists("select id from table where age = 1")--->exists (select id from table where age = 1)
notExists
notExists(String notExistsSql)
notExists(boolean condition, String notExistsSql)
  • 拼接 NOT EXISTS ( sql語句 )
  • 例: notExists("select id from table where age = 1")--->not exists (select id from table where age = 1)

(四) 刪除操作

(1) 可用方法

// 根據 entity 條件,刪除記錄
int delete(@Param(Constants.WRAPPER) Wrapper<T> wrapper);
// 刪除(根據ID 批量刪除)
int deleteBatchIds(@Param(Constants.COLLECTION) Collection<? extends Serializable> idList);
// 根據 ID 刪除
int deleteById(Serializable id);
// 根據 columnMap 條件,刪除記錄
int deleteByMap(@Param(Constants.COLUMN_MAP) Map<String, Object> columnMap);

(2) 簡單刪除

A:根據 id 刪除

@Test
public void testDeleteById(){
    userMapper.deleteById(1L);
}

B:根據 id 集合刪除

@Test
public void testDeleteBatchIds(){
    List list = new ArrayList();
    list.add(1308952901602811906L);
    list.add(1308952901602811907L);
    list.add(1308952901602811908L);
    list.add(1308952901602811909L);
    userMapper.deleteBatchIds(list);
}

C:根據 map 刪除

 @Test
public void testDeleteMap(){
    HashMap<String, Object> map = new HashMap<>();
    map.put("name","理想二旬不止");
    userMapper.deleteByMap(map);
}

(3) 邏輯刪除

刪除這塊再補充一下邏輯刪除的概念,物理刪除很好理解,就是實實在在的在資料庫中刪沒了,但是邏輯刪除,顧名思義只是邏輯上被刪除了,實際上並沒有,只是通過增加一個欄位讓其失效而已,例如 deleted = 0 => deleted = 1

可以

應用的場景就是管理員想檢視刪除記錄,在錯誤刪除下,可以有逆轉的機會等等

首先資料庫增加 deleted 欄位,同時建立其實體和註解

@TableLogic // 邏輯刪除
private Integer deleted;

接著只需要在全域性配置中配置即可

application.properties

# 配置邏輯刪除
MyBatis-Plus.global-config.db-config.logic-delete-value=1
MyBatis-Plus.global-config.db-config.logic-not-delete-value=0

application.yml

MyBatis-Plus:
  global-config:
    db-config:
      logic-delete-field: flag  # 全域性邏輯刪除的實體欄位名(since 3.3.0,配置後可以忽略不配置步驟2)
      logic-delete-value: 1 # 邏輯已刪除值(預設為 1)
      logic-not-delete-value: 0 # 邏輯未刪除值(預設為 0)

效果如下:

你會發現,邏輯刪除會走一個更新操作,通過修改指定欄位 deleted 的值為 0 實現我們想要的效果

五 程式碼自動生成器

MyBatis-Plus 提供了一個非常便捷,有意思的內容,那就是程式碼的自動生成,我們通過一些配置,就可以自動的生成 controller、service、mapper、pojo 的內容,並且介面或者註解等內容都會按照配置指定的格式生成。(提前準備好資料庫和表)

首先除了 MyBatis-Plus 的依賴以外,還需要引入 swagger 和 velocity 的依賴,但是這兩者其實是可選的,可以選擇不配置就不用引入了,預設使用 velocity 這個模板引擎,大家還可以換成別的

<dependency>
    <groupId>org.apache.velocity</groupId>
    <artifactId>velocity-engine-core</artifactId>
    <version>2.0</version>
</dependency>

<dependency>
    <groupId>io.springfox</groupId>
    <artifactId>springfox-swagger2</artifactId>
    <version>2.9.2</version>
</dependency>

<dependency>
    <groupId>io.springfox</groupId>
    <artifactId>springfox-swagger-ui</artifactId>
    <version>2.9.2</version>
</dependency>

例如:

Velocity(預設):

<dependency>
    <groupId>org.apache.velocity</groupId>
    <artifactId>velocity-engine-core</artifactId>
    <version>latest-velocity-version</version>
</dependency>

Freemarker:

<dependency>
    <groupId>org.freemarker</groupId>
    <artifactId>freemarker</artifactId>
    <version>latest-freemarker-version</version>
</dependency>

Beetl:

<dependency>
    <groupId>com.ibeetl</groupId>
    <artifactId>beetl</artifactId>
    <version>latest-beetl-version</version>
</dependency>
  • 注意!如果您選擇了非預設引擎,需要在 AutoGenerator 中 設定模板引擎。

    AutoGenerator generator = new AutoGenerator();
    
    // set freemarker engine
    generator.setTemplateEngine(new FreemarkerTemplateEngine());
    
    // set beetl engine
    generator.setTemplateEngine(new BeetlTemplateEngine());
    
    // set custom engine (reference class is your custom engine class)
    generator.setTemplateEngine(new CustomTemplateEngine());
    
    // other config
    ...
    

下面就是一個主配置了,修改其中的資料庫連線等資訊,以及包的名稱等等等執行就可以了

package cn.ideal;

import com.baomidou.mybatisplus.annotation.DbType;
import com.baomidou.mybatisplus.annotation.FieldFill;
import com.baomidou.mybatisplus.annotation.IdType;
import com.baomidou.mybatisplus.annotation.TableField;
import com.baomidou.mybatisplus.generator.AutoGenerator;
import com.baomidou.mybatisplus.generator.config.DataSourceConfig;
import com.baomidou.mybatisplus.generator.config.GlobalConfig;
import com.baomidou.mybatisplus.generator.config.PackageConfig;
import com.baomidou.mybatisplus.generator.config.StrategyConfig;
import com.baomidou.mybatisplus.generator.config.po.TableFill;
import com.baomidou.mybatisplus.generator.config.rules.DateType;
import com.baomidou.mybatisplus.generator.config.rules.NamingStrategy;

import java.util.ArrayList;


/**
 * @ClassName: AutomaticCodeGenerate
 * @Description: TODO
 * @Author: BWH_Steven
 * @Date: 2020/10/2 21:29
 * @Version: 1.0
 */
public class AutomaticCodeGenerate {
    public static void main(String[] args) {
        // 需要構建一個程式碼自動生成器物件
        AutoGenerator mpg = new AutoGenerator();
        // 配置策略
        // 全域性配置
        GlobalConfig gc = new GlobalConfig();
        String projectPath = System.getProperty("user.dir");
        gc.setAuthor("BWH_Steven");
        gc.setOutputDir(projectPath + "/src/main/java");
        gc.setOpen(false);
        gc.setFileOverride(false); // 是否覆蓋
        gc.setServiceName("%sService"); // 去Service的I字首
        gc.setIdType(IdType.ID_WORKER);
        gc.setDateType(DateType.ONLY_DATE);
        gc.setSwagger2(true);
        mpg.setGlobalConfig(gc);
        // 設定資料來源
        DataSourceConfig dsc = new DataSourceConfig();
        dsc.setUrl("jdbc:mysql://localhost:3306/mybatis_plus?useSSL=false&useUnicode=true&characterEncoding" +
                "=utf-8&serverTimezone=GMT%2B8");
        dsc.setDriverName("com.mysql.cj.jdbc.Driver");
        dsc.setUsername("root");
        dsc.setPassword("root99");
        dsc.setDbType(DbType.MYSQL);
        mpg.setDataSource(dsc);
        // 包的配置
        PackageConfig pc = new PackageConfig();
        pc.setModuleName("test");
        pc.setParent("cn.ideal");
        pc.setEntity("entity");
        pc.setMapper("mapper");
        pc.setService("service");
        pc.setController("controller");
        mpg.setPackageInfo(pc);
        // 策略配置
        StrategyConfig strategy = new StrategyConfig();
        strategy.setInclude("user"); // 設定要對映的表名
        strategy.setNaming(NamingStrategy.underline_to_camel);
        strategy.setColumnNaming(NamingStrategy.underline_to_camel);
        strategy.setEntityLombokModel(true); // 自動lombok;
        strategy.setLogicDeleteFieldName("deleted");
        // 自動填充配置
        TableFill gmtCreate = new TableFill("create_time", FieldFill.INSERT);
        TableFill gmtModified = new TableFill("update_time", FieldFill.INSERT_UPDATE);
        ArrayList<TableFill> tableFills = new ArrayList<>();
        tableFills.add(gmtCreate);
        tableFills.add(gmtModified);
        strategy.setTableFillList(tableFills); // 樂觀鎖
        strategy.setVersionFieldName("version");
        strategy.setRestControllerStyle(true);
        strategy.setControllerMappingHyphenStyle(true);
        mpg.setStrategy(strategy);
        mpg.execute(); //執行
    }
}

生成結構效果如下:

我簡單貼兩段生成的內容:

controller

package cn.ideal.test.controller;

import org.springframework.web.bind.annotation.RequestMapping;
import org.springframework.web.bind.annotation.RestController;

/**
 * <p>
 *  前端控制器
 * </p>
 *
 * @author BWH_Steven
 * @since 2020-10-02
 */
@RestController
@RequestMapping("/test/user")
public class UserController {

}

entity

package cn.ideal.test.entity;

import com.baomidou.mybatisplus.annotation.IdType;
import java.util.Date;
import com.baomidou.mybatisplus.annotation.Version;
import com.baomidou.mybatisplus.annotation.TableId;
import com.baomidou.mybatisplus.annotation.FieldFill;
import com.baomidou.mybatisplus.annotation.TableLogic;
import com.baomidou.mybatisplus.annotation.TableField;
import java.io.Serializable;
import io.swagger.annotations.ApiModel;
import io.swagger.annotations.ApiModelProperty;
import lombok.Data;
import lombok.EqualsAndHashCode;

/**
 * <p>
 * 
 * </p>
 *
 * @author BWH_Steven
 * @since 2020-10-02
 */
@Data
@EqualsAndHashCode(callSuper = false)
@ApiModel(value="User物件", description="")
public class User implements Serializable {

    private static final long serialVersionUID = 1L;

    @ApiModelProperty(value = "主鍵ID")
      @TableId(value = "id", type = IdType.AUTO)
    private Long id;

    @ApiModelProperty(value = "姓名")
    private String name;

    @ApiModelProperty(value = "年齡")
    private Integer age;

    @ApiModelProperty(value = "郵箱")
    private String email;

    @ApiModelProperty(value = "版本")
    @Version
    private Integer version;

    @TableLogic
    private Integer deleted;

    @ApiModelProperty(value = "建立時間")
      @TableField(fill = FieldFill.INSERT)
    private Date createTime;

    @ApiModelProperty(value = "修改時間")
      @TableField(fill = FieldFill.INSERT_UPDATE)
    private Date updateTime;


}

service

package cn.ideal.test.service;

import cn.ideal.test.entity.User;
import com.baomidou.mybatisplus.extension.service.IService;

/**
 * <p>
 *  服務類
 * </p>
 *
 * @author BWH_Steven
 * @since 2020-10-02
 */
public interface UserService extends IService<User> {

}

service 實現類

package cn.ideal.test.service.impl;

import cn.ideal.test.entity.User;
import cn.ideal.test.mapper.UserMapper;
import cn.ideal.test.service.UserService;
import com.baomidou.mybatisplus.extension.service.impl.ServiceImpl;
import org.springframework.stereotype.Service;

/**
 * <p>
 *  服務實現類
 * </p>
 *
 * @author BWH_Steven
 * @since 2020-10-02
 */
@Service
public class UserServiceImpl extends ServiceImpl<UserMapper, User> implements UserService {

}

mapper

package cn.ideal.test.mapper;

import cn.ideal.test.entity.User;
import com.baomidou.mybatisplus.core.mapper.BaseMapper;

/**
 * <p>
 *  Mapper 介面
 * </p>
 *
 * @author BWH_Steven
 * @since 2020-10-02
 */
public interface UserMapper extends BaseMapper<User> {

}

mapper XML

<?xml version="1.0" encoding="UTF-8"?>
<!DOCTYPE mapper PUBLIC "-//mybatis.org//DTD Mapper 3.0//EN" "http://mybatis.org/dtd/mybatis-3-mapper.dtd">
<mapper namespace="cn.ideal.test.mapper.UserMapper">

</mapper>

六 結尾

如果文章中有什麼不足,歡迎大家留言交流,感謝朋友們的支援!

如果能幫到你的話,那就來關注我吧!如果您更喜歡微信文章的閱讀方式,可以關注我的公眾號

在這裡的我們素不相識,卻都在為了自己的夢而努力 ❤

一個堅持推送原創開發技術文章的公眾號:理想二旬不止

相關文章