בואו נבנה לוח מודעות ב Rails בלי JavaScript

29/01/2023

אחת הדוגמאות הראשונות שאני מראה ל Web Sockets היא בניה של לוח מודעות משותף לכמה גולשים - כל פעם שגולש אחד שולח הודעה, ההודעה מיד מופיעה על המסך של כל האחרים. והיום ננסה לבנות את אותו לוח מודעות בלי לכתוב שורה של JavaScript, רק באמצעות המנגנונים המובנים ב Rails.

1. שלב 1 - לוח מודעות

השלב הראשון בדרך ללוח מודעות משותף הוא בניית לוח מודעות. וכן הוא יהיה משותף כי יש לנו צד שרת, אבל עדיין לא בזמן אמת. הקוד לשלב הזה נמצא כאן:

לוח מודעות שלב 1

בניתי מודל בשביל הודעה וקונטרולר עם הקוד הבא:

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

  def create
    @message = Message.new(message_params)
    @message.save!

    redirect_to messages_path
  end

  def message_params
    params.require(:message).permit(:text)
  end
end

הפונקציה היחידה כאן שמייצרת HTML היא index, שאחראית על הצגת כל ההודעות וגם מציגה טופס ליצירת הודעה חדשה. הקוד של התבנית מחולק לשני קבצים, הקובץ הראשון הוא app/views/messages/index.html.erb וזה הקוד שלו:

<h1>Messages#index</h1>
<%= render partial: 'message', collection: @messages %>

<%= form_for @message, url: messages_path, method: :POST do |f| %>
  <%= f.label :text %>
  <%= f.text_field :text %>
  <%= f.submit :send %>
<% end %>

והקובץ השני הוא ה HTML של כל הודעה בקובץ app/views/messages/_message.html.erb:

<p><%= message.text %></p>
<hr />

נשים לב כבר שבלי שום JavaScript Framework או שום דבר אני מקבל בקלות הפרדה שבה כל חלק בעמוד כתוב בקובץ נפרד, וחלק אחד - רשימת ההודעות - משתמש בחלק השני - דף ההודעה.

אם תפעילו את הקוד תוכלו לגלוש מקומית לנתיב:

http://localhost:3000/messages

לראות רשימה של שתי הודעות מובנות וטופס שיוצר הודעות חדשות. הטופס מגיע לפונקציה create ב controller שתשמור את ההודעה החדשה ותשלח Redirect לנתיב רשימת ההודעות, שם הגולש יראה את כל ההודעות ואת ההודעה החדשה שלו.

2. שיפור ביצועים - דילוג על ה Redirect

שלב שני בדרך ליצירת הלוח השיתופי הוא שיפור הארכיטקטורה - במקום לענות לגולש עם הודעת Redirect, אנחנו נשלח הודעת Turbo Stream שזה בעצם בקשה לעדכון העמוד לפי כללים מסוימים. במקרה שלנו יהיו שתי הודעות:

  1. בקשה להוסיף את ההודעה החדשה לרשימת ההודעות.
  2. בקשה לנקות את הטקסט בטופס.

הקוד לשלב הזה נמצא כאן: שלב 2

בשביל לשלוח את ההודעות לגולש כתבתי את הקוד הבא בפונקציה create:

def create
  @message = Message.new(message_params)
  respond_to do |format|
    if @message.save
      format.html { redirect_to messages_path }
      format.turbo_stream do
        render turbo_stream: [
          turbo_stream.append('messages', partial: 'message', locals: { message: @message }),
          turbo_stream.replace('new_message', partial: 'new_message_form')
        ]
      end
    else
      format.html { render :index, status: :unprocessable_entity }
    end
  end
end

וזה הכל - בעצם שתי השורות:

turbo_stream.append('messages', partial: 'message', locals: { message: @message }),
turbo_stream.replace('new_message', partial: 'new_message_form')

הן ההוראות לעמוד לעדכן את ה HTML לפי הפרטים החדשים. כשהדפדפן יקבל את התשובה מה POST, שנראית ככה:

<turbo-stream action="append" target="messages"><template><p>new text</p>
<hr />
</template></turbo-stream><turbo-stream action="replace" target="new_message"><template><form class="new_message" id="new_message" action="/messages" accept-charset="UTF-8" method="post"><input type="hidden" name="authenticity_token" value="Z4ROwg-7zOPU38BqhifwH3AdWu6NZ8f-Oy-xoPV_ZClJxs0FjpwmjtYeG3Pcd2QyasuRhrGRi9QEP1I27gb0DA" autocomplete="off" />
  <label for="message_text">Text</label>
  <input type="text" name="message[text]" id="message_text" />
  <input type="submit" name="commit" value="send" data-disable-with="send" />
</form>
</template></turbo-stream>

קוד JavaScript מתוך הספריה Turbo יפענח את התשובה ויעדכן אוטומטית את העמוד. בלי טעינה מחדש ובלי הפניה למקומות אחרים, ממש כמו שהיה קורה עם כל פריימוורק צד לקוח.

3. שיתוף ההודעות עם גולשים אחרים בזמן אמת

וכמובן השלב הכי מדליק בעמוד הוא השיתוף בזמן אמת באמצעות Web Sockets: במקום לשלוח את הוראת העדכון "בתגובה" לגולש שיצר הודעה חדשה, אני יכול לשלוח את אותה הוראת עדכון דרך Socket, פשוט כי משהו בשרת השתנה. בצורה כזאת כל הגולשים יקבלו את ההודעה החדשה בלי שיצטרכו לרענן את העמוד.

הקוד לשלב השלישי נמצא כאן: שלב 3

ובעיקרון הוא מורכב בסך הכל משני שינויים. במודל app/models/message.rb הוספתי את השורה:

class Message < ApplicationRecord
  after_create_commit { broadcast_append_to('messages') }
end

שמפעילה פקודת שידור אחרי כל יצירת הודעה.

ב Controller מחקתי את ההוראה ליצור הודעה חדשה, כי היא תגיע מה Broadcast:

  def create
    @message = Message.new(message_params)
    respond_to do |format|
      if @message.save
        format.html { redirect_to messages_path }
        format.turbo_stream do
          render turbo_stream: [
            turbo_stream.replace('new_message', partial: 'new_message_form')
          ]
        end
      else
        format.html { render :index, status: :unprocessable_entity }
      end
    end
  end

ובטמפלייט הוספתי את הפקודה:

<%= turbo_stream_from "messages" %>

לקובץ app/views/messages/index.html.erb. שימו לב שהמילה messages שם זהה לפרמטר שהעברתי ל broadcast_append_to שהיה במודל.

התוצאה - כל משתמש יכול לשלוח הודעה לשרת, ההודעה גם תיכנס לבסיס הנתונים וגם תישלח ב Web Socket לכל הגולשים האחרים באתר. וכל זה בלי לכתוב שורת JavaScript אחת.

4. מה הלאה

הדוגמה שהוצגה כאן מאוד פשטנית - בעולם האמיתי היינו צריכים לנהל משתמשים, קבוצות ולשלוח עדכונים רק לאנשים שהעדכון באמת רלוונטי עבורם. תשמחו לשמוע שבריילס יש את כל הכלים לעשות את זה, כולל ניהול הרשאות ברישום לעדכונים והכל בתוך הכלים של ריילס.

טורבו Streams, שזה החלק שמקבל הוראות מהשרת ומעדכן את העמוד תומך במספר פעולות מובנות עבור הוספה ומחיקה בכל מיני מקומות (הוספה לסוף, הוספה להתחלה ודברים כאלה), וגם מאפשר לכם לכתוב קוד JavaScript כדי לממש פעולות חדשות משלכם, כך שגם בתוכניות גדולות יותר כנראה שלא תיתקעו.

בצד השלילי וכמו תמיד בפריימוורקים מהסוג הזה, כשהשליטה שלכם מוגבלת לפעמים קשה יותר למצוא באגים או לממש Use Cases מתוחכמים יותר, למשל לא ברור איך לממש Optimistic Updates או מצב Offline כשצריך את השרת לכל שינוי.

בשורה התחתונה אם אתם כבר בריילס והאפליקציה שלכם לא מאוד מתוחכמת שילוב טכנולוגיה כמו Turbo Streams יעזור לכם להישאר בריילס בלי לשכתב את הכל לפריימוורק צד-לקוח.