Wie benutzt man ReactiveCocoa, um sich vor dem Aufruf von APIs transparent zu authentifizieren?

Ich verwende ReactiveCocoa in einer App, die Aufrufe an Remote-Web-APIs aufruft. Bevor jedoch etwas von einem bestimmten API-Host abgerufen werden kann, muss die App die Anmeldeinformationen des Benutzers bereitstellen und ein API-Token abrufen, mit dem dann nachfolgende Anforderungen signiert werden.

Ich möchte diesen Authentifizierungsprozess so abstrahieren, dass er bei jedem API-Aufruf automatisch erfolgt. Angenommen, ich habe eine API-Client-Klasse, die die Anmeldeinformationen des Benutzers enthält.

// getThing returns RACSignal yielding the data returned by GET /thing.
// if the apiClient instance doesn't already have a token, it must
// retrieve one before calling GET /thing 
RAC(self.thing) = [apiClient getThing]; 

Wie kann ich ReactiveCocoa verwenden, um transparent die erste (und nur die erste) Anforderung an eine API abzurufen und als Nebeneffekt ein API-Token sicher zu speichern, bevor nachfolgende Anforderungen gestellt werden?

Es ist auch eine Voraussetzung, dass ich combineLatest: (oder Ähnliches) verwenden kann, um mehrere gleichzeitige Anforderungen auszulösen, und dass sie alle implizit darauf warten, dass das Token abgerufen wird.

RAC(self.tupleOfThisAndThat) = [RACSignal combineLatest:@[ [apiClient getThis], [apiClient getThat]]];

Wenn die Abruf-Token-Anforderung sich bereits im Flug befindet, wenn ein API-Aufruf erfolgt, muss dieser API-Aufruf warten, bis die Abruf-Token-Anforderung abgeschlossen ist.

Meine Teillösung folgt:

Das Grundmuster wird sein, flattenMap: zu verwenden, um ein Signal, das das Token ergibt, einem Signal zuzuordnen, das bei gegebenem Token die gewünschte Anfrage ausführt und das Ergebnis des API-Aufrufs liefert.

Angenommen, einige praktische Erweiterungen zu NSURLRequest :

- (RACSignal *)requestSignalWithURLRequest:(NSURLRequest *)urlRequest {
    if ([urlRequest isSignedWithAToken])
        return [self performURLRequest:urlRequest];

    return [[self getToken] flattenMap:^ RACSignal * (id token) {
        NSURLRequest *signedRequest = [urlRequest signedRequestWithToken:token];
        assert([urlRequest isSignedWithAToken]);
        return [self requestSignalWithURLRequest:signedRequest];
    }
}

Betrachten Sie nun die Subskriptionsimplementierung von -getToken .

  • Im Trivialfall, wenn das Token bereits abgerufen wurde, liefert die Subskription das Token sofort.
  • Wenn das Token nicht abgerufen wurde, wird das Abonnement an einen Authentifizierungs-API-Aufruf weitergeleitet, der das Token zurückgibt.
  • Wenn sich der Authentifizierungs-API-Aufruf im Flug befindet, sollte es sicher sein, einen weiteren Beobachter hinzuzufügen, ohne dass der Authentifizierungs-API-Aufruf über die Verbindung wiederholt wird.

Ich bin mir jedoch nicht sicher, wie ich das machen soll. Wie und wo kann der Token sicher gespeichert werden? Eine Art beständiges/wiederholbares Signal?

25
nl ja ru

3 Antworten

Also, hier gibt es zwei wichtige Dinge:

  1. Sie möchten einige Nebenwirkungen teilen (in diesem Fall ein Token abrufen), ohne sie bei jedem neuen Abonnenten erneut auszulösen.
  2. Sie möchten, dass jeder, der -getToken abonniert, die gleichen Werte erhält, egal was passiert.

Um Nebenwirkungen zu teilen (# 1 oben), verwenden wir RACMulticastConnection . Wie die Dokumentation sagt:

Eine Multicast-Verbindung kapselt die Idee, ein Abonnement für viele Teilnehmer mit einem Signal zu teilen. Dies ist am häufigsten erforderlich, wenn die Subskription des zugrunde liegenden Signals Nebenwirkungen hat oder nicht mehr als einmal aufgerufen werden sollte.

Fügen wir eine davon als private Eigenschaft in der API-Client-Klasse hinzu:

@interface APIClient ()
@property (nonatomic, strong, readonly) RACMulticastConnection *tokenConnection;
@end

Nun wird dies den Fall von N aktuellen Teilnehmern lösen, die alle das gleiche zukünftige Ergebnis benötigen (API-Aufrufe, die darauf warten, dass das Anfrage-Token in Betrieb ist), aber wir benötigen noch etwas, um diese zukünftigen Teilnehmer sicherzustellen Erhalten Sie das gleiche Ergebnis (das bereits abgerufene Token), ganz gleich, wann sie es abonnieren.

Das ist RACReplaySubject für:

Ein Wiederholungssubjekt speichert die Werte, die es gesendet hat (bis zu seiner definierten Kapazität) und sendet diese an neue Teilnehmer. Es wird auch einen Fehler oder eine Vervollständigung wiedergeben.

Um diese beiden Konzepte miteinander zu verknüpfen, können wir RACSignals -multicast: Methode , die ein normales Signal in eine Verbindung verwandelt, indem eine bestimmte Art von Thema verwendet.

Wir können die meisten Verhaltensweisen bei der Initialisierung verbinden:

- (id)init {
    self = [super init];
    if (self == nil) return nil;

   //Defer the invocation of -reallyGetToken until it's actually needed.
   //The -defer: is only necessary if -reallyGetToken might kick off
   //a request immediately.
    RACSignal *deferredToken = [RACSignal defer:^{
        return [self reallyGetToken];
    }];

   //Create a connection which only kicks off -reallyGetToken when
   //-connect is invoked, shares the result with all subscribers, and
   //pushes all results to a replay subject (so new subscribers get the
   //retrieved value too).
    _tokenConnection = [deferredToken multicast:[RACReplaySubject subject]];

    return self;
}

Dann implementieren wir -getToken , um den Abruf träge auszulösen:

- (RACSignal *)getToken {
   //Performs the actual fetch if it hasn't started yet.
    [self.tokenConnection connect];

    return self.tokenConnection.signal;
}

Danach wird alles, was das Ergebnis von -getToken (wie -requestSignalWithURLRequest: ) abonniert hat, das Token erhalten, wenn es noch nicht abgerufen wurde. oder warten Sie auf eine In-Flight-Anfrage, wenn es eine gibt.

45
hinzugefügt
@ JustinSpahr-Summers Ich denke, die Verwendung von ReactiveCocoa zur Behandlung von Netzwerkanforderungen ist eine häufige Anwendung, die eine Möglichkeit für eine generische API-Client-Klasse bietet, die (transparent) Probleme wie Login, Logout, Netzwerkverfügbarkeitsüberwachung und Fehler bei der Wiederholung behandelt usw. Gedanken?
hinzugefügt der Autor Tony, Quelle
@ColinBarrett Jeder von diesen würde eine detaillierte Antwort an sich erfordern - das war nur die einfachste Lösung für das oben dargelegte Problem. Das Abmelden kann dazu führen, dass tokenSignal in ein RACReplaySubject mit der Kapazität 1 gesetzt wird, so dass Sie nach Belieben ein neues Signal darauf übertragen können. Mehrere Konten wären eine viel größere Änderung, da vermutlich auch die Anfrage-API aktualisiert werden müsste. Ich würde gerne eine detailliertere Antwort auf eine neue Frage zu SO oder ein Problem auf GitHub geben.
hinzugefügt der Autor Justin Spahr-Summers, Quelle
@ Tony Es ist schwer zu wissen, was eigentlich daraus abstrahiert werden könnte, da RAC bereits über Primitive verfügt, die das schwere Heben für dich übernehmen werden. Wenn Sie Ideen haben, können Sie gerne ein Problem im RAC-Repo einreichen und wir können darüber ausführlicher sprechen: github.com/ReactiveCocoa/ReactiveCocoa/Ausgaben
hinzugefügt der Autor Justin Spahr-Summers, Quelle
Dies ist ein großartiges Beispiel dafür, wie eine Aufgabe, die bei der Verwendung von "Standard" -Kakao normalerweise ziemlich komplex ist, mit RAC viel einfacher werden kann. genial!
hinzugefügt der Autor jere, Quelle
Wie würdest du dich abmelden? Oder mehrere Konten?
hinzugefügt der Autor Colin Barrett, Quelle
@ JustinSpahr-Summers Ich war eher neugierig, habe eigentlich selbst keinen Bedarf.
hinzugefügt der Autor Colin Barrett, Quelle
Tolle Erklärung. Vielen Dank!
hinzugefügt der Autor bvanderveen, Quelle
Werfen Sie die APIClient-Instanz aus, um sich abzumelden, und erstellen Sie eine neue mit anderen Anmeldeinformationen, um sich erneut anzumelden. Verwenden Sie mehrere APIClient-Instanzen, um mehrere Konten zu unterstützen.
hinzugefügt der Autor bvanderveen, Quelle

Wie wäre es mit

...

@property (nonatomic, strong) RACSignal *getToken;

...

- (id)init {
    self = [super init];
    if (self == nil) return nil;

    self.getToken = [[RACSignal defer:^{
        return [self reallyGetToken];
    }] replayLazily];
    return self;
}

Natürlich ist diese Lösung identisch mit Justins Antwort oben. Grundsätzlich nutzen wir die Tatsache aus, dass die Convenience-Methode bereits in der öffentlichen API von RACSignal vorhanden ist :)

3
hinzugefügt

Denken über Token wird später ablaufen und wir müssen es aktualisieren.

Ich speichere Token in einer MutableProperty und verwende eine Sperre, um zu verhindern, dass mehrere abgelaufene Anfragen das Token aktualisieren, sobald das Token gewonnen oder aktualisiert wurde, fordere es einfach erneut mit einem neuen Token an.

Für die ersten Anfragen, da kein Token vorhanden ist, wird das Anforderungssignal flatMap auf Fehler setzen und somit refreshAT auslösen, währenddessen wir refreshToken nicht haben, daher refreshRT auslösen und im letzten Schritt sowohl at als auch rt setzen.

Hier ist der vollständige Code

static var headers = MutableProperty(["TICKET":""])
static let atLock = NSLock()
static let manager = Manager(
    configuration: NSURLSessionConfiguration.defaultSessionConfiguration()
)

internal static func GET(path:String!, params:[String: String]) -> SignalProducer<[String: AnyObject], NSError> {
    let reqSignal = SignalProducer<[String: AnyObject], NSError> {
        sink, dispose in
        manager.request(Router.GET(path: path, params: params))
        .validate()
        .responseJSON({ (response) -> Void in
            if let error = response.result.error {
                sink.sendFailed(error)
            } else {
                sink.sendNext(response.result.value!)
                sink.sendCompleted()
            }
        })
    }

    return reqSignal.flatMapError { (error) -> SignalProducer<[String: AnyObject], NSError> in
            return HHHttp.refreshAT()
        }.flatMapError({ (error) -> SignalProducer<[String : AnyObject], NSError> in
            return HHHttp.refreshRT()
        }).then(reqSignal)
}

private static func refreshAT() -> SignalProducer<[String: AnyObject], NSError> {
    return SignalProducer<[String: AnyObject], NSError> {
        sink, dispose in
        if atLock.tryLock() {
            Alamofire.Manager.sharedInstance.request(.POST, "http://example.com/auth/refresh")
                .validate()
                .responseJSON({ (response) -> Void in
                    if let error = response.result.error {
                        sink.sendFailed(error)
                    } else {
                        let v = response.result.value!["data"]
                        headers.value.updateValue(v!["at"] as! String, forKey: "TICKET")
                        sink.sendCompleted()
                    }
                    atLock.unlock()
                })
        } else {
            headers.signal.observe(Observer(next: { value in
                print("get headers from local: \(value)")
                sink.sendCompleted()
            }))
        }
    }
}

private static func refreshRT() -> SignalProducer<[String: AnyObject], NSError> {
    return SignalProducer<[String: AnyObject], NSError> {
        sink, dispose in
        Alamofire.Manager.sharedInstance.request(.POST, "http://example.com/auth/refresh")
        .responseJSON({ (response) -> Void in
            let v = response.result.value!["data"]                
            headers.value.updateValue(v!["at"] as! String, forKey: "TICKET")                
            sink.sendCompleted()
        })
    }
}
0
hinzugefügt