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 :
param | type | mandatory | meaning | remark |
token | string | yes | authenticate | For authority authentication. |
uuid | long | yes | user unique id | User unique identity. |
filter | string | no | the fields you need query | The 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