Skip to content

Commit

Permalink
Cleanup
Browse files Browse the repository at this point in the history
  • Loading branch information
Sleitnick committed Feb 4, 2024
1 parent a7e23c0 commit 658ce94
Show file tree
Hide file tree
Showing 8 changed files with 236 additions and 3 deletions.
1 change: 1 addition & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -2,6 +2,7 @@
- Adds `Knit.GetServices()` function server-side
- Adds `Knit.GetControllers()` function client-side
- Freezes `services`/`controllers` tables so that they can be safely returned in the functions listed above.
- Various code readability adjustments
- Update GitHub workflow dependencies

## 1.6.0
Expand Down
2 changes: 1 addition & 1 deletion LICENSE.md
Original file line number Diff line number Diff line change
@@ -1,4 +1,4 @@
Copyright 2023 Stephen Leitnick
Copyright 2024 Stephen Leitnick

Permission is hereby granted, free of charge, to any person obtaining a copy of this software and associated documentation files (the "Software"), to deal in the Software without restriction, including without limitation the rights to use, copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the Software, and to permit persons to whom the Software is furnished to do so, subject to the following conditions:

Expand Down
2 changes: 1 addition & 1 deletion aftman.toml
Original file line number Diff line number Diff line change
@@ -1 +1 @@
tools = { rojo = "rojo-rbx/rojo@7.3.0" , wally = "UpliftGames/wally@0.3.2" , selene = "Kampfkarren/selene@0.26.1" , stylua = "JohnnyMorganz/StyLua@0.19.1" , remodel = "rojo-rbx/remodel@0.11.0" }
tools = { rojo = "rojo-rbx/rojo@7.3.0" , wally = "UpliftGames/wally@0.3.2" , selene = "Kampfkarren/selene@0.26.1" , stylua = "JohnnyMorganz/StyLua@0.20.0" , remodel = "rojo-rbx/remodel@0.11.0" }
199 changes: 199 additions & 0 deletions docs/intellisense.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,199 @@
---
sidebar_position: 9
---

# 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:
1. Create your own bootstrapper to load in Knit services and controllers.
2. Create your own Knit-like framework using plain ModuleScripts.

:::note Service/Controller
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 always reference 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.

```lua
-- 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.

```lua
-- 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.

```lua
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.AddService(service) -- Add the service into Knit
end
end
end

--Knit.AddServices(parent) (NO LONGER WILL WORK AS EXPECTED)
AddServicesCustom(parent)

Knit.Start()
```

:::tip Loader Module
The [Loader](https://sleitnick.github.io/RbxUtil/api/Loader/) module can be used if you do not want to write your own loader function.

```lua
local services = Loader.LoadChildren(parent, Loader.MatchesName("Service$"))
for _, service in services do
Knit.AddService(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?
1. Knit's various assertions are being side-stepped to allow intellisense to work.
1. A lot of extra custom code has to be written.
1. If you are willing to go to this length, then perhaps a custom-built framework would work better.

## 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](https://sleitnick.github.io/RbxUtil/api/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 make the same assumption 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.

```lua
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":

```lua
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, lets 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:

```lua
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:

```lua
Loader.SpawnAll(services, "OnStart")
```

### Final Loader Script

Let's merge all of the above code in one spot:
```lua
-- 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:
```lua
-- 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.

```lua
-- ServerScriptService.AddService
local AddService = {}

function AddService:Add(a: number, b: number): number
return a + b
end

return AddService
```

```lua
-- ServerScriptService.CalcService

-- Simply require another service to use it:
local AddService = require(somewhere.AddService)

local CalcService = {}

function CalcService:OnStart()
local n1 = 10
local n2 = 20
local sum = AddService:Add(n1, n2)
print(`Sum of {n1} and {n2} is {sum}`)
end

return CalcService
```
2 changes: 1 addition & 1 deletion docs/vscodesnippets.md
Original file line number Diff line number Diff line change
@@ -1,5 +1,5 @@
---
sidebar_position: 9
sidebar_position: 10
---

# VS Code Snippets
Expand Down
17 changes: 17 additions & 0 deletions src/KnitClient.lua
Original file line number Diff line number Diff line change
Expand Up @@ -126,19 +126,22 @@ local onStartedComplete = Instance.new("BindableEvent")

local function DoesControllerExist(controllerName: string): boolean
local controller: Controller? = controllers[controllerName]

return controller ~= nil
end

local function GetServicesFolder()
if not servicesFolder then
servicesFolder = (script.Parent :: Instance):WaitForChild("Services")
end

return servicesFolder
end

local function GetMiddlewareForService(serviceName: string)
local knitMiddleware = if selectedOptions.Middleware ~= nil then selectedOptions.Middleware else {}
local serviceMiddleware = selectedOptions.PerServiceMiddleware[serviceName]

return if serviceMiddleware ~= nil then serviceMiddleware else knitMiddleware
end

Expand All @@ -147,7 +150,9 @@ local function BuildService(serviceName: string)
local middleware = GetMiddlewareForService(serviceName)
local clientComm = ClientComm.new(folder, selectedOptions.ServicePromises, serviceName)
local service = clientComm:BuildObject(middleware.Inbound, middleware.Outbound)

services[serviceName] = service

return service
end

Expand Down Expand Up @@ -178,8 +183,10 @@ function KnitClient.CreateController(controllerDef: ControllerDef): Controller
assert(#controllerDef.Name > 0, "Controller.Name must be a non-empty string")
assert(not DoesControllerExist(controllerDef.Name), `Controller {controllerDef.Name} already exists`)
assert(not started, `Controllers cannot be created after calling "Knit.Start()"`)

local controller = controllerDef :: Controller
controllers[controller.Name] = controller

return controller
end

Expand All @@ -192,13 +199,16 @@ end
]=]
function KnitClient.AddControllers(parent: Instance): { Controller }
assert(not started, `Controllers cannot be added after calling "Knit.Start()"`)

local addedControllers = {}
for _, v in parent:GetChildren() do
if not v:IsA("ModuleScript") then
continue
end

table.insert(addedControllers, require(v))
end

return addedControllers
end

Expand All @@ -207,13 +217,16 @@ end
]=]
function KnitClient.AddControllersDeep(parent: Instance): { Controller }
assert(not started, `Controllers cannot be added after calling "Knit.Start()"`)

local addedControllers = {}
for _, v in parent:GetDescendants() do
if not v:IsA("ModuleScript") then
continue
end

table.insert(addedControllers, require(v))
end

return addedControllers
end

Expand Down Expand Up @@ -274,8 +287,10 @@ function KnitClient.GetService(serviceName: string): Service
if service then
return service
end

assert(started, "Cannot call GetService until Knit has been started")
assert(type(serviceName) == "string", `ServiceName must be a string; got {type(serviceName)}`)

return BuildService(serviceName)
end

Expand All @@ -288,6 +303,7 @@ function KnitClient.GetController(controllerName: string): Controller
if controller then
return controller
end

assert(started, "Cannot call GetController until Knit has been started")
assert(type(controllerName) == "string", `ControllerName must be a string; got {type(controllerName)}`)
error(`Could not find controller "{controllerName}". Check to verify a controller with this name exists.`, 2)
Expand All @@ -298,6 +314,7 @@ end
]=]
function KnitClient.GetControllers(): { [string]: Controller }
assert(started, "Cannot call GetControllers until Knit has been started")

return controllers
end

Expand Down

0 comments on commit 658ce94

Please sign in to comment.