"Wenn die Uhr zwölf schlägt." Oder eine Girlande im Browser

Angenommen, wir haben mehrere Monitore. Und wir wollten diese Monitore als Girlande verwenden. Lassen Sie sie beispielsweise gleichzeitig blinken. Oder ändern Sie die Farbe synchron nach einem intelligenten Algorithmus. Und was ist, wenn Sie es in einem Browser tun - dann können Sie Smartphones und Tablets daran anschließen. All das ist zur Hand.



Und da wir einen Browser verwenden, können Sie auch Sounddesign hinzufügen. Wenn es genau genug ist, um die Geräte rechtzeitig zu synchronisieren, können Sie die Sounds auf jedem so abspielen, als ob ein Mehrkanalsystem klingt.


Was kann bei der Synchronisierung von Web-Audio- und Gameplay-Uhren in einer Javascript-Anwendung auftreten? Wie viele verschiedene "Stunden" gibt es in javasctipt (drei!) und warum werden alle benötigt, sowie eine fertige Anwendung für node.js unter cat.

Überprüfen Sie die Uhr


Für jede bedingte Online-Girlande ist eine genaue Uhrzeitsynchronisation erforderlich. Schließlich können Sie auch zeitweise auftretende Netzwerkverzögerungen ignorieren. Es reicht aus, die Steuerbefehle mit einem Zeitstempel zu versehen und diese Befehle ein wenig "in die Zukunft" zu generieren. Auf Clients werden sie gepuffert und dann synchron und pünktlich ausgeführt.

Oder Sie können sogar noch weiter gehen - nehmen Sie den guten alten deterministischen Zufallsalgorithmus und verwenden Sie einen gemeinsamen Startwert (der vom Server einmal ausgegeben wird, wenn eine Verbindung besteht) auf allen Geräten. Wenn Sie einen solchen Startwert zusammen mit der genauen Zeit verwenden, können Sie das Verhalten des Algorithmus auf allen Geräten vollständig vorgeben. Stellen Sie sich vor, Sie benötigen kein Netzwerk und keinen Server, um den Status eindeutig und synchron zu ändern. Seed enthält bereits die gesamte (bedingt unendliche) „Videoaufzeichnung“ von Aktionen im Voraus. Die Hauptsache ist die genaue Zeit.



Jede Methode hat ihre Anwendungsgrenzen. Bei der sofortigen Benutzereingabe ist natürlich nichts zu tun, es bleibt nur zu übertragen, wie es ist. Aber alles, was berechnet werden kann, ist zu berechnen. In meiner Implementierung verwende ich je nach Situation alle drei Ansätze.

Subjektiv "zur gleichen Zeit"


Im Idealfall sollte alles „zur selben Zeit“ klingen - für das schlechteste Paar unter den kombinierten Geräten sind nicht mehr als ± 10 ms Diskrepanz erforderlich. Sie können sich nicht auf eine solche Genauigkeit der Systemzeit verlassen, und Standardmethoden zum Synchronisieren der Zeit mithilfe des NTP-Protokolls sind im Browser nicht verfügbar. Deshalb werden wir unseren Synchronisationsserver ansteuern. Das Prinzip ist einfach: Helm "pingt" und akzeptiert "Pongs" mit dem Zeitstempel des Servers. Wenn Sie dies mehrmals hintereinander tun, können Sie den Fehler statistisch ausgleichen und die durchschnittliche Verzögerungszeit ermitteln.

Code: Berechnung der Serverzeit auf dem Client
let pingClientTime = 1; // performace.now() time when ping started let pongClientTime = 3; // performace.now() time when pong received let pongServerTime = 20; // server timstamp in pong answer let clientServerRawOffset = pongServerTime - pongClientTime; let pingPongOffset = pongClientTime - pingClientTime; // roundtrip let estimatedPingOffset = pingPongOffset / 2; // one-way let offset = clientServerRawOffset + estimatedPingOffset; console.log(estimatedPingOffset) // 1 console.log(offset); // 18 let sharedServerTime = performace.now() + offset; 



Websockets und darauf basierende Lösungen sind am besten geeignet, da sie keine Zeit zum Herstellen einer TCP-Verbindung benötigen und Sie in beide Richtungen mit ihnen "kommunizieren" können. Natürlich nicht UDP oder ICMP, aber unvergleichlich schneller als eine normale Kaltverbindung über die HTTP-API. Daher ist socket.io. Dort ist alles sehr einfach:

Code: socket.io Implementierung
 // server socket.on('ping', (pongCallback) => { let pongServerTime = performace.now(); pongCallback(pongServerTime); }); //client const binSize = 100; let clientServerCalculatedOffset; function ping() { socket.emit('ping', pongCallback); const pingClientTime = performace.now(); function pongCallback(pongServerTime) { const pongClientTime = performace.now(); const clientServerRawOffset = pongServerTime - pongClientTime; const pingPongOffset = pongClientTime - pingClientTime; // roundtrip const estimatedPingOffset = pingPongOffset / 2; // one-way const offset = clientServerRawOffset + estimatedPingOffset; offsets.unshift(offset); offsets.splice(binSize); let offsetSum = 0; offsets.forEach((offset) => { offsetSum += offset; }); clientServerCalculatedOffset = offsetSum / offset.length(); } } 

Anstatt den Durchschnitt zu berechnen, wäre es schön, den Median zu berechnen - dies verbessert die Genauigkeit bei einer instabilen Verbindung. Die Wahl der Filtermethode liegt beim Leser. Ich vereinfache den Code hier bewusst zugunsten von Schaltplänen. Meine komplette Lösung finden Sie im Repository.


performance.now ()


Ich möchte Sie daran erinnern, dass das performance eine API ist, die den Zugriff auf einen hochauflösenden Zeitgeber ermöglicht. Vergleichen Sie:

  • Date.now() gibt die Anzahl der Millisekunden seit dem 1. Januar 1970 in ganzzahliger Form zurück. Das heißt, der Fehler nur beim Runden beträgt durchschnittlich 0,5 ms. Beispielsweise können Sie bei einer Subtraktionsoperation ab bis zu 2 ms erfolglos „verlieren“. Darüber hinaus garantiert der Zeitmesser selbst historisch und konzeptionell keine hohe Genauigkeit und ist für die Arbeit mit einer größeren Zeitskala geschärft.
  • performance.now() gibt die Anzahl der Millisekunden seit dem Öffnen der Webseite zurück .
    Dies ist eine relativ neue API, die speziell für die genaue Messung von Zeitintervallen "geschärft" wurde. Gibt einen Gleitkommawert zurück , der theoretisch einen Genauigkeitsgrad nahe an den Fähigkeiten des Betriebssystems selbst angibt.


Ich denke, diese Information ist fast allen Javascript-Entwicklern bekannt. Aber nicht jeder weiß das ...

Specter


Aufgrund des sensationellen Timing-Angriffs von Specter im Jahr 2018 geht alles so weit, dass der hochauflösende Timer künstlich aufgeraut wird, wenn es keine andere Lösung für das Schwachstellenproblem gibt. Firefox, beginnend mit Version 60, rundet den Wert dieses Timers auf eine Millisekunde und Edge noch schlimmer.

Hier ist, was MDN sagt:

Der Zeitstempel ist nicht wirklich hochauflösend. Um Sicherheitsbedrohungen wie Spectre abzuschwächen, runden Browser die Ergebnisse derzeit in unterschiedlichem Maße ab. (Firefox hat in Firefox 60 auf 1 Millisekunde gerundet.) Einige Browser können den Zeitstempel auch leicht zufällig einstellen. Die Genauigkeit wird in zukünftigen Versionen möglicherweise wieder verbessert. Browser-Entwickler untersuchen diese Timing-Angriffe noch und wie sie am besten gemindert werden können.

Lassen Sie uns den Test durchführen und die Grafiken betrachten. Dies ist das Ergebnis des Tests für das Intervall von 10 ms:

Testcode: Zeitmessung in einem Zyklus
 function measureTimesLoop(length) { const d = new Array(length); const p = new Array(length); for (let i = 0; i < length; i++) { d[i] = Date.now(); p[i] = performance.now(); } return { d, p } } 


Date.now()
performance.now()

Rand



Statistiken
Browserversion: 44.17763.771.0

Date.now ()

durchschnittliches Intervall: 1.0538336052202284 ms
Abweichung vom Mittelwertintervall, RMS: 0,7547819181245603 ms
Intervallmedian: 1 ms

performance.now ()

Durchschnittsintervall: 1.567100970873786 ms
Abweichung vom Mittelwertintervall, RMS: 0,6748006785171455 ms
Intervallmedian: 1,5015000000003056 ms


Firefox



Statistiken
Browserversion: 71.0

Date.now ()

Durchschnittsintervall: 1.0168350168350169 ms
Abweichung vom Mittelwertintervall, RMS: 0,21645930182417966 ms
Intervallmedian: 1 ms

performance.now ()

Durchschnittsintervall: 1.0134453781512605 ms
Abweichung vom Durchschnittsintervall, RMS: 0,1734108492762375 ms
Intervallmedian: 1 ms


Chrome



Statistiken
Browserversion: 79.0.3945.88

Date.now ()

Durchschnittsintervall: 1.02442996742671 ms
Abweichung vom Durchschnittsintervall, RMS: 0,49858684744444 ms
Intervallmedian: 1 ms

performance.now ()

Durchschnittsintervall: 0.005555847229948915 ms
Abweichung vom Durchschnittsintervall, RMS: 0,027497846727194235 ms
Intervallmedian: 0.0050000089686363935 ms


Ok, Chrome, Zoom auf 1 ms.



Chrome hält also immer noch an und die Implementierung von performance.now() wurde noch nicht erdrosselt und der Schritt ist wunderschön, 0,005 ms. Unter Edge ist der Timer von performance.now() rauer als Date.now() ! In Firefox haben beide Timer die gleiche Millisekundengenauigkeit.

Zu diesem Zeitpunkt können bereits einige Schlussfolgerungen gezogen werden. Aber es gibt einen anderen Timer in Javascript (ohne den wir nicht auskommen können).

WebAudio API Timer


Dies ist ein etwas anderes Tier. Es wird für verzögerte Audio-Warteschlangen verwendet. Tatsache ist, dass Audio-Events (Noten abspielen, Effekte verwalten) nicht auf standardmäßigen asynchronen Javascript-Tools setInterval können: setInterval und setTimeout - wegen ihres zu großen Fehlers. Und dies ist nicht nur der Fehler der Timer- Werte (mit denen wir uns zuvor befasst haben), sondern es ist der Fehler, mit dem die Ereignismaschine Ereignisse ausführt. Und es ist auch bei Gewächshausbedingungen schon etwas um die 5-25 ms.

Grafiken für den asynchronen Fall unter dem Spoiler
Das Ergebnis des Tests über einen Zeitraum von 100 ms:

Testcode: Zeitmessung in einem asynchronen Zyklus
 function pause(duration) { return new Promise((resolve) => { setInterval(() => { resolve(); }, duration); }); } async function measureTimesInAsyncLoop(length) { const d = new Array(length); const p = new Array(length); for (let i = 0; i < length; i++) { d[i] = Date.now(); p[i] = performance.now(); await pause(1); } return { d, p } } 


Date.now()
performance.now()

Rand



Statistiken
Browserversion: 44.17763.771.0

Date.now ()

durchschnittliches Intervall: 25.595959595959595 ms
Abweichung vom Mittelwertintervall, RMS: 10.12639235162126 ms
Intervallmedian: 28 ms

performance.now ()

Durchschnittsintervall: 25.862596938775525 ms
Abweichung vom Mittelwertintervall, RMS: 10.123711255512573 ms
Intervallmedian: 27.027099999999336 ms


Firefox



Statistiken
Browserversion: 71.0

Date.now ()

Durchschnittsintervall: 1.6914893617021276 ms
Abweichung vom Mittelwertintervall, RMS: 0,6018870280772611 ms
Intervallmedian: 2 ms

performance.now ()

Durchschnittsintervall: 1.7865168539325842 ms
Abweichung vom Mittelwertintervall, RMS: 0,6442818510935484 ms
Intervallmedian: 2 ms


Chrome



Statistiken
Browserversion: 79.0.3945.88

Date.now ()

Durchschnittsintervall: 4,7878787878787888, ms
Abweichung vom Mittelwertintervall, RMS: 0,7557553886872682 ms
Intervallmedian: 5 ms

performance.now ()

Durchschnittsintervall: 4.783989898979516 ms
Abweichung vom Mittelwertintervall, RMS: 0,6483716900974945 ms
Medianintervall: 4.750000000058208 ms



Vielleicht erinnert sich jemand an die ersten experimentellen HTML-Audioanwendungen. Bevor vollwertiges WebAudio zu den Browsern kam, klangen sie alle wie ein bisschen betrunken und schlampig. Nur weil sie setTimeout als Sequenzer benutzt haben.

Die moderne WebAudio-API bietet dagegen eine garantierte Auflösung von bis zu 0,02 ms (Spekulation auf Basis der Abtastfrequenz von 44100Hz). Dies liegt daran, dass für die verzögerte setTimeout ein anderer Mechanismus verwendet wird als für setTimeout :

 source.start(when); 

Tatsächlich ist die Wiedergabe eines Audio-Samples „verzögert“. Nur um es zu verlieren "wird nicht verschoben", müssen Sie es "bis jetzt" verschieben.

 source.start(audioCtx.currentTime); 

Über durch Echtzeitsoftware erzeugte Musik
Wenn Sie eine programmsynthetisierte Melodie aus Noten spielen, müssen diese Noten vorab zur Wiedergabewarteschlange hinzugefügt werden. Dann wird die Melodie trotz aller nicht grundlegenden Einschränkungen und Unregelmäßigkeiten der Timer perfekt flüssig abgespielt.

Mit anderen Worten, die in Echtzeit synthetisierte Melodie sollte nicht in Echtzeit "erfunden" werden, sondern ein wenig im Voraus.


Ein Timer, um alle zu regieren


Da audioCtx.currentTime so stabil und genau ist, sollten wir es vielleicht als Hauptquelle für die relative Zeit verwenden? Lassen Sie uns den Test noch einmal durchführen.

Testcode: Messen der synchronen Zeitmessung in einem Zyklus
 function measureTimesInLoop(length) { const d = new Array(length); const p = new Array(length); const a = new Array(length); for (let i = 0; i < length; i++) { d[i] = Date.now(); p[i] = performance.now(); a[i] = audioCtx.currentTime * 1000; } return { d, p, a } } 


Date.now()
performance.now()
audioCtx.currentTime

Rand



Statistiken
Browserversion: 44.17763.771.0

Date.now ()

Durchschnittsintervall: 1.037037037037037 ms
Abweichung vom Mittelwertintervall, RMS: 0,6166609846299806 ms
Intervallmedian: 1 ms

performance.now ()

Durchschnittsintervall: 1.5447103117505993 ms
Abweichung vom Mittelwertintervall, RMS: 0,4390514285320851 ms
Intervallmedian: 1,5015000000000782 ms

audioCtx.currentTime

Durchschnittsintervall: 2.955751134714949 ms
Abweichung vom Mittelwertintervall, RMS: 0,6193645611529503 ms
Intervallmedian: 2.902507781982422 ms



Firefox



Statistiken
Browserversion: 71.0

Date.now ()

Durchschnittsintervall: 1.005128205128205 ms
Abweichung vom Durchschnittsintervall, RMS: 0,12392867665225249 ms
Intervallmedian: 1 ms

performance.now ()

Durchschnittsintervall: 1.00513698630137 ms
Abweichung vom Durchschnittsintervall, RMS: 0,07148844433269844 ms
Intervallmedian: 1 ms

audioCtx.currentTime

Firefox aktualisiert den Audio-Timer-Wert in der Synchronisierungsschleife nicht



Chrome



Statistiken
Browserversion: 79.0.3945.88

Date.now ()

Durchschnittsintervall: 1.0207612456747406 ms
Abweichung vom Durchschnittsintervall, RMS: 0,49870223457982504 ms
Intervallmedian: 1 ms

performance.now ()

Durchschnittsintervall: 0.005414502034674972 ms
Abweichung vom Durchschnittsintervall, RMS: 0,027441293974958335 ms
Medianintervall: 0.004999999873689376 ms

audioCtx.currentTime

Durchschnittsintervall: 3.0877599266656963 ms
Abweichung vom Mittelwert, RMS: 1.1445555956407658 ms
Intervallmedian: 2.9024943310650997 ms



Grafiken für den asynchronen Fall unter dem Spoiler
Testcode: Zeitmessung in einem asynchronen Zyklus
Das Ergebnis des Tests über einen Zeitraum von 100 ms:

 function pause(duration) { return new Promise((resolve) => { setInterval(() => { resolve(); }, duration); }); } async function measureTimesInAsyncLoop(length) { const d = new Array(length); const p = new Array(length); const a = new Array(length); for (let i = 0; i < length; i++) { d[i] = Date.now(); p[i] = performance.now(); await pause(1); } return { d, p } } 



Date.now()
performance.now()
audioCtx.currentTime

Rand



Statistiken
Browserversion: 44.17763.771.0

Date.now ():

Durchschnittsintervall: 24.505050505050505 ms
Abweichung vom Mittelwertintervall: 11.513166584195204 ms
Intervallmedian: 26 ms

performance.now ():

Durchschnittsintervall: 24.50935757575754 ms
Abweichung vom Mittelwertintervall: 11.679091435527388 ms
Intervallmedian: 25.525499999999738 ms

audioCtx.currentTime:

Durchschnittsintervall: 24.76005164944396 ms
Abweichung vom Mittelwertintervall: 11.311571546205316 ms
Intervallmedian: 26.121139526367187 ms


Firefox



Statistiken
Browserversion: 71.0

Date.now ():

Durchschnittsintervall: 1,6875 ms
Abweichung vom Mittelwertintervall: 0.6663410663216448 ms
Intervallmedian: 2 ms

performance.now ():

Durchschnittsintervall: 1.7234042553191489 ms
Abweichung vom Mittelwertintervall: 0.6588877688171075 ms
Intervallmedian: 2 ms

audioCtx.currentTime:

Durchschnittsintervall: 10.158730158730123 ms
Abweichung vom Mittelwertintervall: 1.4512471655330046 ms
Medianintervall: 8.707482993195299 ms


Chrome



Statistiken
Browserversion: 79.0.3945.88

Date.now ():

Durchschnittsintervall: 4.585858585858586 ms
Abweichung vom Mittelwertintervall: 0.9102125516015199 ms
Intervallmedian: 5 ms

performance.now ():

durchschnittliches Intervall: 4.592424242424955 ms
Abweichung vom Mittelwertintervall: 0.719936993603155 ms
Intervallmedian: 4.605000001902226 ms

audioCtx.currentTime:

durchschnittliches Intervall: 10.12648022171832 ms
Abweichung vom Mittelwertintervall: 1.4508887886499262 ms
Intervallmedian: 8.707482993197118 ms



Nun, es wird nicht klappen. „Draußen“ ist dieser Timer am ungenauesten. Firefox aktualisiert den Timer-Wert in der Schleife nicht. Aber im Allgemeinen: Auflösung ist 3 ms und schlechter und spürbarer Jitter. Möglicherweise spiegelt der Wert von audioCtx.currentTime die Position im Ringpuffer des audioCtx.currentTime wider. Mit anderen Worten, es wird die minimale Zeit angezeigt, die es noch möglich ist, die Wiedergabe sicher zu verzögern.

Und was machen? Schließlich benötigen wir sowohl einen genauen Timer für die Synchronisierung mit dem Server und das Starten von Javascript-Ereignissen auf dem Bildschirm als auch einen Audio-Timer für Audio-Ereignisse!

Es stellt sich heraus, dass Sie alle Timer miteinander synchronisieren müssen:

  • Client audioCtx.currentTime mit client performance.now() auf dem Client.
  • Und client performance.now() mit performance.now() serverseitig.

Synchronisiert, synchronisiert


Im Allgemeinen ist dies ziemlich lustig, wenn Sie darüber nachdenken: Sie können zwei gute Zeitquellen A und B haben, von denen jede am Ausgang sehr grob und verrauscht ist (A '= A + err A ; B' = B + err B ), so dass es kann selbst unbrauchbar sein. Der Unterschied d zwischen den ursprünglichen Quellen ohne Rauschen kann jedoch sehr genau wiederhergestellt werden.

Da der tatsächliche Zeitabstand zwischen den idealen Uhren konstant ist und n-mal gemessen wird, wird der Messfehler n-mal verringert. Es sei denn natürlich, die Uhr läuft mit der gleichen Geschwindigkeit.

Ja nicht synchronisiert


Die schlechte Nachricht ist, dass sie nicht mit der gleichen Geschwindigkeit fahren. Und ich spreche nicht von der Streuung der Stunden auf dem Server und auf dem Client - das ist verständlich und zu erwarten. Was noch unerwarteter ist: audioCtx.currentTime allmählich von performance.now() . Es ist im Client. Wir werden es vielleicht nicht bemerken, aber manchmal verschluckt das Audiosystem unter Last möglicherweise keine kleinen Daten und (entgegen der Natur des Ringpuffers) verschiebt sich die Audiozeit relativ zur Systemzeit. Dies kommt nicht so selten vor, es betrifft nur nicht viele Menschen. Wenn Sie beispielsweise zwei YouTube-Videos gleichzeitig auf verschiedenen Computern starten, ist es nicht so, dass die gleichzeitige Wiedergabe unterbrochen wird. Und der Punkt ist natürlich nicht in der Werbung.

Somit für einen stabilen und synchronen Betrieb. Wir müssen regelmäßig alle Uhren miteinander überprüfen und dabei die Serverzeit als Referenz verwenden. Der Kompromiss audioCtx.currentTime sich aus der audioCtx.currentTime Messungen, die für die Mittelwertbildung verwendet werden sollen: Je genauer, aber je größer die Wahrscheinlichkeit, dass ein starker Sprung in audioCtx.currentTime in das Zeitfenster fällt, in dem die Werte gefiltert werden. Wenn wir dann zum Beispiel das Minutenfenster verwenden, ist die Zeit jede Minute abgelaufen. Die Auswahl an Filtern ist groß: Exponential- , Median- , Kalman-Filter usw. Aber dieser Kompromiss ist auf jeden Fall.

Zeitfenster


audioCtx.currentTime mit performance.now() synchronisieren, können Sie in einer asynchronen Schleife eine Messung durchführen, beispielsweise 100 ms, um die Benutzeroberfläche nicht zu beeinträchtigen.
Es sei angenommen, dass der Messfehler err = errA + errB = 1 + 3 = 4 ms ist
Dementsprechend können wir es in 1 Sekunde auf 0,4 ms und in 10 Sekunden auf 0,04 ms reduzieren. Eine weitere Verbesserung des Ergebnisses ist nicht sinnvoll und ein gutes Filterfenster ist: 1 - 10 Sekunden.

Bei der Netzwerksynchronisation sind Verzögerungen und Fehler bereits viel bedeutender, aber es gibt keinen scharfen Zeitsprung wie beim audioCtx.currentTime . Und Sie können sich erlauben, wirklich gute Statistiken zu sammeln. Immerhin kann err for ping bis zu 500 ms betragen. Und die Messungen selbst können wir nicht so oft machen.

An diesem Punkt schlage ich vor, aufzuhören. Wenn jemand interessiert war, erzähle ich Ihnen gerne, wie Sie "den Rest der Eule zeichnen" können. Aber als Teil der Geschichte über Timer denke ich, dass meine Geschichte vorbei ist.

Und ich möchte teilen, was ich habe. Trotzdem das neue Jahr.

Was ist passiert?


Haftungsausschluss: Technisch gesehen handelt es sich um eine PR-Site auf Habré, aber es handelt sich um ein vollständig gemeinnütziges Open-Source-Haustierprojekt, für das ich verspreche, niemals Werbung zu schalten oder anderweitig Geld zu verdienen. Im Gegenteil, ich habe jetzt mehr Instanzen von meinem Geld gesammelt, um einen möglichen Habraeffekt zu überleben. Deshalb bitte, gute Leute, brecht mich nicht und erreicht mich nicht. Das macht alles nur Spaß.

Frohes Neues Jahr, Habr!



snowtime.fun

Sie können die Regler drehen und Visualisierung, Musik und Audioeffekte steuern. Wenn Sie eine normale Grafikkarte haben, gehen Sie zu den Einstellungen und stellen Sie die Anzahl der Partikel auf 100% ein.

Benötigt WebAudio und WebGL.




UPD: Funktioniert nicht in Safari unter MacOS Mojave. Leider ist es nicht möglich, schnell herauszufinden, was passiert, da diese Safari nicht vorhanden ist. iOS scheint zu funktionieren.

UPD2: Wenn snowtime.fun und web.snowtime.fun nicht reagieren, versuchen Sie es mit der neuen Unterdomäne habr .snowtime.fun . Er verlegte den Server in ein anderes Rechenzentrum und die alte IP wurde im DNS zwischengespeichert, expire=1w . :(

Aufbewahrungsort : Bitbucket
Beim Schreiben dieses Artikels wurden Macrovector / Freepik- Illustrationen verwendet.

Source: https://habr.com/ru/post/de482168/


All Articles