Da habe ich meinen Boten geschrieben

Eines Abends, nach einem weiteren frustrierenden Tag voller Versuche, das Spiel auszugleichen, entschied ich, dass ich dringend eine Pause brauchte. Ich werde zu einem anderen Projekt wechseln, es schnell tun, das Selbstwertgefühl zurückgeben, das während der Entwicklung des Spiels nachgelassen hat, und das Spiel mit neuer Kraft im Sturm erobern! Die Hauptsache ist, ein schönes und entspannendes Projekt zu wählen ... Schreiben Sie Ihren eigenen Messenger? Ha! Wie schwer kann es sein?

Den Code finden Sie hier .


Kurzer Hintergrund


Fast ein Jahr lang, bevor er mit der Arbeit am Messenger begann, arbeitete er am Online-Multiplayer-Spiel Line Tower Wars. Die Programmierung verlief gut, alles andere (insbesondere Balance und Bild) war nicht sehr gut. Plötzlich stellte sich heraus, dass es zwei verschiedene Dinge sind, ein Spiel zu machen und ein lustiges Spiel zu machen (Spaß für jemand anderen als sich selbst). Nach einem Jahr der Tortur musste ich mich ablenken lassen, also beschloss ich, mich an etwas anderem zu versuchen. Die Wahl fiel auf die mobile Entwicklung, nämlich Flutter. Ich habe viele gute Dinge über Flutter gehört und den Pfeil nach einem kurzen Experiment gemocht. Ich beschloss, meinen eigenen Boten zu schreiben. Erstens empfiehlt es sich, sowohl Client als auch Server zu implementieren. Zweitens wird es etwas Bedeutendes im Portfolio geben, um nach Arbeit zu suchen. Ich bin gerade dabei.

Geplante Funktionalität


  • Privat- und Gruppenchats
  • Senden von Text, Bildern und Videos
  • Audio- und Videoanrufe
  • Empfangsbestätigung und Lesung (Häkchen von Votsap)
  • "Drucke ..."
  • Benachrichtigungen
  • Suche nach QR-Code und Geolocation

Mit Blick auf die Zukunft kann ich stolz (und erleichtert) sagen, dass fast alles, was geplant wurde und was noch nicht umgesetzt wurde, in naher Zukunft umgesetzt wird.



Sprachauswahl


Ich habe lange nicht über die Wahl der Sprache nachgedacht. Anfangs war es verlockend, den Dart sowohl für den Client als auch für den Server zu verwenden, aber eine genauere Untersuchung ergab, dass nicht viele Dart-Treiber verfügbar sind und solche, die nicht viel Vertrauen schaffen. Obwohl ich nicht dafür bürgen werde, über den aktuellen Moment zu sprechen, hat sich die Situation möglicherweise verbessert. Meine Wahl fiel also auf C #, mit dem ich in Unity gearbeitet habe.

Die Architektur


Er begann mit dem Nachdenken über Architektur. Wenn man bedenkt, dass dreieinhalb Leute höchstwahrscheinlich meinen Messenger benutzen werden, müsste man sich natürlich nicht mit Architektur im Allgemeinen beschäftigen. Sie nehmen und tun wie in unzähligen Tutorials. Hier ist der Knoten, hier ist der Mongo, hier sind die Web-Sockets. Erledigt. Und Firebase ist hier. Aber es ist nicht interessant. Ich habe mich für einen Messenger entschieden, der sich leicht horizontal skalieren lässt, als würde ich Millionen von Clients gleichzeitig erwarten. Da ich jedoch keine Erfahrung in diesem Bereich hatte, musste ich alles in der Praxis durch die Methode der Fehler und wieder Fehler lernen.

Die endgültige Architektur sieht so aus


Ich behaupte nicht, dass eine solche Architektur super cool und zuverlässig ist, aber sie ist realisierbar und sollte theoretisch hohen Belastungen standhalten und horizontal skalieren, aber ich verstehe nicht wirklich, wie man sie überprüft. Und ich hoffe, dass ich keinen offensichtlichen Moment verpasst habe, der allen außer mir bekannt ist.

Nachfolgend finden Sie eine detaillierte Beschreibung der einzelnen Komponenten.

Frontend-Server


Noch bevor ich mit dem Spiel anfing, war ich fasziniert vom Konzept eines asynchronen Single-Threaded-Servers. Effektiv und ohne potenzielle Race'ov - was können Sie noch verlangen. Um zu verstehen, wie solche Server angeordnet sind, begann ich mich mit dem asyncioPython- Sprachmodul zu beschäftigen . Die Lösung, die ich sah, schien sehr elegant. Kurz gesagt, die Pseudocode-Lösung sieht so aus.
//  ,      ,    
//       .      socket.Receive
//     , :
var bytesReceived = Completer<object>();
selector.Register(
    socket,
    SocketEvent.Receive,
    () => bytesReceived.Complete(null)
);

await bytesReceived.Future;

int n = socket.Receive(...); //   

// selector -     poll.   
//        (Receive 
//  ), ,    ,  .
//   completer,      ,
//        , ,     .
//     ,       .

Mit dieser einfachen Technik können wir eine große Anzahl von Sockets in einem einzigen Thread bedienen. Wir blockieren niemals einen Stream, während wir darauf warten, dass Bytes empfangen oder gesendet werden. Der Stream ist immer mit nützlicher Arbeit beschäftigt. Parallelität, mit einem Wort.

Frontend-Server werden auf diese Weise implementiert. Sie sind alle Single-Threaded und asynchron. Für maximale Leistung müssen Sie daher so viele Server auf einem Computer ausführen, wie Kerne haben (4 in der Abbildung).

Der Frontend-Server liest die Nachricht vom Client und sendet sie basierend auf dem Nachrichtencode an eines der Themen in Kafka.

Eine kleine Fußnote für diejenigen, die mit Kafa nicht vertraut sind
, RabbitMQ. . , ( authentication backend authentication, ). ? - , (partition). , . , , . , ( , , , (headers)).

? ? . (consumer) ( consumer'), ( ) . , , , 2 , . 3 — 2. .

Der Frontend-Server sendet eine Nachricht ohne Schlüssel an die Kafka (wenn kein Schlüssel vorhanden ist, sendet die Kafka einfach nacheinander Nachrichten an die Partei). Die Nachricht wird von einem der entsprechenden Backend-Server aus dem Thema abgerufen. Der Server verarbeitet die Nachricht und ... wie geht es weiter? Und was weiter hängt von der Art der Nachricht ab.

Im häufigsten Fall tritt ein Anforderungs-Antwort-Zyklus auf. Zum Beispiel müssen wir für eine Registrierungsanfrage dem Kunden nur eine Antwort geben ( Success,EmailAlreadyInUse, usw). Auf eine Nachricht mit einer Einladung zu einem bestehenden Chat mit neuen Mitgliedern (Vasya, Emil und Julia) müssen wir jedoch sofort mit drei verschiedenen Arten von Nachrichten antworten. Der erste Typ - Sie müssen den Einladenden über das Ergebnis des Vorgangs informieren (plötzlich ist ein Serverfehler aufgetreten). Der zweite Typ - Sie müssen alle aktuellen Mitglieder des Chats benachrichtigen, dass sich jetzt solche und solche neuen Mitglieder im Chat befinden. Die dritte besteht darin, Einladungen an Vasya, Emil und Yulia zu senden.

Okay, das klingt nicht sehr schwierig, aber um eine Nachricht an einen Client zu senden, müssen wir: 1) herausfinden, mit welchem ​​Frontend-Server dieser Client verbunden ist (wir wählen nicht aus, mit welchem ​​Server der Client eine Verbindung herstellen soll, der Balancer entscheidet für uns); 2) Senden einer Nachricht vom Backend-Server an den gewünschten Frontend-Server; 3) Senden Sie tatsächlich eine Nachricht an den Client.

Um die Punkte 1 und 2 zu implementieren, habe ich mich für ein separates Thema entschieden (Thema "Frontend-Server"). Die Trennung von Authentifizierungs-, Sitzungs- und Aufrufthemen in Partitionen dient als Parallelisierungsmechanismus. Wir sehen, dass der Sitzungsserver stark ausgelastet ist? Wir fügen nur ein paar neue Partitions- und Sitzungsserver hinzu, und Kafka verteilt die Last für uns neu und entlädt die vorhandenen Sitzungsserver. Die Aufteilung des Themas "Frontend-Server" in die Partition dient als Routing- Mechanismus .

Jeder Frontend-Server entspricht einem Teil des Themas "Frontend-Server" (mit demselben Index wie der Server selbst). Das heißt, Server 0 - Partition 0 und so weiter. Kafka ermöglicht es, nicht nur ein bestimmtes Thema, sondern auch einen bestimmten Teil eines bestimmten Themas zu abonnieren. Alle Frontend-Server bei Starts abonnieren die entsprechende Partition. Somit kann der Backend-Server eine Nachricht an einen bestimmten Frontend-Server senden, indem er eine Nachricht an eine bestimmte Partition sendet.

Okay, jetzt, wenn der Client beitritt, müssen Sie nur noch ein Paar UserId - Frontend Server Index irgendwo speichern. Im Falle einer Trennung - löschen. Für diese Zwecke reicht jede der vielen speicherinternen Schlüsselwertdatenbanken aus. Ich habe einen Rettich gewählt.

Wie es in der Praxis aussieht. Nachdem die Verbindung hergestellt wurde, sendet Client Andrey zunächst eine Nachricht an den Server Join. Der Frontend-Server empfängt die Nachricht und leitet sie an das Sitzungsthema weiter. Dabei wird vorab der Header "Frontend Server" hinzugefügt: {index}. Einer der Backend-Sitzungsserver empfängt eine Nachricht, liest das Autorisierungstoken, ermittelt, welcher Benutzertyp beigetreten ist, liest den vom Frontend-Server hinzugefügten Index und schreibt UserId - Index in den Rettich. Von diesem Moment an wird der Client als online betrachtet, und jetzt wissen wir, über welchen Frontend-Server (und entsprechend über welchen Teil des Themas "Frontend-Server") wir ihn "erreichen" können, wenn andere Clients Nachrichten an Andrey senden.

* Tatsächlich ist der Prozess etwas komplizierter als ich beschrieben habe. Sie finden es im Quellcode.

Pseudocode des Frontend-Servers


// Frontend Server 6
while (true) {
    // Consume from "Frontend Servers" topic, partition 6
    var messageToClient = consumer.Consume();
    if (message != null) {
        relayMessageToClient(messageToClient);
    }

    var callbacks = selector.Poll();
    while (callbacks.TryDequeue(out callback)) {
        callback();
    }

    long now = DateTimeOffset.UtcNow.ToUnixTimeMilliseconds();
    while (!callAtQueue.IsEmpty && callAtQueue.PeekPriority() <= now) {
        callAtQueue.Dequeue()();
    }

    while (messagesToRelayToBackendServers.TryDequeue(out messageFromClient)) {
        // choose topic
        producer.Produce(topic, messageFromClient);
    }
}


Hier gibt es ein paar Tricks.
1) relayMessageToClient. Es ist ein Fehler, einfach den gewünschten Socket zu nehmen und sofort eine Nachricht an ihn zu senden, da wir möglicherweise bereits eine andere Nachricht an den Client senden. Wenn wir beginnen, Bytes zu senden, ohne zu überprüfen, ob der Socket gerade belegt ist, werden die Nachrichten gemischt. Wie an vielen anderen Stellen, an denen eine ordnungsgemäße Datenverarbeitung erforderlich ist, besteht der Trick darin, eine Warteschlange zu verwenden, nämlich eine Warteschlange von Completers ( TaskCompletionSourcein C #).
void async relayMessageToClient(message) {
    // find client
    await client.ReadyToSend();
    await sendMessage(client, message);
    client.CompleteSend();
}

class Client {
    // ...
    sendMessageQueue = new LinkedList<Completer<object>>();

    async Future ReadyToSend() {
        var sendMessage = Completer<object>();
	if (sendMessageQueue.IsEmpty) {
	    sendMessageQueue.AddLast(sendMessage);
	} else {
	    var prevSendMessage = sendMessageQueue.Last;
	    sendMessageQueue.AddLast(sendMessage);
	    await prevSendMessage.Future;
	}
    }

    void CompleteSend() {
        var sendMessage = sendMessageQueue.RemoveFirst();
	sendMessage.Complete(null);
    }
}

Wenn die Warteschlange nicht leer ist, ist der Socket derzeit bereits belegt. Erstellen Sie eine neue completer, fügen Sie sie der Warteschlange und awaitder vorherigen hinzu completer . Wenn die vorherige Nachricht gesendet wird, CompleteSendwird sie abgeschlossen completer, wodurch der Server mit dem Senden der nächsten Nachricht beginnt. Eine solche Warteschlange ermöglicht auch die reibungslose Weitergabe von Ausnahmen. Angenommen, beim Senden einer Nachricht an einen Client ist ein Fehler aufgetreten. In diesem Fall müssen wir abschließen, mit der Ausnahme, dass nicht nur diese Nachricht gesendet wird, sondern auch alle Nachrichten, die derzeit in der Warteschlange warten (warten Sie auf await'ah). Wenn wir dies nicht tun, hängen sie weiter und wir erhalten einen Speicherverlust. Der Kürze halber wird der Code, der dies tut, hier nicht angezeigt.

2)selector.Poll. Eigentlich ist es nicht einmal ein Trick, sondern nur ein Versuch, die Mängel der Methodenimplementierung auszugleichen Socket.Select( selector- nur ein Wrapper über diese Methode). Abhängig vom Betriebssystem unter der Haube verwendet diese Methode entweder selectoder poll. Das ist hier aber nicht wichtig. Wichtig ist, wie diese Methode mit den Listen funktioniert, die wir der Eingabe zuführen (Liste der Sockets zum Lesen, Schreiben, Fehlerprüfung). Diese Methode verwendet Listen, fragt Sockets ab und belässt nur die Sockets in den Listen, die bereit sind, die erforderliche Operation auszuführen. Alle anderen Sockets werden aus den Listen geworfen. "Treten" erfolgt durchRemoveAt(das heißt, alle nachfolgenden Elemente werden verschoben, was ineffizient ist). Da wir bei jeder Iteration des Zyklus alle registrierten Sockets abfragen müssen, ist eine solche „Bereinigung“ im Allgemeinen schädlich. Daher müssen wir die Listen jedes Mal neu füllen. Wir können all diese Probleme mit einem benutzerdefinierten Problem umgehen List, RemoveAtdessen Methode das Element nicht aus der Liste entfernt, sondern es einfach als gelöscht markiert. Die Klasse ListForPollingist meine Implementierung einer solchen Liste. ListForPollingfunktioniert nur mit der Methode Socket.Selectund ist für nichts anderes geeignet.

3)callAtQueue. In den meisten Fällen erwartet der Frontend-Server, nachdem er die Client-Nachricht an den Backend-Server gesendet hat, eine Antwort (Bestätigung, dass der Vorgang erfolgreich war, oder ein Fehler, wenn ein Fehler aufgetreten ist). Wenn er nicht innerhalb eines konfigurierbaren Zeitraums auf eine Antwort wartet, sendet er einen Fehler an den Client, damit er nicht auf eine Antwort wartet, die niemals kommen wird. callAtQueueIst eine Prioritätswarteschlange. Unmittelbar nachdem der Server die Nachricht an Kafka gesendet hat, geschieht Folgendes:
long now = DateTimeOffset.UtcNow.ToUnixTimeMilliseconds();
callAtQueue.Enqueue(callback, now + config.WaitForReplyMSec);

Im Rückruf wird das Warten auf eine Antwort abgebrochen und das Senden von Serverfehlern beginnt. Wenn eine Antwort vom Backend-Server empfangen wird, führt der Rückruf nichts aus. Es gibt keine Möglichkeit, es zu

verwenden await Task.WhenAny(answerReceivedTask, Task.Delay(x)), da der Code, nachdem er Task.Delayauf dem Thread aus dem Pool ausgeführt wurde.

Hier eigentlich alles über Frontend-Server. Hier ist eine leichte Korrektur erforderlich. In der Tat ist der Server nicht vollSingle Threaded. Natürlich verwendet Kafka unter der Haube Threads, aber ich meine den Anwendungscode. Tatsache ist, dass das Senden einer Nachricht an das Thema kafka (produzieren) möglicherweise nicht erfolgreich ist. Im Falle eines Fehlers wiederholt Kafka das Senden einer bestimmten konfigurierbaren Anzahl von Malen. Wenn jedoch wiederholte Abfahrten fehlschlagen, gibt Kafka dieses Geschäft als hoffnungslos auf. Sie können überprüfen, ob die Nachricht erfolgreich gesendet wurde oder nicht, in deliveryHandlerder wir an die Methode übergeben Produce. Kafka ruft diesen Handler im E / A-Thread des Herstellers auf (dem Thread, der Nachrichten sendet). Wir müssen sicherstellen, dass die Nachricht erfolgreich gesendet wurde, und wenn nicht, das Warten auf eine Antwort vom Back-End-Server abbrechen (die Antwort wird nicht kommen, weil die Anforderung nicht gesendet wurde) und einen Fehler an den Client senden. Das heißt, wir können die Interaktion mit einem anderen Thread nicht vermeiden.

* Beim Schreiben eines Artikels wurde mir plötzlich klar, dass wir nicht deliveryHandleran die Methode übergeben Produceoder einfach alle Kafka-Fehler ignorieren können (der Fehler wird immer noch an das Client gesendet, das ich zuvor beschrieben habe) - dann wird unser gesamter Code Single-Threaded sein. Jetzt denke ich darüber nach, wie ich es besser machen kann.

Warum eigentlich Kafka, kein Kaninchen?
, , , , , RabbitMQ? . , , . ? , frontend . , backend , , . , , . , error-prone. , basicGet , , , . . basicGet, , . .


Backend-Server


Im Vergleich zum Frontend-Server gibt es hier praktisch keine interessanten Punkte. Alle Backend-Server funktionieren auf die gleiche Weise. Beim Start abonniert der Server das Thema (Authentifizierung, Sitzung oder Aufruf, abhängig von der Rolle), und die Kafka weist ihm eine oder mehrere Partitionen zu. Der Server empfängt die Nachricht von Kafka, verarbeitet sie und sendet normalerweise eine oder mehrere Nachrichten als Antwort. Fast echter Code:
void Run() {
    long lastCommitTime = DateTimeOffset.UtcNow.ToUnixTimeMilliseconds();

    while (true) {
        var consumeResult = consumer.Consume(
            TimeSpan.FromMilliseconds(config.Consumer.PollTimeoutMSec)
        );

        if (consumeResult != null) {
            var workUnit = new WorkUnit() {
                ConsumeResult = consumeResult,
            };

            LinkedList<WorkUnit> workUnits;
            if (partitionToWorkUnits.ContainsKey(consumeResult.Partition)) {
                workUnits = partitionToWorkUnits[consumeResult.Partition];
            } else {
                workUnits = partitionToWorkUnits[consumeResult.Partition] =
                    new LinkedList<WorkUnit>();
            }

            workUnits.AddLast(workUnit);

            handleWorkUnit(workUnit);
        }

	if (
            DateTimeOffset.UtcNow.ToUnixTimeMilliseconds() - lastCommitTime >=
            config.Consumer.CommitIntervalMSec
        ) {
            commitOffsets();
	    lastCommitTime = DateTimeOffset.UtcNow.ToUnixTimeMilliseconds();
	}
    }
}

Welche Art von Offsets müssen festgeschrieben werden?
. — (offset) (0, 1 ). 0. TopicPartitionOffset. (consume) , ConsumeResult, , , TopicPartitionOffset. ?

at least once delivery, , ( ). , (commited) . , consumer 16, , 16 , , , . - consumer' consumer' , 16 + 1 ( + 1). 17 . N , .

Ich habe das automatische Festschreiben deaktiviert und mich selbst festgeschrieben. Dies ist erforderlich, da handleWorkUnitdies eine async voidMethode ist , bei der die Nachrichtenverarbeitung tatsächlich ausgeführt wird. Daher gibt es keine Garantie dafür, dass Nachricht 5 vor Nachricht 6 verarbeitet wird. Kafka speichert nur einen festgeschriebenen Offset (und keinen Satz von Offset), bevor der Offset festgeschrieben wird 6 müssen wir sicherstellen, dass alle vorherigen Nachrichten auch verarbeitet wurden. Darüber hinaus kann ein Back-End-Server Nachrichten von mehreren Partitionen gleichzeitig verarbeiten und muss daher sicherstellen, dass der richtige Offset für die entsprechende Partition festgeschrieben wird. Dazu verwenden wir eine Hash-Map der Formularpartition: Arbeitseinheiten. So sieht der Code aus commitOffsets(diesmal echter Code):
private void commitOffsets() {
    foreach (LinkedList<WorkUnit> workUnits in partitionToWorkUnits.Values) {
        WorkUnit lastFinishedWorkUnit = null;
        LinkedListNode<WorkUnit> workUnit;
        while ((workUnit = workUnits.First) != null && workUnit.Value.IsFinished) {
            lastFinishedWorkUnit = workUnit.Value;
            workUnits.RemoveFirst();
        }

        if (lastFinishedWorkUnit != null) {
            offsets.Add(lastFinishedWorkUnit.ConsumeResult.TopicPartitionOffset);
        }
    }

    if (offsets.Count > 0) {
        consumer.Commit(offsets);
        foreach (var offset in offsets) {
            logger.Debug(
                "{Identifier}: Commited offset {TopicPartitionOffset}",
                identifier,
                offset
            );
        }
        offsets.Clear();
    }
}

Wie Sie sehen können, durchlaufen wir die Einheiten, finden die letzte Einheit, die zu diesem Zeitpunkt fertiggestellt ist, danach gibt es keine unvollständigen mehr , und schreiben den entsprechenden Offset fest. Eine solche Schleife ermöglicht es uns, "löchrige" Commits zu vermeiden. Wenn wir beispielsweise derzeit 4 Einheiten ( 0: Finished, 1: Not Finished, 2: Finished, 3: Finished) haben, können wir nur die 0. Einheit festschreiben. Wenn wir die 3. Einheit sofort festschreiben, kann dies zum potenziellen Verlust der 1. Einheit führen, wenn der Server gerade stirbt.
class WorkUnit {
    public ConsumeResult<Null, byte[]> ConsumeResult { get; set; }
    private int finished = 0;

    public bool IsFinished => finished == 1;

    public void Finish() {
        Interlocked.Increment(ref finished);
    }
}


handleWorkUnitWie gesagt, die async voidMethode, und sie ist dementsprechend vollständig eingewickelt try-catch-finally. In tryruft er den notwendigen Dienst und in finally- workUnit.Finish().

Die Dienstleistungen sind ziemlich trivial. Hier zum Beispiel, welcher Code ausgeführt wird, wenn der Benutzer eine neue Nachricht sendet:
private async Task<ServiceResult> createShareItem(CreateShareItemMessage msg) {
    byte[] message;
    byte[] messageToPals1 = null;
    int?[] partitions1 = null;

    //  UserId  .
    long? userId = hashService.ValidateSessionIdentifier(msg.SessionIdentifier);
    if (userId != null) {
        var shareItem = new ShareItemModel(
            requestIdentifier: msg.RequestIdentifier,
            roomIdentifier: msg.RoomIdentifier,
            creatorId: userId,
            timeOfCreation: null,
            type: msg.ShareItemType,
            content: msg.Content
        );

        //      null,
        //     .
        long? timeOfCreation = await storageService.CreateShareItem(shareItem);
        if (timeOfCreation != null) {
            //      .
            List<long> pals = await inMemoryStorageService.GetRoomPals(
                msg.RoomIdentifier
            );
            if (pals == null) {
            	//     -       .
                pals = await storageService.GetRoomPals(msg.RoomIdentifier);
                await inMemoryStorageService.SaveRoomPals(msg.RoomIdentifier, pals);
            }

            //    ,  .
            pals.Remove(userId.Value);

            if (pals.Count > 0) {
            	//  ack,  ,    
                //    .
                await storageService.CreateAck(
                    msg.RequestIdentifier, userId.Value, msg.RoomIdentifier,
                    timeOfCreation.Value, pals
                );

                // in -  UserId, out -   frontend ,
                //    .  -   -
                //   null.
                partitions1 = await inMemoryStorageService.GetUserPartitions(pals);

                List<long> onlinePals = getOnlinePals(pals, partitions1);

                //    ,       .
                //         .
                if (onlinePals.Count > 0) {
                    messageToPals1 = converterService.EncodeNewShareItemMessage(
                        userId.Value, timeOfCreation.Value, onlinePals, shareItem
                    );
                    nullRepeatedPartitions(partitions1);
                    // -         
                    // frontend ,    null' .
                }
            }

            message = converterService.EncodeSuccessfulShareItemCreationMessage(
                msg.RequestIdentifier, timeOfCreation.Value
            );
        } else {
            message = converterService.EncodeMessage(
                MessageCode.RoomNotFound, msg.RequestIdentifier
            );
        }
    } else {
        message = converterService.EncodeMessage(
            MessageCode.UserNotFound, msg.RequestIdentifier
        );
    }

    return new ServiceResult(
        message: message, //    .
        messageToPals1: messageToPals1, //  -    .
        partitions1: partitions1
    );
}


Datenbank


Der größte Teil der Funktionalität von Diensten, die von Backend-Servern aufgerufen werden, besteht darin, der Datenbank einfach neue Daten hinzuzufügen und vorhandene zu verarbeiten. Natürlich ist es für den Messenger sehr wichtig, wie die Datenbank organisiert ist und wie wir damit arbeiten, und hier möchte ich sagen, dass ich mich nach sorgfältiger Prüfung aller Optionen sehr sorgfältig mit der Auswahl einer Datenbank befasst habe, aber dies ist nicht der Fall. Ich habe mich gerade für CockroachDb entschieden, weil es mit minimalem Aufwand viel verspricht und eine Postgres-kompatible Syntax hat (ich habe vorher mit Postgres gearbeitet). Es gab Gedanken, Cassandra zu benutzen, aber am Ende beschloss ich, mich mit etwas Vertrautem zu befassen. Ich hatte noch nie mit Kafka, Rabbit, Flutter und Dart oder WebRtc gearbeitet, deshalb habe ich beschlossen, Cassandra nicht mitzunehmen, weil ich Angst hatte, in einer ganzen Reihe neuer Technologien für mich zu ertrinken.

Von allen Teilen meines Projekts bezweifle ich am meisten das Datenbankdesign. Ich bin mir nicht sicher, ob die Entscheidungen, die ich getroffen habe, wirklich gute Entscheidungen sind. Alles funktioniert, könnte aber besser gemacht werden. Zum Beispiel gibt es Tabellen ShareRooms (wie ich Chats aufrufe) und ShareItems (wie ich Nachrichten aufrufe). Alle Benutzer, die einen Raum betreten, werden im jsonb-Feld dieses Raums aufgezeichnet. Dies ist praktisch, aber offensichtlich sehr langsam, daher werde ich es wahrscheinlich mit Fremdschlüsseln wiederholen. In der ShareItems-Tabelle werden beispielsweise alle Nachrichten gespeichert . Das ist auch praktisch, aber da ShareItems eine der am meisten geladenen Tabellen ist (persistent selectundinsert) könnte es sich lohnen, für jeden Raum einen neuen Tisch zu erstellen oder so ähnlich. Kokroach streut Datensätze auf verschiedenen Knoten, dementsprechend müssen Sie sorgfältig überlegen, welcher Datensatz verwendet werden soll, um maximale Leistung zu erzielen, aber ich habe dies nicht getan. Wie aus all dem hervorgeht, sind Datenbanken im Allgemeinen nicht meine Stärke. Im Moment teste ich im Allgemeinen alles auf Postgres und nicht auf Kokroach, da meine Arbeitsmaschine weniger belastet ist und bereits so schlecht von den Belastungen ist, dass sie bald abheben wird. Glücklicherweise unterscheidet sich der Code für Postgres und Kokroach erheblich, so dass das Umschalten nicht schwierig ist.

Jetzt bin ich dabei zu untersuchen, wie der Cocroach tatsächlich funktioniert (wie die Zuordnung zwischen SQL und Schlüsselwert erfolgt (der Cocroach verwendet RocksDb unter der Haube), wie er Daten zwischen Knoten, Replikaten usw. verteilt. Es hat sich natürlich gelohnt, Cocroach zu studieren, bevor man es benutzt, aber besser spät als nie.

Ich denke, dass sich die Basis stark verändern wird, wenn ich dieses Problem besser verstehe. Im Moment verfolgt mich der Acks-Tisch. In dieser Tabelle speichere ich Daten darüber, wer die Nachricht noch nicht empfangen und wer sie noch nicht gelesen hat (um die Häkchen des Benutzers anzuzeigen). Es ist einfach, den Benutzer zu benachrichtigen, dass seine Nachricht gelesen wurde, wenn der Benutzer jetzt online ist. Wenn nicht, müssen wir diese Informationen speichern, um den Benutzer später zu benachrichtigen. Und da Gruppenchats verfügbar sind, reicht es nicht aus, nur das Flag zu speichern, sondern Sie benötigen Daten zu einzelnen Benutzern. Hier bitten wir also direkt um die Verwendung von Bitfolgen (eine Zeile für Benutzer, die noch nicht empfangen haben, die zweite - für diejenigen, die noch nicht gelesen haben). Besonders Kokroach-Unterstützung bitundbit varying. Ich habe jedoch nie herausgefunden, wie ich dieses Geschäft umsetzen soll, da sich die Zusammensetzung der Räume ständig ändern kann. Damit Bitstrings ihre Bedeutung behalten, müssen Benutzer im Raum in derselben Reihenfolge bleiben, was ziemlich schwierig ist, wenn beispielsweise ein Benutzer den Raum verlässt. Hier gibt es Optionen. Vielleicht lohnt es sich, -1 zu schreiben, anstatt den Benutzer aus dem jsonb-Feld zu löschen, damit die Reihenfolge erhalten bleibt, oder eine Versionsmethode zu verwenden, damit wir wissen, dass sich diese Bitfolge auf die Reihenfolge der Benutzer bezieht, die damals war. und nicht in der aktuellen Reihenfolge der Benutzer. Ich bin immer noch dabei darüber nachzudenken, wie dieses Geschäft besser implementiert werden kann, aber vorerst sind diejenigen, die die Benutzer noch nicht erhalten und nicht gelesen haben, auch nur jsonb-Felder. Da die Acks-Tabelle mit jeder Nachricht geschrieben wird, ist die Datenmenge groß.Obwohl der Datensatz natürlich gelöscht wird, wenn die Nachricht von allen empfangen und gelesen wird.

Flattern


Ich habe lange Zeit auf der Serverseite gearbeitet und für den Test einfache Konsolenclients verwendet, sodass ich nicht einmal ein Flutter-Projekt erstellt habe. Und als ich es erstellt habe, dachte ich, dass der Serverteil ein komplexer Teil ist, und die Anwendung ist so, Müll, ich werde es in ein paar Tagen herausfinden. Während der Arbeit am Server habe ich ein paar Mal Hello Worlds für das Flattern erstellt, um ein Gefühl für das Framework zu bekommen. Da der Messenger keine komplizierte Benutzeroberfläche benötigt, dachte ich, dass er vollständig bereit ist. Die Benutzeroberfläche ist also wirklich Müll, aber die Implementierung der Funktionalität hat mir Probleme bereitet (und sie wird immer noch liefern, da nicht alles fertig ist).

Staatsverwaltung


Das beliebteste Thema. Es gibt tausend Möglichkeiten, Ihren Zustand zu behandeln, und der empfohlene Ansatz wird alle sechs Monate geändert. Jetzt ist der Mainstream Anbieter. Persönlich habe ich zwei Möglichkeiten für mich gewählt: Block und Redux. Block (Business Logic Component) zur Verwaltung des lokalen Status und Redux zur Verwaltung des globalen Status.

Bloc ist keine Art von Bibliothek (obwohl es natürlich auch eine Bibliothek gibt, die die Boilerplate reduziert, aber ich benutze sie nicht). Bloc ist ein Stream-basierter Ansatz. Im Allgemeinen ist Dart eine ziemlich nette Sprache und Streams sind im Allgemeinen so süß. Der Kern dieses Ansatzes besteht darin, dass wir die gesamte Geschäftslogik in Services umwandeln und zwischen der Benutzeroberfläche und den Services über einen Controller kommunizieren, der uns verschiedene Streams zur Verfügung stellt. Hat der Benutzer auf die Schaltfläche "Kontakt suchen" geklickt? Verwenden vonsink(das andere Ende des Streams) Wir senden ein Ereignis an den Controller SearchContactsEvent. Der Controller ruft den gewünschten Dienst auf, wartet auf das Ergebnis und gibt die Liste der Benutzer auch über den Stream an die Benutzeroberfläche zurück. Die Benutzeroberfläche wartet auf Ergebnisse mit StreamBuilder(Widget, das jedes Mal neu erstellt wird, wenn neue Daten in dem Stream eintreffen, den sie abonniert haben). Das ist in der Tat alles. In einigen Fällen müssen wir die Benutzeroberfläche ohne Beteiligung des Benutzers aktualisieren (z. B. wenn eine neue Nachricht eingeht). Dies ist jedoch auch problemlos über Streams möglich. In der Tat, eine einfache MVC mit Streams, keine Magie.

Im Vergleich zu einigen anderen Ansätzen erfordert der Block mehr Boilerplate, aber meiner Meinung nach ist es besser, native Lösungen ohne die Teilnahme von Bibliotheken von Drittanbietern zu verwenden, es sei denn, die Verwendung einer Lösung von Drittanbietern bietet einige signifikante ErgebnisseVorteile. Je mehr Abstraktionen oben sind, desto schwieriger ist es zu verstehen, was der Fehler ist, wenn ein Fehler auftritt. Ich halte die Vorteile des Anbieters nicht für signifikant genug, um darauf umzusteigen. Aber ich habe wenig Erfahrung in diesem Bereich, daher ist es wahrscheinlich, dass ich das Lager in Zukunft wechseln werde.

Nun, über Redux, und so weiß jeder alles, also gibt es nichts zu sagen. Außerdem habe ich es aus der Anwendung herausgeschnitten :) Ich habe es verwendet, um mein Konto zu verwalten, aber als ich merkte, dass es in diesem Fall keine besonderen Vorteile gegenüber dem Block gibt, habe ich es ausgeschnitten, um nicht zu viel zu ziehen. Aber im Allgemeinen halte ich Redux für eine nützliche Sache für die Verwaltung des globalen Staates.

Der qualvollste Teil


Was kann ich tun, wenn der Benutzer eine Nachricht gesendet hat, die Internetverbindung jedoch vor dem Senden unterbrochen wurde? Was kann ich tun, wenn der Benutzer eine Lesebestätigung erhalten hat, die Anwendung jedoch geschlossen hat, bevor der entsprechende Datensatz in der Datenbank aktualisiert wurde? Was soll ich tun, wenn der Benutzer seinen Freund in das Zimmer eingeladen hat, aber bevor die Einladung gesendet wurde, ist seine Batterie leer? Haben Sie jemals ähnliche Fragen gestellt? Hier bin ich. Vor. Aber im Entwicklungsprozess begann ich mich zu wundern. Da die Verbindung jederzeit unterbrochen werden kann und das Telefon jederzeit ausgeschaltet wird, muss alles bestätigt werden . Kein Spaß. Daher Joinlautet die allererste Nachricht, die der Client an den Server sendet ( wenn Sie sich erinnern), nicht nur "Hallo, ich bin online" , sondern "Hallo""Hallo, ich bin online und hier sind unbestätigte Zimmer, hier sind unbestätigte Bestätigungen, hier sind unbestätigte Zimmermitgliedschaftsvorgänge und hier sind die zuletzt empfangenen Nachrichten pro Zimmer . " Und der Server antwortet mit einem ähnlichen Blatt: „Während Sie offline waren, wurden solche und solche Nachrichten von solchen und solchen Benutzern gelesen, und sie luden auch Petja in diesen Raum ein, und Sveta verließ diesen Raum, und Sie wurden in diesen Raum eingeladen, aber zu Diese beiden Räume haben 40 neue Stellen . Ich würde wirklich gerne wissen, wie ähnliche Dinge in anderen Messenger gemacht werden, weil meine Implementierung nicht mit Anmut glänzt.

Bilder


Im Moment können Sie Text, Text + Bilder und nur Bilder senden. Der Video-Upload wurde noch nicht implementiert. Bilder werden etwas komprimiert und im Firebase-Speicher gespeichert. Die Nachricht selbst enthält Links. Nach Erhalt der Nachricht lädt der Client Bilder herunter, generiert Miniaturansichten und speichert alles im Dateisystem. Dateipfade werden in die Datenbank geschrieben. Die Generierung von Miniaturansichten ist übrigens der einzige Code, der in einem separaten Thread ausgeführt wird, da es sich um eine rechenintensive Operation handelt. Ich starte einfach einen Worker-Stream, füttere ihn mit einem Bild und bekomme dafür eine Miniaturansicht. Der Code ist extrem einfach, da Dart praktische Abstraktionen für die Arbeit mit Streams bietet.

ThumbnailGeneratorService
class ThumbnailGeneratorService {
  SendPort _sendPort;
  final Queue<Completer<Uint8List>> _completerQueue =
      Queue<Completer<Uint8List>>();

  ThumbnailGeneratorService() {
    var receivePort = ReceivePort();
    Isolate.spawn(startWorker, receivePort.sendPort);

    receivePort.listen((data) {
      if (data is SendPort) {
        _sendPort = data;
      } else {
        var completer = _completerQueue.removeFirst();
        completer.complete(data);
      }
    });
  }

  static void startWorker(SendPort sendPort) async {
    var receivePort = ReceivePort();
    sendPort.send(receivePort.sendPort);

    receivePort.listen((imageBytes) {
      Image image = decodeImage(imageBytes);
      Image thumbnail = copyResize(image, width: min(image.width, 200));

      sendPort.send(Uint8List.fromList(encodePng(thumbnail)));
    });
  }

  Future<Uint8List> generate(Uint8List imageBytes) {
    var completer = Completer<Uint8List>();
    _completerQueue.add(completer);
    
    _sendPort.send(imageBytes);

    return completer.future;
  }
}


Die Firebase-Authentifizierung wird ebenfalls verwendet, jedoch nur zur Autorisierung des Zugriffs auf den Firebase-Speicher (damit der Benutzer das Profilbild beispielsweise nicht an eine andere Person ausfüllen kann ). Alle anderen Autorisierungen erfolgen über meine Server.

Nachrichtenformat


Sie sind hier wahrscheinlich entsetzt, da ich normale Byte-Arrays verwende. Json verschwindet, weil Effizienz erforderlich ist und ich zu Beginn nichts über Protobuf wusste. Die Verwendung von Arrays erfordert viel Sorgfalt, da ein Index falsch ist und die Dinge schief gehen.

Die ersten 4 Bytes sind die Länge der Nachricht.
Das nächste Byte ist der Nachrichtencode.
Die nächsten 16 Bytes sind die Anforderungskennung (UUID).
Die nächsten 40 Bytes sind das Autorisierungstoken.
Der Rest der Nachricht .

Nachrichtenlängeerforderlich, da ich keine http- oder Web-Sockets oder ein anderes Protokoll verwende, das die Trennung einer Nachricht von einer anderen ermöglicht. Meine Frontend-Server sehen nur Byte-Streams und müssen wissen, wo eine Nachricht endet und eine andere beginnt. Es gibt verschiedene Möglichkeiten, Nachrichten zu trennen (verwenden Sie beispielsweise ein Zeichen, das in Nachrichten nie als Trennzeichen gefunden wurde), aber ich habe es vorgezogen, die Länge anzugeben, da diese Methode die einfachste ist, obwohl sie mit Overhead verbunden ist, da die meisten Nachrichten fehlen und ein Byte zur Angabe der Länge.

Der Nachrichtencode ist nur eines der Mitglieder der AufzählungMessageCode. Das Routing wird gemäß dem Code ausgeführt. Da wir den Code ohne vorherige Deserialisierung aus dem Array extrahieren können, entscheidet der Frontend-Server, in welchem ​​Thema der Kafka eine Nachricht gesendet werden soll, anstatt diese Verantwortung an eine andere Person zu delegieren.

Anfrage IDin den meisten Beiträgen vorhanden, aber nicht in allen. Es führt zwei Funktionen aus: Mit dieser Kennung stellt der Client die Entsprechung zwischen der gesendeten Anforderung und der empfangenen Antwort her (wenn der Client die Nachrichten A, B, C in dieser Reihenfolge gesendet hat, bedeutet dies nicht, dass die Antworten auch in der richtigen Reihenfolge vorliegen). Die zweite Funktion besteht darin, Duplikate zu vermeiden. Wie bereits erwähnt, garantiert kafka mindestens eine Lieferung. Das heißt, in seltenen Fällen können Nachrichten immer noch dupliziert werden. Durch Hinzufügen der Spalte RequestIdentifier mit einer eindeutigen Einschränkung zur gewünschten Datenbanktabelle können Sie das Einfügen eines Duplikats vermeiden.

AutorisierungstokenIst eine UserId (8 Bytes) + 32 Bytes HmacSha256-Signatur. Ich denke nicht, dass es sich lohnt, Jwt hier zu verwenden. Jwt ist ungefähr 7-8 mal größer für was? Meine Benutzer haben keine Ansprüche, daher ist eine einfache hmac-Signatur in Ordnung. Eine Autorisierung durch andere Dienste ist und ist nicht geplant.

Audio- und Videoanrufe


Es ist lustig, dass ich die Implementierung von Audio- und Videoanrufen absichtlich verschoben habe, weil ich sicher war, dass ich die Probleme nicht lösen kann, aber tatsächlich stellte sich heraus, dass dies eine der am einfachsten zu implementierenden Funktionen ist. Zumindest die Grundfunktionalität. Im Allgemeinen dauerte das Hinzufügen von WebRtc zur Anwendung und das Abrufen der ersten Videositzung nur wenige Stunden, und auf wundersame Weise war der erste Test erfolgreich. Vorher dachte ich, dass der Code, der beim ersten Mal funktionierte, ein Mythos war. Normalerweise schlägt der erste Test einer neuen Funktion immer aufgrund eines dummen Fehlers wie "Dienst hinzugefügt, aber nicht in einem DI-Container registriert" fehl.

Nicht sehr kurz über WebRtc für Uneingeweihte
WebRtc — , peer-to-peer , , peer-to-peer , . - , , . , .

(peer-to-peer), , 3 ( , 3 . 3 ).

— stun . stun , — Source IP Source Port , . ? - . IP . - , , , Source IP Source Port IP - NAT [ Source IP | Source Port | Router External IP | Router Port ]. - , Dest IP Dest Port Router External IP Router Port NAT, , Source IP — Source Port , . , , , , , , NAT . stun NAT . stun Router External IP — Router Port. — . , «» NAT (NAT traversal) , NAT , stun .
* NAT , . , , WebRtc .

— turn. , , peer-to-peer . Fallback . , , , , , peer-to-peer . turn — coturn, .

— . , . , . — . . , , , :) — .

WebRtc 3 : offer, answer candidate. offer , answer, . , , , , . ( ) , .


Die WebRtc-Technologie selbst stellt eine Verbindung her und leitet Datenströme hin und her. Dies ist jedoch kein Rahmen für die Erstellung vollwertiger Anrufe. Mit Anruf meine ich eine Kommunikationssitzung mit der Möglichkeit, den Anruf abzubrechen, abzulehnen und anzunehmen sowie aufzulegen. Außerdem müssen Sie dem Anrufer mitteilen, ob die andere Seite bereits besetzt ist. Und auch um kleine Dinge wie "Warten Sie auf eine Antwort auf den Anruf N Sekunden, dann zurücksetzen" zu implementieren. Wenn Sie WebRtc einfach in bloßer Form in der Anwendung implementieren, werden bei einem eingehenden Anruf Kamera und Video spontan eingeschaltet, was natürlich nicht akzeptabel ist.

In seiner reinen Form bedeutet WebRtc normalerweise, Kandidaten so schnell wie möglich an die andere Partei zu senden, damit die Verhandlungen so schnell wie möglich beginnen, was logisch ist. In meinen Tests sind Kandidaten für die empfangende Partei in der Regel immerbegann zu kommen, noch bevor das Angebot kommt. Solche „frühen“ Kandidaten können nicht verworfen werden, sie müssen in Erinnerung bleiben, damit sie später, wenn das Angebot eintrifft und RTCPeerConnectionerstellt wird, zur Verbindung hinzugefügt werden. Die Tatsache, dass Kandidaten möglicherweise bereits vor dem Angebot eintreffen, sowie einige andere Gründe machen die Umsetzung vollwertiger Anrufe zu einer nicht trivialen Aufgabe. Was tun, wenn mehrere Benutzer gleichzeitig anrufen? Wir werden Kandidaten von allen erhalten, und obwohl wir Kandidaten von einem Benutzer von einem anderen trennen können, wird unklar, welche Kandidaten abzulehnen sind, da wir nicht wissen, wessen Angebot früher kommen wird. Es wird auch Probleme geben, wenn Kandidaten zu uns kommen und dann ein Angebot, wenn wir selbst jemanden anrufen.

Nachdem ich mehrere Optionen mit Bare WebRtc getestet hatte, kam ich zu dem Schluss, dass der Versuch, Anrufe in dieser Form zu tätigen, problematisch und mit Speicherlecks behaftet ist. Daher beschloss ich, dem WebRtc-Verhandlungsprozess eine weitere Stufe hinzuzufügen. Ich nenne diese Bühne Inquire - Grant/Refuse.

Die Idee ist sehr einfach, aber ich habe eine ganze Weile gebraucht, um sie zu erreichen. Der Anrufer sendet bereits vor dem Erstellen des Streams und RTCPeerConnection(und im Allgemeinen vor dem Ausführen von Code im Zusammenhang mit WebRtc) eine Nachricht über den Signalserver an die andere Seite Inquire. Auf der Empfangsseite wird geprüft, ob sich der Benutzer gerade in einer anderen Kommunikationssitzung befindet (einfaches boolFeld). Wenn dies der Fall ist, wird eine Nachricht zurückgesendet.RefuseAuf diese Weise teilen wir dem Anrufer mit, dass der Benutzer beschäftigt ist, und dem Empfänger, dass das eine oder andere Telefon angerufen wurde, während er mit einem anderen Gespräch beschäftigt war. Wenn der Benutzer derzeit frei ist, ist er reserviert . Die InquireSitzungskennung wird in der Nachricht gesendet, und diese Kennung wird als Kennung der aktuellen Sitzung festgelegt. Wenn der Benutzer reserviert ist, lehnt er alle Inquire/Offer/CandidateNachrichten mit anderen Sitzungskennungen als der aktuellen ab. Nach der Reservierung sendet der Empfänger eine Nachricht über den Signalserver an den Anrufer Grant. Es ist anzumerken, dass dieser Vorgang für den empfangenden Benutzer nicht sichtbar ist, da noch kein Anruf erfolgt. Und die Hauptsache hier ist nicht zu vergessen, eine Auszeit auf der Empfangsseite aufzuhängen. Plötzlich werden wir eine Sitzung reservieren und es wird kein Angebot folgen.

Der Anrufer empfängt Grant, und hier beginnt WebRtc mit Angeboten, Kandidaten, und dies ist für alle da. Das Angebot fliegt zum Empfänger und er zeigt nach Erhalt einen Bildschirm mit den Schaltflächen Antworten / Ablehnen an. Aber die Kandidaten erwarten wie immer niemanden. Sie kommen wieder früher als das Angebot an, da es keinen Grund gibt, darauf zu warten, dass der Benutzer den Anruf entgegennimmt. Er kann nicht antworten, aber ablehnen oder warten, bis das Zeitlimit abgelaufen ist - dann werden die Kandidaten einfach rausgeworfen.

Aktueller Status und zukünftige Pläne


  • Privat- und Gruppenchats
  • Senden von Text, Bildern und Videos
  • Audio- und Videoanrufe
  • Empfangsbestätigung und Lesen
  • "Drucke ..."
  • Benachrichtigungen
  • Suche nach QR-Code und Geolocation


Die Suche nach QR-Code ist unerwartet problematisch zu implementieren, da fast alle Plugins für den Code-Scan, die ich versucht habe, nicht gestartet werden können oder nicht richtig funktionieren. Aber ich denke, die Probleme werden hier gelöst. Und für die Umsetzung der Suche nach Geolocation habe ich noch nicht aufgenommen. Theoretisch sollte es keine besonderen Probleme geben.

Laufende Benachrichtigungen sowie das Senden von Videos.

Was muss noch getan werden?


Oh, viel.
Erstens gibt es keine Tests. Die Kollegen haben früher Tests geschrieben, also habe ich mich völlig entspannt.
Zweitens ist es derzeit nicht möglich , Benutzer zu einem vorhandenen Chat einzuladen und den Chat zu verlassen. Der Servercode ist dafür bereit, der Clientcode nicht.
Drittens, wenn die Fehlerbehandlung auf dem Server mehr oder weniger ist, gibt es keine Fehlerbehandlung auf dem Client. Es reicht nicht aus, nur einen Protokolleintrag zu erstellen, sondern Sie müssen den Vorgang wiederholen. Jetzt ist beispielsweise der Mechanismus zum erneuten Senden von Nachrichten nicht implementiert.
Viertens pingt der Server den Client nicht an, sodass eine Trennung nicht erkannt wird, wenn beispielsweise der Client das Internet verloren hat. Die Trennung wird nur erkannt, wenn der Client die Anwendung schließt.
Fünftens werden Indizes nicht in der Datenbank verwendet.
Sechstens Optimierung. Der Code hat eine große Anzahl von Stellen, an denen so etwas geschrieben ist // @@TODO: Pool. Die meisten Arrays sind genau newdas. Der Backend-Server erstellt viele Arrays mit fester Länge. Hier können und sollten Sie den Pool verwenden.
Siebtens gibt es viele Stellen auf dem Client, an denen der Code awaitendet, obwohl dies nicht erforderlich ist. Das Senden von Bildern zum Beispiel scheint daher langsam, weil der CodeawaitEs speichert Bilder im Dateisystem und generiert Miniaturansichten, bevor die Nachricht angezeigt wird, obwohl dies nicht erforderlich ist. Wenn Sie beispielsweise die Anwendung öffnen und während Ihrer Abwesenheit Bilder an Sie gesendet werden, ist der Start langsam, da alle diese Bilder erneut heruntergeladen, im System gespeichert, Miniaturansichten generiert werden und erst danach der Start beendet wird und Sie vom Begrüßungsbildschirm geworfen werden auf dem Startbildschirm. Alle diese redundanten Funktionen awaitwurden zum einfacheren Debuggen erstellt, aber natürlich müssen Sie unnötiges Warten vor der Veröffentlichung vermeiden.
AchteDie Benutzeroberfläche ist jetzt zur Hälfte fertig, da ich nicht entschieden habe, wie ich sie sehen möchte. Daher ist jetzt nicht alles intuitiv, die Hälfte der Schaltflächen ist unklar, was sie tun. Und die Tasten werden oft nicht beim ersten Mal gedrückt, da sie jetzt nur noch Symbole mit GestureDetectorund ohne Polsterung sind, sodass es nicht immer möglich ist, darauf zuzugreifen. Außerdem ist der Pixelüberlauf an einigen Stellen nicht behoben.
Neuntens ist es jetzt sogar unmöglich, sich bei einem Konto anzumelden, sondern nur noch bei der Anmeldung. Wenn Sie die Anwendung deinstallieren und neu installieren, können Sie sich daher nicht in Ihrem Konto anmelden :)
Zehntens wird der Bestätigungscode nicht an die E-Mail gesendet. Jetzt ist der Code im Allgemeinen immer derselbe, da das Debuggen einfacher ist.
ElfteDas Prinzip der Einzelverantwortung wird vielerorts verletzt. Benötigen Sie einen Refactor. Die Klassen, die für die Interaktion mit der Datenbank verantwortlich sind (sowohl auf dem Client als auch auf dem Server), sind im Allgemeinen sehr aufgebläht, da sie an allen Datenbankoperationen beteiligt sind.
Zwölftens erwartet der Frontend-Server jetzt immer eine Antwort vom Backend-Server, auch wenn die Nachricht kein Senden einer Antwort impliziert (z. B. eine Nachricht mit einem Code IsTypingund einige WebRtc-bezogene Nachrichten). Ohne auf eine Antwort zu warten, schreibt er daher einen Fehler in die Konsole, obwohl dies kein Fehler ist.
Dreizehnte, vollständige Bilder werden nicht beim Tippen geöffnet.
Einhundert Millionen FünftelEinige Nachrichten, die stapelweise gesendet werden müssen, werden separat gesendet. Gleiches gilt für einige Datenbankoperationen. Anstatt einen einzelnen Befehl auszuführen, werden die Befehle in einer Schleife mit await(brr ..) ausgeführt.
Einhundert Millionen Sechstel, einige Werte sind fest codiert, anstatt konfigurierbar zu sein.
Einhundertein Million SiebtelDie Anmeldung am Server erfolgt jetzt nur noch an der Konsole und auf dem Client im Allgemeinen direkt am Widget. Auf dem Hauptbildschirm befindet sich eine Registerkarte "Protokolle", auf der alle Protokolle gelöscht werden. Tatsache ist, dass meine Arbeitsmaschine sich weigert, sowohl den Emulator als auch alles für den Server Notwendige (Kafka, Datenbank, Rettich und alle Server) auszuführen. Die Belastung mit einem angeschlossenen Gerät hat auch nicht geklappt, in der Hälfte der Fälle hing alles fest, weil der Computer die Lasten nicht bewältigen konnte. Daher müssen Sie jedes Mal einen Build erstellen, ihn auf dem Gerät ablegen, installieren und so testen. Um die Protokolle anzuzeigen, lege ich sie direkt in das Widget. Perversion, ich weiß, aber es gibt keine Wahl. Aus dem gleichen Grund geben viele Methoden Futureund zurückawaitSie sind (um die Ausnahme zu fangen und in das Widget zu werfen), obwohl sie es nicht sollten. Wenn Sie sich den Code ansehen, werden Sie _logErrorin vielen Klassen eine hässliche Methode sehen, die dies tut. Dies wird natürlich auch in den Papierkorb gelangen.
Einhundert Millionen und acht, keine Geräusche.
Einhundert Millionen und neunten müssen Sie mehr Caching verwenden.
Einhundert Millionen Zehntel, viel sich wiederholender Code. Beispielsweise überprüfen viele Aktionen zunächst die Gültigkeit des Tokens, und wenn es nicht gültig ist, senden sie einen Fehler. Ich denke, Sie müssen eine einfache Middleware-Pipeline implementieren.

Und viele kleine Dinge, wie das Verketten von Strings anstelle von StringBuilder'a,DisposeNicht überall heißt es, wo es soll, und so weiter und so fort. Im Allgemeinen befindet sich der Normalzustand des Projekts in der Entwicklung. All das ist lösbar, aber es gibt ein grundlegendes Problem, über das ich bis zum letzten Moment nicht nachgedacht habe, weil es mir aus dem Kopf ging - der Messenger sollte auch dann funktionieren, wenn die Anwendung nicht geöffnet ist und meiner nicht funktioniert. Um ehrlich zu sein, ist mir die Lösung dieses Problems noch nicht in den Sinn gekommen. Hier kann man anscheinend nicht auf den nativen Code verzichten.

Ich würde die Bereitschaft des Projekts mit 70% bewerten.

Zusammenfassung


Seit Beginn der Projektarbeiten sind sechs Monate vergangen. Kombiniert mit Teilzeitarbeit und langen Pausen, aber trotzdem viel Zeit und Energie. Ich plane, alle deklarierten Funktionen zu implementieren und etwas Ungewöhnliches wie Tic-Tac-Toe oder Entwürfe direkt im Raum hinzuzufügen. Ohne Grund, nur weil es interessant ist.

Wenn Sie Fragen haben, schreiben Sie. Mail ist auf Github.

All Articles