ActionController::Live (chat)

ActionController::Live (chat)

Server-Sent Events ermöglichen es Ihnen, Ereignisse im Browser über eine persistente Verbindung zum Server auszulösen. Hierfür ist das Streamen von Daten in Rails erforderlich, was durch ActionController::Live realisiert werden kann.

Erstellung eine App

Erst erschaffen wir uns eine App mit einem kleinen Messanger.
$ rails new ActionController-Live
$ cd ActionController-Live
$ rails g controller Messages index create
$ rails g model Message name content:text
$ rails db:migrate
Jetzt bearbeiten wir den rooter.
// config/routes.rb

Rails.application.routes.draw do
  resources :messages
  root 'messages#index'
  get 'up' => 'rails/health#show', as: :rails_health_check
end
Es fehlt noch ein Basis-Code (15-basis-code) eine etwas ansprechbarere Optik.

1.png 610 KB


Nach der Optik fehlt uns jetzt die Funktionalität. Wir fangen mit dem Controller an. Einmal soll er uns anzeigen und einmal erstellen.
// app/controllers/messages_controller.rb

class MessagesController < ApplicationController
  def index
    @messages = Message.all
  end

  def create
    @message = Message.create!(params[:message].permit(:content, :name))
  end
end
Wir erstellen noch ein Partial und ändern unsere create.html Datei in eine create.js, die index passen wir an und eine _message.scss.
// app/views/messages/_message.html.erb

<li>
  <%= message.name %>: <%= message.content %>
</li>
app/views/messages/create.html.erb ändern in app/views/messages/create.js.erb und folgendes eingeben.
// app/views/messages/create.js.erb

$('#chat').append("<%= j render(@message) %>");
$("#message_content").val('');
Die index Datei in der wir beides aufrufen:
// app/views/messages/index.html.erb

<h1>Chat</h1>

<ul id="chat">
  <%= render @messages %>
</ul>

<%= form_for Message.new, remote: true do |f| %>
  <%= f.text_field :name, placeholder: "Name" %>
  <%= f.text_field :content %>
  <%= f.submit "Send" %>
<% end %>
Der chat ist noch nicht zu sehen, daher erstellen wir noch eine _messages.scss Datei und dann haben wir unsere Basis geschaffen.
// app/assets/stylesheets/_messages.scss

ul#chat {
  font-size: 12px;
  list-style: none;
  padding: 0;
  width: 350px;
  height: 150px;
  border: solid 1px #777;
  margin: 5px 0;
  overflow: auto;

  li {
    margin: 2px 5px;
    padding: 0;
  }
}

input#message_name {
  width: 70px;
}

input#message_content {
  width: 215px;
}
Angenommen, wir bauen eine Chat-Anwendung. Im Folgenden ist ein Screenshot zu sehen, der zeigt, was wir bisher erreicht haben. Ein Benutzer kann eine Nachricht eingeben, und wenn er auf "senden" klickt, wird eine AJAX-Anfrage gesendet, um sie in der Datenbank zu speichern und sie dem Chat-Fenster hinzuzufügen.

2.png 605 KB


Wenn zwei Benutzer chatten, sieht nur der Benutzer, der eine Nachricht sendet, diese in seinem Chat-Fenster erscheinen. Jeder muss die Seite neu laden, um Nachrichten von anderen Benutzern zu sehen. Es gibt mehrere Lösungen für dieses Problem, und eine Möglichkeit besteht darin, nach neuen Nachrichten zu suchen. Dies funktioniert, bedeutet jedoch, dass es eine Verzögerung gibt, bevor die anderen Benutzer die Nachricht sehen, und es erhöht drastisch die Anzahl der Anfragen an unsere Anwendung. Alternativ könnten wir WebSockets verwenden und dies beispielsweise über Faye veröffentlichen. In dieser Episode werden wir eine dritte Technik verwenden, Server-Sent Events, die es uns ermöglichen, Benachrichtigungen über das HTTP-Protokoll zu veröffentlichen.

Mit Server-Sent Events können wir auf dem Client eine neue EventSource erstellen und ihr einen Pfad oder eine URL übergeben. Dadurch wird eine persistente Verbindung zum Server aufrechterhalten. Außerdem können wir eine Rückruffunktion bereitstellen, die immer dann ausgelöst wird, wenn ein Ereignis vom Server empfangen wird. Dies erfordert das Senden von Daten in einem bestimmten Format. Leider wird dies nicht von allen Browsern unterstützt, aber aktuelle Versionen aller gängigen Browser sollten funktionieren, mit Ausnahme von Internet Explorer. Es gibt jedoch eine Drittanbieter-Bibliothek, die Unterstützung für IE hinzufügen kann. Was Rails betrifft, so besteht die knifflige Aufgabe darin, die Ereignisdaten vom Server zu streamen. In früheren Versionen von Rails war dies ziemlich schwierig, aber seit Rails 4 hat eine neue Funktion namens ActionController::Live, die dies erheblich vereinfacht.

Verwendung von ActionController::Live in unserer Anwendung

Wir werden Server-Sent Events nutzen, um Benachrichtigungen in unserer Chat-Anwendung zu empfangen. Dann können wir das ActionController::Live-Modul in einem unserer Controller einbinden und es verwenden, um Antworten zu streamen. Wir werden dies in unserem MessagesController verwenden und eine Events-Aktion erstellen, die Ereignisse an den Client sendet. Wir schreiben in den Stream, indem wir response.stream.write aufrufen, und vorerst schreiben wir einfach drei Stücke Testdaten in den Stream und pausieren zwei Sekunden zwischen jedem Stück. Nachdem wir das Schreiben in einen Stream abgeschlossen haben, ist es wichtig, ihn zu schließen, damit er nicht für immer geöffnet bleibt. Dies erreichen wir, indem wir response.stream.close aufrufen. Es ist ratsam, dies mit ensure zu verwenden, damit es auch aufgerufen wird, wenn eine Ausnahme auftritt.
// app/controllers/messages_controller.rb

class MessagesController < ApplicationController
  include ActionController::Live

  def index
    @messages = Message.all
  end

  def create
    @message = Message.create!(params[:message].permit(:content, :name))
  end

  def events
    3.times do |n|
      response.stream.write "#{n}...\n\n"
      sleep 2
    end
  ensure
    response.stream.close
  end
end
Um dies auszuprobieren, müssen wir diese Aktion zu unseren Routen hinzufügen. Das werden wir jetzt tun.
// config/routes.rb

Rails.application.routes.draw do
  resources :messages do
    collection { get :events }
  end
  root 'messages#index'
  get 'up' => 'rails/health#show', as: :rails_health_check
end
Wir können nun überprüfen, ob wir Daten streamen, indem wir "curl" verwenden.
$ curl localhost:8080/messages/events
0...

1...

2...
Wir können diesen Server starten, indem wir rails s -p 8080 ausführen, und wenn wir den Curl-Befehl erneut ausführen, sehen wir, dass die Antwort genau so gestreamt wird, wie wir es möchten. Wir können auch testen, ob wir mehrere Verbindungen gleichzeitig verarbeiten können, indem wir den Curl-Befehl mehrmals wiederholen. (Beachten Sie, dass die Wiederholung nur mit Zsh funktioniert, nicht mit Bash.)
$ repeat 2 (curl localhost:3000/messages/events)
Wenn wir dies tun, kommen die Antworten nacheinander in einer Anfrage an, sodass es so aussieht, als würden sie nicht asynchron verarbeitet werden. Thread-Support ist standardmäßig im Entwicklungsmodus deaktiviert. Um dies zu aktivieren, müssen wir sowohl cache_classes als auch eager_load auf true setzen.
// config/environments/development.rb

config.eager_load = true
config.cache_classes = true

Streaming Nützliche Daten

Wenn wir den Server neu starten und dann den Curl-Befehl erneut ausführen, kommen die Antworten asynchron an. Mit dieser Funktionalität aktualisieren wir nun den Client, damit er neue Ereignisse an diesem Pfad abhört. Dies geschieht, indem wir eine neue EventSource erstellen und den korrekten Pfad übergeben, dann einen Event-Listener hinzufügen. Dabei müssen wir dem Listener den Namen des Ereignisses mitteilen, auf das wir hören möchten, und wenn wir 'message' übergeben, funktioniert dies als Standardwert. Wir können auch eine Rückruffunktion übergeben, die jedes Mal aufgerufen wird, wenn ein Ereignis ausgelöst wird. Vorläufig zeigen wir nur ein Alert-Fenster an, das die Daten des Ereignisses anzeigt.
// app/javascript/application.js

var source = new EventSource("/messages/events");
source.addEventListener("message", function (e) {
  alert(e.data);
});
Zurück im Controller können wir die events-Aktion anpassen, damit sie so antwortet, wie es vom Browser erwartet wird.
// app/controllers/messages_controller.rb

  def events
    response.headers['Content-Type'] = 'text/event-stream'
    3.times do |n|
      response.stream.write "data: #{n}...\n\n"
      sleep 2
    end
  rescue IOError
    logger.info 'Stream closed'
  ensure
    response.stream.close
  end
Zuerst setzen wir den Header "Content-Type". Immer wenn wir Header für eine Streaming-Antwort anpassen, müssen wir dies vor dem Schreiben oder Schließen des Streams tun, da andernfalls eine Ausnahme ausgelöst wird, wenn wir versuchen, dies später zu tun. Dies muss für alle Aktionen des Controllers erfolgen, auch wenn die anderen Aktionen keinen Streaming verwenden, da sie alle das Verhalten von ActionController::Live einschließen. Wenn wir dies vermeiden möchten, müssen wir unsere Streaming-Aktionen in einen separaten Controller verschieben. Um Ereignisse im Browser auszulösen, müssen wir jede Zeile des Antwort-Streams mit "data:" beginnen und mit zwei Zeilenumbrüchen beenden. Es ist auch eine gute Idee, eventuelle IOError-Ausnahmen abzufangen, die auftreten können, wenn die Stream-Verbindung geschlossen wird, während wir versuchen, in sie zu schreiben. Wenn dies geschieht, loggen wir diesen Fehler. Nachdem wir den Server neu gestartet und die Seite neu geladen haben, sollten wir nun für jede Zeile der Daten, die an den Browser gestreamt wird, einen Alert sehen.

3.png 676 KB

Nachdem alle drei Ereignisse empfangen wurden, wird die Verbindung geschlossen. EventSource versucht jedoch, sie offen zu halten, sodass sie wieder geöffnet und die Ereignisse erneut ausgelöst werden.

Mit unserem Event-System können wir es nun nutzen, um Nachrichten anzuzeigen, sobald sie eintreffen. Eine Möglichkeit, dies zu tun, besteht darin, die Datenbank nach neuen Nachrichten zu durchsuchen, und genau das werden wir tun. Wir werden unsere Events-Aktion entsprechend ändern.
// app/controllers/messages_controller.rb

def events
  response.headers["Content-Type"] = "text/event-stream"
  start = Time.zone.now
  10.times do
    Message.uncached do
      Message.where('created_at > ?', start).each do |message|
        response.stream.write "data: #{message.to_json}\n\n"
        start = Time.zone.now
     end 
   end
  sleep 2
  end
rescue IOError
  logger.info "Stream closed"
ensure
  response.stream.close
end
Hier holen wir die Nachrichten ab, die nach der aktuellen Zeit erstellt wurden, und geben jede als JSON zurück. Nachdem wir das getan haben, setzen wir die Zeit zurück, damit beim nächsten Mal nur neue Nachrichten abgerufen werden. Wir werden dies vorerst zehn Mal pro Anfrage tun, obwohl es automatisch nach einer Neuverbindung erfolgt. In der Produktion würden wir dies für einen längeren Zeitraum tun, aber in der Entwicklung wird dies kurz gehalten, wenn die Verbindung hängt. ActiveRecord zwischenspeichert die Datenbankabfrage, die wir pro Anfrage durchführen, sodass neue Nachrichten nicht jedes Mal gefunden werden. Um dies zu beheben, umgeben wir diesen Code mit einem Message.uncached-Block. Abfragen, die im Block ausgeführt werden, werden nicht zwischengespeichert, sodass wir alle neuen Nachrichten erhalten.

Nun passen wir unsere Javascript-Datei an, sodass wir anstelle eines Alerts, wenn ein Ereignis empfangen wird, das JSON parsen, um eine Nachricht zu erhalten, und diese an die Liste anhängen. Vorher installieren wir noch das jquery-rails gem.
// Gemfile

gem "jquery-rails"
Jetzt muss es noch eingebunden werden.
// app/javascript/application.js

//= require jquery
Und im manifest.
// app/assets/config/manifest.js

//= link jquery.js

// app/javascript/application.js

var source = new EventSource("/messages/events");
source.addEventListener("message", function (e) {
  var message = $.parseJSON(e.data);
  $("#chat").append($("<li>").text(`${message.name}: ${message.content}`));
});
mit aktueller Zeit.
var source = new EventSource("/messages/events");
source.addEventListener("messages", function (e) {
  var message = $.parseJSON(e.data);
  var createdAtDate = new Date(message.created_at);
  var formattedTime = createdAtDate.toLocaleTimeString("de-DE", {
    hour: "numeric",
    minute: "numeric",
    second: "numeric",
  });
  console.log(formattedTime);

  $("#chat").append(
    $("<li>")
      .text(`${message.name}: ${message.content}`)
      .append($("<span>").text(`${formattedTime}`))
  );
}
Wir müssen den Server neu starten, um dies auszuprobieren, aber sobald wir das getan haben, können wir zwei Fenster öffnen und in einem von ihnen eine Nachricht eingeben, um zu sehen, ob sie im anderen erscheint.

4.png 510 KB


Die Nachricht erscheint im anderen Fenster, aber sie wird auch im Fenster wiederholt, in dem wir die Nachricht eingegeben haben. Dies liegt daran, dass wir sie als Reaktion auf die AJAX-Anfrage hinzufügen, aber dies lässt sich leicht beheben. In der create-Aktion haben wir eine JavaScript-Vorlage, die ausgeführt wird, wenn eine Nachricht hinzugefügt wird, und wir können dies entfernen, um zu verhindern, dass die doppelte Nachricht angezeigt wird.
// app/views/messages/create.js.erb

$("#message_content").val('');

Entfernen der Umfrage (Polling)

Es gibt immer noch eine spürbare Verzögerung, wenn wir die Nachricht senden, bevor sie in anderen Fenstern angezeigt wird, und das liegt daran, dass wir alle zwei Sekunden die Datenbank abfragen. Es wäre viel besser, wenn wir benachrichtigt würden, wenn eine neue Nachricht erstellt wird, und es gibt verschiedene Möglichkeiten, dies zu tun. Wenn wir Postgres als Datenbank für unsere Anwendung verwenden, könnten wir die NOTIFY- und LISTEN-Befehle verwenden, um Daten über einen Kanal an alle Zuhörer zu senden. Eine andere Möglichkeit besteht darin, Redis zu verwenden, das seine eigenen Pub/Sub-Funktionen hat, und das ist es, was wir hier verwenden werden. Zuerst fügen wir das entsprechende Gem zur Gemfile hinzu.
// Gemfile

gem 'redis'
Als Nächstes erstellen wir eine Initializer-Datei und richten darin eine gemeinsame Redis-Verbindung ein.
// config/initializers/redis.rb

$redis = Redis.new
Und redis noch auf dem system installieren am einfachsten mit brew.
$ brew install redis
Jedes Mal, wenn wir jetzt eine neue Nachricht erstellen, werden wir sie über Redis veröffentlichen.
// app/controllers/messages_controller.rb
  
  def create
    attributes = params.require(:message).permit(:content, :name)
    @message = Message.create!(attributes)
    $redis.publish('messages.create', @message.to_json)
  end
Wenn wir nach Ereignissen lauschen, müssen wir nicht mehr die Datenbank abfragen. Stattdessen können wir uns für die Redis-Datenbank abonnieren und alle ankommenden Nachrichten streamen.
// app/controllers/messages_controller.rb

def events
  response.headers['Content-Type'] = 'text/event-stream'
  start = Time.zone.now
  redis = Redis.new
  redis.subscribe('messages.create') do |on|
    on.message do |_event, data|
      response.stream.write("data: #{data}\n\n")
    end
  end
rescue IOError
  logger.info 'Stream closed'
ensure
  redis.quit
  response.stream.close
end
Dies sperrt die Verbindung, daher müssen wir eine neue Verbindung zur Datenbank erstellen. Dann können wir uns für messages.create abonnieren, und wenn eine Nachricht eintrifft, können wir sie streamen. Schließlich stellen wir sicher, dass die Verbindung geschlossen wird.

Wir können dies jetzt ausprobieren. Wir starten den redis-server, starten den Puma-Server neu und öffnen dann zwei Browser-Fenster.

5.png 516 KB


Wenn wir eine Nachricht in einem Fenster eingeben, erscheint sie nun sofort im anderen. Es scheint jedoch ein kleines Problem zu geben, da die eingegebene Nachricht nicht aus dem Textfeld gelöscht wurde. Das liegt daran, dass wir den falschen MIME-Typ von der create-Aktion zurückgeben, aber das ist einfach zu beheben.
// app/controllers/messages_controller.rb

  def create
    response.headers['Content-Type'] = 'text/javascript'
    attributes = params.require(:message).permit(:content, :name)
    @message = Message.create!(attributes)
    $redis.publish('messages.create', @message.to_json)
  end
Es scheint, dass beim Starten der Veröffentlichung in Redis der Content-Type auf text/html geändert wurde, und daher müssen wir ihn überschreiben.

Während wir im Controller sind, zeigen wir Ihnen etwas anderes, das wir mit unserer Redis-Konfiguration tun können. Anstatt subscribe zu verwenden, können wir psubscribe verwenden, was für "pattern subscribe" steht, um alle events im Zusammenhang mit Nachrichten abzugleichen. Wir können dann pmessage verwenden, um alle Events anzuhören, die zu einem Muster passen.
// app/controllers/messages_controller.rb

  def events
    response.headers['Content-Type'] = 'text/event-stream'
    start = Time.zone.now
    redis = Redis.new
    redis.psubscribe('messages.*') do |on|
      on.pmessage do |_patern, _event, data|
        response.stream.write("event: #{_event}\n")
        response.stream.write("data: #{data}\n\n")
      end
    end
  rescue IOError
    logger.info 'Stream closed'
  ensure
    redis.quit
    response.stream.close
  end
Mit dieser Konfiguration können wir den Ereignisnamen zusammen mit den Daten an den Client zurücksenden. Beachten Sie, dass nach dem Ereignis nur ein Zeilenumbruchzeichen steht, im Gegensatz zu den Daten, wo wir zwei gesendet haben. Jetzt können wir unsere Script-Datei so ändern, dass wir auf das create-Ereignis hören.
// app/javascript/application.js

var source = new EventSource("/messages/events");
source.addEventListener("messages.create", function (e) {
  var message = $.parseJSON(e.data);
  var createdAtDate = new Date(message.created_at);
  var formattedTime = createdAtDate.toLocaleTimeString("de-DE", {
    hour: "numeric",
    minute: "numeric",
    second: "numeric",
  });
  console.log(formattedTime);

  $("#chat").append(
    $("<li>")
      .text(`${message.name}: ${message.content}`)
      .append($("<span>").text(`${formattedTime}`))
  );
}
Wir können jetzt problemlos weitere Ereignisse zum Controller hinzufügen, um Nachrichten zu aktualisieren oder andere Aktionen sofort auf dem Client zu reflektieren.

Skalierung für die Produktion

Unsere Lösung ist jetzt weitgehend abgeschlossen, und das Hinzufügen einer Nachricht sendet sie sofort an alle lauschenden Clients. Lassen Sie uns kurz betrachten, wie wir dies für eine Produktionsumgebung skalieren könnten. Wenn wir Puma als unseren Webserver verwenden, wird er standardmäßig auf maximal 16 Threads eingestellt. Dies bedeutet, dass wir nur 16 gleichzeitige persistente Verbindungen haben könnten, und diese werden schnell aufgebraucht, wenn wir lang laufende Verbindungen verwenden, wie in unserer App. Wir müssen entweder diese Zahl erhöhen oder einen anderen Server wie Thin oder Rainbows ausprobieren. Eine weitere Einstellung, die wir ändern müssen, ist die Pool-Grenze, die in der Database YAML-Datei festgelegt ist. Diese hat standardmäßig einen Wert von 5, aber wir sollten ihn auf die Anzahl der Threads erhöhen, die wir pro Rails-Instanz akzeptieren möchten. Rails reserviert eine Datenbankverbindung für jede Anfrage, auch wenn diese Anfrage nicht mit der Datenbank spricht.
Meld dich an und schreibe ein Kommentar