Spring環境下MyBatis支援多個Datasource參考實現
需求背景
最近接到一個專案,需要改造一個老的系統。該老系統以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