Mybatis增強型註解簡化SQL語句

1. 背景

MyBatis提供了簡單的Java註解,使得我們可以不配置XML格式的Mapper文件,也能方便的編寫簡單的數據庫操作代碼:

public interface UserMapper {
  @Select("SELECT * FROM users WHERE id = #{userId}")
  User getUser(@Param("userId") String userId);
}

但是註解對動態SQL的支持一直差強人意,即使MyBatis提供了InsertProvider等*Provider註解來支持註解的Dynamic SQL,也沒有降低SQL的編寫難度,甚至比XML格式的SQL語句更難編寫和維護。 
註解的優勢在於能清晰明瞭的看見接口所使用的SQL語句,拋棄了繁瑣的XML編程方式。但沒有良好的動態SQL支持,往往就會導致所編寫的DAO層中的接口冗餘,所編寫的SQL語句很長,易讀性差…… 
Mybatis在3.2版本之後,提供了LanguageDriver接口,我們可以使用該接口自定義SQL的解析方式。故在這裏向大家介紹下以此來實現註解方式下的動態SQL。

2. 實現方案

我們先來看下LanguageDriver接口中的3個方法:

public interface LanguageDriver {
    ParameterHandler createParameterHandler(MappedStatement var1, Object var2, BoundSql var3);

    SqlSource createSqlSource(Configuration var1, XNode var2, Class<?> var3);

    SqlSource createSqlSource(Configuration var1, String var2, Class<?> var3);
}
  1. createParameterHandler方法爲創建一個ParameterHandler對象,用於將實際參數賦值到JDBC語句中
  2. 將XML中讀入的語句解析並返回一個sqlSource對象
  3. 將註解中讀入的語句解析並返回一個sqlSource對象

一旦實現了LanguageDriver,我們即可指定該實現類作爲SQL的解析器,在XML中我們可以使用 lang 屬性來進行指定

<typeAliases>
  <typeAlias type="org.sample.MyLanguageDriver" alias="myLanguage"/>
</typeAliases>

<select id="selectBlog" lang="myLanguage">
  SELECT * FROM BLOG
</select>

也可以通過設置指定解析器的方式:

<settings>
  <setting name="defaultScriptingLanguage" value="myLanguage"/>
</settings>

如果不使用XML Mapper的形式,我們可以使用@Lang註解

public interface Mapper {
  @Lang(MyLanguageDriver.class) 
  @Select("SELECT * FROM users")
  List<User> selectUser();
}

LanguageDriver的默認實現類爲XMLLanguageDriver和RawLanguageDriver;分別爲XML和Raw,Mybatis默認是XML語言,所以我們來看看XMLLanguageDriver中是怎麼實現的:

public class XMLLanguageDriver implements LanguageDriver {
    public XMLLanguageDriver() {
    }

    public ParameterHandler createParameterHandler(MappedStatement mappedStatement, Object parameterObject, BoundSql boundSql) {
        return new DefaultParameterHandler(mappedStatement, parameterObject, boundSql);
    }

    public SqlSource createSqlSource(Configuration configuration, XNode script, Class<?> parameterType) {
        XMLScriptBuilder builder = new XMLScriptBuilder(configuration, script, parameterType);
        return builder.parseScriptNode();
    }

    public SqlSource createSqlSource(Configuration configuration, String script, Class<?> parameterType) {
        if(script.startsWith("<script>")) {
            XPathParser textSqlNode1 = new XPathParser(script, false, configuration.getVariables(),
                                       new XMLMapperEntityResolver());
            return this.createSqlSource(configuration, textSqlNode1.evalNode("/script"), parameterType);
        } else {
            script = PropertyParser.parse(script, configuration.getVariables());
            TextSqlNode textSqlNode = new TextSqlNode(script);
            return (SqlSource)(textSqlNode.isDynamic()?new DynamicSqlSource(configuration, textSqlNode)
                   :new RawSqlSource(configuration, script, parameterType));
        }
    }
}

發現其實mybatis已經幫忙寫好了解析邏輯,而且發現如果是以< script>開頭的字符串傳入後,會被以XML的格式進行解析。那麼方案就可以確認了,我們繼承XMLLanguageDriver這個類,並且重寫其createSqlSource方法,按照自己編寫邏輯解析好sql後,再調用父類的方法即可。

3. 實現自定義註解

本段中給出一些常見的自定義註解的實現和使用方式。

3.1 自定義Select In註解

在使用Mybatis註解的時候,發現其對Select In格式的查詢支持非常不友好,在字符串中輸入十分繁瑣,可以通過將自定義的標籤轉成格式;下面便通過我們自己實現的LanguageDriver來實現SQL的動態解析:

DAO接口層中代碼如下:

@Select("SELECT * FROM users WHERE id IN (#{userIdList})")
@Lang(SimpleSelectInLangDriver.class)
List<User> selectUsersByUserId(List<Integer> userIdList);

LanguageDriver實現類如下:

// 一次編寫即可
public class SimpleSelectInLangDriver extends XMLLanguageDriver implements LanguageDriver {

    private static final Pattern inPattern = Pattern.compile("\\(#\\{(\\w+)\\}\\)");

    @Override
    public SqlSource createSqlSource(Configuration configuration, String script, Class<?> parameterType) {

        Matcher matcher = inPattern.matcher(script);
        if (matcher.find()) {
            script = matcher.replaceAll("<foreach collection=\"$1\" item=\"_item\" open=\"(\" " +
                                        "separator=\",\" close=\")\" >#{_item}</foreach>");
        }


        script = "<script>" + script + "</script>";
        return super.createSqlSource(configuration, script, parameterType);
    }
}

通過自己實現LanguageDriver,在服務器啓動的時候,就會將我們自定義的標籤解析爲動態SQL語句,其等同於:

通過實現LanguageDriver,剝離了冗長的動態SQL語句,簡化了Select In的註解代碼。

需要注意的是在使用Select In的時候,請務必在傳入的參數前加@Param註解,否則會導致Mybatic找不到參數而拋出異常。

3.2 自定義Update Bean註解

在擴展update註解時,數據庫每張表的字段和實體類的字段必須遵循一個約定(數據庫中採用下劃線命名法,實體類中採用駝峯命名法)。當我們update的時候,會根據每個字段的映射關係,寫出如下代碼:

<update id="updateUsersById" parameterType="com.lucifer.bean.User">
    UPDATE users
        <set>
        <if test=“userName != null">
            user_name = #{userName} ,
        </if>
        <if test=“password != null">
            password = #{password} ,
        </if>
        <if test=“phone != null">
            phone = #{phone},
        </if>
        <if test=“email != null">
            email = #{email},
        </if>
        <if test=“address != null">
            address = #{address},
        </if>
        <if test="gmtCreated != null">
            gmt_created = #{gmtCreated},
        </if>
        <if test="gmtModified != null">
            gmt_modified = #{gmtModified},
        </if>
    </set>
    WHERE id = #{id}
</update> 

我們可以將實體類中的駝峯式代碼轉換爲下劃線式命名方式,這樣就可以將這種映射規律自動化 
經過實現LanguageDriver後,註解代碼爲

@Update("UPDATE users (#{user}) WHERE id = #{id}")
@Lang(SimpleUpdateLangDriver.class)
void updateUsersById(User user);

相對於原始的代碼量有很大的減少,並且,一個類中字段越多,改善也就越明顯。實現方式爲:

public class SimpleUpdateLangDriver extends XMLLanguageDriver implements LanguageDriver{

    private final Pattern inPattern = Pattern.compile("\\(#\\{(\\w+)\\}\\)");

    @Override
    public SqlSource createSqlSource(Configuration configuration, String script, Class<?> parameterType) {
        Matcher matcher = inPattern.matcher(script);
        if (matcher.find()) {
            StringBuilder sb = new StringBuilder();
            sb.append("<set>");

            for (Field field : parameterType.getDeclaredFields()) {
                String tmp = "<if test=\"_field != null\">_column=#{_field},</if>";
                sb.append(tmp.replaceAll("_field", field.getName()).replaceAll("_column",
                        CaseFormat.LOWER_CAMEL.to(CaseFormat.LOWER_UNDERSCORE, field.getName())));
            }

            sb.deleteCharAt(sb.lastIndexOf(","));
            sb.append("</set>");

            script = matcher.replaceAll(sb.toString());
            script = "<script>" + script + "</script>";
        }

        return super.createSqlSource(configuration, script, parameterType);
    }
}

3.3 自定義Insert Bean註解

同理,我們可以抽象化Insert操作,簡化後的Insert註解爲

@Insert("INSERT INTO users (#{user})")
@Lang(SimpleInsertLangDriver.class)
void insertUserDAO(User user);

實現方式爲:

public class SimpleInsertLangDriver extends XMLLanguageDriver implements LanguageDriver {

    private final Pattern inPattern = Pattern.compile("\\(#\\{(\\w+)\\}\\)");

    @Override
    public SqlSource createSqlSource(Configuration configuration, String script, Class<?> parameterType) {

        Matcher matcher = inPattern.matcher(script);
        if (matcher.find()) {
            StringBuilder sb = new StringBuilder();
            StringBuilder tmp = new StringBuilder();
            sb.append("(");

            for (Field field : parameterType.getDeclaredFields()) {
                sb.append(CaseFormat.LOWER_CAMEL.to(CaseFormat.LOWER_UNDERSCORE, field.getName()) + ",");
                tmp.append("#{" + field.getName() + "},");
            }

            sb.deleteCharAt(sb.lastIndexOf(","));
            tmp.deleteCharAt(tmp.lastIndexOf(","));
            sb.append(") values (" + tmp.toString() + ")");

            script = matcher.replaceAll(sb.toString());
            script = "<script>" + script + "</script>";
        }

        return super.createSqlSource(configuration, script, parameterType);
    }
}

3.4 自定義Select註解

有的業務場景下,我們需要根據對象中的字段進行查詢,就會寫出如下代碼:

<select id="selectUser" resultType="com.lucifer.bean.User">
    SELECT id,user_name,password,phone,address,email
    FROM users
    <where>
        <if test="isDel != null">
            AND is_del = #{isDel}
        </if>
        <if test="userName != null">
            AND user_name = #{userName}
        </if>
        <if test="email != null">
            AND email = #{email}
        </if>
        <if test="phone != null">
            AND phone = #{phone}
        </if>
    </where>
</select>
  •  

和Update操作一樣,我們可以實現LanguageDriver將where子句抽象化,以此來簡化Select查詢語句。簡化後代碼如下:


@Select("SELECT id,user_name,password,phone,address,email FROM users (#{user})")
@Lang(SimpleSelectLangDriver.class)
void selectUserDAO(User user);
public class SimpleSelectLangDriver extends XMLLanguageDriver implements LanguageDriver {

    private final Pattern inPattern = Pattern.compile("\\(#\\{(\\w+)\\}\\)");

    @Override
    public SqlSource createSqlSource(Configuration configuration, String script, Class<?> parameterType) {

        Matcher matcher = inPattern.matcher(script);
        if (matcher.find()) {
            StringBuilder sb = new StringBuilder();
            sb.append("<where>");

            for (Field field : parameterType.getDeclaredFields()) {
                String tmp = "<if test=\"_field != null\">AND _column=#{_field}</if>";
                sb.append(tmp.replaceAll("_field", field.getName()).replaceAll("_column",
                        CaseFormat.LOWER_CAMEL.to(CaseFormat.LOWER_UNDERSCORE, field.getName())));
            }

            sb.append("</where>");

            script = matcher.replaceAll(sb.toString());
            script = "<script>" + script + "</script>";
        }

        return super.createSqlSource(configuration, script, parameterType);
    }
}

4.排除多餘的變量

一個常見的情況是,可能會遇到實體類中的部分字段在數據庫中並不存在相應的列,這就需要對多餘的不匹配的字段進行邏輯隱藏;我們增加一個自定義的註解,並且對Language的實現稍作修改即可。 
註解爲:

@Target(ElementType.FIELD)
@Retention(RetentionPolicy.RUNTIME)
public @interface Invisible {
}
public class User {
    ...

    @Invisible
    private List<String> userList;


    ...
}

然後在實現類中將被該註解聲明過的字段排除

for (Field field : parameterType.getDeclaredFields()) {
    if (!field.isAnnotationPresent(Invisible.class)) {  // 排除被Invisble修飾的變量
        String tmp = "<if test=\"_field != null\">_column=#{_field},</if>";
        sb.append(tmp.replaceAll("_field", field.getName()).replaceAll("_column",
                CaseFormat.LOWER_CAMEL.to(CaseFormat.LOWER_UNDERSCORE, field.getName())));
    }
}

5.注意事項&遇到的一些坑

  1. 務必確保數據庫中列名和實體類中字段能一一對應
  2. 在使用自定義SQL解析器的時候,只能傳入一個參數,即相應的對象參數即可;傳入多個參數會導致解析器中獲得到的class對象改變,使得sql解析異常
  3. Update的實現能滿足大部分的業務,但有些業務場景可以會遇到根據查詢條件來更新查詢參數的情況,比如Update uesrs SET uesr_name = ‘tom’ WHERE user_name = ‘Jack’; 在這中場景的時候請不要使用自定義的SQL解析器
  4. 請使用Mybatis 3.3以上版本。3.2版本有bug,會另開一篇重新描述

6.總結

通過實現Language Driver,我們可以方便的自定義自己的註解。在遵循一些約定的情況下(數據庫下劃線命名,實體駝峯命名),我們可以大幅度的減少SQL的編寫量,並且可以完全的屏蔽掉麻煩的XML編寫方式,再也不用編寫複雜的動態SQL了有木有

// 簡潔的數據庫操作

@Select("SELECT * FROM users WHERE id IN (#{userIdList})")
@Lang(SimpleSelectInLangDriver.class)
List<User> selectUsersByUserId(List<Integer> userIdList);


@Insert("INSERT INTO users (#{user})")
@Lang(SimpleInsertLangDriver.class)
void insertUserDAO(User user);


@Update("UPDATE users (#{user}) WHERE id = #{id}")
@Lang(SimpleUpdateLangDriver.class)
void updateUsersById(User user);

@Select("SELECT id,user_name,password,phone,address,email FROM users (#{user})")
@Lang(SimpleSelectLangDriver.class)
void selectUserDAO(User user);
發表評論
所有評論
還沒有人評論,想成為第一個評論的人麼? 請在上方評論欄輸入並且點擊發布.
相關文章