代码清理与修复 UI 卡顿资源占用过高的问题 (#1849)

* Lazy initialization of Swing

* Load ISRG Root X1 certificate only on Java 8

* Replace JOptionPane with JavaFX Alert

* Avoid using java.awt.Desktop

* Rewrite TexturesLoader

* Optimization SelfDependencyPatcher

* fix typo

* close #968: Use computeIfAbsent to ensure thread safety

* Optimization GameVersion::minecraftVersion

* code cleanup

* Set the initial capacity of readFullyWithoutClosing

* code cleanup

* Mark inner classes as static if possible

* Cache version icon

* Code cleanup

* Fix ListView scrolling performance issues

* DatapackListPage::items

* Replace OutputStream with FileChannel::write
This commit is contained in:
Glavo
2022-11-23 16:33:14 +08:00
committed by GitHub
parent 035469240d
commit 0f26ae5cfc
48 changed files with 588 additions and 421 deletions

View File

@@ -253,7 +253,7 @@ public class OAuth {
DEVICE,
}
public class Result {
public static final class Result {
private final String accessToken;
private final String refreshToken;
@@ -282,7 +282,7 @@ public class OAuth {
@SerializedName("verification_uri")
public String verificationURI;
// Life time in seconds for device_code and user_code
// Lifetime in seconds for device_code and user_code
@SerializedName("expires_in")
public int expiresIn;

View File

@@ -25,7 +25,6 @@ import org.jackhuang.hmcl.util.Lang;
import org.jackhuang.hmcl.util.gson.JsonUtils;
import org.jackhuang.hmcl.util.gson.UUIDTypeAdapter;
import org.jackhuang.hmcl.util.io.HttpServer;
import org.jackhuang.hmcl.util.io.IOUtils;
import java.io.IOException;
import java.security.*;
@@ -80,8 +79,7 @@ public class YggdrasilServer extends HttpServer {
}
private Response profiles(Request request) throws IOException {
String body = IOUtils.readFullyAsString(request.getSession().getInputStream(), UTF_8);
List<String> names = JsonUtils.fromNonNullJson(body, new TypeToken<List<String>>() {
List<String> names = JsonUtils.fromNonNullJsonFully(request.getSession().getInputStream(), new TypeToken<List<String>>() {
}.getType());
return ok(names.stream().distinct()
.map(this::findCharacterByName)

View File

@@ -240,7 +240,7 @@ public class DefaultCacheRepository extends CacheRepository {
}
}
private class LibraryIndex implements Validation {
private static final class LibraryIndex implements Validation {
private final String name;
private final String hash;
private final String type;

View File

@@ -68,8 +68,7 @@ public class ForgeOldInstallTask extends Task<Version> {
InputStream stream = zipFile.getInputStream(zipFile.getEntry("install_profile.json"));
if (stream == null)
throw new ArtifactMalformedException("Malformed forge installer file, install_profile.json does not exist.");
String json = IOUtils.readFullyAsString(stream);
ForgeInstallProfile installProfile = JsonUtils.fromNonNullJson(json, ForgeInstallProfile.class);
ForgeInstallProfile installProfile = JsonUtils.fromNonNullJsonFully(stream, ForgeInstallProfile.class);
// unpack the universal jar in the installer file.
Library forgeLibrary = new Library(installProfile.getInstall().getPath());

View File

@@ -67,7 +67,7 @@ public class DefaultGameRepository implements GameRepository {
private File baseDirectory;
protected Map<String, Version> versions;
private ConcurrentHashMap<File, Optional<String>> gameVersions = new ConcurrentHashMap<>();
private final ConcurrentHashMap<File, Optional<String>> gameVersions = new ConcurrentHashMap<>();
public DefaultGameRepository(File baseDirectory) {
this.baseDirectory = baseDirectory;
@@ -145,19 +145,13 @@ public class DefaultGameRepository implements GameRepository {
// This implementation may cause multiple flows against the same version entering
// this function, which is accepted because GameVersion::minecraftVersion should
// be consistent.
File versionJar = getVersionJar(version);
if (gameVersions.containsKey(versionJar)) {
return gameVersions.get(versionJar);
} else {
return gameVersions.computeIfAbsent(getVersionJar(version), versionJar -> {
Optional<String> gameVersion = GameVersion.minecraftVersion(versionJar);
if (!gameVersion.isPresent()) {
LOG.warning("Cannot find out game version of " + version.getId() + ", primary jar: " + versionJar.toString() + ", jar exists: " + versionJar.exists());
}
gameVersions.put(versionJar, gameVersion);
return gameVersion;
}
});
}
@Override

View File

@@ -19,8 +19,6 @@ package org.jackhuang.hmcl.game;
import com.google.gson.JsonParseException;
import org.jackhuang.hmcl.util.gson.JsonUtils;
import org.jackhuang.hmcl.util.io.CompressingUtils;
import org.jackhuang.hmcl.util.io.FileUtils;
import org.jenkinsci.constant_pool_scanner.ConstantPool;
import org.jenkinsci.constant_pool_scanner.ConstantPoolScanner;
import org.jenkinsci.constant_pool_scanner.ConstantType;
@@ -28,15 +26,15 @@ import org.jenkinsci.constant_pool_scanner.StringConstant;
import java.io.File;
import java.io.IOException;
import java.nio.file.FileSystem;
import java.nio.file.Files;
import java.nio.file.Path;
import java.io.InputStream;
import java.util.List;
import java.util.Map;
import java.util.Optional;
import java.util.logging.Level;
import java.util.stream.Collectors;
import java.util.stream.StreamSupport;
import java.util.zip.ZipEntry;
import java.util.zip.ZipFile;
import static org.jackhuang.hmcl.util.Lang.tryCast;
import static org.jackhuang.hmcl.util.Logging.LOG;
@@ -48,9 +46,9 @@ public final class GameVersion {
private GameVersion() {
}
private static Optional<String> getVersionFromJson(Path versionJson) {
private static Optional<String> getVersionFromJson(InputStream versionJson) {
try {
Map<?, ?> version = JsonUtils.fromNonNullJson(FileUtils.readText(versionJson), Map.class);
Map<?, ?> version = JsonUtils.fromNonNullJsonFully(versionJson, Map.class);
return tryCast(version.get("name"), String.class);
} catch (IOException | JsonParseException e) {
LOG.log(Level.WARNING, "Failed to parse version.json", e);
@@ -58,7 +56,7 @@ public final class GameVersion {
}
}
private static Optional<String> getVersionOfClassMinecraft(byte[] bytecode) throws IOException {
private static Optional<String> getVersionOfClassMinecraft(InputStream bytecode) throws IOException {
ConstantPool pool = ConstantPoolScanner.parse(bytecode, ConstantType.STRING);
return StreamSupport.stream(pool.list(StringConstant.class).spliterator(), false)
@@ -68,7 +66,7 @@ public final class GameVersion {
.findFirst();
}
private static Optional<String> getVersionFromClassMinecraftServer(byte[] bytecode) throws IOException {
private static Optional<String> getVersionFromClassMinecraftServer(InputStream bytecode) throws IOException {
ConstantPool pool = ConstantPoolScanner.parse(bytecode, ConstantType.STRING);
List<String> list = StreamSupport.stream(pool.list(StringConstant.class).spliterator(), false)
@@ -94,23 +92,29 @@ public final class GameVersion {
if (file == null || !file.exists() || !file.isFile() || !file.canRead())
return Optional.empty();
try (FileSystem gameJar = CompressingUtils.createReadOnlyZipFileSystem(file.toPath())) {
Path versionJson = gameJar.getPath("version.json");
if (Files.exists(versionJson)) {
Optional<String> result = getVersionFromJson(versionJson);
try (ZipFile gameJar = new ZipFile(file)) {
ZipEntry versionJson = gameJar.getEntry("version.json");
if (versionJson != null) {
Optional<String> result = getVersionFromJson(gameJar.getInputStream(versionJson));
if (result.isPresent())
return result;
}
Path minecraft = gameJar.getPath("net/minecraft/client/Minecraft.class");
if (Files.exists(minecraft)) {
Optional<String> result = getVersionOfClassMinecraft(Files.readAllBytes(minecraft));
if (result.isPresent())
return result;
ZipEntry minecraft = gameJar.getEntry("net/minecraft/client/Minecraft.class");
if (minecraft != null) {
try (InputStream is = gameJar.getInputStream(minecraft)) {
Optional<String> result = getVersionOfClassMinecraft(is);
if (result.isPresent())
return result;
}
}
ZipEntry minecraftServer = gameJar.getEntry("net/minecraft/server/MinecraftServer.class");
if (minecraftServer != null) {
try (InputStream is = gameJar.getInputStream(minecraftServer)) {
return getVersionFromClassMinecraftServer(is);
}
}
Path minecraftServer = gameJar.getPath("net/minecraft/server/MinecraftServer.class");
if (Files.exists(minecraftServer))
return getVersionFromClassMinecraftServer(Files.readAllBytes(minecraftServer));
return Optional.empty();
} catch (IOException e) {
return Optional.empty();

View File

@@ -67,7 +67,7 @@ public final class StringArgument implements Argument {
return argument;
}
public class Serializer implements JsonSerializer<StringArgument> {
public static final class Serializer implements JsonSerializer<StringArgument> {
@Override
public JsonElement serialize(StringArgument src, Type typeOfSrc, JsonSerializationContext context) {
return new JsonPrimitive(src.getArgument());

View File

@@ -20,7 +20,6 @@ package org.jackhuang.hmcl.mod;
import com.google.gson.JsonParseException;
import org.jackhuang.hmcl.util.Immutable;
import org.jackhuang.hmcl.util.gson.JsonUtils;
import org.jackhuang.hmcl.util.io.IOUtils;
import java.io.IOException;
import java.nio.file.Path;
@@ -112,7 +111,7 @@ public final class LiteModMetadata {
ZipEntry entry = zipFile.getEntry("litemod.json");
if (entry == null)
throw new IOException("File " + modFile + "is not a LiteLoader mod.");
LiteModMetadata metadata = JsonUtils.GSON.fromJson(IOUtils.readFullyAsString(zipFile.getInputStream(entry)), LiteModMetadata.class);
LiteModMetadata metadata = JsonUtils.fromJsonFully(zipFile.getInputStream(entry), LiteModMetadata.class);
if (metadata == null)
throw new IOException("Mod " + modFile + " `litemod.json` is malformed.");
return new LocalModFile(modManager, modManager.getLocalMod(metadata.getName(), ModLoaderType.LITE_LOADER), modFile, metadata.getName(), new LocalModFile.Description(metadata.getDescription()), metadata.getAuthor(),

View File

@@ -26,10 +26,10 @@ import org.jackhuang.hmcl.game.LaunchOptions;
import org.jackhuang.hmcl.mod.*;
import org.jackhuang.hmcl.task.Task;
import org.jackhuang.hmcl.util.gson.JsonUtils;
import org.jackhuang.hmcl.util.io.IOUtils;
import java.io.File;
import java.io.IOException;
import java.io.InputStream;
import java.nio.charset.Charset;
import java.nio.file.Path;
@@ -66,8 +66,8 @@ public final class McbbsModpackProvider implements ModpackProvider {
config.getManifest().injectLaunchOptions(builder);
}
private static Modpack fromManifestFile(String json, Charset encoding) throws IOException, JsonParseException {
McbbsModpackManifest manifest = JsonUtils.fromNonNullJson(json, McbbsModpackManifest.class);
private static Modpack fromManifestFile(InputStream json, Charset encoding) throws IOException, JsonParseException {
McbbsModpackManifest manifest = JsonUtils.fromNonNullJsonFully(json, McbbsModpackManifest.class);
return manifest.toModpack(encoding);
}
@@ -75,11 +75,11 @@ public final class McbbsModpackProvider implements ModpackProvider {
public Modpack readManifest(ZipFile zip, Path file, Charset encoding) throws IOException, JsonParseException {
ZipArchiveEntry mcbbsPackMeta = zip.getEntry("mcbbs.packmeta");
if (mcbbsPackMeta != null) {
return fromManifestFile(IOUtils.readFullyAsString(zip.getInputStream(mcbbsPackMeta)), encoding);
return fromManifestFile(zip.getInputStream(mcbbsPackMeta), encoding);
}
ZipArchiveEntry manifestJson = zip.getEntry("manifest.json");
if (manifestJson != null) {
return fromManifestFile(IOUtils.readFullyAsString(zip.getInputStream(manifestJson)), encoding);
return fromManifestFile(zip.getInputStream(manifestJson), encoding);
}
throw new IOException("`mcbbs.packmeta` or `manifest.json` cannot be found");
}

View File

@@ -22,7 +22,6 @@ import org.apache.commons.compress.archivers.zip.ZipArchiveEntry;
import org.apache.commons.compress.archivers.zip.ZipFile;
import org.jackhuang.hmcl.util.Immutable;
import org.jackhuang.hmcl.util.gson.JsonUtils;
import org.jackhuang.hmcl.util.io.IOUtils;
import java.io.IOException;
import java.util.List;
@@ -60,8 +59,7 @@ public final class MultiMCManifest {
ZipArchiveEntry mmcPack = zipFile.getEntry(rootEntryName + "mmc-pack.json");
if (mmcPack == null)
return null;
String json = IOUtils.readFullyAsString(zipFile.getInputStream(mmcPack));
MultiMCManifest manifest = JsonUtils.fromNonNullJson(json, MultiMCManifest.class);
MultiMCManifest manifest = JsonUtils.fromNonNullJsonFully(zipFile.getInputStream(mmcPack), MultiMCManifest.class);
if (manifest.getComponents() == null)
throw new IOException("mmc-pack.json malformed.");

View File

@@ -239,7 +239,7 @@ public abstract class FetchTask<T> extends Task<T> {
CACHED
}
protected class DownloadState {
protected static final class DownloadState {
private final int startPosition;
private final int endPosition;
private final int currentPosition;
@@ -272,9 +272,7 @@ public abstract class FetchTask<T> extends Task<T> {
}
}
protected class DownloadMission {
protected static final class DownloadMission {
}

View File

@@ -20,7 +20,6 @@ package org.jackhuang.hmcl.task;
import javafx.application.Platform;
import org.jackhuang.hmcl.util.Logging;
import javax.swing.*;
import java.util.concurrent.*;
import static org.jackhuang.hmcl.util.Lang.threadPool;
@@ -61,10 +60,6 @@ public final class Schedulers {
return Platform::runLater;
}
public static Executor swing() {
return SwingUtilities::invokeLater;
}
public static Executor defaultScheduler() {
return ForkJoinPool.commonPool();
}

View File

@@ -27,9 +27,9 @@ import org.jackhuang.hmcl.util.io.IOUtils;
import java.io.FileNotFoundException;
import java.io.IOException;
import java.io.OutputStream;
import java.io.RandomAccessFile;
import java.net.URLConnection;
import java.nio.ByteBuffer;
import java.nio.channels.Channels;
import java.nio.channels.FileChannel;
import java.nio.channels.FileLock;
@@ -54,7 +54,7 @@ public class CacheRepository {
private Path cacheDirectory;
private Path indexFile;
private Map<String, ETagItem> index;
private Map<String, Storage> storages = new HashMap<>();
private final Map<String, Storage> storages = new HashMap<>();
private final ReadWriteLock lock = new ReentrantReadWriteLock();
public void changeDirectory(Path commonDir) {
@@ -293,9 +293,8 @@ public class CacheRepository {
ETagIndex indexOnDisk = JsonUtils.fromMaybeMalformedJson(new String(IOUtils.readFullyWithoutClosing(Channels.newInputStream(channel)), UTF_8), ETagIndex.class);
Map<String, ETagItem> newIndex = joinETagIndexes(indexOnDisk == null ? null : indexOnDisk.eTag, index.values());
channel.truncate(0);
OutputStream os = Channels.newOutputStream(channel);
ETagIndex writeTo = new ETagIndex(newIndex.values());
IOUtils.write(JsonUtils.GSON.toJson(writeTo).getBytes(UTF_8), os);
channel.write(ByteBuffer.wrap(JsonUtils.GSON.toJson(writeTo).getBytes(UTF_8)));
this.index = newIndex;
} finally {
lock.release();
@@ -303,7 +302,7 @@ public class CacheRepository {
}
}
private class ETagIndex {
private static final class ETagIndex {
private final Collection<ETagItem> eTag;
public ETagIndex() {
@@ -315,7 +314,7 @@ public class CacheRepository {
}
}
private class ETagItem {
private static final class ETagItem {
private final String url;
private final String eTag;
private final String hash;
@@ -429,8 +428,7 @@ public class CacheRepository {
if (indexOnDisk == null) indexOnDisk = new HashMap<>();
indexOnDisk.putAll(storage);
channel.truncate(0);
OutputStream os = Channels.newOutputStream(channel);
IOUtils.write(JsonUtils.GSON.toJson(storage).getBytes(UTF_8), os);
channel.write(ByteBuffer.wrap(JsonUtils.GSON.toJson(storage).getBytes(UTF_8)));
this.storage = indexOnDisk;
} finally {
lock.release();

View File

@@ -23,7 +23,11 @@ import com.google.gson.JsonParseException;
import com.google.gson.JsonSyntaxException;
import java.io.File;
import java.io.IOException;
import java.io.InputStream;
import java.io.InputStreamReader;
import java.lang.reflect.Type;
import java.nio.charset.StandardCharsets;
import java.time.Instant;
import java.util.Date;
import java.util.UUID;
@@ -44,6 +48,18 @@ public final class JsonUtils {
private JsonUtils() {
}
public static <T> T fromJsonFully(InputStream json, Class<T> classOfT) throws IOException, JsonParseException {
try (InputStreamReader reader = new InputStreamReader(json, StandardCharsets.UTF_8)) {
return GSON.fromJson(reader, classOfT);
}
}
public static <T> T fromJsonFully(InputStream json, Type type) throws IOException, JsonParseException {
try (InputStreamReader reader = new InputStreamReader(json, StandardCharsets.UTF_8)) {
return GSON.fromJson(reader, type);
}
}
public static <T> T fromNonNullJson(String json, Class<T> classOfT) throws JsonParseException {
T parsed = GSON.fromJson(json, classOfT);
if (parsed == null)
@@ -58,6 +74,24 @@ public final class JsonUtils {
return parsed;
}
public static <T> T fromNonNullJsonFully(InputStream json, Class<T> classOfT) throws IOException, JsonParseException {
try (InputStreamReader reader = new InputStreamReader(json, StandardCharsets.UTF_8)) {
T parsed = GSON.fromJson(reader, classOfT);
if (parsed == null)
throw new JsonParseException("Json object cannot be null.");
return parsed;
}
}
public static <T> T fromNonNullJsonFully(InputStream json, Type type) throws IOException, JsonParseException {
try (InputStreamReader reader = new InputStreamReader(json, StandardCharsets.UTF_8)) {
T parsed = GSON.fromJson(reader, type);
if (parsed == null)
throw new JsonParseException("Json object cannot be null.");
return parsed;
}
}
public static <T> T fromMaybeMalformedJson(String json, Class<T> classOfT) throws JsonParseException {
try {
return GSON.fromJson(json, classOfT);

View File

@@ -231,7 +231,7 @@ public final class CompressingUtils {
* @return the plain text content of given file.
*/
public static String readTextZipEntry(ZipFile zipFile, String name) throws IOException {
return IOUtils.readFullyAsString(zipFile.getInputStream(zipFile.getEntry(name)), StandardCharsets.UTF_8);
return IOUtils.readFullyAsString(zipFile.getInputStream(zipFile.getEntry(name)));
}
/**
@@ -244,7 +244,7 @@ public final class CompressingUtils {
*/
public static String readTextZipEntry(Path zipFile, String name, Charset encoding) throws IOException {
try (ZipFile s = openZipFile(zipFile, encoding)) {
return IOUtils.readFullyAsString(s.getInputStream(s.getEntry(name)), StandardCharsets.UTF_8);
return IOUtils.readFullyAsString(s.getInputStream(s.getEntry(name)));
}
}

View File

@@ -27,10 +27,11 @@ import java.net.HttpURLConnection;
import static java.nio.charset.StandardCharsets.UTF_8;
public class HttpMultipartRequest implements Closeable {
private static final String endl = "\r\n";
private final String boundary = "*****" + System.currentTimeMillis() + "*****";
private final HttpURLConnection urlConnection;
private final ByteArrayOutputStream stream;
private final String endl = "\r\n";
public HttpMultipartRequest(HttpURLConnection urlConnection) throws IOException {
this.urlConnection = urlConnection;
@@ -69,7 +70,7 @@ public class HttpMultipartRequest implements Closeable {
addLine("--" + boundary + "--");
urlConnection.setRequestProperty("Content-Length", "" + stream.size());
try (OutputStream os = urlConnection.getOutputStream()) {
IOUtils.write(stream.toByteArray(), os);
stream.writeTo(os);
}
}
}

View File

@@ -31,7 +31,6 @@ import java.net.HttpURLConnection;
import java.net.MalformedURLException;
import java.net.SocketTimeoutException;
import java.net.URL;
import java.nio.charset.StandardCharsets;
import java.util.HashMap;
import java.util.HashSet;
import java.util.Map;
@@ -143,7 +142,7 @@ public abstract class HttpRequest {
return getStringWithRetry(() -> {
HttpURLConnection con = createConnection();
con = resolveConnection(con);
return IOUtils.readFullyAsString(con.getInputStream(), StandardCharsets.UTF_8);
return IOUtils.readFullyAsString(con.getInputStream());
}, retryTimes);
}
}

View File

@@ -18,7 +18,6 @@
package org.jackhuang.hmcl.util.io;
import java.io.*;
import java.nio.charset.Charset;
/**
* This utility class consists of some util methods operating on InputStream/OutputStream.
@@ -40,7 +39,7 @@ public final class IOUtils {
* @throws IOException if an I/O error occurs.
*/
public static byte[] readFullyWithoutClosing(InputStream stream) throws IOException {
ByteArrayOutputStream result = new ByteArrayOutputStream();
ByteArrayOutputStream result = new ByteArrayOutputStream(Math.max(stream.available(), 32));
copyTo(stream, result);
return result.toByteArray();
}
@@ -54,7 +53,7 @@ public final class IOUtils {
*/
public static ByteArrayOutputStream readFully(InputStream stream) throws IOException {
try (InputStream is = stream) {
ByteArrayOutputStream result = new ByteArrayOutputStream();
ByteArrayOutputStream result = new ByteArrayOutputStream(Math.max(is.available(), 32));
copyTo(is, result);
return result;
}
@@ -68,18 +67,6 @@ public final class IOUtils {
return readFully(stream).toString("UTF-8");
}
public static String readFullyAsString(InputStream stream, Charset charset) throws IOException {
return readFully(stream).toString(charset.name());
}
public static void write(String text, OutputStream outputStream) throws IOException {
write(text.getBytes(), outputStream);
}
public static void write(byte[] bytes, OutputStream outputStream) throws IOException {
copyTo(new ByteArrayInputStream(bytes), outputStream);
}
public static void copyTo(InputStream src, OutputStream dest) throws IOException {
copyTo(src, dest, new byte[DEFAULT_BUFFER_SIZE]);
}

View File

@@ -21,7 +21,6 @@ import org.jackhuang.hmcl.util.Pair;
import java.io.*;
import java.net.*;
import java.nio.charset.StandardCharsets;
import java.util.*;
import java.util.Map.Entry;
@@ -176,7 +175,7 @@ public final class NetworkUtils {
public static String doGet(URL url) throws IOException {
HttpURLConnection con = createHttpConnection(url);
con = resolveConnection(con);
return IOUtils.readFullyAsString(con.getInputStream(), StandardCharsets.UTF_8);
return IOUtils.readFullyAsString(con.getInputStream());
}
public static String doPost(URL u, Map<String, String> params) throws IOException {
@@ -210,13 +209,13 @@ public final class NetworkUtils {
public static String readData(HttpURLConnection con) throws IOException {
try {
try (InputStream stdout = con.getInputStream()) {
return IOUtils.readFullyAsString(stdout, UTF_8);
return IOUtils.readFullyAsString(stdout);
}
} catch (IOException e) {
try (InputStream stderr = con.getErrorStream()) {
if (stderr == null)
throw e;
return IOUtils.readFullyAsString(stderr, UTF_8);
return IOUtils.readFullyAsString(stderr);
}
}
}

View File

@@ -25,7 +25,6 @@ import org.junit.Test;
import java.io.IOException;
import java.io.InputStream;
import java.nio.charset.StandardCharsets;
import java.util.*;
public class CrashReportAnalyzerTest {
@@ -35,7 +34,7 @@ public class CrashReportAnalyzerTest {
if (is == null) {
throw new IllegalStateException("Resource not found: " + path);
}
return IOUtils.readFullyAsString(is, StandardCharsets.UTF_8);
return IOUtils.readFullyAsString(is);
}
private CrashReportAnalyzer.Result findResultByRule(List<CrashReportAnalyzer.Result> results, CrashReportAnalyzer.Rule rule) {