Vor- / Nachteile der Verwendung von Redux-Saga mit ES6-Generatoren gegenüber Redux-Thunk mit ES2017 async / await


488

Es wird gerade viel über das neueste Kind in der Redux-Stadt gesprochen, die Redux-Saga / Redux-Saga . Es verwendet Generatorfunktionen zum Abhören / Versenden von Aktionen.

Bevor ich mich darum kümmere, möchte ich die Vor- und Nachteile der Verwendung redux-sagaanstelle des folgenden Ansatzes kennen, bei dem ich redux-thunkasync / await verwende.

Eine Komponente könnte so aussehen und Aktionen wie gewohnt auslösen.

import { login } from 'redux/auth';

class LoginForm extends Component {

  onClick(e) {
    e.preventDefault();
    const { user, pass } = this.refs;
    this.props.dispatch(login(user.value, pass.value));
  }

  render() {
    return (<div>
        <input type="text" ref="user" />
        <input type="password" ref="pass" />
        <button onClick={::this.onClick}>Sign In</button>
    </div>);
  } 
}

export default connect((state) => ({}))(LoginForm);

Dann sehen meine Handlungen ungefähr so ​​aus:

// auth.js

import request from 'axios';
import { loadUserData } from './user';

// define constants
// define initial state
// export default reducer

export const login = (user, pass) => async (dispatch) => {
    try {
        dispatch({ type: LOGIN_REQUEST });
        let { data } = await request.post('/login', { user, pass });
        await dispatch(loadUserData(data.uid));
        dispatch({ type: LOGIN_SUCCESS, data });
    } catch(error) {
        dispatch({ type: LOGIN_ERROR, error });
    }
}

// more actions...

// user.js

import request from 'axios';

// define constants
// define initial state
// export default reducer

export const loadUserData = (uid) => async (dispatch) => {
    try {
        dispatch({ type: USERDATA_REQUEST });
        let { data } = await request.get(`/users/${uid}`);
        dispatch({ type: USERDATA_SUCCESS, data });
    } catch(error) {
        dispatch({ type: USERDATA_ERROR, error });
    }
}

// more actions...

6
Siehe auch meine Antwort zum Vergleich von Redux-Thunk mit Redux-Saga hier: stackoverflow.com/a/34623840/82609
Sebastien Lorber

22
Was ist das ::vor dir this.onClick?
Downhillski

37
@ZhenyangHua ist eine Abkürzung zum Binden der Funktion an das Objekt ( this), aka this.onClick = this.onClick.bind(this). Die längere Form wird normalerweise im Konstruktor empfohlen, da die Kurzschrift bei jedem Rendering erneut gebunden wird.
Hampusohlsson

7
Aha. Vielen Dank! Ich sehe Leute, die bind()viel verwenden, um thisan die Funktion weiterzugeben , aber ich habe () => method()jetzt angefangen, sie zu verwenden .
Downhillski

2
@ Hosar Ich habe Redux & Redux-Saga für eine Weile in der Produktion verwendet, bin aber nach ein paar Monaten tatsächlich zu MobX migriert, weil weniger Overhead
hampusohlsson

Antworten:


461

In der Redux-Saga wäre das Äquivalent des obigen Beispiels

export function* loginSaga() {
  while(true) {
    const { user, pass } = yield take(LOGIN_REQUEST)
    try {
      let { data } = yield call(request.post, '/login', { user, pass });
      yield fork(loadUserData, data.uid);
      yield put({ type: LOGIN_SUCCESS, data });
    } catch(error) {
      yield put({ type: LOGIN_ERROR, error });
    }  
  }
}

export function* loadUserData(uid) {
  try {
    yield put({ type: USERDATA_REQUEST });
    let { data } = yield call(request.get, `/users/${uid}`);
    yield put({ type: USERDATA_SUCCESS, data });
  } catch(error) {
    yield put({ type: USERDATA_ERROR, error });
  }
}

Das erste, was zu bemerken ist, ist, dass wir die API-Funktionen über das Formular aufrufen yield call(func, ...args). callführt den Effekt nicht aus, sondern erstellt nur ein einfaches Objekt wie {type: 'CALL', func, args}. Die Ausführung wird an die Redux-Saga-Middleware delegiert, die sich um die Ausführung der Funktion und die Wiederaufnahme des Generators mit dem Ergebnis kümmert.

Der Hauptvorteil besteht darin, dass Sie den Generator außerhalb von Redux mit einfachen Gleichheitsprüfungen testen können

const iterator = loginSaga()

assert.deepEqual(iterator.next().value, take(LOGIN_REQUEST))

// resume the generator with some dummy action
const mockAction = {user: '...', pass: '...'}
assert.deepEqual(
  iterator.next(mockAction).value, 
  call(request.post, '/login', mockAction)
)

// simulate an error result
const mockError = 'invalid user/password'
assert.deepEqual(
  iterator.throw(mockError).value, 
  put({ type: LOGIN_ERROR, error: mockError })
)

Beachten Sie, dass wir das Ergebnis des API-Aufrufs verspotten, indem wir einfach die verspotteten Daten in die nextMethode des Iterators einfügen. Das Verspotten von Daten ist viel einfacher als das Verspotten von Funktionen.

Das zweite, was zu bemerken ist, ist der Anruf bei yield take(ACTION). Thunks werden vom Aktionsersteller bei jeder neuen Aktion aufgerufen (z LOGIN_REQUEST. B. ). dh Maßnahmen werden kontinuierlich gedrückt zu Thunks und Thunks haben keine Kontrolle über beim Umgang mit diesen Aktionen zu stoppen.

In der Redux-Saga ziehen Generatoren die nächste Aktion. dh sie haben die Kontrolle, wann sie auf eine Aktion warten müssen und wann nicht. Im obigen Beispiel befinden sich die Ablaufanweisungen in einer while(true)Schleife, sodass sie auf jede eingehende Aktion warten, was das Thunk-Pushing-Verhalten etwas nachahmt.

Der Pull-Ansatz ermöglicht die Implementierung komplexer Kontrollflüsse. Angenommen, wir möchten beispielsweise die folgenden Anforderungen hinzufügen

  • Behandeln Sie die Benutzeraktion LOGOUT

  • Bei der ersten erfolgreichen Anmeldung gibt der Server ein Token zurück, das mit einer Verzögerung abläuft, die in einem expires_inFeld gespeichert ist. Wir müssen die Autorisierung im Hintergrund alle expires_inMillisekunden aktualisieren

  • Berücksichtigen Sie, dass sich der Benutzer beim Warten auf das Ergebnis von API-Aufrufen (entweder beim ersten Anmelden oder beim Aktualisieren) zwischendurch abmelden kann.

Wie würden Sie das mit Thunks umsetzen? bei gleichzeitiger Bereitstellung einer vollständigen Testabdeckung für den gesamten Fluss? So kann es mit Sagas aussehen:

function* authorize(credentials) {
  const token = yield call(api.authorize, credentials)
  yield put( login.success(token) )
  return token
}

function* authAndRefreshTokenOnExpiry(name, password) {
  let token = yield call(authorize, {name, password})
  while(true) {
    yield call(delay, token.expires_in)
    token = yield call(authorize, {token})
  }
}

function* watchAuth() {
  while(true) {
    try {
      const {name, password} = yield take(LOGIN_REQUEST)

      yield race([
        take(LOGOUT),
        call(authAndRefreshTokenOnExpiry, name, password)
      ])

      // user logged out, next while iteration will wait for the
      // next LOGIN_REQUEST action

    } catch(error) {
      yield put( login.error(error) )
    }
  }
}

Im obigen Beispiel drücken wir unsere Parallelitätsanforderung mit aus race. Wenn take(LOGOUT)das Rennen gewinnt (dh der Benutzer hat auf eine Abmeldeschaltfläche geklickt). Das Rennen bricht die authAndRefreshTokenOnExpiryHintergrundaufgabe automatisch ab . Und wenn das authAndRefreshTokenOnExpirymitten in einem call(authorize, {token})Anruf blockiert wurde , wird es auch abgebrochen. Die Stornierung erfolgt automatisch nach unten.

Sie finden eine ausführbare Demo des obigen Ablaufs


@yassine woher kommt die delayFunktion? Ah, fand es: github.com/yelouafi/redux-saga/blob/…
philk

122
Der redux-thunkCode ist gut lesbar und selbsterklärend. Aber redux-sagasman ist wirklich nicht lesbar, vor allem wegen dieses Verb-ähnlichen Funktionen: call, fork, take, put...
syg

11
@syg, ich stimme zu, dass Call, Fork, Take und Put semantisch freundlicher sein können. Es sind jedoch diese verbartigen Funktionen, die alle Nebenwirkungen testbar machen.
Downhillski

3
@syg immer noch eine Funktion mit diesen seltsamen Verben Funktionen sind besser lesbar als eine Funktion mit tiefen Versprechungen Kette
Yasser Sinjab

3
Diese "seltsamen" Verben helfen Ihnen auch dabei, die Beziehung der Saga zu den Botschaften zu konzipieren, die aus Redux kommen. Sie können nehmen Nachrichtentypen aus Redux - oft die nächste Iteration auslösen, und Sie können setzen in neuen Nachrichten wieder das Ergebnis Ihrer Nebenwirkung auszustrahlen.
Worc

104

Ich werde meine Erfahrung mit der Verwendung von Saga im Produktionssystem zusätzlich zu der ziemlich gründlichen Antwort des Bibliotheksautors hinzufügen.

Pro (mit Saga):

  • Testbarkeit. Es ist sehr einfach, Sagen zu testen, da call () ein reines Objekt zurückgibt. Zum Testen von Thunks müssen Sie normalerweise einen mockStore in Ihren Test aufnehmen.

  • Die Redux-Saga bietet viele nützliche Hilfsfunktionen für Aufgaben. Es scheint mir, dass das Konzept der Saga darin besteht, eine Art Hintergrund-Worker / Thread für Ihre App zu erstellen, der als fehlendes Teil der React Redux-Architektur fungiert (ActionCreators und Reducer müssen reine Funktionen sein.), Was zum nächsten Punkt führt.

  • Sagas bieten einen unabhängigen Ort, um alle Nebenwirkungen zu behandeln. Nach meiner Erfahrung ist es normalerweise einfacher zu ändern und zu verwalten als Thunk-Aktionen.

Con:

  • Generatorsyntax.

  • Viele Konzepte zu lernen.

  • API-Stabilität. Es scheint, dass Redux-Saga immer noch Funktionen hinzufügt (z. B. Kanäle?) Und die Community nicht so groß ist. Es gibt Bedenken, wenn die Bibliothek eines Tages ein nicht abwärtskompatibles Update vornimmt.


9
Ich möchte nur einen Kommentar abgeben, der Action-Ersteller muss keine reine Funktion sein, was Dan selbst schon oft behauptet hat.
Marson Mao

14
Ab sofort werden Redux-Sagen sehr empfohlen, da die Verwendung und die Community erweitert wurden. Außerdem ist die API ausgereifter geworden. Entfernen Sie die Con für API stabilityals Update, um die aktuelle Situation widerzuspiegeln.
Denialos

1
Die Saga hat mehr Starts als Thunk und ihr letztes Commit ist auch nach Thunk
amorenew

2
Ja, FWIW Redux-Saga hat jetzt 12k Sterne, Redux-Thunk hat 8k
Brian Burns

3
Ich werde eine weitere Herausforderung für Sagen hinzufügen: Die Sagen sind standardmäßig vollständig von Aktionen und Aktionserstellern entkoppelt. Während Thunks Action-Schöpfer direkt mit ihren Nebenwirkungen verbinden, lassen Sagas Action-Schöpfer völlig getrennt von den Sagen, die auf sie hören. Dies hat technische Vorteile, kann jedoch das Befolgen von Code erheblich erschweren und einige der unidirektionalen Konzepte verwischen.
Frieden des Spaten

33

Ich möchte nur einige Kommentare aus meiner persönlichen Erfahrung hinzufügen (sowohl mit Sagen als auch mit Thunk):

Sagas sind großartig zu testen:

  • Sie müssen keine mit Effekten umhüllten Funktionen verspotten
  • Daher sind Tests sauber, lesbar und leicht zu schreiben
  • Bei der Verwendung von Sagen geben Aktionsersteller meist einfache Objektliterale zurück. Im Gegensatz zu Thunks Versprechen ist es auch einfacher zu testen und zu behaupten.

Sagen sind mächtiger. Alles, was Sie mit dem Action Creator eines Thunks tun können, können Sie auch in einer Saga tun, aber nicht umgekehrt (oder zumindest nicht einfach). Zum Beispiel:

  • Warten Sie, bis eine Aktion / Aktionen ausgelöst wurden ( take)
  • stornieren Routine ( cancel, takeLatest, race)
  • mehrere Routinen können auf die gleiche Aktion hören ( take, takeEvery, ...)

Sagas bietet auch andere nützliche Funktionen, die einige gängige Anwendungsmuster verallgemeinern:

  • channels externe Ereignisquellen (z. B. Websockets) abhören
  • Gabelmodell ( fork, spawn)
  • drosseln
  • ...

Sagas sind ein großartiges und mächtiges Werkzeug. Mit der Macht geht jedoch auch Verantwortung einher. Wenn Ihre Anwendung wächst, können Sie leicht verloren gehen, indem Sie herausfinden, wer auf den Versand der Aktion wartet oder was alles passiert, wenn eine Aktion gesendet wird. Auf der anderen Seite ist Thunk einfacher und leichter zu überlegen. Die Wahl des einen oder anderen hängt von vielen Aspekten ab, wie Typ und Größe des Projekts, welche Arten von Nebenwirkungen Ihr Projekt behandeln muss oder welche Teampräferenzen es hat. In jedem Fall halten Sie Ihre Anwendung einfach und vorhersehbar.


8

Nur eine persönliche Erfahrung:

  1. Für den Codierungsstil und die Lesbarkeit besteht einer der wichtigsten Vorteile der Verwendung von Redux-Saga in der Vergangenheit darin, die Rückrufhölle in Redux-Thunk zu vermeiden - man muss dann nicht mehr viele Verschachtelungen verwenden / fangen. Mit der Popularität von Async / Wait Thunk könnte man jetzt auch Async-Code im Synchronisationsstil schreiben, wenn man Redux-Thunk verwendet, was als Verbesserung des Redux-Denkens angesehen werden kann.

  2. Bei der Verwendung von Redux-Saga muss möglicherweise viel mehr Code für das Boilerplate geschrieben werden, insbesondere in Typescript. Wenn beispielsweise eine asynchrone Abruffunktion implementiert werden soll, kann die Daten- und Fehlerbehandlung direkt in einer Thunk-Einheit in action.js mit einer einzelnen FETCH-Aktion ausgeführt werden. In der Redux-Saga müssen jedoch möglicherweise die Aktionen FETCH_START, FETCH_SUCCESS und FETCH_FAILURE sowie alle zugehörigen Typprüfungen definiert werden, da eine der Funktionen in der Redux-Saga darin besteht, diese Art von umfangreichem Token-Mechanismus zum Erstellen von Effekten und Anweisungen zu verwenden Redux Store zum einfachen Testen. Natürlich könnte man eine Saga schreiben, ohne diese Aktionen zu verwenden, aber das würde es einem Thunk ähnlich machen.

  3. In Bezug auf die Dateistruktur scheint die Redux-Saga in vielen Fällen expliziter zu sein. Man könnte leicht einen asynchronen Code in jeder sagas.ts finden, aber in Redux-Thunk müsste man ihn in Aktionen sehen.

  4. Einfaches Testen kann ein weiteres gewichtetes Merkmal in der Redux-Saga sein. Das ist wirklich praktisch. Eine Sache, die geklärt werden muss, ist, dass der Redux-Saga-Aufruf-Test beim Testen keinen tatsächlichen API-Aufruf ausführt. Daher müsste das Beispielergebnis für die Schritte angegeben werden, die es nach dem API-Aufruf verwenden können. Daher ist es vor dem Schreiben in Redux-Saga besser, eine Saga und die entsprechenden sagas.spec.ts im Detail zu planen.

  5. Redux-saga bietet auch viele erweiterte Funktionen wie das parallele Ausführen von Aufgaben sowie Hilfsprogramme für die gleichzeitige Verwendung wie takeLatest / takeEvery, fork / spawn, die weitaus leistungsfähiger sind als Thunks.

Abschließend möchte ich persönlich sagen: In vielen normalen Fällen und bei kleinen bis mittelgroßen Apps sollten Sie sich für Redux-Thunk im asynchronen / wartenden Stil entscheiden. Es würde Ihnen viele Boilerplate-Codes / Aktionen / Typedefs ersparen, und Sie müssten nicht viele verschiedene sagas.ts umschalten und einen bestimmten sagas-Baum pflegen. Wenn Sie jedoch eine große App mit einer sehr komplexen asynchronen Logik und dem Bedarf an Funktionen wie Parallelität / Parallelmuster entwickeln oder einen hohen Bedarf an Tests und Wartung haben (insbesondere bei testgetriebener Entwicklung), können Redux-Sagen möglicherweise Ihr Leben retten .

Wie auch immer, Redux-Saga ist nicht schwieriger und komplexer als Redux selbst, und es gibt keine sogenannte steile Lernkurve, da es nur begrenzte Kernkonzepte und APIs gibt. Wenn Sie ein wenig Zeit damit verbringen, Redux-Saga zu lernen, können Sie sich eines Tages in der Zukunft davon profitieren.


5

Nachdem Sagas meiner Erfahrung nach einige verschiedene große React / Redux-Projekte überprüft hat, bieten sie Entwicklern eine strukturiertere Methode zum Schreiben von Code, die viel einfacher zu testen und schwerer zu verwechseln ist.

Ja, es ist anfangs etwas seltsam, aber die meisten Entwickler bekommen an einem Tag genug Verständnis dafür. Ich sage den Leuten immer, sie sollen sich keine Gedanken darüber machen, was yieldsie anfangen sollen, und dass es Ihnen einfällt, wenn Sie ein paar Tests schreiben.

Ich habe einige Projekte gesehen, bei denen Thunks so behandelt wurden, als wären sie Controller aus dem MVC-Patten, und dies wird schnell zu einem nicht zu wartenden Durcheinander.

Mein Rat ist, Sagas dort zu verwenden, wo Sie A-Trigger benötigen, die sich auf ein einzelnes Ereignis beziehen. Für alles, was eine Reihe von Aktionen betreffen könnte, ist es meiner Meinung nach einfacher, Kunden-Middleware zu schreiben und die Meta-Eigenschaft einer FSA-Aktion zu verwenden, um sie auszulösen.


2

Thunks gegen Sagas

Redux-Thunk und Redux-Saga unterscheiden sich in einigen wichtigen Punkten: Beide sind Middleware-Bibliotheken für Redux (Redux-Middleware ist Code, der Aktionen abfängt, die über die dispatch () -Methode in den Store gelangen).

Eine Aktion kann buchstäblich alles sein. Wenn Sie jedoch Best Practices befolgen, ist eine Aktion ein einfaches Javascript-Objekt mit einem Typfeld und optionalen Nutzdaten-, Meta- und Fehlerfeldern. z.B

const loginRequest = {
    type: 'LOGIN_REQUEST',
    payload: {
        name: 'admin',
        password: '123',
    }, };

Redux-Thunk

Mit Redux-ThunkMiddleware können Sie nicht nur Standardaktionen auslösen, sondern auch spezielle Funktionen, die aufgerufen werdenthunks .

Thunks (in Redux) haben im Allgemeinen die folgende Struktur:

export const thunkName =
   parameters =>
        (dispatch, getState) => {
            // Your application logic goes here
        };

Das heißt, a thunkist eine Funktion, die (optional) einige Parameter übernimmt und eine andere Funktion zurückgibt. Die innere Funktion übernimmt eine dispatch functionund eine getStateFunktion - beide werden von der Redux-ThunkMiddleware bereitgestellt .

Redux-Saga

Redux-SagaMit Middleware können Sie komplexe Anwendungslogik als reine Funktionen ausdrücken, die als Sagen bezeichnet werden. Reine Funktionen sind vom Teststandpunkt aus wünschenswert, da sie vorhersehbar und wiederholbar sind, wodurch sie relativ einfach zu testen sind.

Sagas werden durch spezielle Funktionen implementiert, die als Generatorfunktionen bezeichnet werden. Dies ist eine neue Funktion von ES6 JavaScript. Grundsätzlich springt die Ausführung überall dort in einen Generator hinein und aus ihm heraus, wo Sie eine Yield-Anweisung sehen. Stellen Sie sich eine yieldAnweisung so vor, dass der Generator pausiert und den zurückgegebenen Wert zurückgibt. Später kann der Anrufer den Generator bei der folgenden Anweisung wieder aufnehmen yield.

Eine Generatorfunktion ist eine solche. Beachten Sie das Sternchen nach dem Funktionsschlüsselwort.

function* mySaga() {
    // ...
}

Sobald die Login-Saga bei registriert ist Redux-Saga. Aber dann yieldwird die Saga durch die Einstellung in der ersten Zeile angehalten, bis eine Aktion mit Typ 'LOGIN_REQUEST'an das Geschäft gesendet wird. Sobald dies geschieht, wird die Ausführung fortgesetzt.

Weitere Details finden Sie in diesem Artikel .


1

Eine kurze Anmerkung. Generatoren sind stornierbar, asynchron / warten - nicht. Für ein Beispiel aus der Frage macht es also keinen Sinn, was man auswählen soll. Aber für kompliziertere Abläufe gibt es manchmal keine bessere Lösung als die Verwendung von Generatoren.

Eine andere Idee könnte sein, Generatoren mit Redux-Thunk zu verwenden, aber für mich scheint es, als würde man versuchen, ein Fahrrad mit Vierkanträdern zu erfinden.

Und natürlich sind Generatoren einfacher zu testen.


0

Hier ist ein Projekt, das die besten Teile (Profis) von beidem kombiniert redux-sagaund redux-thunk: Sie können alle Nebenwirkungen von Sagen behandeln und gleichzeitig ein Versprechen durch dispatchingdie entsprechende Aktion erhalten: https://github.com/diegohaz/redux-saga-thunk

class MyComponent extends React.Component {
  componentWillMount() {
    // `doSomething` dispatches an action which is handled by some saga
    this.props.doSomething().then((detail) => {
      console.log('Yaay!', detail)
    }).catch((error) => {
      console.log('Oops!', error)
    })
  }
}

1
Die Verwendung then()innerhalb einer React-Komponente ist gegen das Paradigma. Sie sollten den geänderten Status in behandeln, componentDidUpdateanstatt auf die Lösung eines Versprechens zu warten.

3
@ Maxincredible52 Dies gilt nicht für das serverseitige Rendern.
Diego Haz

Nach meiner Erfahrung gilt Max 'Argument immer noch für das serverseitige Rendern. Dies sollte wahrscheinlich irgendwo in der Routing-Schicht erledigt werden.
ThinkingInBits

3
@ Maxincredible52 warum ist es gegen das Paradigma, wo hast du das gelesen? Normalerweise mache ich es ähnlich wie @Diego Haz, aber mache es in componentDidMount (gemäß React docs sollten Netzwerkanrufe vorzugsweise dort durchgeführt werden), also haben wircomponentDidlMount() { this.props.doSomething().then((detail) => { this.setState({isReady: true})} }
user3711421

0

Eine einfachere Möglichkeit ist die Verwendung von Redux-Auto .

von der documantasion

redux-auto hat dieses asynchrone Problem einfach dadurch behoben, dass Sie eine "Aktions" -Funktion erstellen konnten, die ein Versprechen zurückgibt. Begleitend zu Ihrer "Standard" -Funktionsaktionslogik.

  1. Keine andere asynchrone Redux-Middleware erforderlich. zB Thunk, Promise-Middleware, Saga
  2. Ermöglicht es Ihnen ganz einfach, ein Versprechen an Redux weiterzugeben und es für Sie verwalten zu lassen
  3. Ermöglicht es Ihnen, externe Serviceanrufe zusammen mit dem Ort zu lokalisieren, an dem sie transformiert werden
  4. Wenn Sie die Datei "init.js" nennen, wird sie beim Start der App einmal aufgerufen. Dies ist gut zum Laden von Daten vom Server beim Start

Die Idee ist, jede Aktion in einer bestimmten Datei zu haben . Co-Lokalisierung des Serveraufrufs in der Datei mit Reduzierungsfunktionen für "ausstehend", "erfüllt" und "abgelehnt". Dies macht die Handhabung von Versprechungen sehr einfach.

Außerdem wird automatisch ein Hilfsobjekt ("asynchron" genannt) an den Prototyp Ihres Status angehängt , sodass Sie in Ihrer Benutzeroberfläche angeforderte Übergänge verfolgen können.


2
Ich habe +1 gemacht, obwohl es eine irrelevante Antwort ist, weil auch verschiedene Lösungen in Betracht gezogen werden sollten
amorenew

12
Ich denke, die -'s sind da, weil er nicht bekannt gegeben hat, dass er der Autor des Projekts ist
jreptak
Durch die Nutzung unserer Website bestätigen Sie, dass Sie unsere Cookie-Richtlinie und Datenschutzrichtlinie gelesen und verstanden haben.
Licensed under cc by-sa 3.0 with attribution required.