背景
公司有个电商项目,是为公司的一个电商品牌开发的,其中涉及到 微信小程序(C端用户购物)、门店店长和店员们使用的 收银系统(web)、公司管理人员使用的后台管理项目(web)。最初设计的时候是按照商家就是我们自己的单一固定品牌来设计的,没考虑做成类似淘宝和京东那样的平台支持多品牌入驻。
现在老板提了一个需求,我部分小店想使用我们的整套系统,包括小程序+门店系统+后台管理系统。同时,这些小店是没有成本去购买自己的服务器和维护自己的服务的。我们老板这边想把自己的系统改造成既能符合自己品牌使用,也能让其它品牌使用,业务逻辑都一样。
1、问题分析
这次的项目改造,我们存在问题和如下:
- 我们的人手不足,前后端只能分别投入一个人,短期内需要完成旧系统改造+新需求的开发。所以方案必须用最少工作量的方式;
- 第三方品牌方是没有IT能力的,所有系统、数据库等均由我们公司完成并部署在一套系统中;
- 用户使用到的 业务数据是完全不会交叉 的,包括小程序也是每个品牌方申请各自的小程序,再交给我们维护(如果第三方品牌需要线上小程序);
- 我们原有的数据库表中不包含品牌信息,是根据单品牌设计的;
2、方案选型
针对原本的单品牌改为支持多品牌,有两种改造方案:
- 直接在所有表中增加品牌字段,区分是哪个品牌的商家来做的操作。收银系统端登录的时候就能获得登录用户所属的品牌,C端用户从不同的小程序登录自然也有办法知道是哪个品牌。
- 根据品牌分库。每个品牌我们初始化数据的时候设计个唯一编码(该编码做为数据库分库的名称变量,比如w_1、w_2、w_3),收银端登录的时候需要选择品牌或者输入品牌编码。在后台系统中我们加个过滤器,将所有请求都拦截到并获取到品牌编码(这里可以考虑首次登陆带过来编码后存到session中,后续每次请求前端肯定会带登陆标识token,根据token取出该用户session中编码信息),过滤器取到编码后放入ThreadLocal中。
两种方案的优缺点:
- 直接在所有表中增加品牌字段,优点是 a)实现方案简单传统,是个开发人员都能改,b)风险小。缺点是 a)工作量大!我们的背景就是缺人手,所有业务代码从数据库层面到内部接口参数、http请求参数都要加品牌字段,改造工作量较大。b)且后续所有表都需要加品牌标识,有点恶心。
- 根据品牌分库。优点是 a)业务代码无需做大改动,符合我们的人手少时间紧的实际情况。缺点也很明显,a)每个品牌加入,我们就要初始化一套数据库给它。如果后续加入的品牌多了,每次系统升级需要执行的sql就要在每个数据库中执行一遍,超级恶心(要考虑sql执行自动化)。b)商家登录体验较差,还需要去输入或选择品牌。c)还会有些其它缺点,比如定时任务需要改造成所有库轮询执行、第三方回调请求到哪个数据库等细节需要考虑解决方案。
经过最终对比,我们选择了 根据品牌分库 的方案。主要原因是至少半年~1年内我们前后端开发维护人手紧缺,加入的品牌方也少,重点考虑的还是节约劳动力的方式。
3、代码实现
1.先定义一个自己的 CompanyCodeThreadLocal,继承自ThreadLocal
package com.link.common.filter;
public class CompanyCodeThreadLocal extends ThreadLocal<String> {
private static final CompanyCodeThreadLocal instance = new CompanyCodeThreadLocal();
private CompanyCodeThreadLocal() {
}
public static CompanyCodeThreadLocal getInstanse() {
return instance;
}
public static String getCompanyCode() {
CompanyCodeThreadLocal companyCodeThreadLocal = getInstanse();
String companyCode = (String) companyCodeThreadLocal.get();
return null == companyCode ? "" : companyCode;
}
public static void setCompanyCode(String companyCode) {
CompanyCodeThreadLocal companyCodeThreadLocal = getInstanse();
companyCodeThreadLocal.set(companyCode);
}
}
2.过滤器中代码如下:
package com.link.common.filter;
import java.io.IOException;
import javax.servlet.Filter;
import javax.servlet.FilterChain;
import javax.servlet.FilterConfig;
import javax.servlet.ServletException;
import javax.servlet.ServletRequest;
import javax.servlet.ServletResponse;
import javax.servlet.http.HttpServletRequest;
import org.apache.logging.log4j.ThreadContext;
import org.slf4j.Logger;
import org.slf4j.LoggerFactory;
import org.springframework.stereotype.Component;
@Component
public class SessionFilter implements Filter {
private static final Logger LOGGER = LoggerFactory.getLogger(HttpRequestIdFilter.class);
@Override
public void destroy() {
LOGGER.info("RequestIdFilter destroy...");
}
@Override
public void doFilter(ServletRequest request, ServletResponse response, FilterChain filterChain)
throws IOException, ServletException {
try {
HttpServletRequest req = (HttpServletRequest) request;
// 获得品牌编码,放入ThreadLocal
String companyCode = req.getHeader("companyCode111");
CompanyCodeThreadLocal.setCompanyCode(companyCode);
filterChain.doFilter(request, response);
} finally {
ThreadContext.remove(KEY_REQUEST_ID);
RequestIdThreadLocal.removeRequestId();
}
}
@Override
public void init(FilterConfig arg0) throws ServletException {
LOGGER.info("RequestIdFilter init...");
}
}
- mybatis拦截器拦截到所有sql,从ThreadLocal中获取到companyCode并处理所有表名使表名前带上库名前缀
package com.link.common.filter;
import java.lang.reflect.Field;
import java.sql.Connection;
import java.util.Properties;
import org.apache.ibatis.executor.statement.StatementHandler;
import org.apache.ibatis.mapping.BoundSql;
import org.apache.ibatis.mapping.MappedStatement;
import org.apache.ibatis.plugin.Interceptor;
import org.apache.ibatis.plugin.Intercepts;
import org.apache.ibatis.plugin.Invocation;
import org.apache.ibatis.plugin.Plugin;
import org.apache.ibatis.plugin.Signature;
import org.apache.ibatis.reflection.DefaultReflectorFactory;
import org.apache.ibatis.reflection.MetaObject;
import org.apache.ibatis.reflection.SystemMetaObject;
import org.springframework.stereotype.Component;
@Component
@Intercepts({
@Signature(type = StatementHandler.class, method = "prepare", args = { Connection.class, Integer.class }) })
public class MybatisInterceptor implements Interceptor {
@Override
public Object intercept(Invocation invocation) throws Throwable {
StatementHandler statementHandler = (StatementHandler) invocation.getTarget();
// 通过MetaObject优雅访问对象的属性,这里是访问statementHandler的属性;:MetaObject是Mybatis提供的一个用于方便、
// 优雅访问对象属性的对象,通过它可以简化代码、不需要try/catch各种reflect异常,同时它支持对JavaBean、Collection、Map三种类型对象的操作。
MetaObject metaObject = MetaObject.forObject(statementHandler, SystemMetaObject.DEFAULT_OBJECT_FACTORY,
SystemMetaObject.DEFAULT_OBJECT_WRAPPER_FACTORY, new DefaultReflectorFactory());
// 先拦截到RoutingStatementHandler,里面有个StatementHandler类型的delegate变量,其实现类是BaseStatementHandler,然后就到BaseStatementHandler的成员变量mappedStatement
MappedStatement mappedStatement = (MappedStatement) metaObject.getValue("delegate.mappedStatement");
// id为执行的mapper方法的全路径名,如com.uv.dao.UserMapper.insertUser
String id = mappedStatement.getId();
// sql语句类型 select、delete、insert、update
String sqlCommandType = mappedStatement.getSqlCommandType().toString();
// 数据库连接信息
// Configuration configuration = mappedStatement.getConfiguration();
// ComboPooledDataSource dataSource = (ComboPooledDataSource)configuration.getEnvironment().getDataSource();
// dataSource.getJdbcUrl();
BoundSql boundSql = statementHandler.getBoundSql();
// 获取到品牌编码
String companyCode = CompanyCodeThreadLocal.getCompanyCode();
System.out.println("========mybatis intercept msql:【" + mSql + "】=====companyCode:[" + companyCode
+ "]");
// 获取到原始sql语句
String sql = boundSql.getSql();
String mSql = sql + " limit 2"; // 使用companyCode加工sql,这边还没实现
// 通过反射修改sql语句
Field field = boundSql.getClass().getDeclaredField("sql");
field.setAccessible(true);
field.set(boundSql, mSql); // 最终执行的是加工后的sql
return invocation.proceed();
}
@Override
public Object plugin(Object target) {
return Plugin.wrap(target, this);
}
@Override
public void setProperties(Properties properties) {
// 此处可以接收到配置文件的property参数
System.out.println(properties.getProperty("name"));
}
}