Intellisense
Knit was created before intellisense was introduced to Roblox. Unfortunately, due to the nature of how Knit is written, Knit does not benefit much from Roblox's intellisense. While the performance and stability of Knit are top-notch, the lack of intellisense can cause unnecessary strain on developers.
There are a couple ways to help resolve this issue:
- Create your own bootstrapper to load in Knit services and controllers.
- Create your own Knit-like framework using plain ModuleScripts.
In this article, any references to "Service" or "GetService" can also be implied to also include "Controller" or "GetController". It's simply less wordy to avoid referencing both.
Custom Bootstrapper
The verb "bootstrapping" in programming parlance is often used to describe a process that starts everything up (coming from the old phrase, "pull yourself up by your bootstraps"). In the context of Knit, this is usually handled internally when calling functions like Knit.CreateService()
and Knit.Start()
. This is ideal for a framework, as the users of the framework do not need to know the messy details of the startup procedure.
The consequence of Knit taking control of the bootstrapping process is that all loaded services end up in a generic table (think of a bucket of assorted items). Due to the dynamic nature of this process, there is no way for Luau's type system to understand the type of a service simply based on the string name (e.g. Knit.GetService("HelloService")
; Luau can't statically understand that this is pointing to a specific service table).
Thus, the question at hand is: How do we get Luau to understand the type of our service?
ModuleScripts Save the Day
An important factor about Knit services is that they are just Lua tables with some extra items stuffed inside. This is why services are usually designed like any other module, with the exception that Knit.CreateService
is called. Then, the resultant service is returned at the end of the ModuleScript.
Because services are relatively statically defined, Roblox/Luau can understand its "type" if accessed directly. In other words, if the ModuleScript that the service lives inside is directly require
'd, then intellisense would magically become available.
Thus, the fix is to simply require the services directly from their corresponding ModuleScripts, side-stepping Knit's GetService
calls entirely.
-- Old way:
local MyService = Knit.GetService("MyService")
-- New way:
local MyService = require(somewhere.MyService)
Shifting the Problem
The problem, however, is that the call to CreateService
messes it all up. Our day is ruined. Because CreateService
is called within the ModuleScript, this messes up the "type" of the service. Thankfully, this is easy to fix. We simply need to remove our call to CreateService
and instead call it within our custom bootstrap loader. We'll get to that in the next section.
-- Old way:
local SomeService = Knit.CreateService {
Name = "SomeService",
}
return SomeService
-- New way; only getting rid of the Knit.CreateService call:
local SomeService = {
Name = "SomeService",
}
return SomeService
Now, when our service is required, Luau will properly infer the type of the service, which will provide proper intellisense. However, we are no longer calling CreateService
, which means our service is never registered within Knit, thus KnitStart
and KnitInit
never run. Oops. Let's fix this by writing our own service module loader.
Module Loader
Since we are no longer calling CreateService
from the ModuleScript itself, our call to AddServices
will no longer work as expected. Thus, we need to write our own version of AddServices
that also calls CreateService
on behalf of the module.
local function AddServicesCustom(parent: Instance)
-- For deep scan, switch GetChildren() to GetDescendants()
for _, v in parent:GetChildren() do
-- Only match on instances that are ModuleScripts and names that end with "Service":
if v:IsA("ModuleScript") and v.Name:match("Service$") then
local service = require(v) -- Load the service module
Knit.CreateService(service) -- Add the service into Knit
end
end
end
--Knit.AddServices(parent) (NO LONGER WILL WORK AS EXPECTED)
AddServicesCustom(parent)
Knit.Start()
The Loader module can be used if you do not want to write your own loader function.
local services = Loader.LoadChildren(parent, Loader.MatchesName("Service$"))
for _, service in services do
Knit.CreateService(service)
end
Knit.Start()
Cyclical Dependencies
When requiring modules directly, it is possible to run into cyclical dependency errors. In short, Roblox will not allow Module A
to require Module B
, which also then requires Module A
. If A
requires B
, and B
requires A
, we have a cyclical dependency. This can happen in longer chains too (e.g. A
->B
->C
->A
).
A side-effect of Knit's traditional startup procedure is that cyclical dependencies work fine. They work because modules are first loaded into memory before they grab any references to each other. Knit essentially acts as a bridge. However, this is an unintentional side-effect of Knit. Cyclical dependencies are a sign of poor architectural design.
Knit does not seek to allow cyclical dependencies. Knit will not make any effort to allow them to exist. Their allowance is a byproduct of Knit's design. If you are running into cyclical dependency problems after switching to directly requiring services (i.e. using require
instead of Knit.GetService
), this is not an issue of Knit, but rather a code structure issue on your end.
Why Not the Default
A fair question to ask is: Why is this not the preferred setup for Knit?
- Knit's various assertions are being side-stepped to allow intellisense to work.
- A lot of extra custom code has to be written.
- If you are willing to go to this length, then perhaps a custom-built framework would work better.
Client-accessed Services
Services accessed from the client must still go through Knit.GetService
, thus cannot benefit from this structural change. A secondary module could be used as the client-facing service module, but that would be a lot more work to maintain and handle.
Create-a-Knit
Creating your own framework like Knit is quite easy. In this short section, we will set up a simple module loader that works similar to Knit's startup procedure. However, it will lack networking capabilities. There are plenty of third-party networking libraries that can be used. Choosing which networking library to use is out of scope for this section.
Using the RbxUtil Loader Module
To help speed up this whole process, the Loader module will be utilized. This will help us quickly load our modules and kick off any sort of startup method per module.
In keeping with the Service/Controller naming scheme, we will use the same names for our custom framework.
Loading Services
To load in our modules, we can call Loader.LoadChildren
or Loader.LoadDescendants
. This will go through and require
all found ModuleScripts, returning them in a named dictionary table, where each key represents the name of the ModuleScript, and each value is the loaded value from the ModuleScript.
local modules = Loader.LoadDescendants(ServerScriptService)
However, this isn't very useful, as we probably have a lot of non-service ModuleScripts in our codebase. The Loader
module lets us filter which modules to use by passing in a predicate function. A helper MatchesName
function generator can also be used to simply filter based on the name, which is what we will do. Let's load all ModuleScripts that end with the word "Service":
local services = Loader.LoadDescendants(ServerScriptService, Loader.MatchesName("Service$"))
Great, so now we have a key/value table of loaded services! To mirror a bit of Knit, let's call the OnStart
method of each service.
Starting Services
It's often useful to have a startup method that gets automatically called once all of our modules are loaded. This could be done by looping through each module and calling a method if it's found:
for _, service in services do
if typeof(service.OnStart) == "function" then
task.spawn(function()
service:OnStart()
end)
end
end
That's a bit much. Thankfully, the Loader
module also includes a SpawnAll
function. This special function also calls debug.setmemorycategory
so that we can properly profile the memory being used per OnStart service call:
Loader.SpawnAll(services, "OnStart")
Final Loader Script
Let's merge all of the above code in one spot:
-- ServerScriptService.ServerStartup
local services = Loader.LoadDescendants(ServerScriptService, Loader.MatchesName("Service$"))
Loader.SpawnAll(services, "OnStart")
Our client-side code would look nearly identical. Just swap out the names. In this example, our controllers live in ReplicatedStorage:
-- StarterPlayer.StarterPlayerScripts.ClientStartup
local controllers = Loader.LoadDescendants(ReplicatedStorage, Loader.MatchesName("Controller$"))
Loader.SpawnAll(controllers, "OnStart")
Example Services
Due to this incredibly simple setup, our services are also very simple in structure; they're just tables within ModuleScripts. Nothing fancy. To use one service from another, simply require its ModuleScript. As such, intellisense comes natively baked in.
-- ServerScriptService.MathService
local MathService = {}
function MathService:Add(a: number, b: number): number
return a + b
end
return MathService
-- ServerScriptService.CalcService
-- Simply require another service to use it:
local MathService = require(somewhere.MathService)
local CalcService = {}
function CalcService:OnStart()
local n1 = 10
local n2 = 20
local sum = MathService:Add(n1, n2)
print(`Sum of {n1} and {n2} is {sum}`)
end
return CalcService