plumber2的异步端点不会继承全局环境

后端开发 2026-07-10

在使用 async = TRUE 的plumber2的默认mirai评估器时,工作进程不会继承主R 会话全局环境中已加载或已定义的任何内容——这包括数据库连接、环境变量、已加载的库以及全局对象。下面给出一个关于库相关问题的最小可复现示例:

最小可重复的示例:

library(plumber2)
library(dplyr)

pa <- api() |>
  api_get("/test", handler = function() {
    tryCatch({
      my_data <- tibble(x = 1:5)
      my_data |> dplyr::filter(x > 3)
    }, error = function(e) {
      list(error = conditionMessage(e))
    })
  }, async = TRUE)

mirai::daemons(2)

pa |> api_run(port = 8000, block = FALSE)

调用端点

curl http://localhost:8000/test

返回结果:

{"error":["could not find function \"tibble\""]}

尽管在主R 会话的全局环境中已经加载了 dplyr,但mirai的工作进程无法访问它。

预期行为:

api_run() 之前完成的任何设置(加载库、引入文件、创建数据库连接、读取环境变量、加载ML模型)理应能够被异步工作进程使用。必须在每个工作进程中手动重建全局环境,这与内置异步评估器的初衷相悖。

问题:

在plumber2中是否有内置方法可以将全局环境提供给异步的mirai工作进程,还是应当使用自定义评估器作为解决方案?

解决方案

前提是,使用异步plumber2端点存在许多细微之处。参考 https://tidyverse.org/blog/2025/09/plumber2-0-1-0/#async-evaluation,至少需要注意:

由于异步评估在不同的进程中进行,它们无法访问服务器对象,也无法访问请求和响应对象。

这带来很多影响。特别是在 mirai 的情况下,数据传递通常需要明确的操作。尽管在 plumber2 中有了一些改进,但诸如环境变量和已附加的库等并不会立即传输。这在 mirai 中是有意为之,因为将调用方全局环境中的一切都传输过去会变得困难和/或代价高昂……因此作者要求程序员明确指定传输内容。

数据库对象

这些对象(例如来自 DBI::dbConnect() 或类似来源的对象)不会在进程之间传输,这在任何R 的多进程方法中都几乎不可行,包括 miraiplumber2 的默认)、futurecallr 等。这是因为它们通常内部具有一个C 级别的连接,而该连接无法跨进程传输。这对odbc、postgres、duckdb,以及我相信sqlite也一样。我相信大多数若非全部基于 DBI 的连接也是如此,至于ADBC或其他实现我不便确认,但若没有这个特性我会有点惊讶。

可能存在被视为“数据库连接”的“简单R 对象”。例如,bigrquery 通常使用HTTP调用,我不清楚内部是否有某处二进制连接句柄。如果没有,那么它理论上可能跨进程传递,但……我尚未测试,也暂时不便说明。

就当前假设的“传统”数据库连接对象而言,有两种方法来缓解这个问题。

  1. 在创建 mirai-池之后,使用 mirai::everywhere(.) 定义对象,使所有节点都拥有已定义的连接。例如:

r dbargs <- list( driver = "ODBC Driver 18 for SQL Server", uid = Sys.getenv("DBUSER"), pwd = Sys.getenv("DBPASS"), database = "my_database", server = Sys.getenv("DBHOST") ) mirai::daemons(2) mirai::everywhere({ conn <<- do.call(DBI::dbConnect, c(list(drv = odbc::odbc()), dbargs)) }, .args = list(dbargs = dbargs))

注意 <<- 的使用,如 ?mirai::everywhere 中的示例所示。

这种方法会在每个节点中保持一个连接的定义,只要该连接不过期,它们就会继续工作。一个缺点是若连接超时或发生其他问题,端点将全部失效,直到你再次调用 everywhere() 重新定义它们(需要人工干预,在生产环境中不太可能)或重新启动整个进程(相当繁琐)。 2.使用上面相同的 dbargs 列表,在每个端点中始终进行连接、查询和断开。我建议类似如下:

r # some endpoint, whether in a file or within `api_get(handler=)` function(...) { stopifnot(exists("dbargs")) conn <- do.call(DBI::dbConnect, c(list(drv = odbc::odbc()), dbargs)) on.exit(DBI::dbDisconnect(conn), add = TRUE) # rest of function code }

不断的连接/查询/断开所产生的开销取决于你预期的API负载。

其实还有第三种选择:

  1. 不是使用 everywhere({ conn <<- ...}),而是定义一个函数,在内部存储其连接并返回它。这样如果确定需要重新实例化连接,你可以提供一个“自愈”机制。就先留给你作为练习,因为这会增加复杂性,可能使调试变得相当困难;若你的负载不需要,通常也比上面的第2 种方案要额外工作得多。

其他对象

plumber2 的说法中,定义端点的全局环境与端点文件本身的全局环境之间存在差异。如果你能改用“文件”方式,我可以重现如下情况。

### path/to/plumber.R
Sys.setenv(PI = pi)
PI <- pi
PI2 <- Sys.getenv("PI")
library(dplyr)

#* @get /test
#* @async
function() {
  dplyr_attached <- tryCatch({ left_join; TRUE; }, error = function(ign) FALSE)
  list(env = Sys.getenv("PI"), obj = PI, obj2 = PI2, lib = dplyr_attached)
}

在测试时使用以下方式实例化它

mirai::daemons(2) # only needed once during dev
pa <- api("path/to/plumber.R") |> api_run()

并测试端点,返回值如下所示,注意环境变量为空且未找到 dplyr

{
  "env": [
    ""
  ],
  "obj": [
    3.1416
  ],
  "obj2": [
    "3.14159265358979"
  ],
  "lib": [
    false
  ]
}

(测试的快速清理:pa$stop()。)

由于未找到环境变量 "PI",我上面演示的变通办法是把它捕获到R 的外部工作环境中的 PI2,并允许该对象被传递。这当然依赖于对象是否能够被传递。

这意味着如果在端点中添加 library(.) 调用,它们将能够工作。

### path/to/plumber.R
Sys.setenv(PI = pi)
PI <- pi
library(dplyr)

#* @get /test
#* @async
function() {
  library(dplyr)
  dplyr_attached <- tryCatch({ left_join; TRUE; }, error = function(ign) FALSE)
  list(env = Sys.getenv("PI"), obj = PI, obj2 = PI2, lib = dplyr_attached)
}

接着启动该plumber2实例(再次用stop清理),返回值会变为:

{
  "env": [
    ""
  ],
  "obj": [
    3.1416
  ],
  "obj2": [
    "3.14159265358979"
  ],
  "lib": [
    true
  ]
}

一般而言,对 library(.) 的重复调用负载很低,意味着如果相关包已经附加,实际工作并不多。


我将用一个向我有权限访问的数据库发送查询的端点来收尾。我已经在外部定义了相关的 DB* 环境变量。

dbargs <- list(
  driver = "ODBC Driver 18 for SQL Server", 
  timeout = 1L, encrypt = "yes", trustservercertificate = "yes", 
  server = Sys.getenv("DBHOST"), database = Sys.getenv("DBNAME"),
  uid = Sys.getenv("DBUSER"), pwd = Sys.getenv("DBPASS"))

#* @get /dbtest
#* @async
function() {
  stopifnot(exists("dbargs"))
  conn <- do.call(DBI::dbConnect, c(list(drv = odbc::odbc()), dbargs))
  on.exit(DBI::dbDisconnect(conn), add = TRUE)
  DBI::dbGetQuery(conn, "select 1 as number")
}

在R 控制台中,

mirai::daemons(2)
pa <- api("~/tmp/quux/plumber.R") |> api_run()
# tested, results shown below
pa$stop()
[
  {
    "number": 1
  }
]

bottom line:

  • 对于大多数你需要的“连接”,建议使用对象来定义创建连接对象所需的参数,并在端点内部完成实例化;
  • 对于需要通过的环境变量,将它们存放在 @async 的端点定义之外的一个对象中;
  • 对上述内容及你需要的其他内容,使用简单的R 对象。
站内所有文章版权归属LeftHeroAI导航站,无授权禁止任何主体转载、抄袭、复制内容,亦不得私自架设镜像站点。一经侵权,本站将通过法律途径追责。

相关文章