MybatisPlus?構(gòu)造器wrapper的使用與原理解析

上一次我們給大家講解了何為MybatisPlus,我們使用它的目的以及它的主要特性。不過,對于一線開發(fā)而言,如何在代碼中使用上它的特性才是重中之重,本期我們就來好好講一下 MybatisPlus 中的條件構(gòu)造器
一、構(gòu)造器的分類
我們還是使用一張老圖來說明

- 構(gòu)造器都有一個核心父類=
AbstractWrapper=,其他的構(gòu)造器都是它的子類,現(xiàn)在兩種分類方式,一種分類是用途; - 即
查詢或更新構(gòu)造器,另一種則是按使用方式,即一般或lambda構(gòu)造器。 - 兩種分類交錯,最后我們就看到了四個構(gòu)造器實現(xiàn)類。
1. AbstractWrapper 的作用
作為所有條件構(gòu)造器的父類,AbstractWrapper 肩負著絕大部分的功能,來幫助我們實現(xiàn)各類復(fù)雜的SQL. 它實現(xiàn)了下面幾個接口:
Compare:
-定義了一組方法用于比較操作,包括等于(eq)、不等于(ne)、大于(gt)
Nested:
-定義了一組方法用于構(gòu)建嵌套條件,即在查詢條件中可以使用括號包裹的子條件
Join:
-用于實現(xiàn)表的關(guān)聯(lián)查詢,通過指定關(guān)聯(lián)條件、連接方式,可以將多個表的數(shù)據(jù)進行關(guān)聯(lián)查詢。
Func :
-定義了一組方法用于構(gòu)建SQL函數(shù)表達式,包括COUNT、SUM、AVG、MAX、MIN等函數(shù)
2. 普通構(gòu)造器與lambda構(gòu)造器
我們首先看帶不帶lambda有什么區(qū)別,其實兩者幾乎一致,我們以查詢?yōu)槔簿褪菍Ρ纫幌?code>LambdaQueryWrapper和QueryWrapper ,不難發(fā)現(xiàn),兩者的差異重點是因為兩人實現(xiàn)Query接口的定義不一樣


普通的構(gòu)造器實現(xiàn)query,定義了只能使用String,而Lambda構(gòu)造器的入?yún)t是允許是一個function函數(shù)。這樣的區(qū)別,將導(dǎo)致兩種不同的寫法,見如下:
// 普通構(gòu)造器
QueryWrapper<CsdnUserInfo> wrapper = new QueryWrapper<>();
wrapper.eq("is_delete", 0);
wrapper.orderByDesc("user_weight");
return this.list(wrapper);
// lambda構(gòu)造器
LambdaQueryWrapper<CsdnUserInfo> lambdaWrapper = new LambdaQueryWrapper<>();
lambdaWrapper.eq(CsdnUserInfo::getIsDelete, 0);
lambdaWrapper.orderByDesc(CsdnUserInfo::getUserWeight);
return this.list(lambdaWrapper);普通構(gòu)造器在使用時,字段名只能直接寫字符串,而lambda構(gòu)造器只能寫方法。因為直接寫字符串出錯了不容易發(fā)現(xiàn),因此推薦大家還是盡量使用lambda構(gòu)造器,以方便在編碼階段就減少錯漏可能性
3. query構(gòu)造器與update構(gòu)造器
首先兩者因為都繼承了 AbstractWrapper 的,所以大部分的SQL功能兩者是都具備的,比如SQL中where子句、join、排序那些東西。兩者最大的區(qū)別是它們分別實現(xiàn)了兩個不同的接口,一個繼承Query,一個繼承Update


不難看出,兩者核心的不同就是語法的區(qū)別,查詢哪些字段或更新哪些字段
二、使用方式
關(guān)于構(gòu)造器的基本方法,內(nèi)容非常多,我們不再做搬運工,大家可以直接去下面的官網(wǎng)看方法的文檔:條件構(gòu)造器文檔

1. 基礎(chǔ)使用
我們以舉例說明,比方說現(xiàn)在我們想要實現(xiàn)一個查詢功能,根據(jù)以下條件來獲取學(xué)生的信息:
性別為男性; 年齡在18到20歲之間; 成績大于80分; 班級為A班,然后按成績由高到底排
使用QueryWrapper來實現(xiàn)這個查詢的代碼如下
QueryWrapper<Student> queryWrapper = new QueryWrapper<>();
queryWrapper.eq("gender", "男性")
.between("age", 18, 20)
.gt("score", 80)
.eq("class", "A班")
.orderByDesc("score");
List<Student> students = studentMapper.selectList(queryWrapper);又比如說 我們想將姓名為"張三"的學(xué)生年齡更新為20。則可以使用UpdateWrapper實現(xiàn)
String name = "張三";
Integer newAge = 20;
UpdateWrapper<Student> updateWrapper = new UpdateWrapper<>();
updateWrapper.eq("name", name)
.set("age", newAge);
int result = studentMapper.update(null, updateWrapper);2. 易錯點-邏輯范圍
由于條件構(gòu)造器的語法非常符合自然語言,所以有的時候反而讓人疏忽,我們?nèi)耘e一個例子,比如我們想獲取這樣的用戶
用戶狀態(tài)為有效的,名字為 張三 或 李四 或 王五 的用戶
你可能想當(dāng)然寫成如下的樣子
// 錯誤寫法
Set<String> set = new HashSet<>();
set.add("zhangsan");
set.add("lisi");
set.add("wangwu");
LambdaQueryWrapper<CsdnUserInfo> wrapper = new LambdaQueryWrapper<>();
wrapper.eq(CsdnUserInfo::getIsDelete, 0);
for (String name : set) {
wrapper.or(item -> item.eq(CsdnUserInfo::getUserName, name));
}
return this.list(wrapper);但事實上,最終預(yù)編譯產(chǎn)生的SQL是這樣的:
SELECT id,user_name,nick_name,like_status,collect_status,comment_status,user_weight,user_home_url,curr_blog_url,article_type,create_time,update_time,is_delete FROM csdn_user_info WHERE (is_delete = ? OR (user_name = ?) OR (user_name = ?) OR (user_name = ?))
不難發(fā)現(xiàn)這樣我們用戶狀態(tài)篩選 和 用戶名篩選 用 or 關(guān)聯(lián)起來了,這樣其實就會查出所有有效的用戶,不符合我們的預(yù)期。產(chǎn)生這種問題的原因,其實是少用了個括號。我們應(yīng)該把用戶名的 or 限制在一個括號內(nèi),不能讓它擴散出去。
此時我們可以使用 nested 來修復(fù)這個問題,把我們的for循環(huán)扔進 nested 中
Set<String> set = new HashSet<>();
set.add("zhangsan");
set.add("lisi");
set.add("wangwu");
wrapper.eq(CsdnUserInfo::getIsDelete, 0);
wrapper.nested(wp -> {
for (String name : set) {
wp.or(item -> item.eq(CsdnUserInfo::getUserName, name));
}
});
return this.list(wrapper);這樣最后的編譯的SQL是這樣的
SELECT id,user_name,nick_name,like_status,collect_status,comment_status,user_weight,user_home_url,curr_blog_url,article_type,create_time,update_time,is_delete FROM csdn_user_info WHERE (is_delete = ? AND ((user_name = ?) OR (user_name = ?) OR (user_name = ?)))
3. 易錯點-null處理
使用 MybatisPlus 還有一個坑點容易被忽略,那就是 null 值的處理,比如我們在插入或更新一條數(shù)據(jù)時,如果我們對象內(nèi)某一個字段為null,這個null值可能不會插入或更新進表中。如果 null 在你的表中有業(yè)務(wù)意義,此刻就格外需要注意了。
比如我們想把張三的昵稱置為空,寫了這么一段代碼
CsdnUserInfo user = new CsdnUserInfo();
user.setId(99999);
user.setUserName("張三");
user.setNickName(null);
csdnUserInfoService.updateById(user);但我們的目的是不會生效的,因為此時 NickName 根本不會更新,它的SQL是這樣的
UPDATE csdn_user_info SET user_name=? WHERE id=?
此時我們可以在實體類上加一個注解 @TableField(updateStrategy= FieldStrategy.IGNORED) 這是因為對字段的變更默認會有空值校驗,只有顯示的指定為 忽略校驗 才能把空值更新進表中

經(jīng)過這樣的改動,我們再來看看編譯的SQL
UPDATE csdn_user_info SET user_name=?, nick_name=? WHERE id=?
nick_name 的 null 就可以成功更新進表中了。
三、生效原理
如果你看過我們之前的《springboot基于Mybatis mysql實現(xiàn)讀寫分離》,就不難理解,我們其實可以把整個 MybatisPlus 分為項目啟動階段做的準(zhǔn)備階段,和真正要執(zhí)行某段SQL的執(zhí)行階段。準(zhǔn)備階段就是把我們寫在xml的SQL進行解析,構(gòu)造出一個個映射聲明(Mappedstatement),里面包含了我們的SQL主體。執(zhí)行階段則是通過方法全稱找到自己的映射聲明(Mappedstatement),對其進行拼接形成真正的可執(zhí)行SQL。
那在MybatisPlus下,我們明明沒寫SQL,SQL又是從哪來的呢?
正如上面說的,比如一個簡單的插入,我們沒有寫SQL,甚至連xml文件都沒有,而是直接使用的BaseMapper 中的 insert 方法。

所以在啟動的時候,如下面的 service其實就在做準(zhǔn)備工作了,如果說我們以前的xml文件寫了現(xiàn)成的SQL語句,從而解析XML文件內(nèi)容,產(chǎn)生mappedStatement,
而像下面這種service其實也是這樣,不過他多了一步,它得先把service翻譯成xml格式,然后再來轉(zhuǎn)成mappedStatement。
@Service
public class AlgorithmicProblemServiceImpl extends ServiceImpl<AlgorithmicProblemMapper, AlgorithmicProblem> implements AlgorithmicProblemService {
}就拿上面的 insert 舉例,MybatisPlus 中有一個 insert 類,即 com.baomidou.mybatisplus.core.injector.methods.Insert 其中就有關(guān)鍵方法

1. 獲取語法模板SQL
所謂獲取模板SQL,其實就是在MybatisPlus中內(nèi)置了很多方法的基本語法,這些內(nèi)容只要在填上對應(yīng)的表名、字段名就能構(gòu)成一個基礎(chǔ) SQL 的輪廓了

還是以我們的插入語句為例,比如我們想插入一條數(shù)據(jù),它的基礎(chǔ)語法模板就是
"<script>\nINSERT INTO %s %s VALUES %s\n</script>"
其中三個 ‘%s’ 就是待定的表名和字段名信息,將在后面代入表信息
2. 代入表及字段信息
我們上面看到了這樣一句話,它的作用就是根據(jù)語法模板,和表信息,構(gòu)造出一個基礎(chǔ)的SQL
String sql = String.format(sqlMethod.getSql(), tableInfo.getTableName(), columnScript, valuesScript);
上面的第一個入?yún)?sqlMethod.getSql() 就是我們上面說的語法模板,第二個入?yún)⑹潜砻?,三和四字段則是根據(jù)字段名拼出來的SQL,表名的話,我們在實體類里已經(jīng)寫了,即 @TableName 注解后面的內(nèi)容
@Data
@ApiModel("算法題實體類")
@TableName("algorithmic_problem")
public class AlgorithmicProblem extends Model<AlgorithmicProblem> {
@ApiModelProperty("主鍵id")
private Integer id;
@ApiModelProperty("問題名稱")
private String questionName;
@ApiModelProperty("問題類型")
private String questionType;
@ApiModelProperty("1~10的分值")
private Integer degreeOfImportance;
@ApiModelProperty("1:簡單;2:中等;3:困難")
private Integer degreeOfDifficulty;
@ApiModelProperty("困難指數(shù)")
private Integer difficultyOfScore;
@ApiModelProperty("力扣的問題號")
private Integer leetcodeNumber;
@ApiModelProperty("力扣的問題鏈接")
private String leetcodeLink;
@ApiModelProperty("標(biāo)簽")
private String tag;
@ApiModelProperty("創(chuàng)建時間")
private Date createTime;
@ApiModelProperty("邏輯刪除,0未刪除,1已刪除")
private Integer isDelete;
}而后面的 columnScript, valuesScript 則是根據(jù)我們的字段值,生成的一些腳本,其中包含了我們以前在mybatis 的 xml 文件中會寫的動態(tài)標(biāo)簽,這些標(biāo)簽的作用可以參考此片文章《數(shù)據(jù)庫操作不再困難,MyBatis動態(tài)Sql標(biāo)簽解析》


通過上面的操作,不難發(fā)現(xiàn),我們以前在XML文件里需要寫的 SQL 在此刻就被拼接出來了,它們形式是一樣的。需要注意的是,這里的字段全都是非空才會插入的。
<script>
INSERT INTO algorithmic_problem <trim prefix="(" suffix=")" suffixOverrides=",">
<if test="id != null">
id,
</if><if test="questionName != null">question_name,</if>
<if test="questionType != null">question_type,</if>
<if test="degreeOfImportance != null">degree_of_importance,</if>
<if test="degreeOfDifficulty != null">degree_of_difficulty,</if>
<if test="difficultyOfScore != null">difficulty_of_score,</if>
<if test="leetcodeNumber != null">leetcode_number,</if>
<if test="leetcodeLink != null">leetcode_link,</if>
<if test="tag != null">tag,</if>
<if test="createTime != null">create_time,</if>
<if test="isDelete != null">is_delete,</if>
</trim> VALUES <trim prefix="(" suffix=")" suffixOverrides=",">
<if test="id != null">
#{id},
</if><if test="questionName != null">#{questionName},</if>
<if test="questionType != null">#{questionType},</if>
<if test="degreeOfImportance != null">#{degreeOfImportance},</if>
<if test="degreeOfDifficulty != null">#{degreeOfDifficulty},</if>
<if test="difficultyOfScore != null">#{difficultyOfScore},</if>
<if test="leetcodeNumber != null">#{leetcodeNumber},</if>
<if test="leetcodeLink != null">#{leetcodeLink},</if>
<if test="tag != null">#{tag},</if>
<if test="createTime != null">#{createTime},</if>
<if test="isDelete != null">#{isDelete},</if>
</trim>
</script>3. 代入條件構(gòu)造器邏輯
上面的部分對于簡單的 insert來說,其實已經(jīng)夠用了,但是對于一些用戶的查詢或修改邏輯,比如我們在 servece 中寫的那些篩選條件,排序等,它們又是如何起作用的呢?這里我們要分兩個階段來說:啟動階段、執(zhí)行階段
1. 啟動階段的 “ew” 參數(shù)
首先,我們在代碼中寫的所有wrapper(條件構(gòu)造器),這些wrapper包含了篩選條件,排序規(guī)則之類的,其實最終都被視為一個參數(shù)“ew”(Entity Wrapper)放入BaseMapper中進行操作
// service 層
@Override
public List<CsdnUserInfo> allUser() {
QueryWrapper<CsdnUserInfo> wrapper = new QueryWrapper<>();
wrapper.eq("is_delete", 0);
wrapper.orderByDesc("user_weight");
return this.list(wrapper);
}
// IService 類
default List<T> list(Wrapper<T> queryWrapper) {
return this.getBaseMapper().selectList(queryWrapper);
}
// BaseMapper 類
List<T> selectList(@Param("ew") Wrapper<T> queryWrapper);這個ew參數(shù)在下面就是核心,我們上面提到了,在應(yīng)用啟動階段,經(jīng)過基本語法和表的代入后,可以形成一些基礎(chǔ)SQL腳本。而對于像selectList 這種,會帶有ew參數(shù)的,他們的基礎(chǔ)SQL就會比較復(fù)雜了,它們會在SQL中大量預(yù)留ew參數(shù)相關(guān)的內(nèi)容。比如selectList 最終就會生成如下的一大串腳本:
<script>
<if test="ew != null and ew.sqlFirst != null">
${ew.sqlFirst}
</if>
SELECT
<choose>
<when test="ew != null and ew.sqlSelect != null">
${ew.sqlSelect}
</when>
<otherwise>id,user_name,nick_name,like_status,collect_status,comment_status,user_weight,user_home_url,curr_blog_url,article_type,create_time,update_time,is_delete</otherwise>
</choose> FROM csdn_user_info
<if test="ew != null">
<where>
<if test="ew.entity != null">
<if test="ew.entity.id != null">id=#{ew.entity.id}</if>
<if test="ew.entity['userName'] != null"> AND user_name=#{ew.entity.userName}</if>
<if test="ew.entity['nickName'] != null"> AND nick_name=#{ew.entity.nickName}</if>
<if test="ew.entity['likeStatus'] != null"> AND like_status=#{ew.entity.likeStatus}</if>
<if test="ew.entity['collectStatus'] != null"> AND collect_status=#{ew.entity.collectStatus}</if>
<if test="ew.entity['commentStatus'] != null"> AND comment_status=#{ew.entity.commentStatus}</if>
<if test="ew.entity['userWeight'] != null"> AND user_weight=#{ew.entity.userWeight}</if>
<if test="ew.entity['userHomeUrl'] != null"> AND user_home_url=#{ew.entity.userHomeUrl}</if>
<if test="ew.entity['currBlogUrl'] != null"> AND curr_blog_url=#{ew.entity.currBlogUrl}</if>
<if test="ew.entity['articleType'] != null"> AND article_type=#{ew.entity.articleType}</if>
<if test="ew.entity['createTime'] != null"> AND create_time=#{ew.entity.createTime}</if>
<if test="ew.entity['updateTime'] != null"> AND update_time=#{ew.entity.updateTime}</if>
<if test="ew.entity['isDelete'] != null"> AND is_delete=#{ew.entity.isDelete}</if>
</if>
<if test="ew.sqlSegment != null and ew.sqlSegment != '' and ew.nonEmptyOfWhere">
<if test="ew.nonEmptyOfEntity and ew.nonEmptyOfNormal"> AND </if>
${ew.sqlSegment}
</if>
</where>
<if test="ew.sqlSegment != null and ew.sqlSegment != '' and ew.emptyOfWhere">
${ew.sqlSegment}
</if>
</if>
<if test="ew != null and ew.sqlComment != null">
${ew.sqlComment}
</if>
</script>這里面,我們看到這段SQL中,涉及ew參數(shù)的有這么幾個非常重要的元素:
ew.sqlFirst:表示SQL片段,可以自定義SQL片段放在SQL的最開始位置ew.sqlSelect:表示要查詢的字段,默認為"*",即查詢所有字段??梢酝ㄟ^該屬性指定要查詢的字段,多個字段之間用逗號分隔ew.entity:表示要查詢的實體對象??梢酝ㄟ^該屬性指定要查詢的實體對象,可以根據(jù)實體對象的屬性進行條件封裝ew.sqlSegment:表示SQL片段,可以自定義SQL片段,用于在動態(tài)SQL中添加自定義的SQL語句ew.sqlComment:表示SQL注釋,可以為SQL語句添加注釋。可以通過該屬性指定要為SQL語句添加的注釋內(nèi)容。
2. 執(zhí)行階段的 “ew” 參數(shù)
以我們上面提到過的代碼為例
public List<CsdnUserInfo> allUser() {
QueryWrapper<CsdnUserInfo> wrapper = new QueryWrapper<>();
wrapper.eq("is_delete", 0);
wrapper.orderByDesc("user_weight");
return this.list(wrapper);
}這樣的 wrapper 最終會形成這樣一個對象,我們?yōu)樗砑恿藘蓚€屬性,即

有些眼尖的同學(xué)發(fā)現(xiàn)了,xml中用的是 ew.sqlSegment, 而我們這里的條件全部都進了 ew.expression 中,這兩也沒對上啊。其實在條件構(gòu)造器中 ew.sqlSegment = expression.getSqlSegment + lastSql
// AbstractWrapper
public String getSqlSegment() {
return this.expression.getSqlSegment() + this.lastSql.getStringValue();
}而 expression.getSqlSegment() 又是其下的各個小 Segment 拼出來的
this.sqlSegment = this.normal.getSqlSegment() + this.groupBy.getSqlSegment() + this.having.getSqlSegment() + this.orderBy.getSqlSegment();
如此一來,利用ognl,篩選條件也被我們拼湊出來了,最后拼接出 ew.sqlSegment 實際解析成為了一個字符串
(is_delete = #{ew.paramNameValuePairs.MPGENVAL1}) ORDER BY user_weight DESC這個時候,又有眼尖的同學(xué)發(fā)現(xiàn)了,我們在代碼中明明已經(jīng)寫死了 is_delete = 0,為什么解析完卻成了 is_delete = #{ew.paramNameValuePairs.MPGENVAL1}
實際上這里有一個兼容的作用,我們可以在業(yè)務(wù)代碼里寫 wrapper.eq(“is_delete”, 0),也可以填上一個未知的變量 wrapper.eq(“is_delete”, var1),我們都知道為了防止SQL注入,實際上對于入?yún)⑽覀兌际窃谧詈蟛盘峤贿MSQL中的,不可能因為你是寫死的0,就提前把0填寫在這里。所以,此處會先把你填的東西存放在 paramNameValuePairs 參數(shù)鍵值對中,再后面提交SQL的時候才取出來。其源碼如下:
// AbstractWrapper
// 把一個條件翻譯sqlSegment,以上述wrapper.eq("is_delete", 0)為例,此處column 為字符串“is_delete” ;sqlKeyword 為EQ的枚舉值,會被翻譯成字符串,最后的參數(shù)值則會被翻譯成成如 {ew.paramNameValuePairs.MPGENVAL}=
protected Children addCondition(boolean condition, R column, SqlKeyword sqlKeyword, Object val) {
return this.maybeDo(condition, () -> {
this.appendSqlSegments(this.columnToSqlSegment(column), sqlKeyword, () -> {
return this.formatParam((String)null, val);
});
});
}
protected final String formatParam(String mapping, Object param) {
String genParamName = "MPGENVAL" + this.paramNameSeq.incrementAndGet();
String paramStr = this.getParamAlias() + ".paramNameValuePairs." + genParamName;
this.paramNameValuePairs.put(genParamName, param);
return SqlScriptUtils.safeParam(paramStr, mapping);
}那么現(xiàn)在我們已經(jīng)擁有了兩件東西:1.一個大而全的基礎(chǔ)SQL語句(里面依賴了大量的ew內(nèi)容),2.一個完整的“ew”對象。同時擁有這兩者后,就能夠利用 Mybatis 的動態(tài)解析能力去構(gòu)建出一個真正的可執(zhí)行的SQL了
總結(jié)
本次我們介紹了MybatisPlus 構(gòu)造器wrapper的使用方式及其易錯點,同時也針對其運行的原理進行了解釋,只有深刻理解了它的原理,我們才能更靈活的使用,并且更快的排查出問題。所以也希望大家能結(jié)合源碼再思考一下,以便更好地掌握這部分內(nèi)容。
到此這篇關(guān)于MybatisPlus 構(gòu)造器wrapper的使用與原理的文章就介紹到這了,更多相關(guān)MybatisPlus 構(gòu)造器wrapper內(nèi)容請搜索腳本之家以前的文章或繼續(xù)瀏覽下面的相關(guān)文章希望大家以后多多支持腳本之家!
相關(guān)文章
Spring創(chuàng)建Bean的多種方式對比與最佳實踐
本文詳細梳理了Spring項目中Bean的六種常見創(chuàng)建方式(@Component、@Bean、FactoryBean、@Import、XML、運行時注冊),對每種適用場景、優(yōu)缺點與最佳實踐進行了總結(jié),并給出了選擇方法的決策清單與常見誤區(qū)規(guī)避建議,需要的朋友可以參考下2025-10-10
SpringBoot與其他框架集成時的版本沖突問題排查與解決全流程
解決 Spring Boot 與其他框架集成的版本沖突,核心思路是利用 Spring Boot 的版本仲裁機制鎖定依賴版本,顯式控制沖突依賴,排查沖突根源,以下是具體步驟、方法和實戰(zhàn)案例,覆蓋從預(yù)防到排查再到解決的全流程,需要的朋友可以參考下2025-11-11
Java實現(xiàn)儲存對象并按對象某屬性排序的幾種方法示例
這篇文章主要介紹了Java實現(xiàn)儲存對象并按對象某屬性排序的幾種方法,結(jié)合實例形式詳細分析了Java儲存對象并按對象某屬性排序的具體實現(xiàn)方法與操作注意事項,需要的朋友可以參考下2020-05-05
基于Springboot商品進銷存管理系統(tǒng)的設(shè)計與實現(xiàn)
本項目基于springboot實現(xiàn)一個進銷存管理系統(tǒng),主要用戶開設(shè)網(wǎng)店的相關(guān)商品的進貨、銷售、庫存的管理,功能比較完整,需要的可以參考一下2022-08-08
spring-boot整合Micrometer+Prometheus的詳細過程
這篇文章主要介紹了springboot整合Micrometer+Prometheus的詳細過程,本文通過實例代碼給大家介紹的非常詳細,對大家的學(xué)習(xí)或工作具有一定的參考借鑒價值,需要的朋友參考下吧2024-05-05

