需求背景
最近接到一个项目,需要改造一个老的系统。该老系统以Oracle为存储,巨量的PL/SQL代码实现业务代码,C实现Socket Server,作为Client和PL/SQL的桥梁。不出所料,该老系统最大的问题是PL/SQL代码量巨大(上万的Procedure好几个),且毫无组织可言,实在改不动了,其次是性能有问题。改动的方向是,把PL/SQL从Oracle中踢出,用Java改写相关业务逻辑,放到Web Server中,不过Oracle中的Schema不动。
到目前位置,改造老系统和笔者要分享的主题没啥关系。问题来了,老系统有三套,A,B,C,就是说有三个Oracle数据库,Schema以及PL/SQL完全相同,但是数据没有啥关系,完全独立的三个数据库。历史的问题,不好评说,但是只是为了解决性能问题,搞了三套,客户被分配到不同的套系统,和传统的游戏开个服务器一个思路。
我们有3个方案:
1. 部署三套WebServer,对应三个Oracle数据库,客户端连接到不同的Web Server,和原来架构相同。数据库和Server都继续分。
2. 把三个数据库整合为一个数据库,部署一套Web Server。数据和,Server和。
3. 保持三套Oracle数据库不变,一个Web Server,但是Server需要把三个Oracle都管理起来。
方案1最简单,最容易,但是当性能已经不再是问题的时候还是部署三套Web Server,实在是有些说不过去,运维工作增加,客户端维护不同的版本(服务器地址),不太愿意选择,暂时备选。
方案2很难,非常的难。原来的三个Oracle数据库,完全独立,没有全局的主键,基本上无法区分开数据。放弃!
方案3是个折中的方案,中国人讲究中庸之道。最终我们选择了方案3。
设计思路
方案3要解决的问题是同一个Server如何集成3个数据库,具体来说,就是Spring里面如何管理3个Shema完全相同的Datasouce。我们的系统Server的技术选型是常见的Spring+MyBatis。管理多个Shema不同的Datasouce,网上有很多例子,Schema相同这叫分库吗,貌似很少?那进一步在Spring环境下呢?没有找到。强调Spring只想知道一个Mapper,而非3个。是因为,Spring只想知道一个Mapper,而非3个。
其中的关键技术难点如下:
- 如何识别什么样的数据应该存到哪一个Oracle数据库?
我们的解法是根据用户所在的位置来判断,用户在A库,那么后续的操作针对A库,在B库就操作B库。最开始登录的时候先探测用户到底存在于哪一个库。我们认为用户名+密码应该是跨三个数据库唯一的。 - 如何动态切换数据库?
代理,用代理来实现。Spring容器内注册的就是一个代理而已,代理被调用的时候,我们截获调用,根据登录时候获得的环境(哪一个库),来动态切换,委托给背后的MyBatis Mapper来执行。
下图是我画的切换的示意图。偷懒的原因,我只画了A,B两套,实战中是A,B,C。
实例代码
识别应该存于哪一个数据库
稍微改造一下Shiro访问用户信息的地方,增加环境的属性。注意两点,一是遍历数据源,探测用户所在的数据库,而是直接用SqlSessionFactory的bean name作为环境名,够简单直白!
Shiro Realm代码如下:
public class ShiroRealm extends AuthorizingRealm {
@Autowired
private UserService userService;
@Autowired
private SecureService secureService;
@Autowired
private AuthorizationService authorizationService;
@PostConstruct
public void initAlgorithms() {
AllowAllCredentialsMatcher credentialsMatcher = new AllowAllCredentialsMatcher();
setCredentialsMatcher(credentialsMatcher);
}
@Override
protected AuthenticationInfo doGetAuthenticationInfo(AuthenticationToken token) throws AuthenticationException {
UsernamePasswordToken pairToken = (UsernamePasswordToken) token;
String userName = pairToken.getUsername();
String password = new String(pairToken.getPassword());
// determine env
Map<String, Object> envMappers = EnvMapperFactoryBean.getAllMappers(UserMapper.class);
for (String env : envMappers.keySet()) {
User user = validUser((UserMapper) envMappers.get(env), userName, password);
if (user != null) {
ShiroUser shiroUser = new ShiroUser(user.getId(), userName, user.getBranchId(), env);
String salt = user.getSalt();
byte[] saltBytes = Hex.decode(salt);
return new SimpleAuthenticationInfo(shiroUser, user.getPassword(), ByteSource.Util.bytes(saltBytes),
getName());
}
}
return null;
}
private User validUser(UserMapper userMapper, String userName, String password) {
UserExample example = new UserExample();
example.createCriteria().andNameEqualTo(userName);
List<User> users = userMapper.selectByExample(example);
if (users.isEmpty()) {
return null;
}
User user = users.get(0);
String salt = user.getSalt();
String tempEncoded = secureService.hash(password, salt);
if (user.getPassword().equals(tempEncoded)) {
return user;
} else {
return null;
}
}
@Override
protected AuthorizationInfo doGetAuthorizationInfo(PrincipalCollection principals) {
ShiroUser shiroUser = (ShiroUser) principals.getPrimaryPrincipal();
User user = userService.queryUserByName(shiroUser.getName());
SimpleAuthorizationInfo info = new SimpleAuthorizationInfo();
List<Role> roles = authorizationService.queryRoleByUserId(user.getId());
if (roles.size() > 0) {
List<String> roleNames = ObjectProcessor.getFieldList(roles,
new ObjectProcessor.FieldValueGetter<Role, String>() {
@Override
public String getValue(Role role) {
return role.getName();
}
});
info.addRoles(roleNames);
List<Integer> roleIds = ObjectProcessor.getFieldList(roles,
new ObjectProcessor.FieldValueGetter<Role, Integer>() {
@Override
public Integer getValue(Role role) {
return role.getId();
}
});
List<Privilege> privileges = authorizationService.queryPrivilegeByRoleId(roleIds);
if (privileges.size() > 0) {
List<String> privilegeKeys = ObjectProcessor.getFieldList(privileges,
new ObjectProcessor.FieldValueGetter<Privilege, String>() {
@Override
public String getValue(Privilege item) {
return item.getCategory() + ":" + item.getCode();
}
});
info.addStringPermissions(privilegeKeys);
}
}
return info;
}
public static class ShiroUser implements Serializable, Principal {
private static final long serialVersionUID = 3316911162161110480L;
private Integer id;
private String name;
private Integer branchId;
private String env;
public ShiroUser(Integer id, String name, Integer branchId, String env) {
this.id = id;
this.name = name;
this.branchId = branchId;
this.env = env;
}
public Integer getId() {
return id;
}
public String getName() {
return name;
}
public Integer getBranchId() {
return branchId;
}
public String getEnv() {
return env;
}
@Override
public boolean equals(Object obj) {
if (this == obj) {
return true;
}
if (obj == null) {
return false;
}
if (getClass() != obj.getClass()) {
return false;
}
ShiroUser other = (ShiroUser) obj;
if (name == null) {
if (other.name != null) {
return false;
}
} else if (!name.equals(other.name)) {
return false;
}
return true;
}
@Override
public int hashCode() {
final int prime = 31;
int result = 1;
result = prime * result + ((name == null) ? 0 : name.hashCode());
return result;
}
@Override
public String toString() {
return name;
}
}
}
Spring Mapper Proxy注册以及动态切换数据源
我们用了Shiro,可以在任意地方获取用户信息,背后的本质是一个ThreadLocal变量。注意Proxy的背后会有很多帮工—-真正的Mybatis Mapper,启动的时候需要安装上。
代码如下:
public class EnvMapperFactoryBean implements FactoryBean, ApplicationContextAware {
private static final Logger log = LoggerFactory.getLogger(EnvMapperFactoryBean.class);
private Class<T> mapperInterface;
private ApplicationContext context;
private static Map<String, Map<String, Object>> envMappers = new ConcurrentHashMap<>();
/**
* Sets the mapper interface of the MyBatis mapper
*
* @param mapperInterface
* class of the interface
*/
public void setMapperInterface(Class<T> mapperInterface) {
this.mapperInterface = mapperInterface;
}
/**
* {@inheritDoc}
*/
@SuppressWarnings("unchecked")
public T getObject() throws Exception {
installEnv();
return (T) Proxy.newProxyInstance(EnvMapperFactoryBean.class.getClassLoader(),
new Class<?>[] { mapperInterface }, new MapperProxy());
}
public Class<T> getObjectType() {
return this.mapperInterface;
}
public boolean isSingleton() {
return true;
}
private static Object getRealMapper(String env, Class mapperClazz) {
Map<String, Object> mappers = envMappers.get(env);
if (mappers.isEmpty()) {
return null;
}
return mappers.get(mapperClazz.getName());
}
public static Map<String, Object> getAllMappers(Class mapperClazz) {
Map<String, Object> result = new HashMap<>();
String clazzName = mapperClazz.getName();
for (String env : envMappers.keySet()) {
Map<String, Object> mappers = envMappers.get(env);
if (mappers.containsKey(clazzName)) {
result.put(env, mappers.get(clazzName));
}
}
return result;
}
@SuppressWarnings("resource")
private void installEnv() {
String[] sqlSessionFactoryNames = context.getBeanNamesForType(SqlSessionFactory.class);
if (sqlSessionFactoryNames == null || sqlSessionFactoryNames.length == 0) {
throw new RuntimeException("找不到SqlSessionFactory的配置信息");
}
for (String env : sqlSessionFactoryNames) {
SqlSessionFactory sqlSessionFactory = context.getBean(env, SqlSessionFactory.class);
SqlSession sqlSession = new SqlSessionTemplate(sqlSessionFactory);
T mapper = sqlSession.getMapper(mapperInterface);
if (!envMappers.containsKey(env)) {
envMappers.put(env, new ConcurrentHashMap<>());
}
envMappers.get(env).put(mapperInterface.getName(), mapper);
}
}
@Override
public void setApplicationContext(ApplicationContext applicationContext) throws BeansException {
context = applicationContext;
}
private class MapperProxy implements InvocationHandler {
public Object invoke(Object proxy, Method method, Object[] args) throws Throwable {
Object object = null;
try {
ShiroUser shiroUser = (ShiroUser) SecurityUtils.getSubject().getPrincipal();
if (shiroUser == null) {
throw new RuntimeException("用户没有登陆,无法确认环境");
}
String env = shiroUser.getEnv();
Object mapper = getRealMapper(env, method.getDeclaringClass());
if (mapper == null) {
throw new RuntimeException("找不到对应的mapper");
}
object = method.invoke(mapper, args);
} catch (Exception e) {
log.error(e.getMessage(), e);
throw e;
}
return object;
}
}
}
Spring的配置文件
Spring会配置多个Datasource,多个SqlSessionFactory,一个Scanner。
<bean id="dataSource" class="org.apache.tomcat.jdbc.pool.DataSource"
destroy-method="close">
<!-- Connection Info -->
<property name="driverClassName" value="${jdbc.driver}" />
<property name="url" value="${jdbc.url}" />
<property name="username" value="${jdbc.username}" />
<property name="password" value="${jdbc.password}" />
<!-- Connection Pooling Info -->
<property name="maxActive" value="${jdbc.pool.maxActive}" />
<property name="maxIdle" value="${jdbc.pool.maxIdle}" />
<property name="minIdle" value="0" />
<property name="defaultAutoCommit" value="false" />
</bean>
<bean id="sqlSessionFactory" class="org.mybatis.spring.SqlSessionFactoryBean">
<property name="dataSource" ref="dataSource" />
<property name="typeAliasesPackage" value="com.comstar.mars.entity" />
<property name="mapperLocations" value="classpath:/mybatis/*Mapper.xml" />
</bean>
<bean id="dataSource1" class="org.apache.tomcat.jdbc.pool.DataSource"
destroy-method="close">
<!-- Connection Info -->
<property name="driverClassName" value="${jdbc.driver}" />
<property name="url" value="${jdbc.url.moon}" />
<property name="username" value="${jdbc.username.moon}" />
<property name="password" value="${jdbc.password.moon}" />
<!-- Connection Pooling Info -->
<property name="maxActive" value="${jdbc.pool.maxActive}" />
<property name="maxIdle" value="${jdbc.pool.maxIdle}" />
<property name="minIdle" value="0" />
<property name="defaultAutoCommit" value="false" />
</bean>
<bean id="sqlSessionFactory1" class="org.mybatis.spring.SqlSessionFactoryBean">
<property name="dataSource" ref="dataSource1" />
<property name="typeAliasesPackage" value="com.comstar.mars.entity" />
<property name="mapperLocations" value="classpath:/mybatis/*Mapper.xml" />
</bean>
<bean class="com.comstar.mars.env.EnvMapperScannerConfigurer">
<property name="basePackage" value="com.comstar.mars.repository" />
</bean>
结语
这是一个通过代理器来实现运行时动态切换实现的经典案例,代理是一个非常有用的设计模式,值得思考和借鉴。
有人会问,有其它的一些解法吗?
关于识别,用户名+密码唯一识别有人可能觉得不妥,可以自己替换为自己想要的识别算法,甚至于丢给客户端自己决定。
关于动态切换,Spring的AbstractRoutingDataSource是个很好的选择,不过不适合我们的场景,我们需要先拿到三个Mybatis的UserMapper来探测具体用哪一个数据库,Datasource太底层了。如果环境由客户端来决定,AbstractRoutingDataSource确实是更好的选择。
Github地址
https://github.com/kimylrong/multi-datasource.git