在实现Web服务时,经常会在某个时候出现运行循环作业的需求(例如,为了清理/维护或状态共享的目的)。根据不同的设置,这些工作可能在专用服务上运行,也可能在Web服务本身上运行。
在这篇文章中,我们将看看Job Runner的基本实现,它在服务实例之间同步运行作业(因此我们不会在同一时间多次运行同一个作业),并使用tokio异步执行作业,这使得我们能够在作业中使用async/await。
我们可以在作业运行器上注册新的作业,并为其制定运行时间表。作业运行器本身将简单地在可配置的时间间隔内检查要运行的作业,当一个作业正在运行时,将这个作业的条目放入一个共享的redis 缓存。
这个redis缓存用于网络服务的多个实例之间的同步。
我们将一步一步地进行实施,最后会有一个例子的链接,说明如何整合一切。
实施
首先,让我们定义一个用于注册和运行作业的漂亮的API。我们可能需要某种上下文对象来为我们的作业提供一些东西,如数据库连接。
let job_context = JobContext {
db_pool: db_pool.clone(),
};
此外,我们还希望不费吹灰之力就能定义作业和它们的时间表,所以让我们看看Job 特质是怎样的。
pub trait Job {
fn run(&self, ctx: &JobContext) -> JobResult;
fn get_interval(&self) -> Duration;
fn get_name(&self) -> &'static str;
fn get_sync_key(&self) -> &'static str;
fn box_clone(&self) -> BoxedJob;
}
impl Clone for Box<dyn Job> {
fn clone(&self) -> Box<dyn Job> {
self.box_clone()
}
}
所有的工作都需要实现这个特性。它包括工作的名称和它应该运行的时间间隔。它还会返回sync_key ,这是 redis 中用于在实例之间同步运行该作业的密钥。我们还需要能够克隆作业,所以我们还需要实现一个box_clone 方法,基本上就是这样。
fn box_clone(&self) -> BoxedJob {
Box::new((*self).clone())
}
到目前为止,我还没有找到一个更好的方法来为一个盒式特质对象提供克隆,所以现在只能这样做。
在上面的片段中,有两个自定义类型--BoxedJob和JobResult。
pub type BoxedJob = Box<dyn Job + Send + Sync>;
type JobResult = BoxFuture<'static, Result<()>>;
BoxedJob是一个简单的盒装工作,我们可以在线程之间安全地传递。JobResult是我们期望作业运行后返回的东西,在这种情况下,它只是一个返回Result<()> ,因为我们对任何返回值都不感兴趣,而只是对作业是否成功感兴趣。
好了,让我们看看一个具体的作业实现是怎样的。
#[derive(Clone)]
pub struct DummyJob;
impl Job for DummyJob {
fn run(&self, ctx: &JobContext) -> JobResult {
info!("Running Dummyjob...");
let ctx_clone = ctx.clone();
let fut = async move {
let db = ctx_clone.db_pool.get().await?;
db.execute("SELECT 1", &[]).await?;
info!("DummyJob finished!");
Ok(())
};
Box::pin(fut)
}
fn get_interval(&self) -> Duration {
Duration::from_secs(6000)
}
fn get_name(&self) -> &'static str {
DUMMY_JOB_NAME
}
fn get_sync_key(&self) -> &'static str {
DUMMY_JOB_SYNC_CACHE_KEY
}
fn box_clone(&self) -> BoxedJob {
Box::new((*self).clone())
}
}
所以在run ,我们可以在一个async 块中使用JobContext ,在这里我们实际执行我们的工作逻辑--我们可以在这里执行任何我们喜欢的期货。也可以使用spawn_blocking 等来运行CPU密集型的东西。其他的方法应该是不言自明的。
有了这样一个作业,设置我们的作业运行器是相当简单的。
let dummy_job = DummyJob {};
let job_runner = JobRunner::new(
redis_pool.clone(),
job_context,
vec![
Box::new(dummy_job) as BoxedJob,
],
);
job_runner.run_jobs().await?
我们实例化一个DummyJob 实例和一个JobRunner ,把我们的 redis 池和上面定义的作业上下文以及一个作业列表(包括我们装箱的假作业)传给它。
上面提到的redis_pool 和db_pool 是使用mobc 的简单 redis 和 postgres 连接池,但你可以使用任何你喜欢的池子,只要它能安全地跨线程共享。
好了,这就是我们的目标的API。让我们开始构建JobRunner 。
pub struct JobRunner {
jobs: Vec<BoxedJob>,
redis_pool: RedisPool,
job_context: JobContext,
}
到目前为止还不错。JobRunner 持有上面提到的 redis 池和工作上下文,以及一个工作列表。这些工作是一个盒状的特质对象,所以我们可以在同一个列表中使用不同的工作。
我们要看的第一个函数是run_jobs 。这是初始函数的调用,它启动了整个事情。
pub async fn run_jobs(self) -> Result<()> {
self.announce_jobs();
let mut job_interval =
tokio::time::interval(Duration::from_secs(JOB_CHECKING_INTERVAL_SECS));
let arc_jobs = Arc::new(&self.jobs);
loop {
job_interval.tick().await;
match self.check_and_run_jobs(&arc_jobs).await {
Ok(_) => (),
Err(e) => error!("Could not check and run Jobs: {}", e),
};
}
}
在这种情况下,我们将首先公布已注册的作业,记录它们和它们的运行间隔。然后,我们检查一个作业是否需要在预先定义的时间间隔内执行。
这个时间间隔控制了我们的调度的精确程度。例如,如果我们每2分钟检查一次挂起的工作运行,那么每30秒安排一次工作就没有意义了。一般来说,如果作业在某个特定的时间运行不是特别关键,把这个间隔时间调高是个好主意,因为它的执行频率较低,因此对你的服务的运行性能影响较小。
总之,每隔$interval秒,我们就会调用check_and_run_jobs ,接下来我们会看一下这个函数。
async fn check_and_run_jobs(&self, arc_jobs: &Arc<&Vec<BoxedJob>>) -> Result<()> {
let jobs = arc_jobs.clone();
for job in jobs.iter() {
match self.check_and_run_job(job, &self.redis_pool).await {
Ok(_) => (),
Err(e) => error!("Error during Job run: {}", e),
};
}
Ok(())
}
在这个函数中,我们遍历所有注册的作业,并为每个作业调用check_and_run_job 。有趣的是,我们把工作列表移到了Arc 中,以便在迭代过程中能够在线程之间安全地共享工作。
你可能会注意到一件事,那就是我们在一个工作之后迭代另一个工作。根据不同的使用情况,用类似于try_join_all 的方式同时运行check_and_run_job 的调用可能是有意义的。
总之,对于每个作业,我们做以下工作。
async fn check_and_run_job(&self, job: &BoxedJob, redis_pool: &RedisPool) -> Result<()> {
let now = Utc::now().timestamp() as u64;
let j = job.box_clone();
let job_context_clone = self.job_context.clone();
match cache::get_str(&redis_pool, job.get_sync_key()).await {
Ok(v) => {
let last_run = v.parse::<u64>().map_err(|_| ParseLastJobRunError(v))?;
if now > job.get_interval().as_secs() + last_run {
self.set_last_run(now, &redis_pool, job.get_sync_key())
.await;
self.run_job(j, job_context_clone);
}
}
Err(_) => {
self.set_last_run(now, &redis_pool, job.get_sync_key())
.await;
self.run_job(j, job_context_clone);
}
};
Ok(())
}
好了,现在变得更有趣了。首先,我们检查共享的Redis缓存,如果我们已经有了这个工作的条目,如果没有,我们就运行这个工作,并将last_run_date ,现在。如果我们确实有一个值,我们检查是否是时候运行它(now 需要大于last_run +job_interval )。如果是,我们就运行它,否则就放弃,以后再试。
set_last_run 方法只是将最后的运行时间写入共享的redis缓存中。
运行作业本身并不特别令人兴奋。
fn run_job(&self, job: BoxedJob, job_context: JobContext) {
let job_name = job.get_name();
tokio::spawn(async move {
info!("Starting Job {}...", job_name);
match job.run(&job_context).await {
Ok(_) => info!("Job {} finished successfully", job_name),
Err(e) => error!("Job {} finished with error: {}", job_name, e),
};
});
}
因为我们使用的是BoxedJob 特质对象,所以我们可以用给定的上下文调用job.run 。我们在tokio::spawn ,所以实际作业是在tokio线程池上运行的。作业执行后,我们还使用日志语句报告成功或失败。
好了。这就是我们需要的所有基础设施,现在我们可以尝试将其与HTTP服务器集成。在我们的案例中,我们将只使用普通的hyper 。
// first, define job context
let job_context = JobContext {
db_pool: db_pool.clone(),
};
// then, instantiate the jobs
let dummy_job = DummyJob {};
let second_job = SecondJob {};
// ...and the job_runner
let job_runner = JobRunner::new(
redis_pool.clone(),
job_context,
vec![
Box::new(dummy_job) as BoxedJob,
Box::new(second_job) as BoxedJob,
],
);
// set up hyper server..
let make_svc = make_service_fn(|_conn| async { Ok::<_, Infallible>(service_fn(hello)) });
let addr = ([127, 0, 0, 1], 3000).into();
let server = Server::bind(&addr).serve(make_svc);
info!("Listening on http://{}", addr);
// run hyper and our job_runner concurrently
let res = join!(server, job_runner.run_jobs());
res.0.map_err(|e| {
error!("server crashed");
e
})?;
Ok(())
所以,我们首先用我们的DummyJob中需要的db_pool 来定义我们的工作环境。然后,我们实例化Job和JobRunner,将DummyJob和JobContext移到JobRunner中。
之后,我们准备一个基本的超级服务器,使用join! ,同时运行它和我们的job_runner 。如果它们中的任何一个崩溃了,我们就处理这个错误。
如果我们现在运行这个,我们可以访问服务器,我们可以在日志中看到运行的作业。另外,如果我们运行几个实例(在不同的端口),我们可以观察到每个作业在每个区间只运行一次。
完整的示例代码包括redis和postgres池的设置,可以在这里找到。
总结
在Web服务器上运行作业是许多项目的一个重要用例,我发现对于这个小的实现所提供的大量功能(redis-sync、异步作业执行、基本调度)来说,用rust实现并不难。
我最近一直在使用async/await和async rust,尽管它是一个新的生态系统,但到目前为止,它是一个很好的经验。)