diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml index 912e6fd..b4189a5 100644 --- a/.github/workflows/release.yml +++ b/.github/workflows/release.yml @@ -12,38 +12,6 @@ env: CARGO_TERM_COLOR: always jobs: - # ────────────────────────────────────────────────────────────────── - # Java fat JAR(平台无关,只需构建一次) - # 运行方式:java -Djava.library.path= -jar dht-crawler-jni-example-.jar - # ────────────────────────────────────────────────────────────────── - build-java-jar: - name: Build Java Example JAR - runs-on: ubuntu-latest - permissions: - contents: write - steps: - - name: Checkout code - uses: actions/checkout@v3 - - - name: Set up JDK 17 - uses: actions/setup-java@v4 - with: - distribution: temurin - java-version: '17' - - - name: Build fat JAR - working-directory: examples-jni - run: | - chmod +x gradlew - ./gradlew shadowJar -PprojectVersion=${{ github.event.inputs.version }} --no-daemon - shell: bash - - - name: Upload JAR to Release - uses: softprops/action-gh-release@v1 - with: - tag_name: ${{ github.event.inputs.version }} - files: examples-jni/build/libs/dht-crawler-jni-example-${{ github.event.inputs.version }}.jar - # ────────────────────────────────────────────────────────────────── # 各平台 Rust 构建(example 可执行 + JNI so/dll/dylib) # ────────────────────────────────────────────────────────────────── diff --git a/.github/workflows/rust.yml b/.github/workflows/rust.yml index 7896a8a..2eb7313 100644 --- a/.github/workflows/rust.yml +++ b/.github/workflows/rust.yml @@ -59,6 +59,7 @@ jobs: - "mimalloc" - "metrics" - "mimalloc,metrics" + - "jni" steps: - uses: actions/checkout@v4 - name: Install Rust @@ -88,4 +89,4 @@ jobs: cargo build --examples --verbose else cargo build --examples --verbose --features "${{ matrix.features }}" - fi \ No newline at end of file + fi diff --git a/.gitignore b/.gitignore index 8188490..1d9756f 100644 --- a/.gitignore +++ b/.gitignore @@ -18,7 +18,3 @@ torrents/ # 个人脚本(不提交到仓库) scripts/ - -# Java / Gradle(examples-jni) -examples-jni/build/ -examples-jni/.gradle/ diff --git a/README.md b/README.md index 6d0aa2c..774b7ff 100644 --- a/README.md +++ b/README.md @@ -244,7 +244,8 @@ dht-crawler = { version = "0.2", features = ["metrics"] } | `jni` | 构建 Java JNI 接口和 `cdylib` | | `mimalloc` | 将 mimalloc 注册为全局分配器 | -JNI 的构建方式和 Java 示例见 [examples-jni/README.md](examples-jni/README.md)。 +Java/Kotlin 封装、平台 native JAR 和示例由独立项目 +[`dht-crawler-java`](https://github.com/0xddy/dht-crawler-java) 维护。 启用 `mimalloc` 前,请确认最终二进制没有注册其他全局分配器。 ## 开发 diff --git a/examples-jni/README.md b/examples-jni/README.md deleted file mode 100644 index 5a9f862..0000000 --- a/examples-jni/README.md +++ /dev/null @@ -1,124 +0,0 @@ -# dht-crawler JNI Java 示例 - -Gradle 项目,演示通过 JNI 调用 `dht-crawler`(需启用 Cargo feature `jni`)。 - -## 项目结构 - -``` -examples-jni/ -├── build.gradle -├── settings.gradle -└── src/main/java/cn/lmcw/dht/ - ├── model/ # DHTOptions, TorrentInfo, FileInfo - ├── DhtCrawler.java # 面向对象入口(推荐) - ├── DhtCrawlerJni.java # native 方法声明 - ├── DhtListener.java # 回调接口 - └── DhtCrawlerExample.java -``` - -Rust JNI 实现位于 **`dht-crawler/jni/`**(`lib` crate-type 含 `cdylib`)。 - -## 编译 native 库 - -在 **`dht-crawler`** 目录(本 README 的上一级)执行: - -```bash -cargo build --release --features jni -``` - -产物路径(因平台而异): - -- Linux: `target/release/libdht_crawler.so` -- Windows: `target/release/dht_crawler.dll` -- macOS: `target/release/libdht_crawler.dylib` - -## 运行示例 - -### 方式一:Release 预编译包(推荐) - -下载 Release 中的 fat JAR 与对应平台 native 库 zip,放在同一目录: - -```bash -# Linux / macOS -java -Djava.library.path=. -jar dht-crawler-jni-example-.jar - -# Windows -java "-Djava.library.path=." -jar dht-crawler-jni-example-.jar -``` - -### 方式二:源码 + Gradle - -```bash -cd examples-jni -gradle run -# 指定 native 库目录(默认为 ../target/release) -gradle run -Plib.path=/path/to/lib -``` - -构建 fat JAR: - -```bash -gradle shadowJar -``` - -## 集成到自己的 Java 项目 - -1. 复制 `cn/lmcw/dht/` 包(含 `model/`、`DhtCrawler`、`DhtCrawlerJni`、`DhtListener`)。 -2. 将对应平台的 native 库加入 `java.library.path`。 -3. 使用与示例相同的 `dht-crawler` JNI 版本构建 `cdylib`。 - -## API(面向对象) - -```java -DhtCrawler crawler = DhtCrawler.createServer(options, listener); -crawler.start(); // 后台启动,不阻塞调用线程 -// ... -crawler.stop(); // 或 try-with-resources -``` - -| 方法 | 说明 | -|------|------| -| `createServer(options, listener)` | 创建 `ServerHandle`(含独立 tokio `Runtime` + `DHTServer`) | -| `start()` | 在 runtime 内 spawn `server.start()`;同一会话多次调用仅首次生效 | -| `stop()` / `close()` | `shutdown()` 后在后台线程 drop Runtime,避免阻塞 JNI 线程 | -| `getNodePoolSize()` | 节点池大小(`DHTServer::get_node_pool_size`) | - -Native 导出类:`cn.lmcw.dht.DhtCrawlerJni`(`createServer` / `startServer` / `stopServer` / `getNodePoolSize`)。 - -## 回调与线程 - -- `onTorrent` / `onError`:在 Rust 工作线程触发,Java 实现须线程安全。 -- `onMetadataFetch`:在阻塞线程池中调用,应尽快返回 boolean。 - -行为与 Rust 库一致:InfoHash 来自 **`announce_peer`**;`start()` 在 Rust 侧仍阻塞至 `shutdown()`,JNI 通过单独 runtime + `spawn` 避免卡住 Java 主流程。 - -当前 JNI listener 暴露 `onTorrent`、`onMetadataFetch` 和 `onError`,不暴露 Rust -`on_torrent_with_ack` / `on_metadata_fetch_complete`。因此 Java `onTorrent` 返回后始终按 -`Accepted` 处理;需要交付确认和最终状态的应用应扩展 JNI callback contract。 - -## JNI 配置映射 - -Java `DHTOptions` 是 Rust 配置的扁平化子集,不是全部 Rust 字段的一一镜像: - -| Java 字段组 | Rust 目标 | -|---|---| -| port / netMode / hashQueueCapacity | `DHTOptions` 顶层 | -| metadataTimeout / maxMetadataQueueSize / maxMetadataWorkerCount | `metadata.*` | -| poolCapacity / recentProbeTtlSeconds / responsive* / poolLowWatermark | `crawl.pool.*` | -| findNode* / requestTimeout* / response* / pressure / replacements / subnet | `crawl.rate_limit.*` | - -以下配置未通过当前 JNI 暴露,使用 Rust `Default`: - -- `metadata.peer_failure_cache_capacity`、`metadata.peer_failure_ttl_secs` -- `crawl.bootstrap.*` -- `crawl.target.*` -- `crawl.scheduler.*` - -Java 字段默认值与当前 Rust 0.2 默认值保持一致;传入 `null` options 时直接使用完整的 -`DHTOptions::default()`。 - -## 注意事项 - -- Java 11+(见 `build.gradle`)。 -- Java/Rust 版本必须一致,避免 JNI 按字段名和签名读取时失败。 -- 停止后勿再使用同一 `long` 句柄。 diff --git a/examples-jni/build.gradle b/examples-jni/build.gradle deleted file mode 100644 index 10dbc24..0000000 --- a/examples-jni/build.gradle +++ /dev/null @@ -1,39 +0,0 @@ -plugins { - id 'java' - id 'application' - id 'com.gradleup.shadow' version '9.0.2' -} - -group = 'cn.lmcw.dht' -version = project.findProperty('projectVersion') ?: '0.1.0' - -java { - sourceCompatibility = JavaVersion.VERSION_11 - targetCompatibility = JavaVersion.VERSION_11 -} - -application { - mainClass = 'cn.lmcw.dht.DhtCrawlerExample' - def libPath = project.findProperty('lib.path') ?: '.' - applicationDefaultJvmArgs = ["-Djava.library.path=${libPath}"] -} - -repositories { - mavenCentral() -} - -// 运行时将 native 库路径传入 JVM(默认指向仓库根 target/release) -tasks.withType(JavaExec).configureEach { - def libPath = project.findProperty('lib.path') ?: '../target/release' - jvmArgs "-Djava.library.path=${libPath}" -} - -// Fat JAR(含所有依赖 + Main-Class):执行 `gradle shadowJar` 生成 -shadowJar { - archiveBaseName = 'dht-crawler-jni-example' - archiveClassifier = '' - archiveVersion = version - manifest { - attributes 'Main-Class': 'cn.lmcw.dht.DhtCrawlerExample' - } -} diff --git a/examples-jni/gradle/wrapper/gradle-wrapper.jar b/examples-jni/gradle/wrapper/gradle-wrapper.jar deleted file mode 100644 index 61285a6..0000000 Binary files a/examples-jni/gradle/wrapper/gradle-wrapper.jar and /dev/null differ diff --git a/examples-jni/gradle/wrapper/gradle-wrapper.properties b/examples-jni/gradle/wrapper/gradle-wrapper.properties deleted file mode 100644 index 37f78a6..0000000 --- a/examples-jni/gradle/wrapper/gradle-wrapper.properties +++ /dev/null @@ -1,7 +0,0 @@ -distributionBase=GRADLE_USER_HOME -distributionPath=wrapper/dists -distributionUrl=https\://services.gradle.org/distributions/gradle-9.3.1-bin.zip -networkTimeout=10000 -validateDistributionUrl=true -zipStoreBase=GRADLE_USER_HOME -zipStorePath=wrapper/dists diff --git a/examples-jni/gradlew b/examples-jni/gradlew deleted file mode 100644 index 211543e..0000000 --- a/examples-jni/gradlew +++ /dev/null @@ -1,248 +0,0 @@ -#!/bin/sh - -# -# Copyright © 2015 the original authors. -# -# Licensed under the Apache License, Version 2.0 (the "License"); -# you may not use this file except in compliance with the License. -# You may obtain a copy of the License at -# -# https://www.apache.org/licenses/LICENSE-2.0 -# -# Unless required by applicable law or agreed to in writing, software -# distributed under the License is distributed on an "AS IS" BASIS, -# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. -# See the License for the specific language governing permissions and -# limitations under the License. -# -# SPDX-License-Identifier: Apache-2.0 -# - -############################################################################## -# -# Gradle start up script for POSIX generated by Gradle. -# -# Important for running: -# -# (1) You need a POSIX-compliant shell to run this script. If your /bin/sh is -# noncompliant, but you have some other compliant shell such as ksh or -# bash, then to run this script, type that shell name before the whole -# command line, like: -# -# ksh Gradle -# -# Busybox and similar reduced shells will NOT work, because this script -# requires all of these POSIX shell features: -# * functions; -# * expansions «$var», «${var}», «${var:-default}», «${var+SET}», -# «${var#prefix}», «${var%suffix}», and «$( cmd )»; -# * compound commands having a testable exit status, especially «case»; -# * various built-in commands including «command», «set», and «ulimit». -# -# Important for patching: -# -# (2) This script targets any POSIX shell, so it avoids extensions provided -# by Bash, Ksh, etc; in particular arrays are avoided. -# -# The "traditional" practice of packing multiple parameters into a -# space-separated string is a well documented source of bugs and security -# problems, so this is (mostly) avoided, by progressively accumulating -# options in "$@", and eventually passing that to Java. -# -# Where the inherited environment variables (DEFAULT_JVM_OPTS, JAVA_OPTS, -# and GRADLE_OPTS) rely on word-splitting, this is performed explicitly; -# see the in-line comments for details. -# -# There are tweaks for specific operating systems such as AIX, CygWin, -# Darwin, MinGW, and NonStop. -# -# (3) This script is generated from the Groovy template -# https://github.com/gradle/gradle/blob/HEAD/platforms/jvm/plugins-application/src/main/resources/org/gradle/api/internal/plugins/unixStartScript.txt -# within the Gradle project. -# -# You can find Gradle at https://github.com/gradle/gradle/. -# -############################################################################## - -# Attempt to set APP_HOME - -# Resolve links: $0 may be a link -app_path=$0 - -# Need this for daisy-chained symlinks. -while - APP_HOME=${app_path%"${app_path##*/}"} # leaves a trailing /; empty if no leading path - [ -h "$app_path" ] -do - ls=$( ls -ld "$app_path" ) - link=${ls#*' -> '} - case $link in #( - /*) app_path=$link ;; #( - *) app_path=$APP_HOME$link ;; - esac -done - -# This is normally unused -# shellcheck disable=SC2034 -APP_BASE_NAME=${0##*/} -# Discard cd standard output in case $CDPATH is set (https://github.com/gradle/gradle/issues/25036) -APP_HOME=$( cd -P "${APP_HOME:-./}" > /dev/null && printf '%s\n' "$PWD" ) || exit - -# Use the maximum available, or set MAX_FD != -1 to use that value. -MAX_FD=maximum - -warn () { - echo "$*" -} >&2 - -die () { - echo - echo "$*" - echo - exit 1 -} >&2 - -# OS specific support (must be 'true' or 'false'). -cygwin=false -msys=false -darwin=false -nonstop=false -case "$( uname )" in #( - CYGWIN* ) cygwin=true ;; #( - Darwin* ) darwin=true ;; #( - MSYS* | MINGW* ) msys=true ;; #( - NONSTOP* ) nonstop=true ;; -esac - - - -# Determine the Java command to use to start the JVM. -if [ -n "$JAVA_HOME" ] ; then - if [ -x "$JAVA_HOME/jre/sh/java" ] ; then - # IBM's JDK on AIX uses strange locations for the executables - JAVACMD=$JAVA_HOME/jre/sh/java - else - JAVACMD=$JAVA_HOME/bin/java - fi - if [ ! -x "$JAVACMD" ] ; then - die "ERROR: JAVA_HOME is set to an invalid directory: $JAVA_HOME - -Please set the JAVA_HOME variable in your environment to match the -location of your Java installation." - fi -else - JAVACMD=java - if ! command -v java >/dev/null 2>&1 - then - die "ERROR: JAVA_HOME is not set and no 'java' command could be found in your PATH. - -Please set the JAVA_HOME variable in your environment to match the -location of your Java installation." - fi -fi - -# Increase the maximum file descriptors if we can. -if ! "$cygwin" && ! "$darwin" && ! "$nonstop" ; then - case $MAX_FD in #( - max*) - # In POSIX sh, ulimit -H is undefined. That's why the result is checked to see if it worked. - # shellcheck disable=SC2039,SC3045 - MAX_FD=$( ulimit -H -n ) || - warn "Could not query maximum file descriptor limit" - esac - case $MAX_FD in #( - '' | soft) :;; #( - *) - # In POSIX sh, ulimit -n is undefined. That's why the result is checked to see if it worked. - # shellcheck disable=SC2039,SC3045 - ulimit -n "$MAX_FD" || - warn "Could not set maximum file descriptor limit to $MAX_FD" - esac -fi - -# Collect all arguments for the java command, stacking in reverse order: -# * args from the command line -# * the main class name -# * -classpath -# * -D...appname settings -# * --module-path (if needed) -# * DEFAULT_JVM_OPTS, JAVA_OPTS, and GRADLE_OPTS environment variables. - -# For Cygwin or MSYS, switch paths to Windows format before running java -if "$cygwin" || "$msys" ; then - APP_HOME=$( cygpath --path --mixed "$APP_HOME" ) - - JAVACMD=$( cygpath --unix "$JAVACMD" ) - - # Now convert the arguments - kludge to limit ourselves to /bin/sh - for arg do - if - case $arg in #( - -*) false ;; # don't mess with options #( - /?*) t=${arg#/} t=/${t%%/*} # looks like a POSIX filepath - [ -e "$t" ] ;; #( - *) false ;; - esac - then - arg=$( cygpath --path --ignore --mixed "$arg" ) - fi - # Roll the args list around exactly as many times as the number of - # args, so each arg winds up back in the position where it started, but - # possibly modified. - # - # NB: a `for` loop captures its iteration list before it begins, so - # changing the positional parameters here affects neither the number of - # iterations, nor the values presented in `arg`. - shift # remove old arg - set -- "$@" "$arg" # push replacement arg - done -fi - - -# Add default JVM options here. You can also use JAVA_OPTS and GRADLE_OPTS to pass JVM options to this script. -DEFAULT_JVM_OPTS='-Dfile.encoding=UTF-8 "-Xmx64m" "-Xms64m"' - -# Collect all arguments for the java command: -# * DEFAULT_JVM_OPTS, JAVA_OPTS, and optsEnvironmentVar are not allowed to contain shell fragments, -# and any embedded shellness will be escaped. -# * For example: A user cannot expect ${Hostname} to be expanded, as it is an environment variable and will be -# treated as '${Hostname}' itself on the command line. - -set -- \ - "-Dorg.gradle.appname=$APP_BASE_NAME" \ - -jar "$APP_HOME/gradle/wrapper/gradle-wrapper.jar" \ - "$@" - -# Stop when "xargs" is not available. -if ! command -v xargs >/dev/null 2>&1 -then - die "xargs is not available" -fi - -# Use "xargs" to parse quoted args. -# -# With -n1 it outputs one arg per line, with the quotes and backslashes removed. -# -# In Bash we could simply go: -# -# readarray ARGS < <( xargs -n1 <<<"$var" ) && -# set -- "${ARGS[@]}" "$@" -# -# but POSIX shell has neither arrays nor command substitution, so instead we -# post-process each arg (as a line of input to sed) to backslash-escape any -# character that might be a shell metacharacter, then use eval to reverse -# that process (while maintaining the separation between arguments), and wrap -# the whole thing up as a single "set" statement. -# -# This will of course break if any of these variables contains a newline or -# an unmatched quote. -# - -eval "set -- $( - printf '%s\n' "$DEFAULT_JVM_OPTS $JAVA_OPTS $GRADLE_OPTS" | - xargs -n1 | - sed ' s~[^-[:alnum:]+,./:=@_]~\\&~g; ' | - tr '\n' ' ' - )" '"$@"' - -exec "$JAVACMD" "$@" diff --git a/examples-jni/gradlew.bat b/examples-jni/gradlew.bat deleted file mode 100644 index f114325..0000000 --- a/examples-jni/gradlew.bat +++ /dev/null @@ -1,84 +0,0 @@ -@rem -@rem Copyright 2015 the original author or authors. -@rem -@rem Licensed under the Apache License, Version 2.0 (the "License"); -@rem you may not use this file except in compliance with the License. -@rem You may obtain a copy of the License at -@rem -@rem https://www.apache.org/licenses/LICENSE-2.0 -@rem -@rem Unless required by applicable law or agreed to in writing, software -@rem distributed under the License is distributed on an "AS IS" BASIS, -@rem WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. -@rem See the License for the specific language governing permissions and -@rem limitations under the License. -@rem - -@if "%DEBUG%"=="" @echo off -@rem ########################################################################## -@rem -@rem Gradle startup script for Windows -@rem -@rem ########################################################################## - -@rem Set local scope for the variables with windows NT shell -if "%OS%"=="Windows_NT" setlocal - -set DIRNAME=%~dp0 -if "%DIRNAME%"=="" set DIRNAME=. -@rem This is normally unused -set APP_BASE_NAME=%~n0 -set APP_HOME=%DIRNAME% - -@rem Resolve any "." and ".." in APP_HOME to make it shorter. -for %%i in ("%APP_HOME%") do set APP_HOME=%%~fi - -@rem Add default JVM options here. You can also use JAVA_OPTS and GRADLE_OPTS to pass JVM options to this script. -set DEFAULT_JVM_OPTS="-Xmx64m" "-Xms64m" - -@rem Find java.exe -if defined JAVA_HOME goto findJavaFromJavaHome - -set JAVA_EXE=java.exe -%JAVA_EXE% -version >NUL 2>&1 -if %ERRORLEVEL% equ 0 goto execute - -echo. -echo ERROR: JAVA_HOME is not set and no 'java' command could be found in your PATH. -echo. -goto fail - -:findJavaFromJavaHome -set JAVA_HOME=%JAVA_HOME:"=% -set JAVA_EXE=%JAVA_HOME%/bin/java.exe - -if exist "%JAVA_EXE%" goto execute - -echo. -echo ERROR: JAVA_HOME is set to an invalid directory: %JAVA_HOME% -echo. -goto fail - -:execute -@rem Setup the command line - -set CLASSPATH=%APP_HOME%\gradle\wrapper\gradle-wrapper.jar - - -@rem Execute Gradle -"%JAVA_EXE%" %DEFAULT_JVM_OPTS% %JAVA_OPTS% %GRADLE_OPTS% "-Dorg.gradle.appname=%APP_BASE_NAME%" -classpath "%CLASSPATH%" org.gradle.wrapper.GradleWrapperMain %* - -:end -@rem End local scope for the variables with windows NT shell -if %ERRORLEVEL% equ 0 goto mainEnd - -:fail -rem Set variable GRADLE_EXIT_CONSOLE if you need the _script_ return code instead of -rem having the script exit with the same return code as the Gradle build -if not ""=="%GRADLE_EXIT_CONSOLE%" exit 1 -exit /b 1 - -:mainEnd -if "%OS%"=="Windows_NT" endlocal - -:omega diff --git a/examples-jni/settings.gradle b/examples-jni/settings.gradle deleted file mode 100644 index 2f3e755..0000000 --- a/examples-jni/settings.gradle +++ /dev/null @@ -1 +0,0 @@ -rootProject.name = 'dht-crawler-jni' diff --git a/examples-jni/src/main/java/cn/lmcw/dht/DhtCrawler.java b/examples-jni/src/main/java/cn/lmcw/dht/DhtCrawler.java deleted file mode 100644 index a562f04..0000000 --- a/examples-jni/src/main/java/cn/lmcw/dht/DhtCrawler.java +++ /dev/null @@ -1,100 +0,0 @@ -package cn.lmcw.dht; - -import cn.lmcw.dht.model.DHTOptions; - -/** - * DHT 爬虫会话:封装 native 句柄与生命周期,推荐使用的面向对象入口。 - * - *
{@code
- * try (DhtCrawler crawler = DhtCrawler.createServer(options, listener)) {
- *     crawler.start();
- *     // ... 运行 ...
- * } // close → stop,释放 Rust 资源
- *
- * // 或手动:
- * DhtCrawler c = DhtCrawler.createServer(options, listener);
- * c.start();
- * // ...
- * c.stop();
- * }
- */ -public final class DhtCrawler implements AutoCloseable { - - private long handle; - private volatile boolean started; - - private DhtCrawler(long handle) { - this.handle = handle; - } - - /** - * 创建 DHT 服务器会话(尚未 {@link #start()},与 native createServer 对应)。 - * Java 中方法不能命名为 {@code new},故用 {@code createServer}。 - * - * @param options 配置,{@code null} 使用 Rust 默认 - * @param listener 回调,{@code null} 不注册回调 - * @return 已绑定 native 资源的会话 - * @throws IllegalStateException 创建失败(句柄为 0,通常伴随 JVM 异常) - */ - public static DhtCrawler createServer(DHTOptions options, DhtListener listener) { - long h = DhtCrawlerJni.createServer(options, listener); - if (h == 0) { - throw new IllegalStateException("DHT 服务器创建失败(createServer 返回 0)"); - } - return new DhtCrawler(h); - } - - /** - * 在后台启动 DHT(非阻塞)。可多次调用,仅首次生效。 - * - * @throws IllegalStateException 已 {@link #stop()} 关闭后不能再启动 - */ - public synchronized void start() { - if (handle == 0) { - throw new IllegalStateException("会话已关闭,无法 start"); - } - if (started) { - return; - } - DhtCrawlerJni.startServer(handle); - started = true; - } - - /** - * 停止并释放全部 Rust 资源(tokio runtime 等)。幂等:重复调用安全。 - */ - public synchronized void stop() { - if (handle == 0) { - return; - } - DhtCrawlerJni.stopServer(handle); - handle = 0; - started = false; - } - - /** 是否已调用过 {@link #start()} 且尚未 {@link #stop()}。 */ - public synchronized boolean isStarted() { - return started && handle != 0; - } - - /** 会话仍有效(未 stop)时为 true。 */ - public synchronized boolean isOpen() { - return handle != 0; - } - - /** - * 当前 routing table 节点数;已关闭时返回 0。 - */ - public int getNodePoolSize() { - long h = handle; - if (h == 0) { - return 0; - } - return DhtCrawlerJni.getNodePoolSize(h); - } - - @Override - public void close() { - stop(); - } -} diff --git a/examples-jni/src/main/java/cn/lmcw/dht/DhtCrawlerExample.java b/examples-jni/src/main/java/cn/lmcw/dht/DhtCrawlerExample.java deleted file mode 100644 index c314fa4..0000000 --- a/examples-jni/src/main/java/cn/lmcw/dht/DhtCrawlerExample.java +++ /dev/null @@ -1,75 +0,0 @@ -package cn.lmcw.dht; - -import cn.lmcw.dht.model.DHTOptions; -import cn.lmcw.dht.model.TorrentInfo; - -import java.util.concurrent.CountDownLatch; -import java.util.concurrent.atomic.AtomicLong; - -/** - * DHT-Crawler JNI 使用示例。 - * - *

运行前准备

- *
    - *
  1. 在仓库根目录编译 Rust JNI 产物: - *
    cargo build --release --features jni
    - *
  2. - *
  3. 在 {@code examples-jni/} 目录下运行({@code lib.path} 指向 so/dll/dylib 所在目录): - *
    gradle run -Plib.path=../target/release
    - * 或直接用默认路径({@code ../target/release}): - *
    gradle run
    - *
  4. - *
- */ -public class DhtCrawlerExample { - - public static void main(String[] args) throws InterruptedException { - DHTOptions options = new DHTOptions() - .setPort(6881) - .setNetMode(0) - .setMetadataTimeout(5L) - .setMaxMetadataQueueSize(50_000) - .setMaxMetadataWorkerCount(500); - - AtomicLong torrentCount = new AtomicLong(); - CountDownLatch shutdown = new CountDownLatch(1); - - DhtListener listener = new DhtListener() { - @Override - public void onTorrent(TorrentInfo info) { - long n = torrentCount.incrementAndGet(); - System.out.printf("[#%d] %s (%s) files=%d%n", - n, info.getName(), info.getInfoHash(), - info.getFiles() != null ? info.getFiles().size() : 0); - } - - @Override - public void onError(String message) { - System.err.println("[ERROR] " + message); - } - - @Override - public boolean onMetadataFetch(String infoHash) { - return true; - } - }; - - final DhtCrawler crawler = DhtCrawler.createServer(options, listener); - crawler.start(); - System.out.println("DHT 已启动,端口 " + options.getPort()); - - Runtime.getRuntime().addShutdownHook(new Thread(() -> { - System.out.println("\n收到退出信号,正在停止..."); - crawler.stop(); - System.out.println("已关闭,共发现种子:" + torrentCount.get()); - shutdown.countDown(); - })); - - System.out.println("按 Ctrl+C 退出。每 10 秒打印一次节点池大小..."); - while (true) { - Thread.sleep(10_000); - System.out.println("节点池: " + crawler.getNodePoolSize() - + " 已发现种子: " + torrentCount.get()); - } - } -} diff --git a/examples-jni/src/main/java/cn/lmcw/dht/DhtCrawlerJni.java b/examples-jni/src/main/java/cn/lmcw/dht/DhtCrawlerJni.java deleted file mode 100644 index 26e2249..0000000 --- a/examples-jni/src/main/java/cn/lmcw/dht/DhtCrawlerJni.java +++ /dev/null @@ -1,23 +0,0 @@ -package cn.lmcw.dht; - -import cn.lmcw.dht.model.DHTOptions; - -/** - * 包内 JNI 绑定(由 {@link DhtCrawler} 使用)。业务代码请用 {@link DhtCrawler}。 - */ -final class DhtCrawlerJni { - - static { - System.loadLibrary("dht_crawler"); - } - - private DhtCrawlerJni() {} - - static native long createServer(DHTOptions options, DhtListener listener); - - static native void startServer(long handle); - - static native void stopServer(long handle); - - static native int getNodePoolSize(long handle); -} diff --git a/examples-jni/src/main/java/cn/lmcw/dht/DhtListener.java b/examples-jni/src/main/java/cn/lmcw/dht/DhtListener.java deleted file mode 100644 index 701bc78..0000000 --- a/examples-jni/src/main/java/cn/lmcw/dht/DhtListener.java +++ /dev/null @@ -1,38 +0,0 @@ -package cn.lmcw.dht; - -import cn.lmcw.dht.model.TorrentInfo; - -/** - * DHT 爬虫事件回调接口。 - *

实现此接口并传入 {@link DhtCrawlerJni#createServer} 即可接收事件通知。 - * 回调在 Rust 内部的 tokio 工作线程触发,实现方需自行保证线程安全。

- */ -public interface DhtListener { - - /** - * 成功获取到 torrent metadata 时触发。 - * - * @param info 完整的 torrent 信息对象,由 Rust JNI 层映射构造 - */ - void onTorrent(TorrentInfo info); - - /** - * 内部发生错误时触发。 - * - * @param message Rust 侧错误的文本描述 - */ - void onError(String message); - - /** - * 在即将对某 info_hash 拉取 BitTorrent metadata 之前调用。 - * 与 Rust {@code DHTServer::on_metadata_fetch} 对应:返回 {@code true} 才会真正去拉取; - * 返回 {@code false} 则跳过该 hash,不会触发 {@link #onTorrent}。 - *

在 Rust 的 metadata 工作线程中通过阻塞线程池调用,应快速返回,避免长时间阻塞。

- * - * @param infoHash 40 字符小写十六进制 info_hash - * @return 是否允许拉取 metadata - */ - default boolean onMetadataFetch(String infoHash) { - return true; - } -} diff --git a/examples-jni/src/main/java/cn/lmcw/dht/model/DHTOptions.java b/examples-jni/src/main/java/cn/lmcw/dht/model/DHTOptions.java deleted file mode 100644 index f9b41e6..0000000 --- a/examples-jni/src/main/java/cn/lmcw/dht/model/DHTOptions.java +++ /dev/null @@ -1,144 +0,0 @@ -package cn.lmcw.dht.model; - -/** - * Rust {@code DHTOptions} 的 JNI 扁平化子集。 - *

通过 JNI 传入 Rust 侧;未暴露的 Metadata failure cache、Bootstrap、Target 和 - * Scheduler 字段采用 Rust 0.2 默认值。Java 与 native 库版本必须保持一致。

- * - *

netMode 取值

- *
    - *
  • 0 - IPv4 Only
  • - *
  • 1 - IPv6 Only
  • - *
  • 2 - Dual Stack(默认)
  • - *
- */ -public final class DHTOptions { - - /** 监听端口,默认 6881 */ - private int port = 6881; - - /** 获取 metadata 超时(秒),默认 3 */ - private long metadataTimeout = 4L; - - /** metadata 队列最大容量,默认 100000 */ - private int maxMetadataQueueSize = 10_000; - - /** 并发 metadata 拉取 worker 数量,默认 1000 */ - private int maxMetadataWorkerCount = 256; - - /** 节点队列容量,默认 100000 */ - private int poolCapacity = 100_000; - - private int findNodeRatePerSecond = 200; - - private int findNodeBurst = 40; - - private int maxFindNodeInFlight = 512; - - private int maxNewDestinationsPerMinute = 10_000; - - private int maxReplacementsPerMinute = 25_000; - - private long requestTimeoutSeconds = 2L; - private int maxResponseRatePerSecond = 500; - private long maxResponseBytesPerSecond = 1_048_576L; - private int maxResponseRatePerSource = 40; - private int metadataPressureFloorPercent = 25; - private long recentProbeTtlSeconds = 600L; - private int responsiveCapacity = 16_384; - private long responsiveTtlSeconds = 900L; - private int poolLowWatermark = 10_000; - private int maxInFlightPerSubnet = 8; - - /** hash 队列容量,默认 10000 */ - private int hashQueueCapacity = 10_000; - - /** - * 网络模式:0=IPv4Only, 1=IPv6Only, 2=DualStack(默认 0 / IPv4Only, - * 与 Rust 侧 {@code NetMode::Ipv4Only} 对应) - */ - private int netMode = 0; - - public DHTOptions() {} - - public int getPort() { return port; } - public DHTOptions setPort(int port) { this.port = port; return this; } - - public long getMetadataTimeout() { return metadataTimeout; } - public DHTOptions setMetadataTimeout(long metadataTimeout) { - this.metadataTimeout = metadataTimeout; - return this; - } - - public int getMaxMetadataQueueSize() { return maxMetadataQueueSize; } - public DHTOptions setMaxMetadataQueueSize(int maxMetadataQueueSize) { - this.maxMetadataQueueSize = maxMetadataQueueSize; - return this; - } - - public int getMaxMetadataWorkerCount() { return maxMetadataWorkerCount; } - public DHTOptions setMaxMetadataWorkerCount(int maxMetadataWorkerCount) { - this.maxMetadataWorkerCount = maxMetadataWorkerCount; - return this; - } - - public int getPoolCapacity() { return poolCapacity; } - public DHTOptions setPoolCapacity(int poolCapacity) { - this.poolCapacity = poolCapacity; - return this; - } - - public int getFindNodeRatePerSecond() { return findNodeRatePerSecond; } - public DHTOptions setFindNodeRatePerSecond(int value) { this.findNodeRatePerSecond = value; return this; } - - public int getFindNodeBurst() { return findNodeBurst; } - public DHTOptions setFindNodeBurst(int value) { this.findNodeBurst = value; return this; } - - public int getMaxFindNodeInFlight() { return maxFindNodeInFlight; } - public DHTOptions setMaxFindNodeInFlight(int value) { this.maxFindNodeInFlight = value; return this; } - - public int getMaxNewDestinationsPerMinute() { return maxNewDestinationsPerMinute; } - public DHTOptions setMaxNewDestinationsPerMinute(int value) { this.maxNewDestinationsPerMinute = value; return this; } - - public int getMaxReplacementsPerMinute() { return maxReplacementsPerMinute; } - public DHTOptions setMaxReplacementsPerMinute(int value) { this.maxReplacementsPerMinute = value; return this; } - - public long getRequestTimeoutSeconds() { return requestTimeoutSeconds; } - public DHTOptions setRequestTimeoutSeconds(long value) { this.requestTimeoutSeconds = value; return this; } - public int getMaxResponseRatePerSecond() { return maxResponseRatePerSecond; } - public DHTOptions setMaxResponseRatePerSecond(int value) { this.maxResponseRatePerSecond = value; return this; } - public long getMaxResponseBytesPerSecond() { return maxResponseBytesPerSecond; } - public DHTOptions setMaxResponseBytesPerSecond(long value) { this.maxResponseBytesPerSecond = value; return this; } - public int getMaxResponseRatePerSource() { return maxResponseRatePerSource; } - public DHTOptions setMaxResponseRatePerSource(int value) { this.maxResponseRatePerSource = value; return this; } - public int getMetadataPressureFloorPercent() { return metadataPressureFloorPercent; } - public DHTOptions setMetadataPressureFloorPercent(int value) { this.metadataPressureFloorPercent = value; return this; } - public long getRecentProbeTtlSeconds() { return recentProbeTtlSeconds; } - public DHTOptions setRecentProbeTtlSeconds(long value) { this.recentProbeTtlSeconds = value; return this; } - public int getResponsiveCapacity() { return responsiveCapacity; } - public DHTOptions setResponsiveCapacity(int value) { this.responsiveCapacity = value; return this; } - public long getResponsiveTtlSeconds() { return responsiveTtlSeconds; } - public DHTOptions setResponsiveTtlSeconds(long value) { this.responsiveTtlSeconds = value; return this; } - public int getPoolLowWatermark() { return poolLowWatermark; } - public DHTOptions setPoolLowWatermark(int value) { this.poolLowWatermark = value; return this; } - public int getMaxInFlightPerSubnet() { return maxInFlightPerSubnet; } - public DHTOptions setMaxInFlightPerSubnet(int value) { this.maxInFlightPerSubnet = value; return this; } - - public int getHashQueueCapacity() { return hashQueueCapacity; } - public DHTOptions setHashQueueCapacity(int hashQueueCapacity) { - this.hashQueueCapacity = hashQueueCapacity; - return this; - } - - public int getNetMode() { return netMode; } - public DHTOptions setNetMode(int netMode) { this.netMode = netMode; return this; } - - @Override - public String toString() { - return "DHTOptions{" - + "port=" + port - + ", metadataTimeout=" + metadataTimeout - + ", netMode=" + netMode - + '}'; - } -} diff --git a/examples-jni/src/main/java/cn/lmcw/dht/model/FileInfo.java b/examples-jni/src/main/java/cn/lmcw/dht/model/FileInfo.java deleted file mode 100644 index 46decf9..0000000 --- a/examples-jni/src/main/java/cn/lmcw/dht/model/FileInfo.java +++ /dev/null @@ -1,30 +0,0 @@ -package cn.lmcw.dht.model; - -/** - * 与 Rust {@code FileInfo} 一一对应的 POJO。 - *

表示一个种子内单个文件的路径与大小。

- */ -public final class FileInfo { - - private final String path; - private final long size; - - /** - * 供 JNI 层调用的全参构造器。 - * - * @param path 文件相对路径 - * @param size 文件字节数 - */ - public FileInfo(String path, long size) { - this.path = path; - this.size = size; - } - - public String getPath() { return path; } - public long getSize() { return size; } - - @Override - public String toString() { - return "FileInfo{path='" + path + "', size=" + size + '}'; - } -} diff --git a/examples-jni/src/main/java/cn/lmcw/dht/model/TorrentInfo.java b/examples-jni/src/main/java/cn/lmcw/dht/model/TorrentInfo.java deleted file mode 100644 index 0b16eb6..0000000 --- a/examples-jni/src/main/java/cn/lmcw/dht/model/TorrentInfo.java +++ /dev/null @@ -1,70 +0,0 @@ -package cn.lmcw.dht.model; - -import java.util.List; - -/** - * 与 Rust {@code TorrentInfo} 一一对应的 POJO。 - *

由 Rust JNI 层通过 {@code NewObject} / {@code SetField} 构造并填充, - * 通过 {@link cn.lmcw.dht.DhtListener#onTorrent(TorrentInfo)} 回调给 Java 层。

- */ -public final class TorrentInfo { - - private final String infoHash; - private final String magnetLink; - private final String name; - private final long totalSize; - private final List files; - private final long pieceLength; - private final List peers; - private final long timestamp; - - /** - * 供 JNI 层调用的全参构造器。 - * - * @param infoHash 十六进制 info-hash 字符串 - * @param magnetLink magnet 链接 - * @param name 种子名称 - * @param totalSize 总字节数 - * @param files 文件列表 - * @param pieceLength 分片长度(字节) - * @param peers 来源节点地址列表 - * @param timestamp 发现时间戳(Unix 秒) - */ - public TorrentInfo( - String infoHash, - String magnetLink, - String name, - long totalSize, - List files, - long pieceLength, - List peers, - long timestamp) { - this.infoHash = infoHash; - this.magnetLink = magnetLink; - this.name = name; - this.totalSize = totalSize; - this.files = files; - this.pieceLength = pieceLength; - this.peers = peers; - this.timestamp = timestamp; - } - - public String getInfoHash() { return infoHash; } - public String getMagnetLink() { return magnetLink; } - public String getName() { return name; } - public long getTotalSize() { return totalSize; } - public List getFiles() { return files; } - public long getPieceLength() { return pieceLength; } - public List getPeers() { return peers; } - public long getTimestamp() { return timestamp; } - - @Override - public String toString() { - return "TorrentInfo{" - + "infoHash='" + infoHash + '\'' - + ", name='" + name + '\'' - + ", totalSize=" + totalSize - + ", files=" + (files != null ? files.size() : 0) - + '}'; - } -} diff --git a/jni/callbacks.rs b/jni/callbacks.rs index bbe0f7a..e0362f5 100644 --- a/jni/callbacks.rs +++ b/jni/callbacks.rs @@ -58,6 +58,7 @@ fn register_on_error(server: &Arc, callback: JavaCallback) { /// 注册 on_metadata_fetch:在拉取 metadata 前询问 Java `onMetadataFetch(infoHash)`。 /// 返回 `true` 才继续拉取;在阻塞线程池中执行 JNI,避免阻塞 tokio worker。 +/// JNI 调用或阻塞任务失败时按拒绝处理。 fn register_on_metadata_fetch(server: &Arc, callback: JavaCallback) { server.on_metadata_fetch(move |info_hash| { let cb = callback.clone(); @@ -78,12 +79,12 @@ fn register_on_metadata_fetch(server: &Arc, callback: JavaCallback) { match r { Ok(Ok(allow)) => allow, Ok(Err(e)) => { - log::error!("回调 onMetadataFetch 失败: {e},默认允许拉取"); - true + log::error!("回调 onMetadataFetch 失败: {e},拒绝拉取"); + false } Err(e) => { - log::error!("onMetadataFetch spawn_blocking 失败: {e},默认允许拉取"); - true + log::error!("onMetadataFetch spawn_blocking 失败: {e},拒绝拉取"); + false } } } diff --git a/jni/exports.rs b/jni/exports.rs index a42d851..2e9a933 100644 --- a/jni/exports.rs +++ b/jni/exports.rs @@ -12,10 +12,10 @@ use jni::sys::{jint, jlong}; /// 创建 DHTServer 并返回句柄(jlong)。 /// -/// Java 签名:`native long createServer(DHTOptions options, DhtListener listener);` +/// JVM 签名:`native long createServer(Object options, Object listener);` /// -/// - `options`:`cn.lmcw.dht.model.DHTOptions` 对象,或 null 则使用默认选项。 -/// - `listener`:`cn.lmcw.dht.DhtListener` 实现,或 null 则不注册回调。 +/// - `options`:包含 native 配置字段的 DTO,或 null 则使用默认选项。 +/// - `listener`:实现 `onTorrent`、`onError` 和 `onMetadataFetch` 的对象,或 null。 /// - 返回:服务器句柄(成功)或 0(失败)。 #[unsafe(no_mangle)] pub extern "system" fn Java_cn_lmcw_dht_DhtCrawlerJni_createServer( diff --git a/jni/types.rs b/jni/types.rs index 556d79d..d0154ba 100644 --- a/jni/types.rs +++ b/jni/types.rs @@ -3,6 +3,76 @@ use jni::JNIEnv; use jni::objects::{JObject, JString, JValue}; use jni::sys::jlong; +#[derive(Debug, thiserror::Error)] +pub enum DhtOptionsConversionError { + #[error(transparent)] + Jni(#[from] jni::errors::Error), + #[error("invalid DHTOptions.{field}: expected {expected}, got {value}")] + InvalidValue { + field: &'static str, + expected: &'static str, + value: i64, + }, +} + +type DhtOptionsResult = Result; + +fn invalid_value( + field: &'static str, + expected: &'static str, + value: impl Into, +) -> DhtOptionsConversionError { + DhtOptionsConversionError::InvalidValue { + field, + expected, + value: value.into(), + } +} + +fn checked_port(value: i32) -> DhtOptionsResult { + u16::try_from(value).map_err(|_| invalid_value("port", "an integer in 0..=65535", value)) +} + +fn checked_non_negative_u32(field: &'static str, value: i32) -> DhtOptionsResult { + u32::try_from(value).map_err(|_| invalid_value(field, "a non-negative integer", value)) +} + +fn checked_non_negative_u64(field: &'static str, value: i64) -> DhtOptionsResult { + u64::try_from(value).map_err(|_| invalid_value(field, "a non-negative integer", value)) +} + +fn checked_non_negative_usize(field: &'static str, value: i32) -> DhtOptionsResult { + usize::try_from(value).map_err(|_| invalid_value(field, "a non-negative integer", value)) +} + +fn checked_positive_usize(field: &'static str, value: i32) -> DhtOptionsResult { + let value = checked_non_negative_usize(field, value)?; + if value == 0 { + return Err(invalid_value( + field, + "an integer greater than or equal to 1", + 0, + )); + } + Ok(value) +} + +fn checked_percentage(field: &'static str, value: i32) -> DhtOptionsResult { + if !(0..=100).contains(&value) { + return Err(invalid_value(field, "an integer in 0..=100", value)); + } + Ok(u8::try_from(value).expect("0..=100 always fits in u8")) +} + +fn checked_netmode(value: i32) -> DhtOptionsResult { + match value { + 0 => Ok(NetMode::Ipv4Only), + 1 => Ok(NetMode::Ipv6Only), + 2 => Ok(NetMode::DualStack), + _ => Err(invalid_value("netMode", "one of 0, 1, or 2", value)), + } +} + // ────────────────────────────────────────────────────────────────────────────── // 常量:Java 类全限定名 // ────────────────────────────────────────────────────────────────────────────── @@ -32,14 +102,15 @@ pub fn file_info_to_java<'local>( env: &mut JNIEnv<'local>, fi: &FileInfo, ) -> jni::errors::Result> { - let cls = env.find_class(CLASS_FILE_INFO)?; - let path = rust_str_to_jstring(env, &fi.path)?; - let obj = env.new_object( - &cls, - "(Ljava/lang/String;J)V", - &[JValue::Object(&path), JValue::Long(fi.size as jlong)], - )?; - Ok(obj) + env.with_local_frame_returning_local(4, |env| { + let cls = env.find_class(CLASS_FILE_INFO)?; + let path = rust_str_to_jstring(env, &fi.path)?; + env.new_object( + &cls, + "(Ljava/lang/String;J)V", + &[JValue::Object(&path), JValue::Long(fi.size as jlong)], + ) + }) } // ────────────────────────────────────────────────────────────────────────────── @@ -125,40 +196,99 @@ fn build_string_list<'local>( // DHTOptions: Java → Rust // ────────────────────────────────────────────────────────────────────────────── -/// 从 Java `cn.lmcw.dht.model.DHTOptions` 对象读取字段,构造 Rust `DHTOptions`。 -pub fn java_to_dht_options(env: &mut JNIEnv, obj: &JObject) -> jni::errors::Result { - let port = env.get_field(obj, "port", "I")?.i()? as u16; - let metadata_timeout_secs = env.get_field(obj, "metadataTimeout", "J")?.j()? as u64; - let metadata_max_queue_size = env.get_field(obj, "maxMetadataQueueSize", "I")?.i()? as usize; - let metadata_max_worker_count = - env.get_field(obj, "maxMetadataWorkerCount", "I")?.i()? as usize; - let pool_capacity = env.get_field(obj, "poolCapacity", "I")?.i()? as usize; - let find_node_rate = env.get_field(obj, "findNodeRatePerSecond", "I")?.i()? as u32; - let find_node_burst = env.get_field(obj, "findNodeBurst", "I")?.i()? as u32; - let max_find_node_in_flight = env.get_field(obj, "maxFindNodeInFlight", "I")?.i()? as usize; - let max_new_destinations = env - .get_field(obj, "maxNewDestinationsPerMinute", "I")? - .i()? as u32; - let max_replacements = env.get_field(obj, "maxReplacementsPerMinute", "I")?.i()? as u32; - let request_timeout_secs = env.get_field(obj, "requestTimeoutSeconds", "J")?.j()? as u64; - let max_response_rate = env.get_field(obj, "maxResponseRatePerSecond", "I")?.i()? as u32; - let max_response_bytes = env.get_field(obj, "maxResponseBytesPerSecond", "J")?.j()? as u64; - let max_response_per_source = env.get_field(obj, "maxResponseRatePerSource", "I")?.i()? as u32; - let pressure_floor = env - .get_field(obj, "metadataPressureFloorPercent", "I")? - .i()? as u8; - let recent_probe_ttl = env.get_field(obj, "recentProbeTtlSeconds", "J")?.j()? as u64; - let responsive_capacity = env.get_field(obj, "responsiveCapacity", "I")?.i()? as usize; - let responsive_ttl = env.get_field(obj, "responsiveTtlSeconds", "J")?.j()? as u64; - let low_watermark = env.get_field(obj, "poolLowWatermark", "I")?.i()? as usize; - let subnet_in_flight = env.get_field(obj, "maxInFlightPerSubnet", "I")?.i()? as usize; - let hash_queue_capacity = env.get_field(obj, "hashQueueCapacity", "I")?.i()? as usize; - let netmode_ord = env.get_field(obj, "netMode", "I")?.i()?; - let netmode = match netmode_ord { - 0 => NetMode::Ipv4Only, - 1 => NetMode::Ipv6Only, - _ => NetMode::DualStack, - }; +/// 从 JVM bindings 的 options DTO 读取字段,构造 Rust `DHTOptions`。 +pub fn java_to_dht_options(env: &mut JNIEnv, obj: &JObject) -> DhtOptionsResult { + let port = checked_port(env.get_field(obj, "port", "I")?.i()?)?; + let metadata_timeout_secs = checked_non_negative_u64( + "metadataTimeout", + env.get_field(obj, "metadataTimeout", "J")?.j()?, + )?; + let metadata_max_queue_size = checked_positive_usize( + "maxMetadataQueueSize", + env.get_field(obj, "maxMetadataQueueSize", "I")?.i()?, + )?; + let metadata_max_worker_count = checked_positive_usize( + "maxMetadataWorkerCount", + env.get_field(obj, "maxMetadataWorkerCount", "I")?.i()?, + )?; + let pool_capacity = checked_positive_usize( + "poolCapacity", + env.get_field(obj, "poolCapacity", "I")?.i()?, + )?; + let find_node_rate = checked_non_negative_u32( + "findNodeRatePerSecond", + env.get_field(obj, "findNodeRatePerSecond", "I")?.i()?, + )?; + let find_node_burst = checked_non_negative_u32( + "findNodeBurst", + env.get_field(obj, "findNodeBurst", "I")?.i()?, + )?; + let max_find_node_in_flight = checked_positive_usize( + "maxFindNodeInFlight", + env.get_field(obj, "maxFindNodeInFlight", "I")?.i()?, + )?; + let max_new_destinations = checked_non_negative_u32( + "maxNewDestinationsPerMinute", + env.get_field(obj, "maxNewDestinationsPerMinute", "I")? + .i()?, + )?; + let max_replacements = checked_non_negative_u32( + "maxReplacementsPerMinute", + env.get_field(obj, "maxReplacementsPerMinute", "I")?.i()?, + )?; + let request_timeout_secs = checked_non_negative_u64( + "requestTimeoutSeconds", + env.get_field(obj, "requestTimeoutSeconds", "J")?.j()?, + )?; + let max_response_rate = checked_non_negative_u32( + "maxResponseRatePerSecond", + env.get_field(obj, "maxResponseRatePerSecond", "I")?.i()?, + )?; + let max_response_bytes = checked_non_negative_u64( + "maxResponseBytesPerSecond", + env.get_field(obj, "maxResponseBytesPerSecond", "J")?.j()?, + )?; + let max_response_per_source = checked_non_negative_u32( + "maxResponseRatePerSource", + env.get_field(obj, "maxResponseRatePerSource", "I")?.i()?, + )?; + let pressure_floor = checked_percentage( + "metadataPressureFloorPercent", + env.get_field(obj, "metadataPressureFloorPercent", "I")? + .i()?, + )?; + let recent_probe_ttl = checked_non_negative_u64( + "recentProbeTtlSeconds", + env.get_field(obj, "recentProbeTtlSeconds", "J")?.j()?, + )?; + let responsive_capacity = checked_positive_usize( + "responsiveCapacity", + env.get_field(obj, "responsiveCapacity", "I")?.i()?, + )?; + let responsive_ttl = checked_non_negative_u64( + "responsiveTtlSeconds", + env.get_field(obj, "responsiveTtlSeconds", "J")?.j()?, + )?; + let low_watermark = checked_non_negative_usize( + "poolLowWatermark", + env.get_field(obj, "poolLowWatermark", "I")?.i()?, + )?; + if low_watermark > pool_capacity { + return Err(invalid_value( + "poolLowWatermark", + "an integer no greater than poolCapacity", + i64::try_from(low_watermark).expect("Java int always fits in i64"), + )); + } + let subnet_in_flight = checked_positive_usize( + "maxInFlightPerSubnet", + env.get_field(obj, "maxInFlightPerSubnet", "I")?.i()?, + )?; + let hash_queue_capacity = checked_positive_usize( + "hashQueueCapacity", + env.get_field(obj, "hashQueueCapacity", "I")?.i()?, + )?; + let netmode = checked_netmode(env.get_field(obj, "netMode", "I")?.i()?)?; let mut options = DHTOptions { port, @@ -172,33 +302,102 @@ pub fn java_to_dht_options(env: &mut JNIEnv, obj: &JObject) -> jni::errors::Resu }, ..DHTOptions::default() }; - options.crawl.pool.capacity = pool_capacity.max(1); + options.crawl.pool.capacity = pool_capacity; options.crawl.pool.recent_probe_ttl_secs = recent_probe_ttl; - options.crawl.pool.responsive_capacity = responsive_capacity.max(1); + options.crawl.pool.responsive_capacity = responsive_capacity; options.crawl.pool.responsive_ttl_secs = responsive_ttl; - options.crawl.pool.low_watermark = low_watermark.min(pool_capacity); + options.crawl.pool.low_watermark = low_watermark; options.crawl.rate_limit.max_find_node_rate_per_sec = find_node_rate; options.crawl.rate_limit.burst = find_node_burst; - options.crawl.rate_limit.max_in_flight = max_find_node_in_flight.max(1); + options.crawl.rate_limit.max_in_flight = max_find_node_in_flight; options.crawl.rate_limit.max_new_destinations_per_minute = max_new_destinations; options.crawl.rate_limit.request_timeout_secs = request_timeout_secs; options.crawl.rate_limit.max_response_rate_per_sec = max_response_rate; options.crawl.rate_limit.max_response_bytes_per_sec = max_response_bytes; options.crawl.rate_limit.max_response_rate_per_source = max_response_per_source; - options.crawl.rate_limit.metadata_pressure_floor_percent = pressure_floor.min(100); + options.crawl.rate_limit.metadata_pressure_floor_percent = pressure_floor; options.crawl.rate_limit.max_replacements_per_minute = max_replacements; - options.crawl.rate_limit.max_in_flight_per_subnet = subnet_in_flight.max(1); + options.crawl.rate_limit.max_in_flight_per_subnet = subnet_in_flight; Ok(options) } -/// 从 Java `cn.lmcw.dht.model.DHTOptions` 对象读取,或若为 null 则返回默认选项。 +/// 从 JVM bindings 的 options DTO 读取,或若为 null 则返回默认选项。 pub fn java_to_dht_options_or_default( env: &mut JNIEnv, obj: &JObject, -) -> jni::errors::Result { +) -> DhtOptionsResult { if obj.is_null() { Ok(DHTOptions::default()) } else { java_to_dht_options(env, obj) } } + +#[cfg(test)] +mod tests { + use super::*; + + fn assert_invalid(result: DhtOptionsResult, field: &'static str, value: i64) { + match result { + Err(DhtOptionsConversionError::InvalidValue { + field: actual_field, + value: actual_value, + .. + }) => { + assert_eq!(actual_field, field); + assert_eq!(actual_value, value); + } + _ => panic!("expected an invalid-value error"), + } + } + + #[test] + fn port_accepts_java_boundaries_and_rejects_out_of_range_values() { + assert_eq!(checked_port(0).unwrap(), 0); + assert_eq!(checked_port(i32::from(u16::MAX)).unwrap(), u16::MAX); + assert_invalid(checked_port(-1), "port", -1); + assert_invalid(checked_port(i32::from(u16::MAX) + 1), "port", 65_536); + } + + #[test] + fn signed_values_are_checked_before_unsigned_conversion() { + assert_eq!(checked_non_negative_u32("rate", 0).unwrap(), 0); + assert_eq!( + checked_non_negative_u32("rate", i32::MAX).unwrap(), + i32::MAX as u32 + ); + assert_invalid(checked_non_negative_u32("rate", -1), "rate", -1); + + assert_eq!(checked_non_negative_u64("timeout", 0).unwrap(), 0); + assert_eq!( + checked_non_negative_u64("timeout", i64::MAX).unwrap(), + i64::MAX as u64 + ); + assert_invalid(checked_non_negative_u64("timeout", -1), "timeout", -1); + } + + #[test] + fn capacities_and_in_flight_limits_must_be_positive() { + assert_eq!(checked_positive_usize("capacity", 1).unwrap(), 1); + assert_eq!( + checked_positive_usize("capacity", i32::MAX).unwrap(), + i32::MAX as usize + ); + assert_invalid(checked_positive_usize("capacity", 0), "capacity", 0); + assert_invalid(checked_positive_usize("capacity", -1), "capacity", -1); + } + + #[test] + fn percentage_and_netmode_only_accept_documented_values() { + assert_eq!(checked_percentage("percent", 0).unwrap(), 0); + assert_eq!(checked_percentage("percent", 100).unwrap(), 100); + assert_invalid(checked_percentage("percent", -1), "percent", -1); + assert_invalid(checked_percentage("percent", 101), "percent", 101); + + assert_eq!(checked_netmode(0).unwrap(), NetMode::Ipv4Only); + assert_eq!(checked_netmode(1).unwrap(), NetMode::Ipv6Only); + assert_eq!(checked_netmode(2).unwrap(), NetMode::DualStack); + assert_invalid(checked_netmode(-1), "netMode", -1); + assert_invalid(checked_netmode(3), "netMode", 3); + } +} diff --git a/src/lib.rs b/src/lib.rs index fb1fda5..0c46f6c 100644 --- a/src/lib.rs +++ b/src/lib.rs @@ -58,5 +58,5 @@ pub mod prelude { #[cfg(feature = "jni")] #[path = "../jni/mod.rs"] -/// JNI entry points used by the bundled Java wrapper. +/// JNI entry points consumed by the external JVM bindings. pub mod jni_bindings;