|
| 1 | +# How ServiceBag works |
| 2 | + |
| 3 | +## tl;dr |
| 4 | + |
| 5 | +ServiceBag is like a `game` in Roblox. You can retrieve services from it, and |
| 6 | +it will ensure the service exists and is initialized. This will bootstrap any |
| 7 | +other dependent dependencies. |
| 8 | + |
| 9 | +## Why understanding ServiceBag is important |
| 10 | + |
| 11 | +Nevermore tries to be a collection of libraries that can be plugged together, |
| 12 | +and not exist as a set framework that forces specific design decisions. While |
| 13 | +there are certainly some design patterns these libraries will guide you to, |
| 14 | +you shouldn't necessarily feel forced to operate within these set of |
| 15 | +scenarios. |
| 16 | + |
| 17 | +That being said, in order to use certain services, like `CmdrService` or |
| 18 | +permission service, you need to be familiar with `ServiceBag`. |
| 19 | + |
| 20 | +If you're making a game with Nevermore, serviceBag solves a wide variety |
| 21 | +of problems with the lifecycle of the game, and is fundamental to the fast |
| 22 | +iteration cycle intended with Nevermore. |
| 23 | + |
| 24 | +Many prebuilt systems depend upon ServiceBag and expect to be initialized |
| 25 | +through ServiceBag. |
| 26 | + |
| 27 | +## Is ServiceBag good? |
| 28 | + |
| 29 | +ServiceBag supports multiple production games. ServiceBag allows for |
| 30 | +functionality that isn't otherwise available in traditional programming |
| 31 | +techniques in Roblox. More specifically: |
| 32 | + |
| 33 | +* Your games initialization can be controlled specifically |
| 34 | +* Recursive initialization (transient dependencies) will not cause refactoring |
| 35 | + requirements at higher level games. Lower-level packages can add additional |
| 36 | + dependencies without fear of breaking their downstream consumers. |
| 37 | +* Life cycle management is maintained in a standardized way |
| 38 | +* You can technically have multiple copies of your service running at once. This |
| 39 | + is useful for plugins and stuff. |
| 40 | + |
| 41 | +While serviceBag isn't required to make a quality Roblox game, and may seem |
| 42 | +confusing at first, ServiceBag or an equivalent lifecycle management system |
| 43 | +and dependency injection system is a really good idea. |
| 44 | + |
| 45 | +## What ServiceBag tries to achieve |
| 46 | + |
| 47 | +ServiceBag does service dependency injection and initialization. These words |
| 48 | +may be unfamiliar with you. Dependency injection is the process of retrieving |
| 49 | +dependencies instead of constructing them in an object. Lifecycle management is |
| 50 | +the process of managing the life of services, which often includes the game. |
| 51 | + |
| 52 | +For the most part, ServiceBag is interested in the initialization of services |
| 53 | +within your game, since most services will not deconstruct. This allows for |
| 54 | +services that cross-depend upon each other, for example, if service A and |
| 55 | +service B both need to know about each other, serviceBag will allow for this |
| 56 | +to happen. A traditional module script will not allow for a circular dependency |
| 57 | +in the same way. |
| 58 | + |
| 59 | +ServiceBag achieves circular dependency support by having a lifecycle hook |
| 60 | +system. |
| 61 | + |
| 62 | +## What is a service |
| 63 | + |
| 64 | +A service is a singleton, that is, a module of which exactly one exists. This |
| 65 | +is oftentimes very useful, especially in de-duplicating behavior. Services |
| 66 | +are actually something you should be familiar with on Roblox, if you've been |
| 67 | +programming on Roblox for a while. |
| 68 | + |
| 69 | +```lua |
| 70 | +-- Workspace is an example of a service in Roblox |
| 71 | +local workspace = game:GetService("Workspace") |
| 72 | +``` |
| 73 | + |
| 74 | +It's useful to define our own services. A canonical service in Nevermore looks |
| 75 | +like this. |
| 76 | + |
| 77 | +```lua |
| 78 | +--[=[ |
| 79 | + A canonical service in Nevermore |
| 80 | + @class ServiceName |
| 81 | +]=] |
| 82 | + |
| 83 | +local require = require(script.Parent.loader).load(script) |
| 84 | + |
| 85 | +local Maid = require("Maid") |
| 86 | + |
| 87 | +local ServiceName = {} |
| 88 | +ServiceName.ServiceName = "ServiceName" |
| 89 | + |
| 90 | +function ServiceName:Init(serviceBag) |
| 91 | + assert(not self._serviceBag, "Already initialized") |
| 92 | + self._serviceBag = assert(serviceBag, "No serviceBag") |
| 93 | + self._maid = Maid.new() |
| 94 | + |
| 95 | + -- External |
| 96 | + self._serviceBag:GetService(require("OtherService")) |
| 97 | +end |
| 98 | + |
| 99 | +function ServiceName:Start() |
| 100 | + print("Started") |
| 101 | +end |
| 102 | + |
| 103 | +function ServiceName:MyMethod() |
| 104 | + print("Hello") |
| 105 | +end |
| 106 | + |
| 107 | +function ServiceName:Destroy() |
| 108 | + self._maid:DoCleaning() |
| 109 | +end |
| 110 | + |
| 111 | +return ServiceName |
| 112 | +``` |
| 113 | + |
| 114 | +## Service LifeCycle methods |
| 115 | + |
| 116 | +There are 3 methods in a service that are precoded in a `ServiceBag`. These |
| 117 | +are as follows |
| 118 | + |
| 119 | +* `Init(serviceBag)` - Initializes the service. If any more services need to |
| 120 | + be initialized then this should also get those services at this time. |
| 121 | +* `Start()` - Called when the game starts. Cannot yield. Starts actual |
| 122 | + behavior, including logic that depends on other services. |
| 123 | +* `Destroy()` - Cleans up the existing service |
| 124 | + |
| 125 | +All three of these services are optional. However, if you want to have |
| 126 | +services bootstrapped that this service depends upon, then you should |
| 127 | +do this in `Init` |
| 128 | + |
| 129 | +### What happens on ServiceBag:Init() |
| 130 | + |
| 131 | +When init happens, ServiceBag will called :Init() on any service that has been |
| 132 | +retrieved. If any of these services retrieve additional services then these |
| 133 | +will also be initialized and stored in the ServiceBag. Notably ServiceBag |
| 134 | +will not use the direct memory of the service, but instead create a new table |
| 135 | +and store the state in the ServiceBag itself. |
| 136 | + |
| 137 | + |
| 138 | +```lua |
| 139 | +local serviceBag = ServiceBag.new() |
| 140 | +serviceBag:GetService(packages.MyModuleScript) |
| 141 | + |
| 142 | +serviceBag:Init() |
| 143 | +serviceBag:Start() |
| 144 | +``` |
| 145 | + |
| 146 | +:::info |
| 147 | +ServiceBag will not allow your service to yield. This is to prevent a service |
| 148 | +from delaying your entires game start. If you need to yield, do work in start |
| 149 | +or export your API calls as promises. See Cmdr for a good example of how this |
| 150 | +works. |
| 151 | +::: |
| 152 | + |
| 153 | +Retrieving a service from inside of :Init() that service is guaranteed to be |
| 154 | +initialized. Services are started in the order they're initialized. |
| 155 | + |
| 156 | +```lua |
| 157 | +function MyService:Init(serviceBag) |
| 158 | + self._myOtherService = serviceBag:GetService(require("MyOtherService")) |
| 159 | + |
| 160 | + -- Services are guaranteed to be initialized if you retrieve them in an |
| 161 | + -- init of another service, assuming that :Init() is done via ServiceBag. |
| 162 | + self._myOtherService:Register(self) |
| 163 | +end |
| 164 | +``` |
| 165 | + |
| 166 | + |
| 167 | +When init is over, no more services can be added to the serviceBag. |
| 168 | + |
| 169 | +### What happens on ServiceBag:Start() |
| 170 | + |
| 171 | +When Start happens the serviceBag will go through each of its services |
| 172 | +that have been initialized and attempt to call the :Start() method on it |
| 173 | +if it exists. |
| 174 | + |
| 175 | +This is a good place to use other services that you may have needed as they |
| 176 | +are guaranteed to be initialized. However, you can also typically assume |
| 177 | +initialization is done in the :Init() method. However, sometimes you may |
| 178 | +assume initialization but no start. |
| 179 | + |
| 180 | +### What happens on ServiceBag:Destroy() |
| 181 | + |
| 182 | +When :Destroy() is called, all services are destroyed. The serviceBag will |
| 183 | +call `Destroy()` on services if they offer it. This functionality is useful |
| 184 | +if you're initializing services during hoarcekat stories or unit tests. |
| 185 | + |
| 186 | + |
| 187 | +## How do I retrieve services |
| 188 | + |
| 189 | +You retrieve a service by calling `GetService`. `GetService` takes in a table. |
| 190 | +If you pass it a module script, the service bag will require the module |
| 191 | +script and use the resulting definition as the service definition. |
| 192 | + |
| 193 | +```lua |
| 194 | +local serviceBag = ServiceBag.new() |
| 195 | + |
| 196 | +local myService = serviceBag:GetService(packages.MyModuleScript) |
| 197 | + |
| 198 | +serviceBag:Init() |
| 199 | +serviceBag:Start() |
| 200 | +``` |
| 201 | + |
| 202 | +As soon as you retrieve the service you should be able to call methods on it. |
| 203 | +However, the state of the service will be whatever it is before init or start. |
| 204 | +You may want to call :Init() or :Start() before using methods on the service. |
| 205 | + |
| 206 | + |
| 207 | +## Why can't you pass in arguments into :GetService() |
| 208 | + |
| 209 | +Service configuration is not offered in the retrieval of :GetService() because |
| 210 | +inherently we don't want unstable or random behavior in our games. If we had |
| 211 | +arguments in ServiceBag then you better hope that your initialization order |
| 212 | +gets to configure the first service first. Otherwise, if another package adds |
| 213 | +a service in the future then you will have different behavior. |
| 214 | + |
| 215 | +### How do you configure a service instead of arguments? |
| 216 | + |
| 217 | +Typically, you can configure a service by calling a method after :Init() is |
| 218 | +called, or after :Start() is called. |
| 219 | + |
| 220 | +### Should services have side effects when initialized or started? |
| 221 | + |
| 222 | +Services should typically not have side effects when initialized or started. |
| 223 | + |
| 224 | +## Dependency injection |
| 225 | + |
| 226 | +ServiceBag is also effectively a dependency injection system. In this system |
| 227 | +you can of course, inject services into other services. |
| 228 | + |
| 229 | +For this reason, we inject the ServiceBag into the actual package itself. |
| 230 | + |
| 231 | +```lua |
| 232 | +-- Service bag injection |
| 233 | +function CarCommandService:Init(serviceBag) |
| 234 | + self._serviceBag = assert(serviceBag, "No serviceBag") |
| 235 | + |
| 236 | + self._cmdrService = self._serviceBag:GetService(require("CmdrService")) |
| 237 | +end |
| 238 | +``` |
| 239 | + |
| 240 | +### Dependency injection in objects |
| 241 | + |
| 242 | +If you've got an object, it's typical you may need a service there |
| 243 | + |
| 244 | +```lua |
| 245 | +--[=[ |
| 246 | + @class MyClass |
| 247 | +]=] |
| 248 | + |
| 249 | +local require = require(script.Parent.loader).load(script) |
| 250 | + |
| 251 | +local BaseObject = require("BaseObject") |
| 252 | + |
| 253 | +local MyClass = setmetatable({}, BaseObject) |
| 254 | +MyClass.ClassName = "MyClass" |
| 255 | +MyClass.__index = MyClass |
| 256 | + |
| 257 | +function MyClass.new(serviceBag) |
| 258 | + local self = setmetatable(BaseObject.new(), MyClass) |
| 259 | + |
| 260 | + self._serviceBag = assert(serviceBag, "No serviceBag") |
| 261 | + self._cameraStackService = self._serviceBag:GetService(require("CameraStackService")) |
| 262 | + |
| 263 | + return self |
| 264 | +end |
| 265 | + |
| 266 | +return MyClass |
| 267 | +``` |
| 268 | + |
| 269 | +It's very common to pass or inject a service bag into the service |
| 270 | + |
| 271 | +### Dependency injection in binders |
| 272 | + |
| 273 | +Binders explicitly support dependency injection. You can see that a |
| 274 | +binderProvider here retrieves a serviceBag (or any argument you want) |
| 275 | +and then the binder retrieves the extra argument. |
| 276 | + |
| 277 | +```lua |
| 278 | +return BinderProvider.new(script.Name, function(self, serviceBag) |
| 279 | + -- ... |
| 280 | + self:Add(Binder.new("Ragdoll", require("RagdollClient"), serviceBag)) |
| 281 | + -- ... |
| 282 | +end) |
| 283 | +``` |
| 284 | + |
| 285 | +Binders then will get the `ServiceBag` as the second argument. |
| 286 | + |
| 287 | +```lua |
| 288 | + |
| 289 | +function Ragdoll.new(humanoid, serviceBag) |
| 290 | + local self = setmetatable(BaseObject.new(humanoid), Ragdoll) |
| 291 | + |
| 292 | + self._serviceBag = assert(serviceBag, "No serviceBag") |
| 293 | + -- Use services here. |
| 294 | + |
| 295 | + return self |
| 296 | +end |
| 297 | +``` |
| 298 | + |
| 299 | +## Memory management - ServiceBag will annotate stuff for you |
| 300 | + |
| 301 | +ServiceBag will automatically annotate your service with a memory profile name |
| 302 | +so that it is easy to track down which part of your codebase is using memory. |
| 303 | +This fixes a standard issue with diagnosing memory in a single-script |
| 304 | +architecture. |
| 305 | + |
| 306 | +## Using ServiceBag with stuff that doesn't have access to ServiceBag |
| 307 | + |
| 308 | +If you're working with legacy code, or external code, you may not want |
| 309 | +to pass an initialized ServiceBag around. This will typically make the code |
| 310 | +less testable, so take this with caution, but you can typically use a few |
| 311 | +helper methods to return fully initialized services instead of having to |
| 312 | +retrieve them through the servicebag. |
| 313 | + |
| 314 | +```lua |
| 315 | +local function getAnyModule(module) |
| 316 | + if serviceBag:HasService(module) then |
| 317 | + return serviceBag:GetService(module) |
| 318 | + else |
| 319 | + return module |
| 320 | + end |
| 321 | +end |
| 322 | +``` |
| 323 | + |
| 324 | +It's preferably your systems interop with ServiceBag directly as ServiceBag |
| 325 | +provides more control, better testability, and more clarity on where things |
| 326 | +are coming from. |
0 commit comments