简介
- 我们在做服务器文件上传下载功能的场景下,面对海量用户访问文件的需求,我们一般会把文件上传到第三方CDN服务器上,但是很多时候,我们的运营后台也需要访问这些文件,为了提升运营后台的管理效率,我们采取了先缓存远程CND服务器的文件到本地缓存目录下,当运营人员访问该文件时免去了下载文件的长时间等待,但是如果文件发生了改变,我们怎么办呢?如果多个运营人员同时访问同一个文件我们该如何做到有效的缓存一次呢?本文将以简短有效的代码工具类解决文件下载缓存、缓存更新、并发更新等问题
本文内容有
- 远程文件缓存思路
- 缓存文件更新思路
- 文件并发控制思路
- 工具类源码
参考文章
1、远程文件缓存思路
一般来说,我们读取远程文件有两种方式。如果是支持部分读取的流文件,我们可以直接打开远程文件下载流在线读流内容;而有的文件需要下载完整的文件才能打开进行读取;不管是哪一种,我们读取远程文件时,都会有一个下载过程,我们很多时候在读取远程大文件时往往耗时很久。
计算机读取数据来源耗时排序如下:
寄存器 > 内存 > 硬盘 > 网络
可以看到,网络上的数据读取是最慢的,所以我们很多场景需要把远程文件下载到本地,这也是一种缓存思想。
本文将把文件缓存到硬盘上的临时目录,这样我们的应用程序,在读取远程文件前,可以先检查一下本地文件是否存在,如果存在则直接读取本地文件,不存在则先下载远程文件到本地临时目录下,然后再读取。
缓存文件步骤和场景如下:
- 读取远程文件
- 检查缓存到本地的远程文件是否存在
- 如果存在本地文件,再检查本地文件是否是最新的
- 如果是最新的,则直接读取本地文件即可
- 如果不存在本地缓存文件,则直接下载远程文件到本地临时目录,然后读取缓存完成的本地文件内容
- 如果存在本地缓存文件,但是检测到文件不是最新的,则需要先删除本地文件,重新下载远程文件到本地,然后再读取下载完成的本地文件内容
- 还有为了避免由于网络异常导致的文件下载不完整,本示例中对本地文件和远程文件大小也进行了判断,避免缓存的文件内容缺失。
2、缓存文件更新思路
如何检查本地的缓存文件是否是最新的呢?
我们可以利用HTTP协议中对文件变化情况的标记进行判断文件是否有更新,用到的信息主要是ETAG和Last-Modified响应头,以下是官方解读
- HTTP 协议规格说明定义ETag为“被请求变量的实体值” (参见 —— 章节 14.19)。 另一种说法是,ETag是一个可以与Web资源关联的记号(token)。典型的Web资源可以一个Web页,但也可能是JSON或XML文档。服务器单 独负责判断记号是什么及其含义,并在HTTP响应头中将其传送到客户端,以下是服务器端返回的格式:
ETag: “50b1c1d4f775c61:df3”
客户端的查询更新格式是这样的:
If-None-Match: W/“50b1c1d4f775c61:df3”
如果ETag没改变,则返回状态304然后不返回- 在浏览器第一次请求某一个URL时,服务器端的返回状态会是200,内容是你请求的资源,同时有一个Last-Modified的属性标记此文件在服务期端最后被修改的时间,格式类似这样:
Last-Modified: Fri, 12 May 2006 18:53:33 GMT
客户端第二次请求此URL时,根据 HTTP 协议的规定,浏览器会向服务器传送 If-Modified-Since 报头,询问该时间之后文件是否有被修改过:
If-Modified-Since: Fri, 12 May 2006 18:53:33 GMT
如果服务器端的资源没有变化,则自动返回 HTTP 304 (Not Changed.)状态码,内容为空,这样就节省了传输数据量。总结一下哈,其实ETAG和Last-Modified就是两个标记,都可以用来标识文件是否被更新过。
- ETAG:文件每次改变会被服务器分配一个唯一的ID标识,下载文件时,会返回这个ID给客户端,客户端下次下载请求If-None-Match请求头带上上次返回的ETAG ID内容,服务器端拿到这个ETAG和当前文件的ETAG进行比对,如果一致,则返回304状态码,告诉客户端文件未改变,否则直接返回新的文件内容给客户端。
- Last-Modified:其实这个字段出现的更早,用法也跟ETAG类似,先返回文件的最近修改时间,下次请求时再带上 If-Modified-Since内容就是上次返回的时间,服务器拿当前文件的最近修改时间进行比对,如果相等则返回304状态码告知客户端文件未改变,否则返回文件内容。不过这里值得注意的是,这里的时间是以GTM0时区的时间,千万不要搞错了。
3、文件并发控制思路
java应用对文件的读写独占控制比较弱,我尝试过使用文件输出流的FileLock来控制独占写入,但是效果不理想,这种方式一旦对写入流加锁后就会清空原有文件内容。
经过查询资料,找到一种使用文件锁的方式控制文件并发写时的独占写,大概思路就很简单,在写文件时创建一个.lock文件标识正在写文件,下载完文件后立即删除.lock文件,如果其他线程也想下载文件,可以先检查是否存在这个.lock文件,如果存在,则等待其他线程下载完成,然后再检查刚下载的本地文件是否有更新,如果没有更新则直接读取本地文件即可。
4、工具类源码
4.1 文件锁工具类源码
/**
* Title FileLockUtil.java
* Description
* @author danyuan
* @date Sep 15, 2020
* @version 1.0.0
* site: www.danyuanblog.com
*/
package com.danyuanblog.common.utils;
import java.io.File;
import java.io.IOException;
import com.danyuanblog.common.utils.IOUtils;
import lombok.extern.slf4j.Slf4j;
@Slf4j
public class FileLockUtil {
private static String LOCK_FILENAME = "%s.lock";
public static void waitReleaseLock(String filePath){
int maxWait = 500;// 最大等待次数,锁超时时间,避免文件下载耗时过长
while (FileLockUtil.isLocked(filePath)) {
try {
Thread.sleep(2000);
} catch (InterruptedException e) {
log.warn(IOUtils.getThrowableInfo(e));
}
maxWait--;
if (maxWait < 0) {
break;
}
}
}
public static boolean isLocked(String filePath){
String lockerName = String.format(LOCK_FILENAME, filePath);
File file = new File(lockerName);
return file.exists();
}
public static boolean aquireLock(String filePath){
String lockerName = String.format(LOCK_FILENAME, filePath);
File file = new File(lockerName);
if(file.exists()){
return false;
}
try {
file.createNewFile();
return true;
} catch (IOException e) {
log.warn(IOUtils.getThrowableInfo(e));
return false;
}
}
public static void releaseLock(String filePath){
String lockerName = String.format(LOCK_FILENAME, filePath);
File file = new File(lockerName);
if(file.exists()){
file.delete();
}
}
}
4.2 缓存远程文件工具类源码
/**
* Title CacheRemoteFileUtil.java
* Description 缓存远程文件到本地缓存目录下
* @author danyuan
* @date Sep 11, 2020
* @version 1.0.0
* site: www.danyuanblog.com
*/
package com.danyuanblog.common.utils;
import java.io.File;
import java.io.FileNotFoundException;
import java.io.FileOutputStream;
import java.io.IOException;
import java.io.InputStream;
import java.net.HttpURLConnection;
import java.net.URL;
import java.net.URLConnection;
import java.nio.channels.FileLock;
import java.text.SimpleDateFormat;
import java.util.Base64;
import java.util.Date;
import java.util.Locale;
import java.util.TimeZone;
import com.skyroam.simo.base.utils.IOUtils;
import lombok.extern.slf4j.Slf4j;
@Slf4j
public class CacheRemoteFileUtil {
public static String TEMP_PATH = System.getProperty("java.io.tmpdir")
+ File.separator;
public static void main(String[] args) {
// System.out.println(urlToFilename("https://host/xxx.txt"));
// System.out.println(getFileSuffix("https://host/xxx.txt"));
long start = System.currentTimeMillis() / 1000;
log.info(
"开始下载文件,缓存到[{}]!",
TEMP_PATH
+ urlToFilename("https://host/xxx.txt"));
cacheFile("https://host/xxx.txt");
long end = System.currentTimeMillis() / 1000;
log.info("下载结束,耗时[{}]s!", end - start);
}
public static String getFilename(String url) {
return url.substring(url.lastIndexOf("/") + 1);
}
public static String getFileSuffix(String url) {
String filename = getFilename(url);
return filename.substring(filename.lastIndexOf("."));
}
public static String urlToFilename(String url) {
String filename = getFilename(url);
String name = filename.substring(0, filename.lastIndexOf("."));
String base64Str = Base64.getUrlEncoder()
.encodeToString(url.getBytes());
StringBuffer buffer = new StringBuffer(name);
buffer.append(base64Str.replaceAll("\\W", "")).append(
getFileSuffix(url));
return buffer.toString();
}
public static String urlToFilePath(String url) {
return TEMP_PATH + urlToFilename(url);
}
public static boolean existsCacheFile(String url) {
String filename = urlToFilename(url);
File file = new File(TEMP_PATH + filename);
return file.exists();
}
public static void cacheFile(String remoteUrl) {
cacheFile(remoteUrl, false);
}
public static void cacheFile(String remoteUrl, boolean updateForce) {
// 下载网络文件
int byteRead = 0;
String filename = urlToFilename(remoteUrl);
String localFilePath = TEMP_PATH + filename;
File file = new File(localFilePath);
long fileLen = file.length();
try {
URL url = new URL(remoteUrl);
// 建立URL链接
URLConnection conn = url.openConnection();
// 设置模拟请求头
conn.setRequestProperty("User-Agent",
"Mozilla/4.0 (compatible; MSIE 5.0; Windows NT; DigExt)");
if (file.exists() && !updateForce) {
// 初始链接,检查文件大小
conn.connect();
// 校验文件大小是否匹配
log.info("local file size:{},remote file size:{}", fileLen,
conn.getContentLengthLong());
if (!FileLockUtil.isLocked(localFilePath) && (fileLen != conn.getContentLengthLong())) { // 文件已经下载完成,但是文件不完整,需要重新下载
cacheFile(remoteUrl, true);
return;
}
// 建立URL链接
URLConnection newConn = url.openConnection();
// 设置模拟请求头
newConn.setRequestProperty("User-Agent",
"Mozilla/4.0 (compatible; MSIE 5.0; Windows NT; DigExt)");
// 文件存在,检查文件是否过期
Date date = new Date(file.lastModified());
SimpleDateFormat sdf = new SimpleDateFormat(
"EEE, dd MMM yyyy HH:mm:ss", Locale.ENGLISH);
// 需要使用0时区的修改时间字符串
sdf.setTimeZone(TimeZone.getTimeZone("GMT"));
String lastModifiedTime = sdf.format(date) + " GMT";
if (log.isDebugEnabled()) {
log.debug("url:{},lastModifiedTime:{}", remoteUrl,
lastModifiedTime);
}
newConn.addRequestProperty("If-Modified-Since",
lastModifiedTime);
conn = newConn;
} else if (file.exists()) {
// 文件存在,则强制删除文件
file.delete();
file.createNewFile();
} else {
// 创建新文件
file.createNewFile();
}
// 开始链接
conn.connect();
// 因为要用到URLConnection子类的方法,所以强转成子类
HttpURLConnection urlConn = (HttpURLConnection) conn;
if (urlConn.getResponseCode() == HttpURLConnection.HTTP_OK) {
if(FileLockUtil.aquireLock(localFilePath)){
log.info("Download begin , file name:{}, len: {}",
localFilePath, file.length());
FileOutputStream fs = new FileOutputStream(file);
InputStream inStream = urlConn.getInputStream();
// 获取到锁,进行文件下载和缓存
byte[] buffer = new byte[1024];
while ((byteRead = inStream.read(buffer)) != -1) {
fs.write(buffer, 0, byteRead);
}
//lock.release();
fs.close();
inStream.close();
file.setLastModified(urlConn.getLastModified());
log.info("Download success , file name:{}, len: {}",
localFilePath, file.length());
FileLockUtil.releaseLock(localFilePath);
}else{
// 该文件已经被别的线程占用,阻塞当前线程等待占用文件的线程释放文件锁
FileLockUtil.waitReleaseLock(localFilePath);
log.info("last file[{}] download success, give up this one !",localFilePath);
return;
}
} else if (urlConn.getResponseCode() == 304) {
log.info("url:{} not modified!", remoteUrl);
} else {
log.warn("Download failed , response code : {}",
urlConn.getResponseCode());
}
} catch (FileNotFoundException e) {
log.warn("文件[{}]不存在!", localFilePath);
} catch (IOException e) {
log.warn("URL【{}】,file【{}】文件IO异常!", remoteUrl, localFilePath);
log.warn(IOUtils.getThrowableInfo(e));
}finally{
FileLockUtil.releaseLock(localFilePath);
}
}
}