Phoenix LiveView / Laravel LiveWire in Go
In Phoenix (Elixir) und Laravel (PHP) gibt es die Idee, Single-Page-Applications (SPAs) zu entwickeln, die komplett auf dem Server gerendert werden. Dadurch muss im Idealfall kein paralleles JavaScript-Projekt geschrieben werden oder auf komplexe Frameworks wie React oder Vue zurückgegriffen werden.
Um trotzdem SEO-optimiert zu sein, wird die Webseite einmal komplett auf dem Server gerendert. Der Browser öffnet einen WebSocket zum Server und empfängt darüber die Änderungen.
Die Implementierungen unterscheiden sich voneinander. Da PHP WebSockets nur umständlich unterstützt, wird hier auf Polling gesetzt und die Verbindung wird bei jedem Request neu aufgebaut. Um dabei den State zu erhalten, wird dieser bei jedem Request als JSON komplett übertragen und im Browser gespeichert.
In Elixir wird für die WebSocket-Verbindung ein Prozess auf dem Server gestartet. Solange der WebSocket geöffnet ist, kann der Zustand auf dem Server gehalten werden und bei jedem Request nur die Änderungen übertragen werden. Elixir erkennt beim Kompilieren des Templates automatisch, welche Teile dynamisch sind und nummeriert sie durch. So können nur die Änderungen an den Client übertragen werden.
Es gibt auch Implementierungen in Go, jedoch sind die meisten unvollständig oder werden nicht mehr weiterentwickelt. Allerdings wird diese Implementierung höchstwahrscheinlich auch nicht vollständig sein. Mein Ziel ist es, das System zu verstehen, deshalb versuche ich, eine eigene Implementierung zu schreiben. Es ist wichtig zu beachten, dass das Errorhandling oft fehlen wird! Eine viel weiter entwickelte Implementierung ist jfyne / live.
Setup link
Ich werde meine Implementierung auf dem Fiber Framework aufbauen. Fiber ist ein schnelles Webframework für Go und hat eine Implementierung für Templates sowie WebSockets.
Komponenten link
Komponenten sind vergleichbar mit SingleFileComponents in Vue. Sie halten ihren Zustand isoliert, können Events empfangen und ihre eigene View rendern.
package liveview
type Component interface {
ComponentName() string
Mount(data []interface{}) error
OnEvent(eventName string, payload map[string]interface{})
Render(ctx *Ctx) string
}
Der ComponentName
wird verwendet, um die Komponente in normalen statischen Templates verwenden zu können.
Mount
wird aufgerufen, wenn die Komponente gestartet wird. Dies geschieht zweimal. Beim statischen Rendern der Webseite enthält data
die im Template übergebenen Daten. Wird die Komponente ein zweites Mal über den Websocket gestartet, enthält data
keine Daten! Alle public Variablen werden mit JSON initialisiert.
Für mein Beispiel habe ich eine einfache Komponente geschrieben, die einen Zähler darstellt. Zusätzlich hat die Komponente noch ein Formularfeld.
package main
import (
"log/slog"
"github.com/gofiber/fiber/v2"
liveview "github.com/pkuebler/go-liveview"
)
type User struct {
Name string
Password string
}
type CounterComponent struct {
customLog *slog.Logger
// Count and Name are public and will send to the client!
Count int
Name string
}
func (c *CounterComponent) ComponentName() string {
return "counter"
}
func (c *CounterComponent) Mount(data []interface{}) error {
c.customLog.Info("CounterComponent mounted")
if data == nil {
return nil
}
// set the data from the template
// set not the full User struct to avoid passwort leaks
c.Name = data[0].(*User).Name
return nil
}
In Fiber werden Templates über fiber.Ctx.Render()
gerendert und als Response zurückgeschickt. Leider kann ich den Fiber Context im WebSocket nicht verwenden, da ich das Template über den WebSocket schicken muss. Um genauso flexibel zu sein, werde ich später einen eigenen Context verwenden.
func (c *CounterComponent) Render(ctx *liveview.Ctx) string {
c.customLog.Info("Render CounterComponent")
return ctx.Render("counter", fiber.Map{
"Count": c.Count,
"Name": c.Name,
})
}
Der Aufruf der Render-Funktion mit den zur Verfügung stehenden Variablen funktioniert weiterhin wie von Fiber gewohnt. Ein Beispiel für das Template:
<div>
<h1>Counter</h1>
<p>Count: <span id="count">{{ .Count }}</span></p>
<a ghx-click="increment">Increment</a>
<a ghx-click="decrement">Decrement</a>
<div>
{{ if gt .Count 10 }}
<p>Count is greater than or equal to 10</p>
{{ else }}
<p>Count is less than 10</p>
{{ end }}
</div>
<form ghx-change="change" ghx-submit="submit">
<div>
<input name="name" value="{{ .Name }}">
</div>
<input type="submit" value="Submit">
</form>
</div>
Integration von LiveView-Komponenten in Go-Templates link
Wie bereits erwähnt, sollen die Komponenten in statischen Templates gerendert werden, aber auch später über WebSockets verwendet werden können. Dazu füge ich der Engine eine Funktion hinzu, die die Komponente in ein Template einfügt. Um die Engine dynamischer wechseln zu können, ruft diese Funktion nur den ComponentHandler des LiveViews auf.
package main
func main() {
liveView := liveview.NewLiveView()
// Go-Template Engine
engine := html.New("./views", ".html")
// Add a helper function to add a liveView component to the Template
engine.AddFunc("liveComponent", func(componentName string, data ...interface{}) template.HTML {
// return template string as template.HTML, so it will not be escaped by the template engine
return template.HTML(liveView.ComponentHandler(componentName, data...))
})
// Set the Engine to liveView, so it can render the components
liveView.SetViews(engine)
log := slog.Default()
// Register a function to create a new CounterComponent
liveView.RegisterComponentFactory("counter", func() liveview.Component {
// this allow to inject custom data into the component
// for example a logger or a database connection
return &CounterComponent{
customLog: log,
}
})
}
Anschließend muss die eigentliche Fiber App gestartet werden. Hier wird die Engine als View Engine definiert und die normalen Router mit Controller angelegt. Um später den WebSocket JS Client auszuliefern, definiere ich noch einen Static File Router.
func main() {
// ...
app := fiber.New(fiber.Config{
Views: engine,
})
// Serve JS Websocket Client
app.Static("/", "./public")
// Setup App Routes
app.Get("/", func(c *fiber.Ctx) error {
user := &User{
Name: "Peter",
Password: "SuperSecretPassword",
}
return c.Render("index", fiber.Map{
"User": user,
})
})
// Start App
app.Listen(":3000")
}
Im HTML-Template kann die Komponente nun mit {{ liveComponent "counter" }}
gerendert werden. Um weitere Daten an die Komponente zu übergeben, können diese als Parameter übergeben werden. Für den folgenden Code habe ich bereits zwei JavaScript-Dateien eingebunden. morphdom
ist ein schneller DOM Diff Algorithmus und live.js
wird später der WebSocket Client sein.
<html>
<body>
{{ liveComponent "counter" .User }}
<script src="https://cdn.jsdelivr.net/npm/morphdom@2.7.2/dist/morphdom-umd.min.js"></script>
<script src="/live.js"></script>
</body>
</html>
Komponenten und Renderengine registrieren link
Zur Registrierung werden die übermittelten Daten einfach in einer Map gespeichert.
package liveview
type LiveView struct {
views fiber.Views
componentFactories map[string]func() Component
}
func NewLiveView() *LiveView {
return &LiveView{
componentFactories: make(map[string]func() Component),
}
}
func (l *LiveView) SetViews(views fiber.Views) {
l.views = views
}
func (l *LiveView) RegisterComponentFactory(name string, factory func() Component) {
l.componentFactories[name] = factory
}
Der ComponentHandler
ruft die Render
Funktion der Komponente auf und gibt das gerenderte Template zurück. Um die Komponenten später unterscheiden zu können, wird eine eindeutige ID generiert.
// ComponentHandler at template
func (l *LiveView) ComponentHandler(componentName string, data ...interface{}) string {
// get the factory function for the component
factory := l.componentFactories[componentName]
// create a new component
component := factory()
// mount the component with the parameters from the template
if err := component.Mount(data); err != nil {
fmt.Println("Error mounting component", err)
return ""
}
// call a internal render function, add a unique id to the component
return l.render(uuid.New().String(), component)
}
Die interne Funktion render
erzeugt den Context und ruft die Render-Funktion der Komponente auf. Anschließend wird im Template mit gohtml "golang.org/x/net/html"
die ID und der Name der Komponente hinzugefügt.
Da die html
Funktion beim Parsen leider immer ein html
und ein body
Element hinzufügt, muss das Root-Element der Komponente gefunden werden. Dann werden die ID und der Name der Komponente als Attribute hinzugefügt.
func (l *LiveView) render(id string, component Component) string {
ctx := &Ctx{
views: l.views,
}
output := component.Render(ctx)
// add to root html element attrs
doc, err := gohtml.Parse(strings.NewReader(output))
if err != nil {
return ""
}
// find root element (html adds a html and body element)
var componentRoot *gohtml.Node
var f func(*gohtml.Node)
f = func(n *gohtml.Node) {
if n.Type == gohtml.ElementNode && n.Data == "body" {
componentRoot = n.FirstChild
return
}
for c := n.FirstChild; c != nil; c = c.NextSibling {
f(c)
}
}
f(doc)
if componentRoot.Type != gohtml.ElementNode {
fmt.Println("Component allows only one root element")
return ""
}
componentRoot.Attr = append(componentRoot.Attr, gohtml.Attribute{
Namespace: "",
Key: "data-ghx-id",
Val: id,
}, gohtml.Attribute{
Namespace: "",
Key: "data-ghx-component",
Val: component.ComponentName(),
})
// render html back to string
a := &strings.Builder{}
gohtml.Render(a, componentRoot)
return a.String()
}
Render Context link
Um in der Render
Funktion der Komponente auf die Render
Funktion der Engine zugreifen zu können, wird ein eigener Context erstellt. Der Context kann stark erweitert werden. Ich habe nur den nötigsten Code von Fiber.Ctx
kopiert.
package liveview
import (
"github.com/gofiber/fiber/v2"
"github.com/valyala/bytebufferpool"
)
type Ctx struct {
views fiber.Views
}
func (c *Ctx) Render(name string, bind interface{}, layouts ...string) string {
// Get new buffer from pool
buf := bytebufferpool.Get()
defer bytebufferpool.Put(buf)
// Initialize empty bind map if bind is nil
if bind == nil {
bind = make(fiber.Map)
}
c.views.Render(buf, name, bind, layouts...)
return buf.String()
}
Gegebenenfalls wäre es sinnvoll, mehr Renderlogik in den Context zu verlagern, um möglichst lange mit dem ByteBuffer arbeiten zu können.
WebSocket link
Die eigentliche Magie findet im WebSocket statt. Der Client sendet Events an den Server, der Server ruft die OnEvent Funktion der Komponente auf und sendet das gerenderte Template zurück.
Das Austauschformat ist ein JSON-Array. Der erste Wert ist der Event-Name, der zweite Wert ist die Komponenten-ID. Die anderen Werte variieren je nach Ereignis.
["ghx-join", "uuid", "componentName", "initData as JWT"]
["ghx-click", "uuid", "eventName"]
["ghx-change", "uuid", "eventName", "formData"]
["ghx-submit", "uuid", "eventName", "formData"]
Das ghx-join
Event wird aufgerufen, wenn der Client die Komponente zum ersten Mal lädt. Der Server gibt das gerenderte Template zurück. Der initData
Wert ist ein JWT Token, der die Daten für die Komponente enthält, da der WebSocket keine Information darüber hat, woher die Komponente kommt.
// WebsocketHandler
func (l *LiveView) WebsocketHandler() func(ctx *fiber.Ctx) error {
return websocket.New(func(c *websocket.Conn) {
// new client connected
componentsByID := make(map[string]Component)
// read messages
for {
// wait for next message
_, msg, err := c.ReadMessage()
if err != nil {
break
}
// parse message
msgParts := []interface{}{}
if err := json.Unmarshal(msg, &msgParts); err != nil {
continue
}
eventType := msgParts[0].(string)
switch msgParts[0] {
case "ghx-join":
ghxID := msgParts[1].(string)
componentName := msgParts[2].(string)
tokenString := msgParts[3].(string)
// validate session
token, err := jwt.ParseWithClaims(tokenString, &Session{}, func(token *jwt.Token) (interface{}, error) {
return []byte("secret"), nil
})
// create new component by name
factory := l.componentFactories[componentName]
component := factory()
// inject the component data from the jwt token
if err := json.Unmarshal([]byte(token.Claims.(*Session).Data), component); err != nil {
fmt.Println("Error unmarshalling session data", err)
continue
}
// mount the component
if err := component.Mount(nil); err != nil {
fmt.Println("Error mounting component", err)
continue
}
componentsByID[ghxID] = component
// render component
html := l.render(ghxID, component)
b, _ := json.Marshal([]interface{}{"ghx-reply", ghxID, html})
// send the rendered component back to the client
c.WriteMessage(websocket.TextMessage, b)
// ... other eventTypes
}
}
})
}
Die anderen Eventtypen funktionieren ähnlich, benötigen aber das Token nicht mehr.
JavaScript Client link
Die JavaScript-Clients von Phoenix und Laravel sind sehr umfangreich. Mein Client ist sehr rudimentär und basiert wie der Phoenix Client auf der JavaScript Lib morphdom
. Es gibt Ansätze hier AlpineJS zu verwenden. Für den Prototyp habe ich bereits das Index Template morphdom per CDN eingebunden.
Als erstes werden alle Komponenten anhand des Attributs data-ghx-id
gesucht. Wenn keine Live Komponenten gefunden werden, wird auch kein WebSocket geöffnet.
(function () {
// find all elements with data-ghx-id attribute
var elements = document.querySelectorAll('[data-ghx-id]');
if (elements.length == 0) {
return;
}
// connect websocket
const ws = new WebSocket('ws://localhost:3000/live/ws');
ws.onmessage = function (event) {
// ...
};
ws.onopen = function () {
// ...
}
window.liveWS = ws;
// initial event handlers
elements.forEach(function (element) {
// ...
});
}());
Jede Komponente muss auf dem Server gestartet werden. Dazu wird für jede Komponente ein ghx-join
Event an den Server gesendet.
ws.onopen = function () {
console.log('connected to websocket');
elements.forEach(function (element) {
var id = element.getAttribute('data-ghx-id');
var component = element.getAttribute('data-ghx-component');
var session = element.getAttribute('data-ghx-session');
ws.send(JSON.stringify(['ghx-join', id, component, session]));
})
}
Da das gerenderte Template vom Server durch einen Morph eingefügt wird, werden nur die geänderten HTML-Elemente mit Listenern versehen. Daher werden zu Beginn für alle Elemente die Listener gesetzt.
// initial event handlers
elements.forEach(function (element) {
var id = element.getAttribute('data-ghx-id');
var component = element.getAttribute('data-ghx-component');
console.log(id, component);
// ghx-click
var clickElements = element.querySelectorAll('[ghx-click]');
clickElements.forEach(function (clickElement) {
clickElement.addEventListener('click', onClick);
});
});
Server Side Rendering morphen link
Zuletzt wird das gerenderte Template vom Server mit dem Algorithmus morphdom
in das bestehende DOM eingefügt. Ich habe Ansätze gesehen, die das mit AlpineJS.Morph umsetzen, das sollte auch funktionieren. Für meinen Anwendungsfall habe ich mich für morphdom
entschieden.
Da JavaScript EventListener mehrfach gesetzt werden können und dann auch mehrere Events auslösen, muss darauf geachtet werden, dass die Listener nur einmal gesetzt werden. Daher werden die Listener für neue Elemente im morphdom Event onNodeAdded
gesetzt und für entfernte Elemente im Event onBeforeNodeDiscarded
entfernt.
ws.onmessage = function (event) {
// parse the message (json array)
const data = JSON.parse(event.data);
const action = data[0];
const id = data[1];
// find the component element by id
var element = document.querySelector('[data-ghx-id="' + id + '"]');
if (!element) {
console.error('element not found', id);
return
}
if (action === 'ghx-reply') {
// convert the html string to a dom element
var div = document.createElement('div');
div.innerHTML = data[2];
console.log('ghx-reply', div.firstChild);
// morph the dom
morphdom(
element,
div.firstChild,
{
// add listener to new elements
onNodeAdded: function (node) {
console.log('node added', node);
// add ghx-click event handlers
var clickElements = node.querySelectorAll('[ghx-click]');
clickElements.forEach(function (clickElement) {
clickElement.addEventListener('click', onClick);
});
},
// remove listener from removed elements
onBeforeNodeDiscarded: function (node) {
console.log('node discarded', node);
// remove ghx-click event handlers
var clickElements = node.querySelectorAll('[ghx-click]');
clickElements.forEach(function (clickElement) {
clickElement.removeEventListener('click', onClick);
});
},
}
);
}
}
Zur Vervollständigung des Prototyps fehlt noch die Event-Handling für ghx-click
.
// find closest parent with data-ghx-id or ghx-change
function findParentAttr(element, attrName) {
while (element) {
if (element.hasAttribute(attrName)) {
return element.getAttribute(attrName);
}
element = element.parentElement;
}
return null;
}
function onClick(event) {
var id = findParentAttr(event.target, 'data-ghx-id');
var action = findParentAttr(event.target, 'ghx-click');
console.log('click', id, action);
window.liveWS.send(JSON.stringify(['ghx-click', id, action]));
}
Fazit link
Der Prototyp funktioniert und zeigt, dass es relativ einfach ist, LiveView Komponenten in Go zu implementieren. Es fehlen noch relativ viel Error Handling, Security Features wie CSRF Token und einige Performance Optimierungen. Dazu ist die größte Schwachstelle im deutschen Internet der WebSocket. Hier müssten noch einige Optimierungen für den Reconnect implementiert werden.
LiveView und LiveWire sind sehr interessante Konzepte, die es ermöglichen komplexe Webanwendungen ohne JavaScript zu schreiben. Der WebSocket benötigt eine stabilere Internetverbindung als der Laravel LiveWire Polling Ansatz. Beide Ansätze machen aber nur Spaß, wenn das UI wirklich schnell reagiert, da alles vom Server kommt. Ich würde ihn für Software empfehlen, die immer mit Internet auf dem Server arbeitet.