MyBatis – make interface more flexible by Interceptor

1. Background

Under micro-service architecture, we always expose our services for other micro-services by interfaces(usually REST style), and we also often face the challenge of speedy iterating our interface to meet fast requirement iteration. For instance, there is a interface of user-service(provide user info service) supplies function of querying user name, user phone and user address, and currently, it can meet the querying requirement of order-sevice. But some time later, another service, pay-service need obtain user bank card number and user ID number from user-service, so, above interface is powerless.

There are usually two ways to solve above problem. One is to create a new interface for pay-service to obtain user bank card number and user ID number, the other is to upgrade original interface by adding new fields in response. Both of the two ways have their own shortcomings.

  • For the first way, you cannot always create different new interfaces of a service for different other invoker services, because the interfaces just provide same kind of information(user info). If you do that, I think it’s a nightmare to maintain the interfaces.:(
  • For the second way, it is also a nightmare, because upgrading(or changing) an existing interface always brings some unexpected compatibility problems, and the cost, usually out of your control, specially for some based service(user-service, auth-service etc.).

So, is there a way to provide the information is only needed by other serivce through single interface without any changing it? In other words, how to design a flexible interface to provide on-demand capability, espacially for common service.

NOTE : the ORM framework for our common service is MyBatis, it’s important to know this.

Next, according to my work experiene, I will introduce a possible solution try to solve above problem. We make a upper interface more flexible by the underlying ORM of MyBatis.

2. Profile of interceptor of MyBatis

2.1. The function of interceptor

The function of interceptor of MyBatis is like a proxy, it can get the mapped statement from MyBatis mapping xml file before actually execute JDBC statement(SQL command), then, this time, you can do some customized operation for the mapped statement, e.g., modify the original SQL command as you like. This can make SQL operation more flexible.

2.2. The theory of interceptor

Even if the theory of interceptor is not focus of this post, but I still want give a few introduction of it.

The base technology of interceptor is dynamic proxy of Java, MyBatis implements “java.lang.reflect.InvocationHandler” for its Plugin, then you can customize your own interceptor by the Plugin. Your customized interceptor will be added into a interceptor chain to be executed.

MyBatis Plugin : https://mybatis.org/mybatis-3/configuration.html#plugins

3. Implement

There is a common service as my example, we still name it as user-service, and there is a interface of the serivce to get user info.

3.1. Interface Specification

URL : /v1/user/info?token={token}&uuid={uuid}&filter={filter}
HTTP : GET
Content-Type : application/json
Request Params : 
paramtypemandatorymeaningremark
tokenstringyesauthenticateFor authority authentication.
uuidlongyesuser unique idUser unique identity.
filterstringnothe fields you need queryThe fileds invoker needed.
[1]. the filter param is separated by comma
[2]. uuid and user_name will be returned by default if
the param is missed.
[3]. optional fields are : uuid, user_name,
user_phone, user_address, user_bank_card_number, user_identity_id

Reponse :

{
    "version": 0,
    "status": 0,
    "errMsg": "ok",
    "ts": 1573544966303,
    "data": {
        "uuid": 1150602865345420696,
        "user_name": "abei",
        "user_phone": "82f027a09a8a8d3d3e2f3",
        "user_address": "DT3BxWUNaYmFBPT0=",
        "user_bank_card_number": "QTE1NzM1NDQ5NjYc05HS2FURX",
        "user_identity_id": "UUNDGTWKLNG8G967jNy64GG"
    }
}

NOTE : we will encrypt the sensitive information of our users when persisting them, so the returned info need be decrypted for further using by a common service(encryption and decryption service).

3.2. Mapping xml

<select id="selectUserInfo" resultType="com.abei.entity.UserInfoEntity">
    SELECT uuid
    FROM
        user_info ui
    WHERE
    <choose>
        <when test="uuid == null">
            1 = 0
        </when>
        <otherwise>
            ui.uuid = #{uuid, jdbcType=NUMERIC}
        </otherwise>
    </choose>
</select>

Don’t be confused why I just select uuid, because next I will explain how to change the SQL statement you wrote down dynamically by a customized inteceptor of MyBatis.

3.3. Serivce logic code

/**
 * get user info
 *
 * @param uuid
 * @return
 */
public UserInfoDto getUserInfo(Long uuid, String filter) {
    UserInfoDto userInfoDto = null;
    // USER_INFO_DEFAULT_FILTER is a static const equals "uuid,user_name"
    String filterFields = Objects.isNull(filter) || filter.isEmpty() ? USER_INFO_DEFAULT_FILTER :
            Arrays.stream(filter.split(",")).filter(field -> Objects.nonNull(field) && !field.isEmpty())
                    .map(String::trim).collect(Collectors.joining(","));
    // Pipeline is a transfer used to take something from one place to another during the whole life of a thread.
    // Actually, it is just a ThreadLocal object.
    Pipeline.set(filterFields);
    try {
        UserInfoEntity userInfoEntity = userInfoMapper.selectUserInfo(uuid);
        if (Objects.isNull(userInfoEntity)) {
            return null;
        }
        userInfoDto = BeanConvertUtil.convertIgnoreNullProperty(userInfoEntity, new UserInfoDto());
    } catch (Exception e) {
        Pipeline.clear();
        throw e;
    }
    return userInfoDto;
}

NOTE : in service logic, you can enhance your code by adding a parameter check for the filter that invoker passed in to avoid invalid or illegal parameters.

The main work of above codes is getting the filter that invoker passed, and transfer it to my customized interceptor by a transfer tool, Pipeline. Next, you will see how interceptor works. Wow, almost forget to attach the Pipeline.

/**
 * Created by abei on 2019-08-13.
 */
public class Pipeline {
    private static final ThreadLocal transfer = new ThreadLocal();

    public static <V> V get() {
        try {
            return (V) transfer.get();
        } finally {
            transfer.remove();
        }
    }

    public static <V> void set(V value) {
        transfer.set(value);
    }

    public static void clear() {
        transfer.remove();
    }
}

3.4. Customized Interceptor

import org.apache.ibatis.executor.Executor;
import org.apache.ibatis.mapping.BoundSql;
import org.apache.ibatis.mapping.MappedStatement;
import org.apache.ibatis.mapping.SqlCommandType;
import org.apache.ibatis.mapping.SqlSource;
import org.apache.ibatis.plugin.Interceptor;
import org.apache.ibatis.plugin.Invocation;
import org.apache.ibatis.plugin.Plugin;
import org.apache.ibatis.reflection.DefaultReflectorFactory;
import org.apache.ibatis.reflection.MetaObject;
import org.apache.ibatis.reflection.factory.DefaultObjectFactory;
import org.apache.ibatis.reflection.wrapper.DefaultObjectWrapperFactory;

import java.sql.SQLException;
import java.util.Objects;
import java.util.Properties;

/**
 * Created by abei on 2019-08-20.
 */
public abstract class DynamicSqlInterceptor implements Interceptor {

    @Override
    public Object intercept(Invocation invocation) throws Throwable {
        try {
            String originalSql = this.getOriginalSql(invocation);
            String mapperId = this.getMapperId(invocation);
            if (Objects.isNull(originalSql) || originalSql.isEmpty() || Objects.isNull(mapperId) || mapperId.isEmpty()) {
                return invocation.proceed();
            }
            String currentSql = this.handleOriginalSql(originalSql, mapperId);
            this.resetCurrentSql(invocation, currentSql);
        } finally {
            return invocation.proceed();
        }
    }

    /**
     * If is Executor engine, the proxy will be used.
     *
     * @param target original obejct
     */
    @Override
    public Object plugin(Object target) {
        return target instanceof Executor ? Plugin.wrap(target, this) : target;
    }

    /**
     * Handle the original SQL script, you can handle different SQL script by different mapperId.
     *
     * @param originalSql
     * @param mapperId
     */
    public abstract String handleOriginalSql(String originalSql, String mapperId);

    @Override
    public void setProperties(Properties arg0) {
        // do nothing
    }

    /**
     * Get the SQL script defined in mapping xml.
     *
     * @param invocation
     * @return
     */
    private String getOriginalSql(Invocation invocation) {
        final Object[] args = invocation.getArgs();
        MappedStatement ms = (MappedStatement) args[0];
        Object parameterObject = args[1];
        BoundSql boundSql = ms.getBoundSql(parameterObject);
        return boundSql.getSql();
    }

    /**
     * Get the mapperId defined in mapping xml.
     *
     * @param invocation
     * @return
     */
    private String getMapperId(Invocation invocation) {
        final Object[] args = invocation.getArgs();
        MappedStatement ms = (MappedStatement) args[0];
        return ms.getId();
    }

    /**
     * Wrap modified SQL script, then set it into invocation again.
     *
     * @param invocation
     * @param sql
     * @throws SQLException
     */
    private void resetCurrentSql(Invocation invocation, String sql) throws SQLException {
        final Object[] args = invocation.getArgs();
        MappedStatement statement = (MappedStatement) args[0];
        Object parameterObject = args[1];
        BoundSql boundSql = statement.getBoundSql(parameterObject);
        MappedStatement newStatement = newMappedStatement(statement, new BoundSqlSqlSource(boundSql));
        MetaObject msObject = MetaObject.forObject(newStatement, new DefaultObjectFactory(), new DefaultObjectWrapperFactory(), new DefaultReflectorFactory());
        msObject.setValue("sqlSource.boundSql.sql", sql);
        args[0] = newStatement;
    }

    /**
     * New a mapper statement again for the modified SQL script.
     *
     * @param ms
     * @param newSqlSource
     */
    private MappedStatement newMappedStatement(MappedStatement ms, SqlSource newSqlSource) {
        MappedStatement.Builder builder =
                new MappedStatement.Builder(ms.getConfiguration(), ms.getId(), newSqlSource, ms.getSqlCommandType());
        builder.resource(ms.getResource());
        builder.fetchSize(ms.getFetchSize());
        builder.statementType(ms.getStatementType());
        builder.keyGenerator(ms.getKeyGenerator());
        if (Objects.nonNull(ms.getKeyProperties()) && ms.getKeyProperties().length != 0) {
            StringBuilder keyProperties = new StringBuilder();
            for (String keyProperty : ms.getKeyProperties()) {
                keyProperties.append(keyProperty).append(",");
            }
            keyProperties.delete(keyProperties.length() - 1, keyProperties.length());
            builder.keyProperty(keyProperties.toString());
        }
        builder.timeout(ms.getTimeout());
        builder.parameterMap(ms.getParameterMap());
        builder.resultMaps(ms.getResultMaps());
        builder.resultSetType(ms.getResultSetType());
        builder.cache(ms.getCache());
        builder.flushCacheRequired(ms.isFlushCacheRequired());
        builder.useCache(ms.isUseCache());
        return builder.build();
    }

    private String getOperateType(Invocation invocation) {
        final Object[] args = invocation.getArgs();
        MappedStatement ms = (MappedStatement) args[0];
        SqlCommandType commondType = ms.getSqlCommandType();
        if (commondType.compareTo(SqlCommandType.SELECT) == 0) {
            return "select";
        }
        if (commondType.compareTo(SqlCommandType.INSERT) == 0) {
            return "insert";
        }
        if (commondType.compareTo(SqlCommandType.UPDATE) == 0) {
            return "update";
        }
        if (commondType.compareTo(SqlCommandType.DELETE) == 0) {
            return "delete";
        }
        return null;
    }

    class BoundSqlSqlSource implements SqlSource {
        private BoundSql boundSql;

        public BoundSqlSqlSource(BoundSql boundSql) {
            this.boundSql = boundSql;
        }

        @Override
        public BoundSql getBoundSql(Object parameterObject) {
            return boundSql;
        }
    }
}

The practical Interceptor as following :

import com.abei.common.util.Pipeline;
import lombok.extern.slf4j.Slf4j;
import org.apache.ibatis.executor.Executor;
import org.apache.ibatis.mapping.MappedStatement;
import org.apache.ibatis.plugin.Intercepts;
import org.apache.ibatis.plugin.Signature;
import org.apache.ibatis.session.ResultHandler;
import org.apache.ibatis.session.RowBounds;

import java.util.Objects;

/**
 * Created by abei on 2019-08-26.
 */
@Slf4j
@Intercepts(
        @Signature(
                type = Executor.class,
                method = "query",
                args = {MappedStatement.class, Object.class, RowBounds.class, ResultHandler.class}))
public class UserInfoSqlInterceptor extends DynamicSqlInterceptor {
    public static final String selectUserInfoMapperId = "com.abei.mapper.UserInfoMapper.selectUserInfo";

    @Override
    public String handleOriginalSql(String originalSql, String mapperId) {
        if (!Objects.equals(selectUserInfoMapperId, mapperId)) {
            return originalSql;
        }
        String filterFields = Pipeline.get();
        if (Objects.nonNull(filterFields) && !filterFields.isEmpty()) {
            originalSql = originalSql.replace("uuid", filterFields);
        }
        return originalSql;
    }
}

In UserInfoSqlInterceptor, the string “uuid” hard coded in my mapping xml, now, is replaced with the filter that invoker passed. That’s why interceptor can make my interface more flexible.

3.5. Configure customized Interceptor

In my work, all the projects are based on SpringBoot, so the last step is to configure the customized interceptor into Spring context.

import org.springframework.context.annotation.Bean;
import org.springframework.context.annotation.Configuration;

/**
 * Created by abei on 2019-08-30.
 */
@Configuration
public class InterceptorConfig {

    @Bean
    public DynamicSqlInterceptor userInfoSqlInterceptor() {
        return new UserInfoSqlInterceptor();
    }
}

Then, you can get the user information that you need, and for user-service, no need change anything.

/v1/user/info?token=xeujsji38jjd&uuid=1150602865345420696&filter=uuid,user_name,user_bank_card_number

/v1/user/info?token=xeujsji38jjd&uuid=1150602865345420706&filter=user_name,user_phone,user_identity_id

Leave a Reply

Your email address will not be published. Required fields are marked *