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

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

一 引言

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

再到後再使用 Mybatis 等一些優秀的持久層框架,大大的簡化了開發,咱們只須要使用必定的 XML 或者註解就能夠完成原來的工做java

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

二 初識 MyBatis-Plus

下列介紹來自官網:web

(一) 概述

MyBatis-Plus(簡稱 MP)是一個 MyBatis 的加強工具,在 MyBatis 的基礎上只作加強不作改變,爲簡化開發、提升效率而生。算法

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

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

(二) 特性

  • 無侵入:只作加強不作改變,引入它 不會 對現有工程產生影響,如絲般順滑
  • 損耗小:啓動即會自動注入基本 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、DB二、H二、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> {

}

自行建立一個數據庫便可,而後導入官網給出的案例表,而後插入以下數據apache

-- ----------------------------
-- 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>

六 結尾

若是文章中有什麼不足,歡迎你們留言交流,感謝朋友們的支持!

若是能幫到你的話,那就來關注我吧!若是您更喜歡微信文章的閱讀方式,能夠關注個人公衆號

在這裏的咱們素不相識,卻都在爲了本身的夢而努力 ❤

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

相關文章
相關標籤/搜索