Feature: 下载并发控制 (#5026)

This commit is contained in:
Burning_TNT
2026-01-11 21:45:31 +08:00
committed by GitHub
parent 74d0a89820
commit 3860e68caa
5 changed files with 181 additions and 96 deletions

View File

@@ -18,6 +18,7 @@
package org.jackhuang.hmcl.setting; package org.jackhuang.hmcl.setting;
import javafx.beans.InvalidationListener; import javafx.beans.InvalidationListener;
import org.jackhuang.hmcl.task.FetchTask;
import org.jackhuang.hmcl.util.StringUtils; import org.jackhuang.hmcl.util.StringUtils;
import org.jackhuang.hmcl.util.io.NetworkUtils; import org.jackhuang.hmcl.util.io.NetworkUtils;
import org.jetbrains.annotations.NotNull; import org.jetbrains.annotations.NotNull;
@@ -114,6 +115,8 @@ public final class ProxyManager {
config().hasProxyAuthProperty().addListener(updateAuthenticator); config().hasProxyAuthProperty().addListener(updateAuthenticator);
config().proxyUserProperty().addListener(updateAuthenticator); config().proxyUserProperty().addListener(updateAuthenticator);
config().proxyPassProperty().addListener(updateAuthenticator); config().proxyPassProperty().addListener(updateAuthenticator);
FetchTask.notifyInitialized();
} }
private static abstract class AbstractProxySelector extends ProxySelector { private static abstract class AbstractProxySelector extends ProxySelector {

View File

@@ -1217,12 +1217,14 @@ public final class FXUtils {
public static Task<Image> getRemoteImageTask(String url, int requestedWidth, int requestedHeight, boolean preserveRatio, boolean smooth) { public static Task<Image> getRemoteImageTask(String url, int requestedWidth, int requestedHeight, boolean preserveRatio, boolean smooth) {
return new CacheFileTask(url) return new CacheFileTask(url)
.setSignificance(Task.TaskSignificance.MINOR)
.thenApplyAsync(file -> loadImage(file, requestedWidth, requestedHeight, preserveRatio, smooth)) .thenApplyAsync(file -> loadImage(file, requestedWidth, requestedHeight, preserveRatio, smooth))
.setSignificance(Task.TaskSignificance.MINOR); .setSignificance(Task.TaskSignificance.MINOR);
} }
public static Task<Image> getRemoteImageTask(URI uri, int requestedWidth, int requestedHeight, boolean preserveRatio, boolean smooth) { public static Task<Image> getRemoteImageTask(URI uri, int requestedWidth, int requestedHeight, boolean preserveRatio, boolean smooth) {
return new CacheFileTask(uri) return new CacheFileTask(uri)
.setSignificance(Task.TaskSignificance.MINOR)
.thenApplyAsync(file -> loadImage(file, requestedWidth, requestedHeight, preserveRatio, smooth)) .thenApplyAsync(file -> loadImage(file, requestedWidth, requestedHeight, preserveRatio, smooth))
.setSignificance(Task.TaskSignificance.MINOR); .setSignificance(Task.TaskSignificance.MINOR);
} }
@@ -1237,6 +1239,7 @@ public final class FXUtils {
LOG.warning("An exception encountered while loading remote image: " + url, exception); LOG.warning("An exception encountered while loading remote image: " + url, exception);
} }
}) })
.setSignificance(Task.TaskSignificance.MINOR)
.start(); .start();
return image; return image;
} }

View File

@@ -35,7 +35,15 @@ import java.io.IOException;
import java.io.InputStream; import java.io.InputStream;
import java.nio.file.Files; import java.nio.file.Files;
import java.nio.file.Path; import java.nio.file.Path;
import java.util.*; import java.util.ArrayList;
import java.util.Collections;
import java.util.Comparator;
import java.util.HashMap;
import java.util.List;
import java.util.Locale;
import java.util.Map;
import java.util.Optional;
import java.util.concurrent.Semaphore;
import java.util.stream.Stream; import java.util.stream.Stream;
import static org.jackhuang.hmcl.util.Lang.mapOf; import static org.jackhuang.hmcl.util.Lang.mapOf;
@@ -46,6 +54,7 @@ public final class CurseForgeRemoteModRepository implements RemoteModRepository
private static final String PREFIX = "https://api.curseforge.com"; private static final String PREFIX = "https://api.curseforge.com";
private static final String apiKey = System.getProperty("hmcl.curseforge.apikey", JarUtils.getAttribute("hmcl.curseforge.apikey", "")); private static final String apiKey = System.getProperty("hmcl.curseforge.apikey", JarUtils.getAttribute("hmcl.curseforge.apikey", ""));
private static final Semaphore SEMAPHORE = new Semaphore(16);
private static final int WORD_PERFECT_MATCH_WEIGHT = 5; private static final int WORD_PERFECT_MATCH_WEIGHT = 5;
@@ -110,46 +119,51 @@ public final class CurseForgeRemoteModRepository implements RemoteModRepository
@Override @Override
public SearchResult search(DownloadProvider downloadProvider, String gameVersion, @Nullable RemoteModRepository.Category category, int pageOffset, int pageSize, String searchFilter, SortType sortType, SortOrder sortOrder) throws IOException { public SearchResult search(DownloadProvider downloadProvider, String gameVersion, @Nullable RemoteModRepository.Category category, int pageOffset, int pageSize, String searchFilter, SortType sortType, SortOrder sortOrder) throws IOException {
int categoryId = 0; SEMAPHORE.acquireUninterruptibly();
if (category != null && category.getSelf() instanceof CurseAddon.Category) { try {
categoryId = ((CurseAddon.Category) category.getSelf()).getId(); int categoryId = 0;
} if (category != null && category.getSelf() instanceof CurseAddon.Category) {
Response<List<CurseAddon>> response = withApiKey(HttpRequest.GET(downloadProvider.injectURL(NetworkUtils.withQuery(PREFIX + "/v1/mods/search", mapOf( categoryId = ((CurseAddon.Category) category.getSelf()).getId();
pair("gameId", "432"), }
pair("classId", Integer.toString(section)), Response<List<CurseAddon>> response = withApiKey(HttpRequest.GET(downloadProvider.injectURL(NetworkUtils.withQuery(PREFIX + "/v1/mods/search", mapOf(
pair("categoryId", Integer.toString(categoryId)), pair("gameId", "432"),
pair("gameVersion", gameVersion), pair("classId", Integer.toString(section)),
pair("searchFilter", searchFilter), pair("categoryId", Integer.toString(categoryId)),
pair("sortField", Integer.toString(toModsSearchSortField(sortType))), pair("gameVersion", gameVersion),
pair("sortOrder", toSortOrder(sortOrder)), pair("searchFilter", searchFilter),
pair("index", Integer.toString(pageOffset * pageSize)), pair("sortField", Integer.toString(toModsSearchSortField(sortType))),
pair("pageSize", Integer.toString(pageSize))))))) pair("sortOrder", toSortOrder(sortOrder)),
.getJson(Response.typeOf(listTypeOf(CurseAddon.class))); pair("index", Integer.toString(pageOffset * pageSize)),
if (searchFilter.isEmpty()) { pair("pageSize", Integer.toString(pageSize)))))))
return new SearchResult(response.getData().stream().map(CurseAddon::toMod), calculateTotalPages(response, pageSize)); .getJson(Response.typeOf(listTypeOf(CurseAddon.class)));
} if (searchFilter.isEmpty()) {
return new SearchResult(response.getData().stream().map(CurseAddon::toMod), calculateTotalPages(response, pageSize));
// https://github.com/HMCL-dev/HMCL/issues/1549
String lowerCaseSearchFilter = searchFilter.toLowerCase(Locale.ROOT);
Map<String, Integer> searchFilterWords = new HashMap<>();
for (String s : StringUtils.tokenize(lowerCaseSearchFilter)) {
searchFilterWords.put(s, searchFilterWords.getOrDefault(s, 0) + 1);
}
StringUtils.LevCalculator levCalculator = new StringUtils.LevCalculator();
return new SearchResult(response.getData().stream().map(CurseAddon::toMod).map(remoteMod -> {
String lowerCaseResult = remoteMod.getTitle().toLowerCase(Locale.ROOT);
int diff = levCalculator.calc(lowerCaseSearchFilter, lowerCaseResult);
for (String s : StringUtils.tokenize(lowerCaseResult)) {
if (searchFilterWords.containsKey(s)) {
diff -= WORD_PERFECT_MATCH_WEIGHT * searchFilterWords.get(s) * s.length();
}
} }
return pair(remoteMod, diff); // https://github.com/HMCL-dev/HMCL/issues/1549
}).sorted(Comparator.comparingInt(Pair::getValue)).map(Pair::getKey), response.getData().stream().map(CurseAddon::toMod), calculateTotalPages(response, pageSize)); String lowerCaseSearchFilter = searchFilter.toLowerCase(Locale.ROOT);
Map<String, Integer> searchFilterWords = new HashMap<>();
for (String s : StringUtils.tokenize(lowerCaseSearchFilter)) {
searchFilterWords.put(s, searchFilterWords.getOrDefault(s, 0) + 1);
}
StringUtils.LevCalculator levCalculator = new StringUtils.LevCalculator();
return new SearchResult(response.getData().stream().map(CurseAddon::toMod).map(remoteMod -> {
String lowerCaseResult = remoteMod.getTitle().toLowerCase(Locale.ROOT);
int diff = levCalculator.calc(lowerCaseSearchFilter, lowerCaseResult);
for (String s : StringUtils.tokenize(lowerCaseResult)) {
if (searchFilterWords.containsKey(s)) {
diff -= WORD_PERFECT_MATCH_WEIGHT * searchFilterWords.get(s) * s.length();
}
}
return pair(remoteMod, diff);
}).sorted(Comparator.comparingInt(Pair::getValue)).map(Pair::getKey), response.getData().stream().map(CurseAddon::toMod), calculateTotalPages(response, pageSize));
} finally {
SEMAPHORE.release();
}
} }
@Override @Override
@@ -173,48 +187,69 @@ public final class CurseForgeRemoteModRepository implements RemoteModRepository
return Optional.empty(); return Optional.empty();
} }
Response<FingerprintMatchesResult> response = withApiKey(HttpRequest.POST(PREFIX + "/v1/fingerprints/432")) SEMAPHORE.acquireUninterruptibly();
.json(mapOf(pair("fingerprints", Collections.singletonList(hash)))) try {
.getJson(Response.typeOf(FingerprintMatchesResult.class)); Response<FingerprintMatchesResult> response = withApiKey(HttpRequest.POST(PREFIX + "/v1/fingerprints/432"))
.json(mapOf(pair("fingerprints", Collections.singletonList(hash))))
.getJson(Response.typeOf(FingerprintMatchesResult.class));
if (response.getData().getExactMatches() == null || response.getData().getExactMatches().isEmpty()) { if (response.getData().getExactMatches() == null || response.getData().getExactMatches().isEmpty()) {
return Optional.empty(); return Optional.empty();
}
return Optional.of(response.getData().getExactMatches().get(0).getFile().toVersion());
} finally {
SEMAPHORE.release();
} }
return Optional.of(response.getData().getExactMatches().get(0).getFile().toVersion());
} }
@Override @Override
public RemoteMod getModById(String id) throws IOException { public RemoteMod getModById(String id) throws IOException {
Response<CurseAddon> response = withApiKey(HttpRequest.GET(PREFIX + "/v1/mods/" + id)) SEMAPHORE.acquireUninterruptibly();
.getJson(Response.typeOf(CurseAddon.class)); try {
return response.data.toMod(); Response<CurseAddon> response = withApiKey(HttpRequest.GET(PREFIX + "/v1/mods/" + id))
.getJson(Response.typeOf(CurseAddon.class));
return response.data.toMod();
} finally {
SEMAPHORE.release();
}
} }
@Override @Override
public RemoteMod.File getModFile(String modId, String fileId) throws IOException { public RemoteMod.File getModFile(String modId, String fileId) throws IOException {
Response<CurseAddon.LatestFile> response = withApiKey(HttpRequest.GET(String.format("%s/v1/mods/%s/files/%s", PREFIX, modId, fileId))) SEMAPHORE.acquireUninterruptibly();
.getJson(Response.typeOf(CurseAddon.LatestFile.class)); try {
return response.getData().toVersion().getFile(); Response<CurseAddon.LatestFile> response = withApiKey(HttpRequest.GET(String.format("%s/v1/mods/%s/files/%s", PREFIX, modId, fileId)))
.getJson(Response.typeOf(CurseAddon.LatestFile.class));
return response.getData().toVersion().getFile();
} finally {
SEMAPHORE.release();
}
} }
@Override @Override
public Stream<RemoteMod.Version> getRemoteVersionsById(String id) throws IOException { public Stream<RemoteMod.Version> getRemoteVersionsById(String id) throws IOException {
Response<List<CurseAddon.LatestFile>> response = withApiKey(HttpRequest.GET(PREFIX + "/v1/mods/" + id + "/files", SEMAPHORE.acquireUninterruptibly();
pair("pageSize", "10000"))) try {
.getJson(Response.typeOf(listTypeOf(CurseAddon.LatestFile.class))); Response<List<CurseAddon.LatestFile>> response = withApiKey(HttpRequest.GET(PREFIX + "/v1/mods/" + id + "/files",
return response.getData().stream().map(CurseAddon.LatestFile::toVersion); pair("pageSize", "10000")))
} .getJson(Response.typeOf(listTypeOf(CurseAddon.LatestFile.class)));
return response.getData().stream().map(CurseAddon.LatestFile::toVersion);
public List<CurseAddon.Category> getCategoriesImpl() throws IOException { } finally {
Response<List<CurseAddon.Category>> categories = withApiKey(HttpRequest.GET(PREFIX + "/v1/categories", pair("gameId", "432"))) SEMAPHORE.release();
.getJson(Response.typeOf(listTypeOf(CurseAddon.Category.class))); }
return reorganizeCategories(categories.getData(), section);
} }
@Override @Override
public Stream<RemoteModRepository.Category> getCategories() throws IOException { public Stream<RemoteModRepository.Category> getCategories() throws IOException {
return getCategoriesImpl().stream().map(CurseAddon.Category::toCategory); SEMAPHORE.acquireUninterruptibly();
try {
Response<List<CurseAddon.Category>> categories = withApiKey(HttpRequest.GET(PREFIX + "/v1/categories", pair("gameId", "432")))
.getJson(Response.typeOf(listTypeOf(CurseAddon.Category.class)));
return reorganizeCategories(categories.getData(), section).stream().map(CurseAddon.Category::toCategory);
} finally {
SEMAPHORE.release();
}
} }
private List<CurseAddon.Category> reorganizeCategories(List<CurseAddon.Category> categories, int rootId) { private List<CurseAddon.Category> reorganizeCategories(List<CurseAddon.Category> categories, int rootId) {

View File

@@ -24,7 +24,11 @@ import org.jackhuang.hmcl.mod.LocalModFile;
import org.jackhuang.hmcl.mod.ModLoaderType; import org.jackhuang.hmcl.mod.ModLoaderType;
import org.jackhuang.hmcl.mod.RemoteMod; import org.jackhuang.hmcl.mod.RemoteMod;
import org.jackhuang.hmcl.mod.RemoteModRepository; import org.jackhuang.hmcl.mod.RemoteModRepository;
import org.jackhuang.hmcl.util.*; import org.jackhuang.hmcl.util.DigestUtils;
import org.jackhuang.hmcl.util.Immutable;
import org.jackhuang.hmcl.util.Lang;
import org.jackhuang.hmcl.util.Pair;
import org.jackhuang.hmcl.util.StringUtils;
import org.jackhuang.hmcl.util.gson.JsonUtils; import org.jackhuang.hmcl.util.gson.JsonUtils;
import org.jackhuang.hmcl.util.io.HttpRequest; import org.jackhuang.hmcl.util.io.HttpRequest;
import org.jackhuang.hmcl.util.io.NetworkUtils; import org.jackhuang.hmcl.util.io.NetworkUtils;
@@ -35,7 +39,14 @@ import java.io.IOException;
import java.nio.file.NoSuchFileException; import java.nio.file.NoSuchFileException;
import java.nio.file.Path; import java.nio.file.Path;
import java.time.Instant; import java.time.Instant;
import java.util.*; import java.util.ArrayList;
import java.util.Collections;
import java.util.List;
import java.util.Map;
import java.util.Objects;
import java.util.Optional;
import java.util.Set;
import java.util.concurrent.Semaphore;
import java.util.stream.Collectors; import java.util.stream.Collectors;
import java.util.stream.Stream; import java.util.stream.Stream;
@@ -49,6 +60,8 @@ public final class ModrinthRemoteModRepository implements RemoteModRepository {
public static final ModrinthRemoteModRepository RESOURCE_PACKS = new ModrinthRemoteModRepository("resourcepack"); public static final ModrinthRemoteModRepository RESOURCE_PACKS = new ModrinthRemoteModRepository("resourcepack");
public static final ModrinthRemoteModRepository SHADER_PACKS = new ModrinthRemoteModRepository("shader"); public static final ModrinthRemoteModRepository SHADER_PACKS = new ModrinthRemoteModRepository("shader");
private static final Semaphore SEMAPHORE = new Semaphore(16);
private static final String PREFIX = "https://api.modrinth.com"; private static final String PREFIX = "https://api.modrinth.com";
private final String projectType; private final String projectType;
@@ -81,30 +94,36 @@ public final class ModrinthRemoteModRepository implements RemoteModRepository {
@Override @Override
public SearchResult search(DownloadProvider downloadProvider, String gameVersion, @Nullable RemoteModRepository.Category category, int pageOffset, int pageSize, String searchFilter, SortType sort, SortOrder sortOrder) throws IOException { public SearchResult search(DownloadProvider downloadProvider, String gameVersion, @Nullable RemoteModRepository.Category category, int pageOffset, int pageSize, String searchFilter, SortType sort, SortOrder sortOrder) throws IOException {
List<List<String>> facets = new ArrayList<>(); SEMAPHORE.acquireUninterruptibly();
facets.add(Collections.singletonList("project_type:" + projectType)); try {
if (StringUtils.isNotBlank(gameVersion)) { List<List<String>> facets = new ArrayList<>();
facets.add(Collections.singletonList("versions:" + gameVersion)); facets.add(Collections.singletonList("project_type:" + projectType));
if (StringUtils.isNotBlank(gameVersion)) {
facets.add(Collections.singletonList("versions:" + gameVersion));
}
if (category != null && StringUtils.isNotBlank(category.getId())) {
facets.add(Collections.singletonList("categories:" + category.getId()));
}
Map<String, String> query = mapOf(
pair("query", searchFilter),
pair("facets", JsonUtils.UGLY_GSON.toJson(facets)),
pair("offset", Integer.toString(pageOffset * pageSize)),
pair("limit", Integer.toString(pageSize)),
pair("index", convertSortType(sort))
);
Response<ProjectSearchResult> response = HttpRequest.GET(downloadProvider.injectURL(NetworkUtils.withQuery(PREFIX + "/v2/search", query)))
.getJson(Response.typeOf(ProjectSearchResult.class));
return new SearchResult(response.getHits().stream().map(ProjectSearchResult::toMod), (int) Math.ceil((double) response.totalHits / pageSize));
} finally {
SEMAPHORE.release();
} }
if (category != null && StringUtils.isNotBlank(category.getId())) {
facets.add(Collections.singletonList("categories:" + category.getId()));
}
Map<String, String> query = mapOf(
pair("query", searchFilter),
pair("facets", JsonUtils.UGLY_GSON.toJson(facets)),
pair("offset", Integer.toString(pageOffset * pageSize)),
pair("limit", Integer.toString(pageSize)),
pair("index", convertSortType(sort))
);
Response<ProjectSearchResult> response = HttpRequest.GET(downloadProvider.injectURL(NetworkUtils.withQuery(PREFIX + "/v2/search", query)))
.getJson(Response.typeOf(ProjectSearchResult.class));
return new SearchResult(response.getHits().stream().map(ProjectSearchResult::toMod), (int) Math.ceil((double) response.totalHits / pageSize));
} }
@Override @Override
public Optional<RemoteMod.Version> getRemoteVersionByLocalFile(LocalModFile localModFile, Path file) throws IOException { public Optional<RemoteMod.Version> getRemoteVersionByLocalFile(LocalModFile localModFile, Path file) throws IOException {
String sha1 = DigestUtils.digestToString("SHA-1", file); String sha1 = DigestUtils.digestToString("SHA-1", file);
SEMAPHORE.acquireUninterruptibly();
try { try {
ProjectVersion mod = HttpRequest.GET(PREFIX + "/v2/version_file/" + sha1, ProjectVersion mod = HttpRequest.GET(PREFIX + "/v2/version_file/" + sha1,
pair("algorithm", "sha1")) pair("algorithm", "sha1"))
@@ -118,14 +137,21 @@ public final class ModrinthRemoteModRepository implements RemoteModRepository {
} }
} catch (NoSuchFileException e) { } catch (NoSuchFileException e) {
return Optional.empty(); return Optional.empty();
} finally {
SEMAPHORE.release();
} }
} }
@Override @Override
public RemoteMod getModById(String id) throws IOException { public RemoteMod getModById(String id) throws IOException {
id = StringUtils.removePrefix(id, "local-"); SEMAPHORE.acquireUninterruptibly();
Project project = HttpRequest.GET(PREFIX + "/v2/project/" + id).getJson(Project.class); try {
return project.toMod(); id = StringUtils.removePrefix(id, "local-");
Project project = HttpRequest.GET(PREFIX + "/v2/project/" + id).getJson(Project.class);
return project.toMod();
} finally {
SEMAPHORE.release();
}
} }
@Override @Override
@@ -135,20 +161,28 @@ public final class ModrinthRemoteModRepository implements RemoteModRepository {
@Override @Override
public Stream<RemoteMod.Version> getRemoteVersionsById(String id) throws IOException { public Stream<RemoteMod.Version> getRemoteVersionsById(String id) throws IOException {
id = StringUtils.removePrefix(id, "local-"); SEMAPHORE.acquireUninterruptibly();
List<ProjectVersion> versions = HttpRequest.GET(PREFIX + "/v2/project/" + id + "/version") try {
.getJson(listTypeOf(ProjectVersion.class)); id = StringUtils.removePrefix(id, "local-");
return versions.stream().map(ProjectVersion::toVersion).flatMap(Lang::toStream); List<ProjectVersion> versions = HttpRequest.GET(PREFIX + "/v2/project/" + id + "/version")
} .getJson(listTypeOf(ProjectVersion.class));
return versions.stream().map(ProjectVersion::toVersion).flatMap(Lang::toStream);
public List<Category> getCategoriesImpl() throws IOException { } finally {
List<Category> categories = HttpRequest.GET(PREFIX + "/v2/tag/category").getJson(listTypeOf(Category.class)); SEMAPHORE.release();
return categories.stream().filter(category -> category.getProjectType().equals(projectType)).collect(Collectors.toList()); }
} }
@Override @Override
public Stream<RemoteModRepository.Category> getCategories() throws IOException { public Stream<RemoteModRepository.Category> getCategories() throws IOException {
return getCategoriesImpl().stream().map(Category::toCategory); SEMAPHORE.acquireUninterruptibly();
try {
List<Category> categories = HttpRequest.GET(PREFIX + "/v2/tag/category").getJson(listTypeOf(Category.class));
return categories.stream()
.filter(category -> category.getProjectType().equals(projectType))
.map(Category::toCategory);
} finally {
SEMAPHORE.release();
}
} }
public static class Category { public static class Category {
@@ -160,7 +194,7 @@ public final class ModrinthRemoteModRepository implements RemoteModRepository {
private final String projectType; private final String projectType;
public Category() { public Category() {
this("","",""); this("", "", "");
} }
public Category(String icon, String name, String projectType) { public Category(String icon, String name, String projectType) {

View File

@@ -509,12 +509,22 @@ public abstract class FetchTask<T> extends Task<T> {
return downloadExecutorConcurrency; return downloadExecutorConcurrency;
} }
private static volatile boolean initialized = false;
public static void notifyInitialized() {
initialized = true;
}
/// Ensure that [#HTTP_CLIENT] is initialized after ProxyManager has been initialized. /// Ensure that [#HTTP_CLIENT] is initialized after ProxyManager has been initialized.
private static final class Holder { private static final class Holder {
private static final HttpClient HTTP_CLIENT; private static final HttpClient HTTP_CLIENT;
private static final String USER_AGENT = System.getProperty("http.agent", "HMCL"); private static final String USER_AGENT = System.getProperty("http.agent", "HMCL");
static { static {
if (!initialized) {
throw new AssertionError("FetchTask.Holder accessed before ProxyManager initialization.");
}
boolean useHttp2 = !"false".equalsIgnoreCase(System.getProperty("hmcl.http2")); boolean useHttp2 = !"false".equalsIgnoreCase(System.getProperty("hmcl.http2"));
HTTP_CLIENT = HttpClient.newBuilder() HTTP_CLIENT = HttpClient.newBuilder()