Skip to content

Commit a7d0d9e

Browse files
committed
Support :autoload option and autoload method for autoloading files and directories
This makes it possible to use the same +autoload+ call in all cases, and handle four separate scenarios: 1. Autoload then reload: Fast development mode startup, loading the minimum number of files, but reloading if those files are changed 2. Autoload without reload: Useful for faster testing of a subset of an application, so the untested subsets is not loaded. 3. Require then reload: Slower development mode startup, but have entire application loaded before accepting requests 4. Require without reload: Normal production/testing mode with nothing autoloaded or reloaded
1 parent ebc4bb0 commit a7d0d9e

File tree

7 files changed

+476
-8
lines changed

7 files changed

+476
-8
lines changed

CHANGELOG

+4
Original file line numberDiff line numberDiff line change
@@ -1,3 +1,7 @@
1+
= master
2+
3+
* Support :autoload option and autoload method for autoloading files and directories (jeremyevans)
4+
15
= 2.0.0 (2022-06-23)
26

37
* Fix TypeError being raised when requiring a file results in an error (jeremyevans)

README.rdoc

+40-1
Original file line numberDiff line numberDiff line change
@@ -195,7 +195,7 @@ class.
195195

196196
== Requiring
197197

198-
Rack::Unreloader#require is a little different than require in that it takes
198+
Rack::Unreloader#require is a little different than Kernel#require in that it takes
199199
a file glob, not a normal require path. For that reason, you must specify
200200
the extension when requiring the file, and it will only look in the current
201201
directory by default:
@@ -253,6 +253,45 @@ decide that instead of specifying the constants, ObjectSpace should be used to
253253
automatically determine the constants loaded. You can specify this by having the
254254
block return the :ObjectSpace symbol.
255255

256+
=== Autoload
257+
258+
To further speed things up in development mode, or when only running a subset of
259+
tests, it can be helpful to autoload files instead of require them, so that if
260+
the related constants are not accessed, you don't need to pay the cost of loading
261+
the related files. To enable autoloading, pass the +:autoload+ option when
262+
creating the reloader:
263+
264+
Unreloader = Rack::Unreloader.new(autoload: true){App}
265+
266+
Then, you can call +autoload+ instead of +require+:
267+
268+
Unreloader.autoload('models'){|f| File.basename(f).sub(/\.rb\z/, '').capitalize}
269+
270+
This will monitor the models directory for files, setting up autoloads for each
271+
file. After the file has been loaded, normal reloading will happen for the
272+
file. Note that for +autoload+, a block is required because the constant names
273+
are needed before loading the file to setup the autoload.
274+
275+
If the <tt>reload: false</tt> option is given when creating the reloader,
276+
autoloads will still be setup by +autoload+, but no reloading will happen. This
277+
can be useful when testing subsets of an application. When testing subsets of
278+
an application, you don't need reloading, but you can benefit from autoloading,
279+
so parts of the application you are not testing are not loaded.
280+
281+
If you do not pass the +:autoload+ option when creating the reloader, then calls
282+
to +autoload+ will implicitly be transformed to calls to +require+. This makes
283+
it possible to use the same +autoload+ call in all cases, and handle four
284+
separate scenarios:
285+
286+
1. Autoload then reload: Fast development mode startup, loading the minimum
287+
number of files, but reloading if those files are changed
288+
2. Autoload without reload: Useful for faster testing of a subset of an
289+
application, so the untested subsets is not loaded.
290+
3. Require then reload: Slower development mode startup, but have entire
291+
application loaded before accepting requests
292+
4. Require without reload: Normal production/testing mode with nothing autoloaded
293+
or reloaded
294+
256295
== Usage Outside Rack
257296

258297
While +Rack::Unreloader+ is usually in the development of rack applications,

lib/rack/unreloader.rb

+71-3
Original file line numberDiff line numberDiff line change
@@ -8,9 +8,12 @@ class Unreloader
88
# Mutex used to synchronize reloads
99
MUTEX = Monitor.new
1010

11-
# Reference to ::File as File would return Rack::File by default.
11+
# Reference to ::File as File may return Rack::File by default.
1212
File = ::File
1313

14+
# Regexp for valid constant names, to prevent code execution.
15+
VALID_CONSTANT_NAME_REGEXP = /\A(?:::)?([A-Z]\w*(?:::[A-Z]\w*)*)\z/.freeze
16+
1417
# Given the list of paths, find all matching files, or matching ruby files
1518
# in subdirecories if given a directory, and return an array of expanded
1619
# paths.
@@ -41,11 +44,51 @@ def self.ruby_files(dir)
4144
files.sort
4245
end
4346

47+
# Autoload the file for the given objects. objs should be a string, symbol,
48+
# or array of them holding a Ruby constant name. Access to the constant will
49+
# load the related file. A non-nil logger will have output logged to it.
50+
def self.autoload_constants(objs, file, logger)
51+
strings = Array(objs).map(&:to_s)
52+
if strings.empty?
53+
# Remove file from $LOADED_FEATURES if there are no constants to autoload.
54+
# In general that is because the file is part of another class that will
55+
# handle loading the file separately, and if that class is reloaded, we
56+
# want to remove the loaded feature so the file can get loaded again.
57+
$LOADED_FEATURES.delete(file)
58+
else
59+
logger.info("Setting up autoload for #{file}: #{strings.join(' ')}") if logger
60+
strings.each do |s|
61+
obj, mod = split_autoload(s)
62+
63+
if obj
64+
obj.autoload(mod, file)
65+
elsif logger
66+
logger.info("Invalid constant name: #{s}")
67+
end
68+
end
69+
end
70+
end
71+
72+
# Split the given string into an array. The first is a module/class to add the
73+
# autoload to, and the second is the name of the constant to be autoloaded.
74+
def self.split_autoload(mod_string)
75+
if m = VALID_CONSTANT_NAME_REGEXP.match(mod_string)
76+
ns, sep, mod = m[1].rpartition('::')
77+
if sep.empty?
78+
[Object, mod]
79+
else
80+
[Object.module_eval("::#{ns}", __FILE__, __LINE__), mod]
81+
end
82+
end
83+
end
84+
4485
# The Rack::Unreloader::Reloader instead related to this instance, if one.
4586
attr_reader :reloader
4687

4788
# Setup the reloader. Options:
4889
#
90+
# :autoload :: Whether to allow autoloading. If not set to true, calls to
91+
# autoload will eagerly require the related files instead of autoloading.
4992
# :cooldown :: The number of seconds to wait between checks for changed files.
5093
# Defaults to 1. Set to nil/false to not check for changed files.
5194
# :handle_reload_errors :: Whether reload to handle reload errors by returning
@@ -59,12 +102,19 @@ def self.ruby_files(dir)
59102
# match exactly, since modules don't have superclasses.
60103
def initialize(opts={}, &block)
61104
@app_block = block
105+
@autoload = opts[:autoload]
106+
@logger = opts[:logger]
62107
if opts.fetch(:reload, true)
63108
@cooldown = opts.fetch(:cooldown, 1)
64109
@handle_reload_errors = opts[:handle_reload_errors]
65110
@last = Time.at(0)
66-
require_relative 'unreloader/reloader'
67-
@reloader = Reloader.new(opts)
111+
if @autoload
112+
require_relative('unreloader/autoload_reloader')
113+
@reloader = AutoloadReloader.new(opts)
114+
else
115+
require_relative('unreloader/reloader')
116+
@reloader = Reloader.new(opts)
117+
end
68118
reload!
69119
else
70120
@reloader = @cooldown = @handle_reload_errors = false
@@ -99,6 +149,24 @@ def require(paths, opts={}, &block)
99149
end
100150
end
101151

152+
# Add a file glob or array of file global to autoload and monitor
153+
# for changes. A block is required. It will be called with the
154+
# path to be autoloaded, and should return the symbol for the
155+
# constant name to autoload. Accepts the same options as #require.
156+
def autoload(paths, opts={}, &block)
157+
raise ArgumentError, "block required" unless block
158+
159+
if @autoload
160+
if @reloader
161+
@reloader.autoload_dependencies(paths, opts, &block)
162+
else
163+
Unreloader.expand_directory_paths(paths).each{|f| Unreloader.autoload_constants(yield(f), f, @logger)}
164+
end
165+
else
166+
require(paths, opts, &block)
167+
end
168+
end
169+
102170
# Records that each path in +files+ depends on +dependency+. If there
103171
# is a modification to +dependency+, all related files will be reloaded
104172
# after +dependency+ is reloaded. Both +dependency+ and each entry in +files+
+89
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,89 @@
1+
require_relative 'reloader'
2+
3+
module Rack
4+
class Unreloader
5+
class AutoloadReloader < Reloader
6+
def initialize(opts={})
7+
super
8+
9+
# Files that autoloads have been setup for, but have not yet been loaded.
10+
# Hash with realpath keys and values that are arrays with the
11+
# a block that will return constant name strings that will autoload the
12+
# file, the modified time the file, and the delete hook.
13+
@autoload_files = {}
14+
15+
# Directories where new files will be setup for autoloading.
16+
# Uses same format as @monitor_dirs.
17+
@autoload_dirs = {}
18+
end
19+
20+
def autoload_dependencies(paths, opts={}, &block)
21+
delete_hook = opts[:delete_hook]
22+
23+
Unreloader.expand_paths(paths).each do |file|
24+
if File.directory?(file)
25+
@autoload_dirs[file] = [nil, [], block, delete_hook]
26+
check_autoload_dir(file)
27+
else
28+
# Comparisons against $LOADED_FEATURES need realpaths
29+
@autoload_files[File.realpath(file)] = [block, modified_at(file), delete_hook]
30+
Unreloader.autoload_constants(yield(file), file, @logger)
31+
end
32+
end
33+
end
34+
35+
def remove_autoload(file, strings)
36+
strings = Array(strings)
37+
log("Removing autoload for #{file}: #{strings.join(" ")}") unless strings.empty?
38+
strings.each do |s|
39+
obj, mod = Unreloader.split_autoload(s)
40+
# Assume that if the autoload string was valid to create the
41+
# autoload, it is still valid when removing the autoload.
42+
obj.send(:remove_const, mod)
43+
end
44+
end
45+
46+
def check_autoload_dir(dir)
47+
subdir_times, files, block, delete_hook = md = @autoload_dirs[dir]
48+
return if subdir_times && subdir_times.all?{|subdir, time| File.directory?(subdir) && modified_at(subdir) == time}
49+
md[0] = subdir_times(dir)
50+
51+
cur_files = Unreloader.ruby_files(dir)
52+
return if files == cur_files
53+
54+
removed_files = files - cur_files
55+
new_files = cur_files - files
56+
57+
# Removed files that were never required should have the constant removed
58+
# so that accesses to the constant do not attempt to autoload a file that
59+
# no longer exists.
60+
removed_files.each do |file|
61+
remove_autoload(file, block.call(file)) unless @monitor_files[file]
62+
end
63+
64+
# New files not yet loaded should have autoloads added for them.
65+
autoload_dependencies(new_files, :delete_hook=>delete_hook, &block) unless new_files.empty?
66+
67+
files.replace(cur_files)
68+
end
69+
70+
def reload!
71+
(@autoload_files.keys & $LOADED_FEATURES).each do |file|
72+
# Files setup for autoloads were autoloaded, move metadata to locations
73+
# used for required files.
74+
log("Autoloaded file required, setting up reloading: #{file}")
75+
block, *metadata = @autoload_files.delete(file)
76+
@constants_defined[file] = block
77+
@monitor_files[file] = metadata
78+
@files[file] = {:features=>Set.new, :constants=>Array(block.call(file))}
79+
end
80+
81+
@autoload_dirs.each_key do |dir|
82+
check_autoload_dir(dir)
83+
end
84+
85+
super
86+
end
87+
end
88+
end
89+
end

lib/rack/unreloader/reloader.rb

-3
Original file line numberDiff line numberDiff line change
@@ -5,9 +5,6 @@ class Unreloader
55
class Reloader
66
File = ::File
77

8-
# Regexp for valid constant names, to prevent code execution.
9-
VALID_CONSTANT_NAME_REGEXP = /\A(?:::)?([A-Z]\w*(?:::[A-Z]\w*)*)\z/.freeze
10-
118
# Setup the reloader. Supports :logger and :subclasses options, see
129
# Rack::Unloader.new for details.
1310
def initialize(opts={})

spec/spec_helper.rb

+4
Original file line numberDiff line numberDiff line change
@@ -89,9 +89,13 @@ def log_match(*logs)
8989

9090
after do
9191
ru.reloader.clear! if ru.reloader
92+
dirname = File.dirname(__FILE__)
93+
keep_files = %w'unreloader_spec.rb spec_helper.rb'
94+
$LOADED_FEATURES.delete_if{|f| f.start_with?(dirname) && !keep_files.include?(File.basename(f))}
9295
Object.send(:remove_const, :RU)
9396
Object.send(:remove_const, :App) if defined?(::App)
9497
Object.send(:remove_const, :App2) if defined?(::App2)
98+
Object.send(:remove_const, :B) if defined?(::B)
9599
Dir['spec/app*.rb'].each{|f| File.delete(f)}
96100
end
97101
end

0 commit comments

Comments
 (0)