APDPlat提供了web接口的数据库备份与恢复,支持手工操作和定时调度,可下载备份文件到本地,也可把备份文件发送到异地容错,极大地简化了数据库的维护工作。
设计目标:
1、多数据库支持
2、横切关注点隔离
3、异地容错
下面阐述具体的设计及实现:
1、为了支持多数据库,统一的接口是不可避免的,如下所示:
/** * 备份恢复数据库接口 * @author 杨尚川 */ public interface BackupService { /** * 备份数据库 * @return 是否备份成功 */ public boolean backup(); /** * 恢复数据库 * @param date * @return 是否恢复成功 */ public boolean restore(String date); /** * 获取已经存在的备份文件名称列表 * @return 备份文件名称列表 */ public ListgetExistBackupFileNames(); /** * 获取备份文件存放的本地文件系统路径 * @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(){ Mapmap = new HashMap<>(); List 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 getExistBackupFileNames(){ List 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首先要引入依赖:
org.aspectj aspectjrt ${aspectj.version} org.aspectj aspectjweaver ${aspectj.version}
其次是要在spring配置文件中指定启用自动代理:
最后就可以编写代码实现日志记录:
/** * 备份恢复数据库日志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("记录完毕"); } }
3、怎么样才能异地容错呢?将备份文件保存到与服务器处于不同地理位置的机器上,最好能多保存几份。除了能自动把备份文件传输到异地服务器上面,用户也可以从web界面下载。
APDPlat使用推模型来发送备份文件,接口如下:
/** * 备份文件发送器 * 将最新的备份文件发送到其他机器,防止服务器故障丢失数据 * @author 杨尚川 */ public interface BackupFileSender { public void send(File file); }
有了统一的接口,就可以有灵活的实现方式,如通过HTTP、FTP、SOCKET等方式发送到异地机房。
在上面的BackupServiceExecuter类中我们已经看到,当备份成功之后就会调用BackupFileSenderExecuter的send方法发送备份文件,如下:
boolean result = backupService.backup(); //如果备份成功,则将备份文件发往他处 if(result){ backupFileSenderExecuter.send(getNewestBackupFile()); }
BackupFileSenderExecuter的设计和BackupServiceExecuter类似,不过策略不一样,如果配置有多个Sender,那么会调用所有的Sender,达到拥有多个副本的目的。BackupFileSenderExecuter利用配置项log.backup.file.sender的值来指定启用哪些Sender,并依次调用各个Sender的send方法来完成文件的发送,如下所示:
log.backup.file.sender=localBackupFileSender;
这里 localBackupFileSender是LocalBackupFileSender的Spring Bean名称。
/** *执行备份文件的发送服务,根据配置文件来判断使用哪些发送器,并按配置的前后顺序依次调用 * @author 杨尚川 */ @Service public class BackupFileSenderExecuter implements BackupFileSender, ApplicationListener{ protected final APDPlatLogger LOG = new APDPlatLogger(getClass()); private static final ListbackupFileSenders = new LinkedList<>(); @Override public void send(File file) { for(BackupFileSender sender : backupFileSenders){ sender.send(file); } } @Override public void onApplicationEvent(ApplicationEvent event){ if(event instanceof ContextRefreshedEvent){ LOG.info("spring容器初始化完成,开始解析BackupFileSender"); String senderstr = PropertyHolder.getProperty("log.backup.file.sender"); if(StringUtils.isBlank(senderstr)){ LOG.info("未配置log.backup.file.sender"); return; } LOG.info("log.backup.file.sender:"+senderstr); String[] senders = senderstr.trim().split(";"); for(String sender : senders){ BackupFileSender backupFileSender = SpringContextUtils.getBean(sender.trim()); if(backupFileSender != null){ backupFileSenders.add(backupFileSender); LOG.info("找到BackupFileSender:"+sender); }else{ LOG.info("未找到BackupFileSender:"+sender); } } } } }
看一个备份文件发送者示例LocalBackupFileSender:
/** * 将备份文件从本地一个目录复制到另一个目录 * @author 杨尚川 */ @Service public class LocalBackupFileSender implements BackupFileSender{ protected final APDPlatLogger LOG = new APDPlatLogger(getClass()); @Override public void send(File file) { try { String dist = PropertyHolder.getProperty("log.backup.file.local.dir"); LOG.info("备份文件:"+file.getAbsolutePath()); LOG.info("目标目录:"+dist); FileUtils.copyFile(file, new File(dist,file.getName())); } catch (IOException ex) { LOG.info("LocalBackupFileSender失败", ex); } } }
APDPlat托管在Github