Änderungen zusammenführen
Bei langen Transaktionen wird der gleiche Datensatz möglicherweise von verschiedenen Benutzern verändert. Der Benutzer, der seine Änderungen zuerst abspeichert, merkt nichts von dieser Situation. Allen weiteren Benutzern muss beim Versuch, ihre Änderungen abzuspeichern,
- angezeigt werden, dass die Daten geändert wurden;
- die Änderungen anderer Benutzer sichtbar gemacht werden;
- die eigene Änderung möglichst erhalten bleiben, soweit sie konfliktfrei ist.
Als Grundlage für diese Anzeigen müssen die Daten in der Datenbank mit den Benutzereingaben verglichen und in möglichst sinnvoller Weise zusammengeführt werden.
3 Weg Merge
In der Beispiel-Applikation ist ein 3 Weg Merge implementiert, um Konflikte bei langen Transaktionen aufzulösen. Bei einem 3 Weg Merge werden die parallel entstandenen unterschiedlichen Versionen mit dem gemeinsamen Vorgänger verglichen. Änderungen, die jeweils nur in einer Version des Datensatzes entstanden sind, werden automatisch zusammengeführt. Unterschiedliche Änderungen, die in beiden Versionen entstanden sind, werden als Konflikt markiert. Dieser muss vom Benutzer aufgelöst werden. Die Skizze soll dieses verdeutlichen.
Der Benutzer greift zum Bearbeiten über die Web Applikation auf Version V.0 eines Objekts zu. Die Applikation speichert diese Version in der Session und lädt sie in den Browser. Hier kann der Benutzer seine Änderungen durchführen (grüne Felder). In der Zwischenzeit wurde das Objekt ein- oder mehrmals in der Datenbank geändert, so dass es dort in Version V.x vorliegt. Der Handler in der Web Applikation kann den POST Request nicht erfolgreich abschließen, da das Objekt nicht in die Datenbank gespeichert werden kann. Der UpdatedAt Zeitstempel der Version in der DB stimmt nicht mit dem Wert des aktuellen Objekts (Version V.1 in der Skizze) überein. Es wird ein Stale Object Error ausgelöst und die Transaktion abgebrochen, siehe Gorm Code für lange Transaktionen. Die Änderungen in Datenbank und POST Request werden jetzt zusammengeführt, markiert und in den Browser geladen. Der Benutzer kann jetzt die Änderungen überprüfen, mögliche Konflikte auflösen und den aktualisierten Datensatz (Version V.x+1) abspeichern.
Werte für den 3 Weg Merge bereitstellen
Drei Versionen eines Objekts werden verglichen, wenn das Update des editierten Objekts einen Fehler ergibt. Im Beispiel sind dies die wichtigen Schritte in der Funktion saveApplicantSubmission in Datei utils.go:
// save edited applicant data to database func saveApplicantSubmission(w http.ResponseWriter, r *http.Request, maySetResults bool) { if err := parseSubmission(w, r); err != nil { http.Error(w, "Request parse error: " + err.Error(), http.StatusInternalServerError) log.Printf("error %v, status %v\n", "Request parse error: " + err.Error(), http.StatusInternalServerError) return } action := html.EscapeString(r.PostFormValue("action")) appsession, err := applicantFromSession(action, r) if err != nil { http.Error(w, "Session store error: " + err.Error(), http.StatusInternalServerError) log.Printf("action %s, error %v, status %v\n", action, "Session store error: " + err.Error(), http.StatusInternalServerError) return } // copy old applicant from session app := appsession // update data from form values setApplicantData(&app, r) switch action { case "enrol": setEnrolledAt(&app) case "results": // make sure that results may be modified by current user if maySetResults { setResultData(&app, r) } } if err := model.Db().Save(&app).Error; err != nil { var appModified model.Applicant dataOld := appsession.Data dataSubmitted := app.Data // read modified applicant from db model.Db().Preload("Data").Preload("Data.Oblast").First(&appModified, appsession.ID) if appModified.ID == 0 { w.Header().Set("Content-Type", "application/json") json, _ := json.Marshal("Object was deleted") log.Printf("editing deleted Object, message is %s\n", string(json)) w.Write(json) return } dataModified := appModified.Data merge, err := MergeDiff(&dataOld, &dataSubmitted, &dataModified, true, "form") if err != nil { http.Error(w, "Submission merge error: " + err.Error(), http.StatusInternalServerError) log.Printf("error %v, status %v\n", "Submission merge error: " + err.Error(), http.StatusInternalServerError) return } MergeScaleResults(merge, "result") json, err := json.Marshal(merge) if err != nil { log.Printf("Json marshalling error %v\n", err) http.Error(w, "Json marshalling error: " + err.Error(), http.StatusInternalServerError) log.Printf("error %v, status %v\n", "Json marshalling error: " + err.Error(), http.StatusInternalServerError) return } // store in session if err := storeApplicant(w, r, appModified, action); err != nil { log.Printf("Session store error %v\n", err) http.Error(w, "Session store error: " + err.Error(), http.StatusInternalServerError) log.Printf("error %v, status %v\n", "Session store error: " + err.Error(), http.StatusInternalServerError) } w.Header().Set("Content-Type", "application/json") w.Write(json) } else { w.WriteHeader(http.StatusNoContent) } }
- 180
- Das in der Session gespeicherte Applicant Objekt (V.0) holen
- 187 – 190
- Das Objekt aus der Session kopieren und alle Formular-Werte aus dem POST Request in die Kopie übernehmen (func setApplicantData)
- 200
- Das editierte Objekt in die Datenbank speichern:
Wenn err != nil ist, beginnt der Merge Code. Dabei wird nur das ApplicantData Objekt betrachtet, das die eigentlichen Daten enthält. - 202 – 205, 213
- dataOld: V.0 aus der Session,
dataSubmitted: V.1 aus der aktuellen Form Submission,
dataModified: V.x aus der Datenbank - 207-209
- Spezialfall, dass Objekt gelöscht wurde, z.B. als Folge einer Exmatrikulation
- 214
- Mit diesen drei Werten wird die MergeDiff Funktion aufgerufen, die eine Datenstruktur zurückgibt in der alle voneinander abweichenden Werte und die Art der Differenz enthalten ist.
- 237 – 238
- Wenn alles geklappt hat, wird das Merge-Resultat im JSON Format an den Browser zurückgesendet.
{ "district": { "Mine": 4123, "Other": 4130, "Conflict": 2 }, "email": { "Mine": "Ronald@disney.com", "Other": "Donald@disney.com", "Conflict": 1 }, "firstname": { "Mine": "Ronald", "Other": "Donald", "Conflict": 1 }, "home": { "Mine": "Oberpfaffenhofen", "Other": "Ducksburgh", "Conflict": 2 }, "lastname": { "Mine": "Ducker", "Other": "Dunck", "Conflict": 3 } }
- 5, 20
- Änderungen in der Datenbank, jemand anderes hat diesen Wert verändert – Conflict = 2
- 10, 15
- Lokale Änderungen, aktueller Benutzer hat diesen Wert verändert – Conflict = 1
- 25
- Änderungen in der Datenbank und lokal, Benutzer muss Änderungen manuell zusammenführen – Conflict = 3
Implementierung des 3 Weg Merge
Die Implementierung nutzt das go reflect package. Sie ist an die Anforderungen des Beispiels angepasst und kann noch verallgemeinert werden. Es werden nur einfache Datentypen und Arrays mit einfachen Datentypen unterstützt. Die Felder, die verglichen werden sollen, können ein Tag besitzen, mit dem sie zu Feldern des UI zugeordnet werden können. Wenn keine Tags verwendet werden, wird der Name des Feldes in der Go struct genommen.
Die Ergebnisse des Vergleichs werden für jedes Feld in der Datenstruktur MergeInfo abgelegt. Die Felder Mine und Other enthalten die unterschiedlichen Werte. Das Feld Conflict beschreibt die registrierte Veränderung.
type MergeDiffType int const ( NONE MergeDiffType = iota // no changes at all MINE // only my value changed THEIRS // only their value changed BOTH // both values changed and are different SAME // both values changed but are equal ) // type to record value changes for web forms type MergeInfo struct { Mine interface{} Other interface{} Conflict MergeDiffType }
Die Funktion MergeDiff erstellt eine map von MergeInfo Objekten. Die Methode verwendet Go Reflection, also die Möglichkeit, zur Laufzeit des Programms auf den dynamischen Typ der Parameter zuzugreifen, ihre Metaeigenschaften zu bestimmen und ihre Werte zu verändern. Um diesen Code zu verstehen sollte man sich das Package reflect und den dort empfohlenen Artikel „The Laws of Reflection“ als Einführung zu Reflection in Go näher ansehen.
// function MergeDiff runs a three way diff between the old and new versions of my struct and a // different changed version of the same struct, similar to git merge. Differences are flagged in // return parameter diffs using type MergeDiffType.If automerge is true, mineNew fields get // updated to updated fields of otherNew and only changed fields are flagged in diffs. // mineOld, mineNew, otherNew must be pointers to same struct types. // If tag is not empty, only fields are compared that are tagged with the given tag and the tag value // will be used as key in the diffs map. func MergeDiff(mineOld, mineNew, otherNew interface{}, automerge bool, tag ...string) (diffs map[string]MergeInfo, err error) { useTags := len(tag) == 1 // get value objects of struct variables referenced by interfaces vMineOld := reflect.ValueOf(mineOld).Elem() vMineNew := reflect.ValueOf(mineNew).Elem() vOther := reflect.ValueOf(otherNew).Elem() diffs = make(map[string]MergeInfo) // make sure you don't compare apples to pears if vMineOld.Type() != vOther.Type() || vMineOld.Type() != vMineNew.Type() { err = errors.New("different types") return } // loop over all fields for i := 0; i < vMineOld.NumField(); i++ { fieldInfo := vMineOld.Type().Field(i) fieldName := fieldInfo.Name fieldTags := fieldInfo.Tag fieldMineOld := vMineOld.Field(i).Interface() fieldMineNew := vMineNew.Field(i).Interface() fieldOther := vOther.Field(i).Interface() // ignore anonymous fields if !fieldInfo.Anonymous { var diffkey string if useTags { diffkey = fieldTags.Get(tag[0]) if diffkey == "" { continue } } else { diffkey = fieldName } // simple field of array/slice? switch fieldInfo.Type.Kind() { case reflect.Array: fallthrough case reflect.Slice: mergeArray(vMineOld.Field(i), vMineNew.Field(i), vOther.Field(i), diffkey, diffs, automerge) default: cmine := fieldMineNew == fieldMineOld // has my field changed ctheirs := fieldOther == fieldMineOld // has their field changed cdiff := fieldMineNew == fieldOther // are new fields same var mergeDiff MergeDiffType if cdiff && cmine { // no change mergeDiff = NONE } else if !cdiff && cmine { // they changed mergeDiff = THEIRS } else if !cdiff && !cmine && ctheirs { // mine changed mergeDiff = MINE } else if !cdiff && !cmine && ! ctheirs { // both changed differently mergeDiff = BOTH } else { // both changed but are same mergeDiff = SAME } switch mergeDiff { case THEIRS: fallthrough case BOTH: diffs[diffkey] = MergeInfo{fieldMineNew, fieldOther, mergeDiff} case MINE: fallthrough case SAME: if automerge { diffs[diffkey] = MergeInfo{fieldMineNew, fieldOther, mergeDiff} } } } } } return }
- 38 – 40
- Die Parameter der Funktion sind vom Typ interface{}, also untypisiert. Da Werte verändert werden sollen, müssen sie als Pointer übergeben werden. Mit reflect.ValueOf(…).Elem() wird das reflect.Value Objekt erhalten, auf das der Pointer zeigt. Das sind also die Value Objekte der drei structs, die verglichen werden sollen.
- 43
- Es muss sichergestellt werden, dass Objekte des gleichen Typs verglichen werden, sonst kommt Unsinn heraus.
- 48
- Jetzt wird über die Felder iteriert und in den nächsten 3 Zeilen etwas Metainformation gesammelt.
- 52 – 54
- Um mit den Werten der Reflection Objekte zu arbeiten, braucht man ihr Interface(), nicht ihren Value(). Das Value Objekt beschreibt den Wert, mit Interface() greift man auf den Speicherplatz des Wertes zu und kann ihn ändern. Das erscheint zwar nicht besonders intuitiv, aber funktioniert.
- 71
- Felder vom Array- oder Slice-Typ werden separat in mergeArray(…) behandelt. Dort wird über das gesamte Attay oder Slice iteriert und jede Zelle analog wie hier behandelt.
- 74 – 76
- Damit können jetzt die Werte verglichen werden für die große Fallunterscheidung.
- 98, 103
- Die Ergebnisse des Vergleichs werden in ein neues MergeInfo Objekt geschrieben und in die diffs map eingefügt. Eigene Änderungen, die nicht in Konflikt zu Änderungen in der Datenbank stehen, werden nur in die map aufgenommen, wenn automerge auf true gesetzt ist.
Als Schlüssel für die diffs map sollten Tags verwendet werden, die den Feldnamen in der HTML Form entsprechen. Als Beispiel die struct ApplicantData:
// all data of an applicant are stored in a separate structure in order // to maintain a full history of changes to these sensitive data // form: tags are used to identify html form fields and request parameters type ApplicantData struct { Model ApplicantID uint Number uint `gorm:"AUTO_INCREMENT" form:"applid"` LastName string `form:"lastname"` FirstName string `form:"firstname"` FathersName string `form:"fathersname"` LastNameTx string `form:"lastnametx"` FirstNameTx string `form:"firstnametx"` FathersNameTx string `form:"fathersnametx"` Phone string `form:"phone"` Email string `form:"email"` Home string `form:"home"` School string `form:"school"` SchoolOk bool `form:"schoolok"` Oblast Oblast // Belongs To Association OblastID uint `form:"district"` OblastOk bool `form:"districtok"` OrtSum int16 `form:"ort"` OrtMath int16 `form:"ortmath"` OrtPhys int16 `form:"ortphys"` OrtOk bool `form:"ortok"` Results [NQESTION]int `gorm:"-" form:"result#"` // marks multiplied by 10 Resultsave string LanguageResult int `form:"languageresult"` Language Lang `form:"language"` EnrolledAt time.Time `form:"enrolledat"` CancelledAt time.Time `form:"cancelledat"` }
Hier werden Tags der Art ´form:“feldname“´ verwendet. Eine Besonderheit ist der Tag von Results. Da dies ein Array ist, muss der Index der unterschiedlichen Werte für das Merging übergeben werden. Das ‚#‘ Zeichen im Tag wird daher in der Funktion mergeArray(…) durch den entsprechenden Index ersetzt. Dadurch kann das passende Eingabefeld in der HTML Form identifiziert werden.
Die Darstellung der Merge-Ergebnisse im Browser wird (noch nicht) auf einer anderen Seite beschrieben.