缓存文件时遵循 Cache-Control 设置 (#4462)

This commit is contained in:
Glavo
2025-09-16 15:15:30 +08:00
committed by GitHub
parent e4bc8f48c5
commit 5005343d00
3 changed files with 96 additions and 16 deletions

View File

@@ -17,6 +17,7 @@
*/ */
package org.jackhuang.hmcl.task; package org.jackhuang.hmcl.task;
import org.jackhuang.hmcl.util.CacheRepository;
import org.jackhuang.hmcl.util.io.NetworkUtils; import org.jackhuang.hmcl.util.io.NetworkUtils;
import org.jetbrains.annotations.NotNull; import org.jetbrains.annotations.NotNull;
@@ -51,8 +52,11 @@ public final class CacheFileTask extends FetchTask<Path> {
// Check cache // Check cache
for (URI uri : uris) { for (URI uri : uris) {
try { try {
setResult(repository.getCachedRemoteFile(uri)); setResult(repository.getCachedRemoteFile(uri, true));
LOG.info("Using cached file for " + NetworkUtils.dropQuery(uri));
return EnumCheckETag.CACHED; return EnumCheckETag.CACHED;
} catch (CacheRepository.CacheExpiredException e) {
LOG.info("Cache expired for " + NetworkUtils.dropQuery(uri));
} catch (IOException ignored) { } catch (IOException ignored) {
} }
} }

View File

@@ -100,6 +100,17 @@ public abstract class FetchTask<T> extends Task<T> {
int repeat = 0; int repeat = 0;
download: download:
for (URI uri : uris) { for (URI uri : uris) {
if (checkETag) {
// Handle cache
try {
Path cache = repository.getCachedRemoteFile(uri, true);
useCachedResult(cache);
LOG.info("Using cached file for " + NetworkUtils.dropQuery(uri));
return;
} catch (IOException ignored) {
}
}
for (int retryTime = 0; retryTime < retry; retryTime++) { for (int retryTime = 0; retryTime < retry; retryTime++) {
if (isCancelled()) { if (isCancelled()) {
break download; break download;
@@ -113,8 +124,7 @@ public abstract class FetchTask<T> extends Task<T> {
URLConnection conn = NetworkUtils.createConnection(uri); URLConnection conn = NetworkUtils.createConnection(uri);
if (conn instanceof HttpURLConnection) { if (conn instanceof HttpURLConnection httpConnection) {
var httpConnection = (HttpURLConnection) conn;
httpConnection.setRequestProperty("Accept-Encoding", "gzip"); httpConnection.setRequestProperty("Accept-Encoding", "gzip");
if (checkETag) repository.injectConnection(httpConnection); if (checkETag) repository.injectConnection(httpConnection);
@@ -172,10 +182,12 @@ public abstract class FetchTask<T> extends Task<T> {
if (responseCode == HttpURLConnection.HTTP_NOT_MODIFIED) { if (responseCode == HttpURLConnection.HTTP_NOT_MODIFIED) {
// Handle cache // Handle cache
try { try {
Path cache = repository.getCachedRemoteFile(NetworkUtils.toURI(conn.getURL())); Path cache = repository.getCachedRemoteFile(NetworkUtils.toURI(conn.getURL()), false);
useCachedResult(cache); useCachedResult(cache);
LOG.info("Using cached file for " + NetworkUtils.dropQuery(uri)); LOG.info("Using cached file for " + NetworkUtils.dropQuery(uri));
return; return;
} catch (CacheRepository.CacheExpiredException e) {
LOG.info("Cache expired for " + NetworkUtils.dropQuery(uri));
} catch (IOException e) { } catch (IOException e) {
LOG.warning("Unable to use cached file, redownload " + NetworkUtils.dropQuery(uri), e); LOG.warning("Unable to use cached file, redownload " + NetworkUtils.dropQuery(uri), e);
repository.removeRemoteEntry(conn.getURL().toURI()); repository.removeRemoteEntry(conn.getURL().toURI());

View File

@@ -36,12 +36,15 @@ import java.nio.file.Files;
import java.nio.file.Path; import java.nio.file.Path;
import java.nio.file.StandardOpenOption; import java.nio.file.StandardOpenOption;
import java.nio.file.attribute.FileTime; import java.nio.file.attribute.FileTime;
import java.time.Instant;
import java.time.ZonedDateTime; import java.time.ZonedDateTime;
import java.time.format.DateTimeFormatter; import java.time.format.DateTimeFormatter;
import java.util.*; import java.util.*;
import java.util.concurrent.locks.ReadWriteLock; import java.util.concurrent.locks.ReadWriteLock;
import java.util.concurrent.locks.ReentrantReadWriteLock; import java.util.concurrent.locks.ReentrantReadWriteLock;
import java.util.function.BiFunction; import java.util.function.BiFunction;
import java.util.regex.Matcher;
import java.util.regex.Pattern;
import static java.nio.charset.StandardCharsets.UTF_8; import static java.nio.charset.StandardCharsets.UTF_8;
import static org.jackhuang.hmcl.util.gson.JsonUtils.*; import static org.jackhuang.hmcl.util.gson.JsonUtils.*;
@@ -150,7 +153,7 @@ public class CacheRepository {
return cache; return cache;
} }
public Path getCachedRemoteFile(URI uri) throws IOException { public Path getCachedRemoteFile(URI uri, boolean checkExpires) throws IOException {
lock.readLock().lock(); lock.readLock().lock();
ETagItem eTagItem; ETagItem eTagItem;
try { try {
@@ -160,6 +163,9 @@ public class CacheRepository {
} }
if (eTagItem == null) throw new IOException("Cannot find the URL"); if (eTagItem == null) throw new IOException("Cannot find the URL");
if (StringUtils.isBlank(eTagItem.hash) || !fileExists(SHA1, eTagItem.hash)) throw new FileNotFoundException(); if (StringUtils.isBlank(eTagItem.hash) || !fileExists(SHA1, eTagItem.hash)) throw new FileNotFoundException();
if (checkExpires && System.currentTimeMillis() > eTagItem.expires)
throw new CacheExpiredException(eTagItem.expires);
Path file = getFile(SHA1, eTagItem.hash); Path file = getFile(SHA1, eTagItem.hash);
if (Files.getLastModifiedTime(file).toMillis() != eTagItem.localLastModified) { if (Files.getLastModifiedTime(file).toMillis() != eTagItem.localLastModified) {
String hash = DigestUtils.digestToString(SHA1, file); String hash = DigestUtils.digestToString(SHA1, file);
@@ -222,6 +228,8 @@ public class CacheRepository {
}); });
} }
private static final Pattern MAX_AGE = Pattern.compile("(s-maxage|max-age)=(?<time>[0-9]+)");
private Path cacheData(URLConnection connection, ExceptionalSupplier<CacheResult, IOException> cacheSupplier) throws IOException { private Path cacheData(URLConnection connection, ExceptionalSupplier<CacheResult, IOException> cacheSupplier) throws IOException {
String eTag = connection.getHeaderField("ETag"); String eTag = connection.getHeaderField("ETag");
if (StringUtils.isBlank(eTag)) return null; if (StringUtils.isBlank(eTag)) return null;
@@ -231,9 +239,41 @@ public class CacheRepository {
} catch (IllegalArgumentException e) { } catch (IllegalArgumentException e) {
throw new IOException(e); throw new IOException(e);
} }
long expires = 0L;
expires:
try {
String cacheControl = connection.getHeaderField("Cache-Control");
if (StringUtils.isNotBlank(cacheControl)) {
if (cacheControl.contains("no-store"))
return null;
Matcher matcher = MAX_AGE.matcher(cacheControl);
if (matcher.find()) {
long seconds = Long.parseLong(matcher.group("time"));
expires = Instant.now().plusSeconds(seconds).toEpochMilli();
break expires;
}
}
String expiresHeader = connection.getHeaderField("Expires");
if (StringUtils.isNotBlank(expiresHeader)) {
expires = ZonedDateTime.parse(expiresHeader.trim(), DateTimeFormatter.RFC_1123_DATE_TIME)
.toInstant().toEpochMilli();
}
} catch (Throwable e) {
LOG.warning("Failed to parse expires time", e);
}
String lastModified = connection.getHeaderField("Last-Modified"); String lastModified = connection.getHeaderField("Last-Modified");
CacheResult cacheResult = cacheSupplier.get(); CacheResult cacheResult = cacheSupplier.get();
ETagItem eTagItem = new ETagItem(uri.toString(), eTag, cacheResult.hash, Files.getLastModifiedTime(cacheResult.cachedFile).toMillis(), lastModified); ETagItem eTagItem = new ETagItem(uri.toString(),
eTag,
cacheResult.hash,
Files.getLastModifiedTime(cacheResult.cachedFile).toMillis(),
lastModified,
expires);
lock.writeLock().lock(); lock.writeLock().lock();
try { try {
index.compute(uri, updateEntity(eTagItem, true)); index.compute(uri, updateEntity(eTagItem, true));
@@ -336,20 +376,26 @@ public class CacheRepository {
private final long localLastModified; private final long localLastModified;
@SerializedName("remote") @SerializedName("remote")
private final String remoteLastModified; private final String remoteLastModified;
private final long expires;
/** /**
* For Gson. * For Gson.
*/ */
public ETagItem() { public ETagItem() {
this(null, null, null, 0, null); this(null, null, null, 0, null, 0L);
} }
public ETagItem(String url, String eTag, String hash, long localLastModified, String remoteLastModified) { public ETagItem(String url, String eTag, String hash, long localLastModified, String remoteLastModified, long expires) {
this.url = url; this.url = url;
this.eTag = eTag; this.eTag = eTag;
this.hash = hash; this.hash = hash;
this.localLastModified = localLastModified; this.localLastModified = localLastModified;
this.remoteLastModified = remoteLastModified; this.remoteLastModified = remoteLastModified;
this.expires = expires;
}
public long getExpires() {
return expires;
} }
public int compareTo(ETagItem other) { public int compareTo(ETagItem other) {
@@ -367,18 +413,18 @@ public class CacheRepository {
@Override @Override
public boolean equals(Object o) { public boolean equals(Object o) {
if (this == o) return true; if (this == o) return true;
if (o == null || getClass() != o.getClass()) return false; return o instanceof ETagItem that
ETagItem eTagItem = (ETagItem) o; && localLastModified == that.localLastModified
return localLastModified == eTagItem.localLastModified && && Objects.equals(url, that.url)
Objects.equals(url, eTagItem.url) && && Objects.equals(eTag, that.eTag)
Objects.equals(eTag, eTagItem.eTag) && && Objects.equals(hash, that.hash)
Objects.equals(hash, eTagItem.hash) && && Objects.equals(remoteLastModified, that.remoteLastModified)
Objects.equals(remoteLastModified, eTagItem.remoteLastModified); && this.expires == that.expires;
} }
@Override @Override
public int hashCode() { public int hashCode() {
return Objects.hash(url, eTag, hash, localLastModified, remoteLastModified); return Objects.hash(url, eTag, hash, localLastModified, remoteLastModified, expires);
} }
@Override @Override
@@ -389,6 +435,7 @@ public class CacheRepository {
", hash='" + hash + '\'' + ", hash='" + hash + '\'' +
", localLastModified=" + localLastModified + ", localLastModified=" + localLastModified +
", remoteLastModified='" + remoteLastModified + '\'' + ", remoteLastModified='" + remoteLastModified + '\'' +
", expires=" + expires +
']'; ']';
} }
} }
@@ -404,4 +451,21 @@ public class CacheRepository {
} }
public static final String SHA1 = "SHA-1"; public static final String SHA1 = "SHA-1";
public static class CacheExpiredException extends IOException {
private final long expires;
public CacheExpiredException(long expires) {
this.expires = expires;
}
public CacheExpiredException(String message, long expires) {
super(message);
this.expires = expires;
}
public long getExpires() {
return expires;
}
}
} }