javascript - Beroendeinversion (IOC) av tredje parts singleton


Jag har svårigheter med att implementera inversion av kontroll för att behålla lös koppling mellan mina moduler, och även förhindra beroendecykler.

I mitt exempel vill jag dynamiskt registrera konfiguration för ett tredjepartsbibliotek (Apollo Client).

Nuvarande implementering

Med min nuvarande implementation har jag problem med beroendecykler

Mappstruktur

src
├── apolloClient.ts
├── modules
│   ├── messages
│   │   ├── ui.ts
│   │   └── state.ts
│   ├── users
│   │   ├── ui.ts
│   │   └── state.ts
...

apolloClient.ts

import { InMemoryCache, ApolloClient } from '@apollo/client'
import { messagesTypePolicies } from './modules/messages/state.ts'
import { usersTypePolicies } from './modules/users/state.ts'

export const client = new ApolloClient({
  cache: new InMemoryCache({
    typePolicies: {
      Query: {
        // register manually all modules
        ...messagesTypePolicies,
        ...usersTypePolicies
      }
    }
  }),
})

moduler/meddelanden/state.ts

import { client } from '../../apolloClient.ts'

export const messagesTypePolicies = {
  messagesList: {
    read() {
      return ['hello', 'yo']
    }
  }
}

export const updateMessage = () => {
  client.writeQuery(...) // Use of client, so here we have a dependency cycle issue
}

Vad jag skulle vilja

För att förhindra beroendecykler och stark koppling skulle jag vilja att varje modul registrerar sig själv, så att apolloClient-singeltonen inte har något beroende av UI-moduler.

moduler/meddelanden/state.ts

import { client, registerTypePolicies } from '../../apolloClient.ts'

// Use custom function to register typePolicies on UI modules
registerTypePolicies({
  messagesList: {
    read() {
      return ['hello', 'yo']
    }
  }
})

export const updateMessage = () => {
  client.writeQuery(...) // Dependency cycle issue solved :)
}

apolloClient.ts

export const registerTypePolicies = () => {
  // What I am trying to solve using IOC pattern
  // Not sure how to dynamically update the singleton
}

Svar #1

Jag har inte mycket erfarenhet av ApolloClient, men det ser ut som om inställningarna för ApolloClient och InMemoryCache skapas genom att skicka konfigurationer när du anropar new. Min oro är att du kanske inte kan ändra dem i efterhand. Av denna anledning föreslår jag att du import dina moduler till klientfilen istället för tvärtom.

Självklart kommer du att ha metoder i dina moduler som är beroende av client-instansen, men vi kan göra dem till funktioner som tar client som ett argument. Det gör att vi kan skriva metoderna innan klienten har skapats.

Jag kom på en design baserad på Builder-mönstret. Vi definierar varje modul som ett object med två egenskaper: policies är ett nyckel-värde-objekt av policyspecifikationer, som passar typen TypePolicies från apollo-paketet; makeMethods är en funktion som tar client och returnerar ett nyckel-värde-objekt av funktioner. Vi använder det returnerade funktionsobjektet som generisk för gränssnittet så att vi kan få de exakta definitionerna för funktionen senare.

type MethodFactory<MethodMap> = (
  client: ApolloClient<NormalizedCacheObject>
) => MethodMap;

interface Module<MethodMap> {
  policies: TypePolicies;
  makeMethods: MethodFactory<MethodMap>;
}

När vi bygger in metoderna i en client, kommer vi att skicka alla policys till cache. Vi kommer också att skicka den nyss skapade client till makeMethods fabriken och returnera metoder som inte längre kräver client eftersom den redan har injicerats.

Låt mig pausa här för att säga att något inte är helt rätt med policies-delen av min kod eftersom jag inte helt förstår hur en policy ska se ut. Så jag lämnar det åt dig att ta reda på. (dokumentationslänk)

Vår Builder börjar utan inställningar. Varje gång vi lägger till en modul kombinerar vi inställningarna med de befintliga och returnerar en ny Builder. Så småningom kallar vi build som returnerar ett objekt med två egenskaper: client som är ApolloClient och methods som är ett objekt med metoder som är bundna till klienten.

class Builder<MethodMap> {
  policies: TypePolicies;
  makeMethods: MethodFactory<MethodMap>;

  protected constructor(module: Module<MethodMap>) {
    this.makeMethods = module.makeMethods;
    this.policies = module.policies;
  }

  public static create(): Builder<{}> {
    return new Builder({ makeMethods: () => ({}), policies: {} });
  }

  public addModule<AddedMethods>(
    module: Module<AddedMethods>
  ): Builder<MethodMap & AddedMethods> {
    return new Builder({
      policies: {
        ...this.policies,
        ...module.policies
      },
      makeMethods: (client) => ({
        ...this.makeMethods(client),
        ...module.makeMethods(client)
      })
    });
  }

  public build(): {
    client: ApolloClient<NormalizedCacheObject>;
    methods: MethodMap;
  } {
    const client = new ApolloClient({
      cache: new InMemoryCache({
        typePolicies: this.policies
      })
    });
    const methods = this.makeMethods(client);

    return { client, methods };
  }
}

Användning ser ut så här:

const messagesModule = {
  policies: {
    messagesList: {
      read() {
        return ["hello", "yo"];
      }
    } as TypePolicy // I don't know enough about TypePolicies to understand why this is needed
  },
  makeMethods: (client: ApolloClient<NormalizedCacheObject>) => ({
    updateMessage: (): void => {
      client.writeQuery({});
    }
  })
};

// some dummy data
const otherModule = {
  policies: {},
  makeMethods: (client: ApolloClient<NormalizedCacheObject>) => ({
    doSomething: (): number => {
      return 5;
    },
    somethingElse: (arg: string): void => {}
  })
};

// build through chaining
const { client, methods } = Builder.create()
  .addModule(messagesModule)
  .addModule(otherModule)
  .build();

// can call a method without any arguments
methods.updateMessage();

// methods with arguments know their correct argument type
methods.somethingElse('');

// can call any method on the client
const resolvers = client.getResolvers();

// can destructure methods from the map object
const {updateMessage, doSomething, somethingElse} = methods;

Du skulle exportera client härifrån och använda det i hela din app.

Du kan destrukturera methods och exportera metoder individuellt, eller så kan du bara exportera hela methods-objektet. Observera att du inte kan ha metoder med samma namn i flera moduler eftersom de inte är nästlade.