Implement template auto-reload.

Resolves #163.
This commit is contained in:
jeb 2018-01-10 12:27:51 -07:00 committed by Sergio Benitez
parent 83cead775f
commit 491b04cf5a
8 changed files with 264 additions and 35 deletions

View File

@ -40,5 +40,8 @@ tera = { version = "0.11", optional = true }
[dev-dependencies]
rocket_codegen = { version = "0.4.0-dev", path = "../../core/codegen" }
[target.'cfg(debug_assertions)'.dependencies]
notify = { version = "^4.0" }
[package.metadata.docs.rs]
all-features = true

View File

@ -11,11 +11,14 @@ pub struct Context {
pub root: PathBuf,
/// Mapping from template name to its information.
pub templates: HashMap<String, TemplateInfo>,
/// Mapping from template name to its information.
pub engines: Engines
/// Loaded template engines
pub engines: Engines,
}
impl Context {
/// Load all of the templates at `root`, initialize them using the relevant
/// template engine, and store all of the initialized state in a `Context`
/// structure, which is returned if all goes well.
pub fn initialize(root: PathBuf) -> Option<Context> {
let mut templates: HashMap<String, TemplateInfo> = HashMap::new();
for ext in Engines::ENABLED_EXTENSIONS {
@ -45,9 +48,8 @@ impl Context {
}
}
Engines::init(&templates).map(|engines| {
Context { root, templates, engines }
})
Engines::init(&templates)
.map(|engines| Context { root, templates, engines } )
}
}

View File

@ -0,0 +1,168 @@
use super::DEFAULT_TEMPLATE_DIR;
use super::context::Context;
use super::engine::Engines;
use rocket::Rocket;
use rocket::config::ConfigError;
use rocket::fairing::{Fairing, Info, Kind};
pub use self::context::ContextManager;
#[cfg(not(debug_assertions))]
mod context {
use std::ops::Deref;
use super::Context;
/// Wraps a Context. With `cfg(debug_assertions)` active, this structure
/// additionally provides a method to reload the context at runtime.
pub struct ContextManager(Context);
impl ContextManager {
pub fn new(ctxt: Context) -> ContextManager {
ContextManager(ctxt)
}
pub fn context<'a>(&'a self) -> impl Deref<Target=Context> + 'a {
&self.0
}
}
}
#[cfg(debug_assertions)]
mod context {
extern crate notify;
use std::ops::{Deref, DerefMut};
use std::sync::{RwLock, Mutex};
use std::sync::mpsc::{channel, Receiver};
use super::{Context, Engines};
use self::notify::{raw_watcher, RawEvent, RecommendedWatcher, RecursiveMode, Watcher};
/// Wraps a Context. With `cfg(debug_assertions)` active, this structure
/// additionally provides a method to reload the context at runtime.
pub struct ContextManager {
/// The current template context, inside an RwLock so it can be updated.
context: RwLock<Context>,
/// A filesystem watcher and the receive queue for its events.
watcher: Option<(RecommendedWatcher, Mutex<Receiver<RawEvent>>)>,
}
impl ContextManager {
pub fn new(ctxt: Context) -> ContextManager {
let (tx, rx) = channel();
let watcher = if let Ok(mut watcher) = raw_watcher(tx) {
if watcher.watch(ctxt.root.clone(), RecursiveMode::Recursive).is_ok() {
Some((watcher, Mutex::new(rx)))
} else {
warn!("Could not monitor the templates directory for changes.");
warn_!("Live template reload will be unavailable");
None
}
} else {
warn!("Could not instantiate a filesystem watcher.");
warn_!("Live template reload will be unavailable");
None
};
ContextManager {
watcher,
context: RwLock::new(ctxt),
}
}
pub fn context<'a>(&'a self) -> impl Deref<Target=Context> + 'a {
self.context.read().unwrap()
}
fn context_mut<'a>(&'a self) -> impl DerefMut<Target=Context> + 'a {
self.context.write().unwrap()
}
/// Checks whether any template files have changed on disk. If there
/// have been changes since the last reload, all templates are
/// reinitialized from disk and the user's customization callback is run
/// again.
pub fn reload_if_needed<F: Fn(&mut Engines)>(&self, custom_callback: F) {
self.watcher.as_ref().map(|w| {
let rx = w.1.lock().expect("receive queue");
let mut changed = false;
while let Ok(_) = rx.try_recv() {
changed = true;
}
if changed {
info_!("Change detected: reloading templates.");
let mut ctxt = self.context_mut();
if let Some(mut new_ctxt) = Context::initialize(ctxt.root.clone()) {
custom_callback(&mut new_ctxt.engines);
*ctxt = new_ctxt;
} else {
warn_!("An error occurred while reloading templates.");
warn_!("The previous templates will remain active.");
};
}
});
}
}
}
/// The TemplateFairing initializes the template system on attach, running
/// custom_callback after templates have been loaded. In debug mode, the fairing
/// checks for modifications to templates before every request and reloads them
/// if necessary.
pub struct TemplateFairing {
/// The user-provided customization callback, allowing the use of
/// functionality specific to individual template engines. In debug mode,
/// this callback might be run multiple times as templates are reloaded.
pub(crate) custom_callback: Box<Fn(&mut Engines) + Send + Sync + 'static>,
}
impl Fairing for TemplateFairing {
fn info(&self) -> Info {
// The on_request part of this fairing only applies in debug
// mode, so only register it in debug mode.
Info {
name: "Templates",
#[cfg(debug_assertions)]
kind: Kind::Attach | Kind::Request,
#[cfg(not(debug_assertions))]
kind: Kind::Attach,
}
}
/// Initializes the template context. Templates will be searched for in the
/// `template_dir` config variable or the default ([DEFAULT_TEMPLATE_DIR]).
/// The user's callback, if any was supplied, is called to customize the
/// template engines. In debug mode, the `ContextManager::new` method
/// initializes a directory watcher for auto-reloading of templates.
fn on_attach(&self, rocket: Rocket) -> Result<Rocket, Rocket> {
let mut template_root = rocket.config().root_relative(DEFAULT_TEMPLATE_DIR);
match rocket.config().get_str("template_dir") {
Ok(dir) => template_root = rocket.config().root_relative(dir),
Err(ConfigError::NotFound) => { /* ignore missing configs */ }
Err(e) => {
e.pretty_print();
warn_!("Using default templates directory '{:?}'", template_root);
}
};
match Context::initialize(template_root) {
Some(mut ctxt) => {
(self.custom_callback)(&mut ctxt.engines);
Ok(rocket.manage(ContextManager::new(ctxt)))
}
None => Err(rocket),
}
}
#[cfg(debug_assertions)]
fn on_request(&self, req: &mut ::rocket::Request, _data: &::rocket::Data) {
let cm = req.guard::<::rocket::State<ContextManager>>()
.expect("Template ContextManager registered in on_attach");
cm.reload_if_needed(&*self.custom_callback);
}
}

View File

@ -2,7 +2,7 @@ use rocket::{Request, State, Outcome};
use rocket::http::Status;
use rocket::request::{self, FromRequest};
use templates::Context;
use super::ContextManager;
/// The `TemplateMetadata` type: implements `FromRequest`, allowing dynamic
/// queries about template metadata.
@ -48,7 +48,7 @@ use templates::Context;
/// }
/// }
/// ```
pub struct TemplateMetadata<'a>(&'a Context);
pub struct TemplateMetadata<'a>(&'a ContextManager);
impl<'a> TemplateMetadata<'a> {
/// Returns `true` if the template with name `name` was loaded at start-up
@ -65,7 +65,7 @@ impl<'a> TemplateMetadata<'a> {
/// }
/// ```
pub fn contains_template(&self, name: &str) -> bool {
self.0.templates.contains_key(name)
self.0.context().templates.contains_key(name)
}
}
@ -76,9 +76,9 @@ impl<'a, 'r> FromRequest<'a, 'r> for TemplateMetadata<'a> {
type Error = ();
fn from_request(request: &'a Request) -> request::Outcome<Self, ()> {
request.guard::<State<Context>>()
request.guard::<State<ContextManager>>()
.succeeded()
.and_then(|ctxt| Some(Outcome::Success(TemplateMetadata(ctxt.inner()))))
.and_then(|cm| Some(Outcome::Success(TemplateMetadata(cm.inner()))))
.unwrap_or_else(|| {
error_!("Uninitialized template context: missing fairing.");
info_!("To use templates, you must attach `Template::fairing()`.");

View File

@ -4,7 +4,9 @@ extern crate glob;
#[cfg(feature = "tera_templates")] mod tera_templates;
#[cfg(feature = "handlebars_templates")] mod handlebars_templates;
mod engine;
mod fairing;
mod context;
mod metadata;
@ -12,6 +14,7 @@ pub use self::engine::Engines;
pub use self::metadata::TemplateMetadata;
use self::engine::Engine;
use self::fairing::{TemplateFairing, ContextManager};
use self::context::Context;
use self::serde::Serialize;
use self::serde_json::{Value, to_value};
@ -22,10 +25,9 @@ use std::path::PathBuf;
use rocket::{Rocket, State};
use rocket::request::Request;
use rocket::fairing::{Fairing, AdHoc};
use rocket::fairing::Fairing;
use rocket::response::{self, Content, Responder};
use rocket::http::{ContentType, Status};
use rocket::config::ConfigError;
const DEFAULT_TEMPLATE_DIR: &'static str = "templates";
@ -76,6 +78,11 @@ const DEFAULT_TEMPLATE_DIR: &'static str = "templates";
/// [Serde](https://github.com/serde-rs/json) and would serialize to an `Object`
/// value.
///
/// In debug mode (without the `--release` flag passed to `cargo`), templates
/// will be automatically reloaded from disk if any changes have been made to
/// the templates directory since the previous request. In release builds,
/// template reloading is disabled to improve performance and cannot be enabled.
///
/// # Usage
///
/// To use, add the `handlebars_templates` feature, the `tera_templates`
@ -205,26 +212,10 @@ impl Template {
/// # ;
/// }
/// ```
pub fn custom<F>(f: F) -> impl Fairing where F: Fn(&mut Engines) + Send + Sync + 'static {
AdHoc::on_attach(move |rocket| {
let mut template_root = rocket.config().root_relative(DEFAULT_TEMPLATE_DIR);
match rocket.config().get_str("template_dir") {
Ok(dir) => template_root = rocket.config().root_relative(dir),
Err(ConfigError::NotFound) => { /* ignore missing configs */ }
Err(e) => {
e.pretty_print();
warn_!("Using default templates directory '{:?}'", template_root);
}
};
match Context::initialize(template_root) {
Some(mut ctxt) => {
f(&mut ctxt.engines);
Ok(rocket.manage(ctxt))
}
None => Err(rocket)
}
})
pub fn custom<F>(f: F) -> impl Fairing
where F: Fn(&mut Engines) + Send + Sync + 'static
{
TemplateFairing { custom_callback: Box::new(f) }
}
/// Render the template named `name` with the context `context`. The
@ -289,7 +280,7 @@ impl Template {
pub fn show<S, C>(rocket: &Rocket, name: S, context: C) -> Option<String>
where S: Into<Cow<'static, str>>, C: Serialize
{
let ctxt = rocket.state::<Context>().or_else(|| {
let ctxt = rocket.state::<ContextManager>().map(ContextManager::context).or_else(|| {
warn!("Uninitialized template context: missing fairing.");
info!("To use templates, you must attach `Template::fairing()`.");
info!("See the `Template` documentation for more information.");
@ -299,6 +290,9 @@ impl Template {
Template::render(name, context).finalize(&ctxt).ok().map(|v| v.0)
}
/// Aactually render this template given a template context. This method is
/// called by the `Template` `Responder` implementation as well as
/// `Template::show()`.
#[inline(always)]
fn finalize(self, ctxt: &Context) -> Result<(String, ContentType), Status> {
let name = &*self.name;
@ -329,12 +323,12 @@ impl Template {
/// rendering fails, an `Err` of `Status::InternalServerError` is returned.
impl Responder<'static> for Template {
fn respond_to(self, req: &Request) -> response::Result<'static> {
let ctxt = req.guard::<State<Context>>().succeeded().ok_or_else(|| {
let ctxt = req.guard::<State<ContextManager>>().succeeded().ok_or_else(|| {
error_!("Uninitialized template context: missing fairing.");
info_!("To use templates, you must attach `Template::fairing()`.");
info_!("See the `Template` documentation for more information.");
Status::InternalServerError
})?;
})?.inner().context();
let (render, content_type) = self.finalize(&ctxt)?;
Content(content_type, render).respond_to(req)

View File

@ -116,5 +116,61 @@ mod templates_tests {
let response = client.get("/tera/test").dispatch();
assert_eq!(response.status(), Status::NotFound);
}
#[test]
#[cfg(debug_assertions)]
fn test_template_reload() {
use std::fs::File;
use std::io::Write;
use std::path::Path;
use std::thread;
use std::time::Duration;
use rocket::local::Client;
const RELOAD_TEMPLATE: &str = "hbs/reload";
const INITIAL_TEXT: &str = "initial";
const NEW_TEXT: &str = "reload";
fn write_file(path: &Path, text: &str) {
let mut file = File::create(path).expect("open file");
file.write_all(text.as_bytes()).expect("write file");
file.sync_all().expect("sync file");
}
let reload_path = Path::join(
Path::new(env!("CARGO_MANIFEST_DIR")),
"tests/templates/hbs/reload.txt.hbs"
);
// set up the template before initializing the Rocket instance so
// that it will be picked up in the initial loading of templates.
write_file(&reload_path, INITIAL_TEXT);
let client = Client::new(rocket()).unwrap();
// verify that the initial content is correct
let initial_rendered = Template::show(client.rocket(), RELOAD_TEMPLATE, ());
assert_eq!(initial_rendered, Some(INITIAL_TEXT.into()));
// write a change to the file
write_file(&reload_path, NEW_TEXT);
for _ in 0..6 {
// dispatch any request to trigger a template reload
client.get("/").dispatch();
// if the new content is correct, we are done
let new_rendered = Template::show(client.rocket(), RELOAD_TEMPLATE, ());
if new_rendered == Some(NEW_TEXT.into()) {
return;
}
// otherwise, retry a few times, waiting 250ms in between
thread::sleep(Duration::from_millis(250));
}
panic!("failed to reload modified template in 1.5s");
}
}
}

View File

@ -0,0 +1 @@
reload

View File

@ -263,6 +263,11 @@ engine used to render a template depends on the template file's extension. For
example, if a file ends with `.hbs`, Handlebars is used, while if a file ends
with `.tera`, Tera is used.
When your application is compiled in `debug` mode (without the `--release` flag
passed to `cargo`), templates are automatically reloaded when they are modified.
This means that you don't need to rebuild your application to observe template
changes: simply refresh! In release builds, reloading is disabled.
For templates to be properly registered, the template fairing must be attached
to the instance of Rocket. The [Fairings](/guide/fairings) sections of the guide
provides more information on fairings. To attach the template fairing, simply