Skip to content
This repository has been archived by the owner on Aug 20, 2024. It is now read-only.

Latest commit

 

History

History
479 lines (372 loc) · 19.5 KB

FirebaseComponentSystem.md

File metadata and controls

479 lines (372 loc) · 19.5 KB

Using Core's Component System

FirebaseCore has a dependency injection system (referred to as "Interop") used to depend on functionalities provided by other Firebase products (specifically, the frameworks that offer those products). This gives the ability to depend on a typesafe interface-only API to consume without depending on the entire product and simulates optional dependencies - depending on the definition but not the product itself and only functioning when the product implementing that definition is included.

Table of Contents

Overview

When a Firebase framework wants to provide functionality to another Firebase framework, it must be done through the Interop system. Both frameworks depend on a shared protocol in the Interop folder that describes the functionality provided by one framework and required by the other. Let's use A and B, where B depends on functionality provided by A and the functionality is described by protocol AInterop.

During configuration, A tells Core that it provides functionality for AInterop and B tells Core it would like functionality AInterop (and specifies whether it is required or optional) as well as how to instantiate an instance of B. When a developer requests B, FirebaseCore instantiates B and passes a container that contains the instance of A that provides AInterop.

B has no idea what class A is, and it doesn't need to. All B needs to know is that it has an instance of an object that conforms to AInterop and provides the functionality it needs.

This system allows Firebase frameworks to depend on each other in a typesafe way and allows us to explicitly declare version dependencies on the interfaces required instead of the product's version.

Protocol Headers

In order to share protocols between two frameworks, we introduced headers that declare the desired protocol(s).

Both the implementing and dependent framework will import the <ProductName>Interop headers: the implementing framework must conform to the protocols defined and register it with Core, while the dependent framework will use the protocol definition to use methods defined by it.

An Interop folder can have multiple protocols, but all should be implemented by the product it is named after.

Protocols can not declare class methods. This is an intentional decision to ensure all interfaces interact properly based on the FIRApp that's used.

Types and Core API

For the rest of the documentation, it's important to be familiar with the various classes and API provided by Core. Since the frameworks are written in Objective-C, we'll use the Objective-C names. The Swift names are identical but dropping the FIR prefix.

  • @class FIRDependency
    • A dependency on a specific protocol's functionality. Created with the factory method [FIRDependency dependencyWithProtocol:isRequired:]
  • @class FIRComponent
    • A component to register with Core to be consumed by other frameworks. It declares the protocol offered, dependencies, and a block for Core to instantiate it.
  • @class FIRComponentContainer
    • A container that holds different components that are registered with Core.
  • #define FIR_COMPONENT(protocol, container) (macro)
    • The macro to request an instance conforming to a given protocol from a container. Due to Objective-C's lightweight generic system, the safest and most readable API is provided by a macro that uses internal types to give compiler warnings if a developer tries to assign the result to a variable with the incorrect type.
  • @protocol FIRLibrary
    • Describes functionality for frameworks registering components in the FIRComponentContainer as well as other Core configuration functionality. It allows Core to fetch components lazily from the implementing framework.

Registering with Core

Each Firebase framework should register with Core in the +load method of the class conforming to FIRLibrary. This needs to happen at +load time because Core needs to resolve any dependencies before a class has a chance to be called by a developer (if called at all).

#import "FirebaseCore/Extension/FirebaseCoreInternal.h"

@interface FIRFoo <FIRLibrary>
@end

@implementation FIRFoo

+ (void)load {
  // Register with Core as a library. The version should be fetched from a constant defined
  // elsewhere, but that's not covered or relevant for this example.
  [FIRApp registerInternalLibrary:self
                         withName:@"fire-foo"
                      withVersion:@"1.0.0"];
}

// TODO: Conform to `FIRLibrary`. See later sections for more information.

@end

Singletons and Instance Management

All Firebase frameworks provide singleton access for convenience that map to a specific FIRApp: [FIRAuth auth], [FIRFunctions functionsForApp:], etc. Some frameworks can also have multiple instances per FIRApp such as Storage: [FIRStorage storageForApp:URL:].

These instances must be created and managed by Core through the component system. This allows the FIRApp lifecycle to control the lifecycle of instances associated with itself. There are different ways to do so depending on the product's offerings.

Single Instance per FIRApp

The registration for a single instance per FIRApp changes if the framwork provides functionality to other frameworks or not.

Framework does not provide functionality (example: Functions)

In this case, the framework is a "leaf node" since no other frameworks depend on functionality from it. It has a private, empty protocol that it uses to register with the container. Using Functions as an example:

// FIRFunctions.m

/// Empty protocol to register Functions as a component with Core.
@protocol FIRFunctionsInstanceProvider
@end

/// Privately conform to the protocol for component registration.
@interface FIRFunctions () <FIRFunctionsInstanceProvider, FIRLibrary>
@end

@implementation FIRFunctions

+ (void)load {
  NSString *version = @"<# Fetch the version here #>";
  [FIRApp registerInternalLibrary:self withName:@"fire-fun" withVersion:version];
}

/// The array of components to register with Core. Since Functions is a leaf node and
/// doesn't provide any functionality to other frameworks, it should use Core for instance
/// management only.
+ (NSArray<FIRComponent *> *)componentsToRegister {
  // Each component needs a block for Core to call in order to instantiate instances of the
  // desired class.
  FIRComponentCreationBlock creationBlock =
    ^id _Nullable(FIRComponentContainer *container, BOOL *isCacheable) {
      // We want the same instance to be returned when requested from the container, enable
      // `isCacheable`.
      *isCacheable = YES;

      // Use an appropriate initializer and inject anything required from the container.
      return [[FIRFunctions alloc] initWithApp:container.app];
    };

  // Create the component that can create instances of `FIRFunctions`.
  FIRComponent *internalProvider =
      [FIRComponent componentWithProtocol:@protocol(FIRFunctionsInstanceProvider)
                            creationBlock:creationBlock];

  // Return the array of components, in this case only the internal instance provider.
  return @[ internalProvider ];
}

// The public entry point for the SDK.
+ (FIRFunctions *)functionsForApp:(FIRApp *)app {
  // Get the instance from the `FIRApp`'s container. This will create a new instance the
  // first time it is called, and since `isCacheable` is set in the component creation
  // block, it will return the existing instance on subsequent calls.
  id<FIRFunctionsInstanceProvider> instance =
      FIR_COMPONENT(FIRFunctionsInstanceProvider, app.container);

  // In the component creation block, we return an instance of `FIRFunctions`. Cast it and
  // return it.
  return (FIRFunctions *)instance;
}

// ... Other `FIRFunctions` methods.

@end
Framework provides functionality to other Frameworks (example: Auth)

This example will be very similar to the one above, but let's define a simple protocol that Auth could conform to and provide to other frameworks:

// FIRAuthInterop.h in the FirebaseAuthInterop framework.

@protocol FIRAuthInterop
/// Get the current Auth user's UID. Returns nil if there is no user signed in.
- (nullable NSString *)getUserID;
@end
// FIRAuth.m in the FirebaseAuth framework.

/// Privately conform to the protocol for interop and component registration.
@interface FIRAuth () <FIRAuthInteroperable, FIRLibrary>
@end

+ (void)load {
  // Remember to register in +load!
  NSString *version = @"<# Fetch the version here #>";
  [FIRApp registerInternalLibrary:self withName:@"fire-auth" withVersion:version];
}

/// The components to register with Core.
+ (NSArray<FIRComponent *> *)componentsToRegister {
  // Provide a component that will return an instance of `FIRAuth`.
  FIRComponentCreationBlock authCreationBlock =
      ^id _Nullable(FIRComponentContainer *container, BOOL *isCacheable) {
        // Cache so the same `FIRAuth` instance is returned each time.
        *isCacheable = YES;
        return [[FIRAuth alloc] initWithApp:container.app];
      };
  FIRComponent *authInterop =
      [FIRComponent componentWithProtocol:@protocol(FIRAuthInteroperable)
                            creationBlock:authCreationBlock];
  return @[authInterop];
}

// The public entry point for the SDK.
+ (FIRAuth *)authForApp:(FIRApp *)app {
  // Use the instance from the provided app's container.
  id<FIRAuthInteroperable> auth = FIR_COMPONENT(FIRAuthInteroperable, app.container);
  return (FIRAuth *)auth;
}

Multiple Instances per FIRApp

Instead of directly providing an instance from the container, Firestore and similar products should create a "provider" that stores and creates instances with the required parameters. This means a single provider per FIRApp, but multiple instances are possible per provider.

/// Provider protocol to register with Core.
@protocol FSTFirestoreMultiDBProvider

/// Cached instances of Firestore objects.
@property(nonatomic, strong) NSMutableDictionary<NSString *, FIRFirestore *> *instances;

/// Firestore can be initialized with an app as well as a database. The instance provider is already
/// associated with a `FIRApp` so pass in any other required parameters (in this case, just the
/// database string).
- (FIRFirestore *)firestoreForDatabase:(NSString *)database;

@end

Instead of the Firestore class conforming to FSTInstanceProvider, the work can be done in a separate class to keep Firestore.m cleaner.

/// A concrete implementation for FSTFirestoreMultiDBProvider to create Firestore instances.
@interface FSTFirestoreComponent : NSObject <FSTFirestoreMultiDBProvider, FIRLibrary>

/// The `FIRApp` that instances will be set up with.
@property(nonatomic, weak, readonly) FIRApp *app;

/// Cached instances of Firestore objects.
@property(nonatomic, strong) NSMutableDictionary<NSString *, FIRFirestore *> *instances;

/// Default method for retrieving a Firestore instance, or creating one if it doesn't exist.
- (FIRFirestore *)firestoreForDatabase:(NSString *)database;

/// Default initializer.
- (instancetype)initWithApp:(FIRApp *)app NS_DESIGNATED_INITIALIZER;
- (instancetype)init NS_UNAVAILABLE;
@end

@implementation FSTFirestoreInstanceProvider

// Explicitly @synthesize because instances is part of the FSTInstanceProvider protocol.
@synthesize instances = _instances;

+ (void)load {
  // Remember to register in +load!
  NSString *version = @"<# Fetch the version here #>";
  [FIRApp registerInternalLibrary:self withName:@"fire-fst" withVersion:version];
}

- (instancetype)initWithApp:(FIRApp *)app {
  self = [super init];
  if (self) {
    _instances = [[NSMutableDictionary alloc] init];
    _app = app;
  }
  return self;
}

/// `FSTFirestoreMultiDBProvider` conformance.
- (FIRFirestore *)firestoreForDatabase:(NSString *)database {
  // Regular initialization code to create Firestore instances with required parameters...
}

// `FIRLibrary` conformance.
+ (NSArray<FIRComponent *> *)componentsToRegister {
  // Ignore any dependencies for simplicity in this example.
  FIRComponentCreationBlock creationBlock =
    ^id _Nullable(FIRComponentContainer *container, BOOL *isCacheable) {
      *isCacheable = YES;

      // NOTE: Instead of returning an instance of Firestore, return an instance of the
      //       instance provider.
      return [[FIRFirestoreComponent alloc] initWithApp:container.app];
    };
  FIRComponent *firestoreProvider =
      [FIRComponent componentWithProtocol:@protocol(FSTFirestoreMultiDBProvider)
                            creationBlock:creationBlock];
  return @[ firestoreProvider ];
}

@end

All Firestore.m needs to do now is call the component container from the singleton calls:

+ (instancetype)firestoreForApp:(FIRApp *)app database:(NSString *)database {
  id<FSTFirestoreMultiDBProvider> provider =
      FIR_COMPONENT(FSTFirestoreMultiDBProvider, app.container);
  return [provider firestoreForDatabase:database];
}

Depending on Functionality from Another Framework

If you haven't already read Registering with Core, please do so until you get back to this spot as it lays the groundwork necessary to understand this section.

Adding dependencies is easy once components are registered with Core. Let's take the example from Functions above and add a dependency to FIRAuthInterop defined above.

Important: You will also need to add FirebaseAuthInterop headers to your product's podspec source_files attribute for CocoaPods and something comparable for any other package manager supported. Note, for Swift Package Manager, nothing special is needed as long as all the pods and headers are in the same repo.

Before adding the dependency on FIRAuthInterop.

+ (NSArray<FIRComponent *> *)componentsToRegister {
  FIRComponentCreationBlock creationBlock =
    ^id _Nullable(FIRComponentContainer *container, BOOL *isCacheable) {
      *isCacheable = YES;
      return [[FIRFunctions alloc] initWithApp:container.app];
    };

  FIRComponent *internalProvider =
      [FIRComponent componentWithProtocol:@protocol(FIRFunctionsInstanceProvider)
                            creationBlock:creationBlock];

  return @[ internalProvider ];
}

After adding the dependency on FIRAuthInterop. See comments with "ADDED:".

+ (NSArray<FIRComponent *> *)componentsToRegister {
  FIRComponentCreationBlock creationBlock =
    ^id _Nullable(FIRComponentContainer *container, BOOL *isCacheable) {
      *isCacheable = YES;

      // ADDED: Retrieve an instance that conforms to `FIRAuthInterop` from the container.
      id<FIRAuthInterop> auth = FIR_COMPONENT(FIRAuthInterop, container);

      // ADDED: Note the constructor has a new parameter: auth. It's good practice to inject
      //        the instance needed in the constructor instead of pulling it from the app
      //        passed in. This allows for better unit testing with fakes since any object
      //        can conform to `FIRAuthInterop` and be verified easily.
      return [[FIRFunctions alloc] initWithApp:container.app auth:auth];
    };

  // ADDED: Define a dependency on the `FIRAuthInteroperable` protocol. Declare if the
  //        dependency is required or not.
  FIRDependency *auth =
      [FIRDependency dependencyWithProtocol:@protocol(FIRAuthInteroperable)
                                 isRequired:NO];

  // ADDED: A longer constructor is used to instantiate the `FIRComponent`; this time
  //        it includes instantiation timing and an array of dependencies. The timing
  //        allows components to be initialized upon configure time or lazily, when
  //        it is requested from the container. Pass in the `auth` dependency created
  //        above.
  FIRComponent *internalProvider =
      [FIRComponent componentWithProtocol:@protocol(FIRFunctionsInstanceProvider)
                      instantiationTiming:FIRInstantiationTimingLazy
                             dependencies:@[ auth ]
                            creationBlock:creationBlock];

  return @[ internalProvider ];
}

Based on the new constructor, Functions can now use the auth instance as defined by the protocol:

NSString *userID = [auth getUserID];
if (userID) {
  // Auth is available and a user is signed in!
}

Advanced Use Cases

Providing Multiple Components and Sharing Instances

Consider a situation where a framework wants to offer functionality defined in multiple protocols with the same instance. For example, Auth could provide FIRAuthUserInterop and FIRAuthSignInInterop. If a single Auth instance should be shared between those two protocols, the system currently doesn't work.

In order to alleviate this, Auth could create a third private protocol (FIRAuthCombinedInterop) that conforms to both FIRAuthUserInterop and FIRAuthSignInInterop and becomes a dependency for each of those two components and returned in the component creation block. An abbreviated code sample:

+ (NSArray<FIRComponent *> *)componentsToRegister {
  // Standard creation block to get an instance of Auth.
  FIRComponentCreationBlock authBlock =
    ^id _Nullable(FIRComponentContainer *container, BOOL *isCacheable) {
      *isCacheable = YES;
      return [[FIRAuth alloc] initWithApp:container.app];
    };

  FIRComponentCreationBlock combinedBlock =
    ^id _Nullable(FIRComponentContainer *container, BOOL *isCacheable) {
      // No need to cache, let it use the cached value from the combined component.
      return FIR_COMPONENT(FIRAuthCombinedInterop, container);
    };

  // Declare a self dependency on the combined interop component.
  FIRDependency *auth =
      [FIRDependency dependencyWithProtocol:@protocol(FIRAuthCombinedInterop)
                                 isRequired:YES];

  // Declare the three components provided.
  FIRComponent *authComponent =
      [FIRComponent componentWithProtocol:@protocol(FIRAuthCombinedInterop)
                            creationBlock:authBlock];

  // Both the user and sign in components depend on the previous component as
  // declared in the dependency above.

  FIRComponent *userComponent =
      [FIRComponent componentWithProtocol:@protocol(FIRAuthUserInterop)
                      instantiationTiming:FIRInstantiationTimingLazy
                             dependencies:@[ auth ]
                            creationBlock:combinedBlock];

  FIRComponent *signInComponent =
      [FIRComponent componentWithProtocol:@protocol(FIRAuthSignInInterop)
                      instantiationTiming:FIRInstantiationTimingLazy
                             dependencies:@[ auth ]
                            creationBlock:combinedBlock];

  return @[ authComponent, userComponent, signInComponent ];
}