Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Remove MenuRegistry in favour of dependency injection #4527

Merged
merged 14 commits into from
Dec 16, 2021

Conversation

jansav
Copy link
Contributor

@jansav jansav commented Dec 8, 2021

What we did

  1. We removed a typical registry MenuRegistry and replaced it with an injectable ElectronMenuItems. The injectable instantiates a MobX-computed observed from all applicable extensions. With this, we propose dependency injection over shared global state, and reactivity over statefulness.
  2. With removal of MenuRegistry, also an instance of Singleton-base-class got removed. With this, we also limit shared global state.
  3. Now that there is friendlier unit-testability, we added unit tests for said ElectronMenuItems.
  4. We made sure that “Extension API” remains unmolested.
  5. We made injectables usable in Main process similar to Renderer.

Rationale

We recognized implementations of base-classes Singleton and BaseRegistry as main causes of problems like code rigidity, non-testability, tendency to bug etc. These classes describe shared global state, logical dependencies, and statefulness over reactivity. Now that we have infrastructure in place to inject dependencies (i.e. injectable), we can replace these anti-patterns with dependency injection. It is also handy that outsourcing lifecycles (e.g. singleton) to DI comes naturally, without the dependee even knowing about it.

What’s next:

  1. We will keep applying this solution to other registries for same benefits.

Closes #4475.

jansav and others added 2 commits December 8, 2021 13:10
…d local state

Signed-off-by: Janne Savolainen <janne.savolainen@live.fi>
Co-authored-by: Mikko Aspiala <mikko.aspiala@gmail.com>
Signed-off-by: Janne Savolainen <janne.savolainen@live.fi>
@jansav jansav added the enhancement New feature or request label Dec 8, 2021
@jansav jansav requested a review from a team as a code owner December 8, 2021 11:21
@jansav jansav requested review from jweak and removed request for a team December 8, 2021 11:21
onQuitCleanup.push(
initMenu(windowManager),
initMenu(windowManager, menuItems),
Copy link
Contributor Author

@jansav jansav Dec 8, 2021

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

This would be cleaner if function initMenu was injectable. @panuhorsmalahti would you like to do this with us? :)

Copy link
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I guess, what is the benefit of this being injectable here? Instead of just computing the entries?

Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

This file shouldn’t need to know what are the dependencies for initMenu. Simplicity.

Copy link
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I meant, why is menuItems not just a computed without this injection

Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Same argument:

menuItems has dependencies that I shouldn’t need to know when I use it.

All this code should care is that the required interface is somehow fulfilled.

Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

@jansav I'm afraid I'm a bit busy with other Lens 🔥 issues for the time being..

Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

No worries @panuhorsmalahti :)

We can share you the result, if you want.

Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Sure, that sounds interesting.

Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I do believe the promised result will satisfy @Nokel81, as it will extract responsibilities related to instantiation away from this file, where it doesn't quite belong. @Nokel81 how about you, wanna have a go doing this with us? It may be trivial here, but having this freedom somewhere else will be a big deal.

Or the beer's on me ;)

Copy link
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

freedom?


return removeItems;
});
this.autoInitExtensions(() => Promise.resolve([]));
}
Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Notice how we no longer need to dispose items from a state when extension is disabled or removed.

Copy link
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Yes I have wanted to do this for some time, I even made #2245 about 10 months ago, but no one wanted to me to pursue it.

Copy link
Contributor Author

@jansav jansav Dec 8, 2021

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Glad to see that you have had same kind of changes in mind. However, I don’t see a reason not to pursue simplicity that reactive programming gives. Current state is less than optimal.

Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Awesome :)

Signed-off-by: Janne Savolainen <janne.savolainen@live.fi>
@jansav jansav force-pushed the make-menu-registry-obsolete branch from 1c7d29a to 41837e8 Compare December 8, 2021 12:17
@@ -61,11 +61,15 @@ import { FilesystemProvisionerStore } from "./extension-filesystem";
import { SentryInit } from "../common/sentry";
import { ensureDir } from "fs-extra";
import { Router } from "./router";
import { initMenu } from "./menu";
import { initMenu } from "./menu/menu";
Copy link
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

why change this import?

Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Moved file to separate directory. Prefer not to create index.ts when really not needed.

Copy link
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Okay I guess, I find index files much cleaner to read especially since the rest of this repo we use them.

Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

We have seen both used in this repo. We prefer it this way so that we don’t need so much boilerplate code.

Not that big deal though for us

Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

To me it seems that more common practice is to use "index" in this repo but that has a downside because it requires tree-shaking (which might not work in practice too well). Without tree-shaking we are actually "loading" all dependencies from index ... of course import shouldn't have side-effects in a perfect world (we are not there yet).

Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Hmm. The index-files do feel like an unnecessary indirection and complication. We could consider if the benefits of encapsulation really overweigh them. As said, not a bid deal, as there's far bigger fish out there :)

...but in general, when it comes to side-effects on import, I'm afraid it's not something our team should tolerate at all. Let's make a hard line for this, right guys? :)

Copy link
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I agree, it also makes circular dependencies too easy

import { initTray } from "./tray";
import { kubeApiRequest, shellApiRequest, ShellRequestAuthenticator } from "./proxy-functions";
import { AppPaths } from "../common/app-paths";
import { ShellSession } from "./shell-session/shell-session";
import { getDi } from "./getDi";
import electronMenuItemsInjectable from "./menu/electron-menu-items.injectable";
Copy link
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

why is this "electron" menu items?

Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Since menuItems is not enough to make it obvious. Other option was appMenuItems but that has same effect. App can be anything. Using Electron in name is not best, but still lot better than without it. Maybe mainAppMenuItems?

{ extensionLoader: ExtensionLoader }
> = {
getDependencies: () => ({
extensionLoader: ExtensionLoader.createInstance(),
Copy link
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

No you aren't supposed to create the instances in more than once place.

Copy link
Contributor Author

@jansav jansav Dec 8, 2021

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Yep, this is the issue with current Singleton. I shouldn’t need to know how and when instance is created.

If we change this to .getInstance() we need to be sure that .createInstance() has been called.

This is something that we need to fix by getting rid of Singleton

Copy link
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I completely disagree, just because something is a singleton doesn't mean there aren't dependency's on other instances, and those instances might need arguments to be created.

Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Solution is to use DI to make instances. That’s how I don’t need to worry about creating instance and getting arguments somewhere.

In this case, it’s clear that ExtensionLoader needs to be injected. Therefore injectable should be created.

Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I think @Nokel81 means that we shouldn't call ExtensionLoader.createInstance() in multiple places (see main/index.ts for other call). That said, it's a bit unfortunate that caller needs to know anything about this -> maybe instance should always come via injectable.

Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I know and I agree.

To be honest, I’m not sure why it was left as .createInstance() since .getInstance() is more than enough.

However it works as a great example of an issue caused Singleton and permits this kind discussion.

I’ll change it tomorrow anyway :)

Copy link
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Because not every place that consumes an instance needs/should know how to create it.

Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

This we know ;)

@@ -47,7 +46,7 @@ const logModule = "[EXTENSIONS-LOADER]";
*/
export class ExtensionLoader extends Singleton {
protected extensions = observable.map<LensExtensionId, InstalledExtension>();
protected instances = observable.map<LensExtensionId, LensExtension>();
instances = observable.map<LensExtensionId, LensExtension>();
Copy link
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I don't think this should be public. A getter for the list of instances could be useful if it doesn't already exist.

Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Agreed, this being exposed for external mutation is scary. Good catch.

@@ -0,0 +1,132 @@
/**
Copy link
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Please put test files under a __test__ folder

Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Why? There are tests already in codebase that are not in __tests__ directory.

Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I'd argue for having tests in a tests folder (being a bit vague to not bikeshed too much). If Lens codebase is not consistent wrt, we should strive to make it so. I think file navigation becomes less convenient if test files are located next to application code.

Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

You might be both right:

Sometimes unit tests specify real life behaviour instead of workings of certain technical entrypoint (i.e. a file). For this, __tests__makes sense, as the entrypoint becomes less relevant. This does not happen in current codebase yet. Some call this integration testing or functional testing, but let's not get into semantics.

At other times unit tests specify workings of a technical detail. For this, placing the test file next to entrypoint of unit makes sense, as the presence of tests (or lack thereof) is a valuable piece of information not to be swept away in a directory. When doing this, inconvenience in navigation is oftentimes sign of another problem, namely files not being organised in directories properly. Tests should not make directories feel cluttered.

}),

instantiate: ({ extensions }) =>
computed(() => extensions.get().flatMap(extension => extension.appMenus)),
Copy link
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

what about disabled extensions?

Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Just noticed that there is something weird happening here. Disabled extensions work already, but it seems that is not enough. We need to be sure that .init() has been called for extension as well.

I have required changes done already and pushing those here tomorrow.

Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Hmm. In general, with DI we will have more guarantees that temporal requirements, such as .init() this or .createInstance() that, will have been called when expected. Good.

onQuitCleanup.push(
initMenu(windowManager),
initMenu(windowManager, menuItems),
Copy link
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I guess, what is the benefit of this being injectable here? Instead of just computing the entries?

@@ -18,15 +18,8 @@
* IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN
* CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE.
*/

// Extensions API -> Global menu customizations
Copy link
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Why remove a comment?

Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

It serves no purpose -> dead weight.

@@ -18,15 +18,8 @@
* IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN
Copy link
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Also, why move this file?

Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

This was located in a file which was deleted.

Copy link
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Ah well the git diff implied that it was moved here instead. Then why delete that file in registries instead of just deleting the registry?

Copy link
Contributor Author

@jansav jansav Dec 8, 2021

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Would be silly to leave empty file there? (Or file that doesn’t do anything and doesn’t have anything to do with registries.)

Before it was the place that this interface was used first time, but since that doesn’t exist anymore, it makes sense to move it closer to useplace.

Copy link
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

It is also used in LensMainExtension and I would argue that is its "first use" and this is merely its consumer.

Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I would say that interface MenuRegistration exists for making sure that Menu has items with proper interface. That being said, Menu is the one that defines the requirements for the interface -> logical place for the interface is next to Menu.

for (const { parentId, ...menuItem } of MenuRegistry.getInstance().getItems()) {
if (!appMenu.has(parentId)) {
logger.error(`[MENU]: cannot register menu item for parentId=${parentId}, parent item doesn't exist`, { menuItem });
for (const menuItem of electronMenuItems) {
Copy link
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

IMO it is cleaner to not pass menuItem.parentId to the list of submenus.

Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Hmm. I suppose I considered the complication in typing not worth this. Do you agree? I see your point of encapsulation, but this case is trivial, and therefore simplicity is more pragmatic.

Copy link
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

How is the new code more simple?

Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I don't see this issue as good time investment. I can change it back if you feel strongly about it :)

jansav and others added 4 commits December 9, 2021 07:41
…as enabled extensions

Signed-off-by: Janne Savolainen <janne.savolainen@live.fi>
…h no changes required on use places

Signed-off-by: Janne Savolainen <janne.savolainen@live.fi>
…ld be created

Signed-off-by: Janne Savolainen <janne.savolainen@live.fi>
Signed-off-by: Iku-turso <mikko.aspiala@gmail.com>
Co-authored-by: Janne Savolainen <janne.savolainen@live.fi>
@github-actions
Copy link
Contributor

This pull request has conflicts, please resolve those before we can evaluate the pull request.

Signed-off-by: Janne Savolainen <janne.savolainen@live.fi>
Signed-off-by: Janne Savolainen <janne.savolainen@live.fi>
…lass with no changes required on use places"

This reverts commit 34a47f5

Signed-off-by: Janne Savolainen <janne.savolainen@live.fi>
…y-obsolete

Signed-off-by: Janne Savolainen <janne.savolainen@live.fi>
@github-actions
Copy link
Contributor

Conflicts have been resolved. A maintainer will review the pull request shortly.

@Iku-turso
Copy link
Contributor

Alright! With the latest changes, this will be one of those bigger PR's we hinted the team about. The big push was getting ExtensionLoader to comply with dependency injection, and more importantly the design choices which made that possible.

Here's our sincerest hopes to fast-track this PR, as it will directly support the next cool stuff to come. Onwards!

@Nokel81 Nokel81 changed the title Replace a registry with an injectable for benefits Remove MenuRegistry in favour of dependency injection Dec 15, 2021
Nokel81
Nokel81 previously approved these changes Dec 15, 2021
@Nokel81 Nokel81 added chore and removed enhancement New feature or request labels Dec 15, 2021
@Nokel81 Nokel81 added this to the 5.4.0 milestone Dec 15, 2021
Signed-off-by: Janne Savolainen <janne.savolainen@live.fi>
Signed-off-by: Janne Savolainen <janne.savolainen@live.fi>
Signed-off-by: Janne Savolainen <janne.savolainen@live.fi>
@jansav jansav force-pushed the make-menu-registry-obsolete branch from 2c13b5d to 8337dfd Compare December 16, 2021 05:14
@jansav jansav requested review from Nokel81 and Iku-turso and removed request for jakolehm and panuhorsmalahti December 16, 2021 05:16
@jansav
Copy link
Contributor Author

jansav commented Dec 16, 2021

@Nokel81, I fixed failing tests and linting errors. Needs to be approved again.

@jansav jansav merged commit 3418c0a into lensapp:master Dec 16, 2021
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment
Labels
Projects
None yet
Development

Successfully merging this pull request may close these issues.

Injectables in Main Process
6 participants