APDPlat中數據庫備份恢復的設計與實現
設計目標:
1、多數據庫支持
2、橫切關注點隔離
3、異地容錯
下面闡述具體的設計及實現:
1、為了支持多數據庫,統一的接口是不可避免的,如下所示:
/** * 備份恢復數據庫接口 * @author 楊尚川 */ public interface BackupService { /** * 備份數據庫 * @return 是否備份成功 */ public boolean backup(); /** * 恢復數據庫 * @param date * @return 是否恢復成功 */ public boolean restore(String date); /** * 獲取已經存在的備份文件名稱列表 * @return 備份文件名稱列表 */ public List<String> getExistBackupFileNames(); /** * 獲取備份文件存放的本地文件系統路徑 * @return 備份文件存放路徑 */ public String getBackupFilePath(); /** * 獲取最新的備份文件 * @return 最新的備份文件 */ public File getNewestBackupFile();} |
對于各個不同的數據庫來說,有一些通用的操作,如對加密的數據庫用戶名和密碼的解密操作,還有接口定義的備份文件存放的本地文件系統路徑,用一個抽象類來實現接口中的通用方法以及其他通用方法如decrypt:
/** *備份恢復數據庫抽象類,抽象出了針對各個數據庫來說通用的功能 * @author */ public abstract class AbstractBackupService implements BackupService{ protected final APDPlatLogger LOG = new APDPlatLogger(getClass()); protected static final StandardPBEStringEncryptor encryptor; protected static final String username; protected static final String password; //從配置文件中獲取數據庫用戶名和密碼,如果用戶名和密碼被加密,則解密 static{ EnvironmentStringPBEConfig config=new EnvironmentStringPBEConfig(); config.setAlgorithm("PBEWithMD5AndDES"); config.setPassword("config"); encryptor=new StandardPBEStringEncryptor(); encryptor.setConfig(config); String uname=PropertyHolder.getProperty("db.username"); String pwd=PropertyHolder.getProperty("db.password"); if(uname!=null && uname.contains("ENC(") && uname.contains(")")){ uname=uname.substring(4,uname.length()-1); username=decrypt(uname); }else{ username=uname; } if(pwd!=null && pwd.contains("ENC(") && pwd.contains(")")){ pwd=pwd.substring(4,pwd.length()-1); password=decrypt(pwd); }else{ password=pwd; } } @Override public String getBackupFilePath(){ String path="/WEB-INF/backup/"+PropertyHolder.getProperty("jpa.database")+"/"; path=FileUtils.getAbsolutePath(path); File file=new File(path); if(!file.exists()){ file.mkdirs(); } return path; } @Override public File getNewestBackupFile(){ Map<String,File> map = new HashMap<>(); List<String> list = new ArrayList<>(); String path=getBackupFilePath(); File dir=new File(path); File[] files=dir.listFiles(); for(File file : files){ String name=file.getName(); if(!name.contains("bak")) { continue; } map.put(name, file); list.add(name); } if(list.isEmpty()){ return null; } //按備份時間排序 Collections.sort(list); //最新備份的在最前面 Collections.reverse(list); String name = list.get(0); File file = map.get(name); //加速垃圾回收 list.clear(); map.clear(); return file; } @Override public List<String> getExistBackupFileNames(){ List<String> result=new ArrayList<>(); String path=getBackupFilePath(); File dir=new File(path); File[] files=dir.listFiles(); for(File file : files){ String name=file.getName(); if(!name.contains("bak")) { continue; } name=name.substring(0, name.length()-4); String[] temp=name.split("-"); String y=temp[0]; String m=temp[1]; String d=temp[2]; String h=temp[3]; String mm=temp[4]; String s=temp[5]; name=y+"-"+m+"-"+d+" "+h+":"+mm+":"+s; result.add(name); } //按備份時間排序 Collections.sort(result); //最新備份的在最前面 Collections.reverse(result); return result; } /** * 解密用戶名和密碼 * @param encryptedMessage 加密后的用戶名或密碼 * @return 解密后的用戶名或密碼 */ protected static String decrypt(String encryptedMessage){ String plain=encryptor.decrypt(encryptedMessage); return plain; } } |
下面來看一個MySQL數據庫的實現:
/** *MySQL備份恢復實現 * @author 楊尚川 */ @Service("MYSQL") public class MySQLBackupService extends AbstractBackupService{ /** * MySQL備份數據庫實現 * @return */ @Override public boolean backup() { try { String path=getBackupFilePath()+DateTypeConverter.toFileName(new Date())+".bak"; String command=PropertyHolder.getProperty("db.backup.command"); command=command.replace("${db.username}", username); command=command.replace("${db.password}", password); command=command.replace("${module.short.name}", PropertyHolder.getProperty("module.short.name")); Runtime runtime = Runtime.getRuntime(); Process child = runtime.exec(command); InputStream in = child.getInputStream(); try(OutputStreamWriter writer = new OutputStreamWriter(new FileOutputStream(path), "utf8");BufferedReader reader = new BufferedReader(new InputStreamReader(in, "utf8"))){ String line=reader.readLine(); while (line != null) { writer.write(line+"\n"); line=reader.readLine(); } writer.flush(); } LOG.debug("備份到:"+path); return true; } catch (Exception e) { LOG.error("備份出錯",e); } return false; } /** * MySQL恢復數據庫實現 * @param date * @return */ @Override public boolean restore(String date) { try { String path=getBackupFilePath()+date+".bak"; String command=PropertyHolder.getProperty("db.restore.command"); command=command.replace("${db.username}", username); command=command.replace("${db.password}", password); command=command.replace("${module.short.name}", PropertyHolder.getProperty("module.short.name")); Runtime runtime = Runtime.getRuntime(); Process child = runtime.exec(command); try(OutputStreamWriter writer = new OutputStreamWriter(child.getOutputStream(), "utf8");BufferedReader reader = new BufferedReader(new InputStreamReader(new FileInputStream(path), "utf8"))){ String line=reader.readLine(); while (line != null) { writer.write(line+"\n"); line=reader.readLine(); } writer.flush(); } LOG.debug("從 "+path+" 恢復"); return true; } catch (Exception e) { LOG.error("恢復出錯",e); } return false; } } |
這里的關鍵有兩點,一是從配置文件db.properties或db.local.properties中獲取指定的命令進行備份和恢復操作,二是為實現類指定注解@Service("MYSQL"),這里服務名稱必須和配置文件db.properties或db.local.properties中jpa.database的值一致,jpa.database的值指定了當前使用哪一種數據庫,如下所示:
#mysql db.driver=com.mysql.jdbc.Driver db.url=jdbc:mysql://localhost:3306/${module.short.name}?useUnicode=true&characterEncoding=UTF-8&createDatabaseIfNotExist=true&autoReconnect=true db.username=ENC(i/TOu44AD6Zmz0fJwC32jQ==) db.password=ENC(i/TOu44AD6Zmz0fJwC32jQ==) jpa.database=MYSQL db.backup.command=mysqldump -u${db.username} -p${db.password} ${module.short.name} db.restore.command=mysql -u${db.username} -p${db.password} ${module.short.name} |
有了接口和多個實現,那么備份和恢復的時候究竟選擇哪一種數據庫實現呢?BackupServiceExecuter充當工廠類(Factory),負責從多個數據庫備份恢復實現類中選擇一個并執行相應的備份和恢復操作,BackupServiceExecuter也實現了BackupService接口,這也是一個典型的外觀(Facade)設計模式,封裝了選擇特定數據庫的邏輯。
定時調度器和web前端控制器也是使用BackupServiceExecuter來執行備份恢復操作,BackupServiceExecuter通過每個實現類以@Service注解指定的名稱以及配置文件
db.properties或db.local.properties中jpa.database的值來做選擇的依據,如下所示: /** *執行備份恢復的服務,自動判斷使用的是什么數據庫,并找到該數據庫備份恢復服務的實現并執行 * @author 楊尚川 */ @Service public class BackupServiceExecuter extends AbstractBackupService{ private BackupService backupService=null; @Resource(name="backupFileSenderExecuter") private BackupFileSenderExecuter backupFileSenderExecuter; /** * 查找并執行正在使用的數據的備份實現實例 * @return */ @Override public boolean backup() { if(backupService==null){ backupService=SpringContextUtils.getBean(PropertyHolder.getProperty("jpa.database")); } boolean result = backupService.backup(); //如果備份成功,則將備份文件發往他處 if(result){ backupFileSenderExecuter.send(getNewestBackupFile()); } return result; } /** * 查找并執行正在使用的數據的恢復實現實例 * @param date * @return */ @Override public boolean restore(String date) { if(backupService==null){ backupService=SpringContextUtils.getBean(PropertyHolder.getProperty("jpa.database")); } return backupService.restore(date); } } |
關鍵是這行代碼backupService=SpringContextUtils.getBean(PropertyHolder.getProperty("jpa.database"));
2、在記錄備份恢復日志的時候,如果每種數據庫的實現類都要粘貼復制通用的代碼到備份和恢復方法的開始和結束位置,那么四處就飄散著重復的代碼,對易讀性和可修改性都是極大的破壞。
AOP是解決這個問題的不二之選,為了AOP能工作,良好設計的包結構、類層級,規范的命名都是非常重要的,尤其是這里的BackupServiceExecuter和真正執行備份恢復的實現類有共同的方法簽名(都實現了BackupService接口),所以把他們放到不同的包里有利于AOP。
使用AOP首先要引入依賴:
<dependency> <groupId>org.aspectj</groupId> <artifactId>aspectjrt</artifactId> <version>${aspectj.version}</version> </dependency> <dependency> <groupId>org.aspectj</groupId> <artifactId>aspectjweaver</artifactId> <version>${aspectj.version}</version> </dependency> |
其次是要在spring配置文件中指定啟用自動代理:
<aop:aspectj-autoproxy />
最后就可以編寫代碼實現日志記錄:
/** * 備份恢復數據庫日志Aspect * org.apdplat.module.system.service.backup.impl包下面有多個數據庫的備份恢復實現 * 他們實現了BackupService接口的backup方法(備份數據庫)和restore(恢復數據庫)方法 * @author 楊尚川 */ @Aspect @Service public class BackupLogAspect { private static final APDPlatLogger LOG = new APDPlatLogger(BackupLogAspect.class); private static final boolean MONITOR_BACKUP = PropertyHolder.getBooleanProperty("monitor.backup"); private BackupLog backupLog = null; static{ if(MONITOR_BACKUP){ LOG.info("啟用備份恢復日志"); LOG.info("Enable backup restore log", Locale.ENGLISH); }else{ LOG.info("禁用備份恢復日志"); LOG.info("Disable backup restore log", Locale.ENGLISH); } } //攔截備份數據庫操作 @Pointcut("execution( boolean org.apdplat.module.system.service.backup.impl.*.backup() )") public void backup() {} @Before("backup()") public void beforeBackup(JoinPoint jp) { if(MONITOR_BACKUP){ before(BackupLogType.BACKUP); } } @AfterReturning(value="backup()", argNames="result", returning = "result") public void afterBackup(JoinPoint jp, boolean result) { if(MONITOR_BACKUP){ after(result); } } //攔截恢復數據庫操作 @Before(value="execution( boolean org.apdplat.module.system.service.backup.impl.*.restore(java.lang.String) ) && args(date)", argNames="date") public void beforeRestore(JoinPoint jp, String date) { if(MONITOR_BACKUP){ before(BackupLogType.RESTORE); } } @AfterReturning(pointcut="execution( boolean org.apdplat.module.system.service.backup.impl.*.restore(java.lang.String) )", returning = "result") public void afterRestore(JoinPoint jp, boolean result) { if(MONITOR_BACKUP){ after(result); } } private void before(String type){ LOG.info("準備記錄數據庫"+type+"日志"); User user=UserHolder.getCurrentLoginUser(); String ip=UserHolder.getCurrentUserLoginIp(); backupLog=new BackupLog(); if(user != null){ backupLog.setUsername(user.getUsername()); } backupLog.setLoginIP(ip); try { backupLog.setServerIP(InetAddress.getLocalHost().getHostAddress()); } catch (UnknownHostException e) { LOG.error("無法獲取服務器IP地址", e); LOG.error("Can't get server's ip address", e, Locale.ENGLISH); } backupLog.setAppName(SystemListener.getContextPath()); backupLog.setStartTime(new Date()); backupLog.setOperatingType(type); } private void after(boolean result){ if(result){ backupLog.setOperatingResult(BackupLogResult.SUCCESS); }else{ backupLog.setOperatingResult(BackupLogResult.FAIL); } backupLog.setEndTime(new Date()); backupLog.setProcessTime(backupLog.getEndTime().getTime()-backupLog.getStartTime().getTime()); //將日志加入內存緩沖區 BufferLogCollector.collect(backupLog); LOG.info("記錄完畢"); } } |
select
Vcs_Accd_Info.Date_Rcv AS "Date Rcv",
Vcs_Accd_Info.Date_Send AS "Date Send",
Vcs_Accd_Info.Rgn_Accd_Code AS "Rgn Accd Code",
Count (*) AS "Case Cnt",
Vcd_Sys_Cd.Desc AS "Region",
'THIS' AS "Period",
Vmi_Report_Date.Date_Rpt_Start AS "Date Rpt Start",
Vmi_Report_Date.Date_Rpt_End AS "Date Rpt End",
to_char(Vmi_Report_Date.Date_Rpt_Start ,'DD-MM-YYYY') ||' - '||to_char(Vmi_Report_Date.Date_Rpt_End,'DD-MM-YYYY') AS "Rep",
case when Count (*)= 0 then 0 else days(Vcs_Accd_Info.Date_Rcv) - days(Vcs_Accd_Info.Date_Send)+1 end "Duration",
case when Vcs_Accd_Info.Rgn_Accd_Code = '1' then Count (*) else 0 end "THKI_Case_Cnt",
case when Vcs_Accd_Info.Rgn_Accd_Code = '2' then Count (*) else 0 end "TKE_Case_Cnt",
case when Vcs_Accd_Info.Rgn_Accd_Code = '3' then Count (*) else 0 end "TKW_Case_Cnt",
case when Vcs_Accd_Info.Rgn_Accd_Code = '5' then Count (*) else 0 end "TNTN_Case_Cnt",
case when Vcs_Accd_Info.Rgn_Accd_Code = '4' then Count (*) else 0 end "TNTS_Case_Cnt",
case when Vcs_Accd_Info.Rgn_Accd_Code = '6' then Count (*) else 0 end "OTH_Case_Cnt",
case when Vcs_Accd_Info.Rgn_Accd_Code = '' or Vcs_Accd_Info.Rgn_Accd_Code is null then 1 else 0 end "Unknown_Case_Cnt"
FROM
Vcs_Accd_Info,
Vcd_Sys_Cd,Vmi_Report_Date
WHERE
(Vcs_Accd_Info.Last_Upd_Ts<>Vmi_Report_Date.Join_Ts
AND Vcd_Sys_Cd.Last_Upd_Ts<>Vcs_Accd_Info.Last_Upd_Ts)
AND ((Vcd_Sys_Cd.Sys_Code_Type='RG1'
AND Vcs_Accd_Info.Date_Rcv >= Vmi_Report_Date.Date_Rpt_Start
and Vcs_Accd_Info.Date_Rcv <= Vmi_Report_Date.Date_Rpt_End
AND Vmi_Report_Date.Rpt_Group=6
AND Vcd_Sys_Cd.sys_code_key = Vcs_Accd_Info.Rgn_Accd_Code
AND Vcs_Accd_Info.Date_Send<>'1111-11-11'))
GROUP BY
Vcs_Accd_Info.DATE_RCV,
Vcs_Accd_Info.DATE_SEND,
Vcs_Accd_Info.Rgn_Accd_Code,
Vcd_Sys_Cd.DESC,
'THIS',
Vmi_Report_Date.DATE_RPT_START,
Vmi_Report_Date.DATE_RPT_END,
to_char(Vmi_Report_Date.Date_Rpt_Start ,'DD-MM-YYYY') ||' - '||to_char(Vmi_Report_Date.Date_Rpt_End,'DD-MM-YYYY')
3、怎么樣才能異地容錯呢?將備份文件保存到與服務器處于不同地理位置的機器上,最好能多保存幾份。除了能自動把備份文件傳輸到異地服務器上面,用戶也可以從web界面下載。
APDPlat使用推模型來發送備份文件,接口如下:
/** * 備份文件發送器 * 將最新的備份文件發送到其他機器,防止服務器故障丟失數據 * @author 楊尚川 */ public interface BackupFileSender { public void send(File file); } |