• בלוג
  • עמוד 151
  • מדריך קוד: בואו נכתוב אפליקציית הודעות בזמן אמת עם Pheonix ו React

מדריך קוד: בואו נכתוב אפליקציית הודעות בזמן אמת עם Pheonix ו React

12/10/2020

פיניקס היא פריימוורק צד השרת האהובה עליי בימים אלה. היא לקחה הרבה השראה מ Rails, כתובה בשפת Elixir ורצה בתוך המכונה הוירטואלית של Erlang. היא מספקת ביצועים מעולים ליישומים המבוססים על Web Sockets (קצת כמו Node.JS), ובאותו הזמן מגיעה עם כל הבטריות בפנים (קצת כמו Rails).

בפוסט זה אראה איך לכתוב אפליקציית Pheonix ראשונה עם קוד Front End בריאקט ולהשתמש ביכולות ה Sockets של פיניקס כדי לסנכרן בזמן אמת בין מספר דפדפנים הצופים באותו עמוד.

1. מה אנחנו בונים

המטרה שלי היא להכיר את פיניקס ולהראות את החיבור שלה לריאקט, ובמיוחד להתמקד בהצגת המידע שעל השרת לכל הדפדפנים המחוברים. בדיוק כמו שהיינו עושים עם Socket.IO בעולם של Node.JS או עם ActionCable בעולם של ריילס.

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

קוד התוכנית המלא זמין בגיטהאב בפרויקט https://github.com/ynonp/pheonix-react-demo.

נמשיך לראות איך זה עובד דרך הצגת קטעי הקוד המרכזיים ביישום.

2. איך פיניקס מטפל בפניה מדפדפן

כמו הרבה פריימוורקים בצד השרת גם בפיניקס המסלול שלנו לטיפול בלקוח מתחיל ב Router. במבנה פרויקט ברירת המחדל של פיניקס ה Router יושב בקובץ בתוך תיקיית lib ותת תיקיה ששמה הוא שם הפרויקט קו תחתי והמילה web. אני בחרתי את המילה hello בתור שם הפרויקט ולכן ה Router שמור בקובץ lib/hello_web/router.ex. זה התוכן:

defmodule HelloWeb.Router do
  use HelloWeb, :router

  pipeline :browser do
    plug :accepts, ["html"]
    plug :fetch_session
    plug PhoenixGon.Pipeline
    plug :fetch_flash
    plug :protect_from_forgery
    plug :put_secure_browser_headers
  end

  pipeline :api do
    plug :accepts, ["json"]
  end

  scope "/", HelloWeb do
    pipe_through :browser

    get "/", PageController, :index
    get "/hello", HelloController, :index
  end
end

רצף pipeline מקביל לרצף של Middlewares באקספרס או ריילס. פקודת ה pipe_through בהתחלה של scope מחברת בין נתיב או אוסף נתיבים לרצף middlewares ובדוגמה הפשוטה שלנו נשארתי כמעט עם כל הגדרות ברירת המחדל של פיניקס.

בשביל להוסיף נתיב חדש צריך רק להוסיף פקודת get חדשה (או post, או put, נו כמו במקומות אחרים). הפרמטר הראשון הוא הנתיב (וכן אפשר להשתמש בפרמטרים עם נקודותיים כמו באקספרס), אחריו מגיע המודול שמטפל בבקשה ובסוף הפונקציה בתוך המודול שאחראית על הטיפול. לדוגמה אם היינו מוסיפים את השורה:

get "/pages/:page", PageController, :show

אז כל פניה לנתיב שמתחיל ב /pages ואחרי הסלאש מגיע שם או מזהה עמוד תנותב לתוך מודול טיפול בשם PageController ולפונקציה show שלו.

במקרה שלנו הנתיב היחיד שמעניין אותי הוא:

get "/hello", HelloController, :index

את המודול HelloController אני מוצא בקובץ lib/hello_web/controllers/hello_controller.ex, וכן בסגנון ריילס גם כאן שם הקובץ דומה לשם המודול עם החלפת הקו התחתי באות ראשונה גדולה. זה תוכן הקובץ:

defmodule HelloWeb.HelloController do
  use HelloWeb, :controller
  import PhoenixGon.Controller

  plug :put_view, HelloWeb.LayoutView

  def index(conn, _params) do
    messages = Hello.Message |> Hello.Repo.all
    conn = put_gon(conn, messages: messages)
    render(conn, "app.html")
  end
end

השורות הראשונות שם בשביל פיניקס, ואותנו מעניינת הפונקציה index. הפונקציה מקבלת אוביקט שנקרא conn שזה האוביקט שמייצג גם את הבקשה מהדפדפן וגם את תשובת השרת. בדומה לריילס ולאקספרס, אנחנו "מוסיפים" אליו משתנים ובסוף שולחים אותו ל render כדי לבנות ללקוח קובץ html.

בפרויקט הדוגמה השתמשתי בספריה בשם pheonix_gon כדי להעביר משתנים בקלות מהשרת ל JavaScript. בגדול gon חסך לי לכתוב JSON לעמוד ואז מ JavaScript לפענח אותו. במקום זה יש לי עכשיו ב JavaScript פונקציה בשם:

window.Gon.assets()

שמחזירה אוביקט עם כל המשתנים ששלחתי באמצעות put_gon. בנתיב index שלחתי משתנה עם השם messages שמכיל את כל ההודעות, ולכן בקוד JavaScript אני אוכל לכתוב:

console.log(window.Gon.assets().messages);

ולקבל את תוכן המשתנה.

3. איך פיניקס ניגש לבסיס הנתונים

יש עדיין שורה אחת מבלבלת בקוד ה Controller והיא השורה:

messages = Hello.Message |> Hello.Repo.all

טוב הדבר הראשון שמרתיע בה הוא סימן ה Pipeline של אליקסיר, אז בואו נכתוב אותה מחדש בכתיב לא-אליקסירי:

messages = Hello.Repo.all(Hello.Message)

ועכשיו אנחנו מוכנים לדבר על החיבור בין פיניקס לבסיס הנתונים.

פיניקס משתמשת בספריית ORM שנקראת Ecto. אני עדיין מתרגל לכתיב של אקטו, אבל בגדול בגלל שאנחנו בשפה פונקציונאלית אז אקטו לא מספק "מחלקות" שיתאימו לטבלאות בבסיס הנתונים, אלא במקום זה יש לי מודול של בסיס הנתונים שנקרא Hello.Repo ועליו יש המון פונקציות לקריאה, כתיבה ועדכון הטבלאות. כל פונקציה מקבלת משהו שנקרא Schema שבעצם מתאר טבלה.

איך יוצרים סכימה? טוב בשביל זה אנחנו צריכים שני דברים: הראשון הוא קובץ Migration שבאמת יוצר את הטבלה בבסיס הנתונים. שלי שמור עם השם priv/repo/migrations/20201010122214_create_messages.exs , ובו כתוב התוכן הבא:

defmodule Hello.Repo.Migrations.CreateMessages do
  use Ecto.Migration

  def change do
    create table(:messages) do
      add :text, :string
      timestamps()
    end
  end
end

והשני הוא קובץ הסכימה ששמור אצלי בתור lib/hello/message.ex ובו מופיע התוכן הבא:

defmodule Hello.Message do
  use Ecto.Schema
  import Ecto.Changeset

  @derive {Jason.Encoder, only: [:text, :id]}
  schema "messages" do
    field :text, :string
    timestamps()
  end

  @doc false
  def changeset(message, attrs) do
    message
    |> cast(attrs, [:text])
    |> validate_required([:text])
  end
end

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

mix phx.gen.schema Message messages text:string

שאגב אחריה הייתי צריך להריץ גם:

mix ecto.migrate

ואם זה מזכיר למישהו את ריילס אז זה כמובן לא בטעות.

ב Ecto תפקיד הסכימה הוא לתאר את המיפוי בין טבלה בבסיס הנתונים לבין מבנה הנתונים בזיכרון של פיניקס. עכשיו אפשר לחזור לשורת השליפה:

messages = Hello.Repo.all(Hello.Message)

הפקודה all היא שליפה שמחזירה את כל השורות מבסיס הנתונים (כן, אני יודע, אף פעם אל תכתבו את זה באפליקציה אמיתית), ואני מעביר לה את המודול Message. המודול כולל הגדרת Schema שמחברת אותנו לטבלה וכך all יודעת להביא את כל הרשומות מהטבלה messages וגם להפוך אותם ל Struct-ים של אליקסיר לפי המבנה של Hello.Message.

ומה עם הכנסת הודעה חדשה? אם הגעתם עד לכאן אז השורה הבאה לא צריכה לבוא בהפתעה:

Hello.Repo.insert(%Hello.Message{text: "Hello World"})

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

4. איך התשובה שלנו נשלחת לדפדפן

עד עכשיו יצרנו נתיב חדש ב Router ששלח את הבקשה ל Controller חדש שהסתיים ב render. התחנה הבאה של הבקשה שלנו היא ה View ובפיניקס זה קובץ שנמצא בתיקיית views. השורה הבאה ב Controller קבעה את שם הקובץ המדויק:

plug :put_view, HelloWeb.LayoutView

במקרה שלי הקובץ נמצא ב lib/hello_web/views/layout_view.ex וזה תוכנו:

defmodule HelloWeb.LayoutView do
  use HelloWeb, :view
  import PhoenixGon.View
end

נכון זה לא נראה עמוס אבל זה מספיק. שם הקובץ לבד (המילה layout) אומרת שבאופן אוטומטי כל תשובה שעוברת דרכו תישלח להפוך ל HTML דרך תבנית שנמצאת בתיקיית templates/layout. בעתיד אוכל להוסיף לקובץ זה פונקציות שיהיו זמינות לקוד התבנית.

מאחר וה Controller ביקש לשלוח את התשובה דרך תבנית בשם app.html נוכל להמשיך עם פיניקס ולחפש את הקובץ lib/hello_web/templates/layout/app.html.eex. תוכן הקובץ נראה כך:

<!DOCTYPE html>
<html lang="en">
  <head>
    <meta charset="utf-8"/>
    <meta http-equiv="X-UA-Compatible" content="IE=edge"/>
    <meta name="viewport" content="width=device-width, initial-scale=1.0"/>
    <title>Hello · Phoenix Framework</title>
    <link rel="stylesheet" href="<%= Routes.static_path(@conn, "/css/app.css") %>"/>
    <%= render_gon_script(@conn) %>
    <script defer type="text/javascript" src="<%= Routes.static_path(@conn, "/js/app.js") %>"></script>
  </head>
  <body>
    <main></main>
  </body>
</html>

תבנית פשוטה המשלבת קוד HTML עם קוד צד שרת ובעיקר משאירה את ה HTML שלנו ריק כדי שריאקט יוכל למלא את אלמנט main בתוכן אמיתי.

5. איך לכתוב קוד Front End ביישום פיניקס

נשים לב לשורה הבאה מתוך קובץ הטמפלייט:

<script defer type="text/javascript" src="<%= Routes.static_path(@conn, "/js/app.js") %>"></script>

פיניקס קורא קובץ בשם app.js מתוך תיקיית js. אגב פיניקס מגיע כבר עם וובפאק ו package.json כך שמתכנתי Front End יוכלו להרגיש בבית. קובץ ה js, קובץ הגדרות ה webpack וכל תיקיית node_modules נמצאים בתוך תיקיית assets של הפרויקט. בתוך app אני משתמש בפקודות import כדי לטעון את קבצי ה js וה CSS האמיתיים של הפרויקט:

import "../css/app.scss"
import "phoenix_html"

import "./helloworld";
import "./socket";

וקובץ ריאקט הראשי שלי נקרא helloworld.js ובו נמצא התוכן הבא:

import React, { useEffect, useState } from 'react';
import ReactDOM from 'react-dom';
import socket from "./socket"

function useChannel(onNewMessage) {
  let channel = socket.channel("room:lobby", {})
  channel.join()
    .receive("ok", resp => { console.log("Joined successfully", resp) })
    .receive("error", resp => { console.log("Unable to join", resp) })

  channel.on("new_msg", payload => {
    console.log(payload);
    onNewMessage(payload.body);
  })

  return channel;
}

function App(props) {
  const [messages, setMessages] = useState(props.messages);
  const channel = useChannel(function(newMessage) {
    setMessages(val => [...val, newMessage]);
  });

  function sendMessage(e) {
    e.preventDefault();
    const inputElement = e.target.querySelector('input[name="message-text"]');
    const messageText = inputElement.value;

    channel.push("new_msg", {body: messageText})
    inputElement.value = '';
  }

  return (
    <div>
      <h1>Hello React World</h1>
      <form onSubmit={sendMessage}>
        <label>
          Message:
          <input type="text" name="message-text" />
        </label>
        <input type="submit" value="Send" />
      </form>
      <h1>Messages</h1>
      <ul>
        {messages.map(msg => (
          <li key={msg.id}>{msg.text}</li>
        ))}
      </ul>
    </div>
  );
}

ReactDOM.render(<App {...window.Gon.assets()} />, document.querySelector('main'));

בקובץ יש שני מנגנונים: הראשון מציג את רשימת ההודעות שקיבלנו מהשרת והשני אחראי לעקוב ולהמשיך להתעדכן כשמגיעות הודעות חדשות. נדבר תחילה על המנגנון הראשון.

כמו שאתם זוכרים ה Controller שלנו השתמש ב Gon כדי להעביר משתנים מפיניקס ל JavaScript. הוא יצר משתנה בשם messages ושם בו את רשימת ההודעות. לכן כשאני כותב את פקודת ה render הבאה:

ReactDOM.render(<App {...window.Gon.assets()} />, document.querySelector('main'));

אני מעביר את כל המשתנים מ Gon לתוך קומפוננטת ריאקט שלי, והיא בתורה לוקחת את משתנה messages משם ושומרת אותו בתור State עם הפקודה:

const [messages, setMessages] = useState(props.messages);

בהמשך קוד הקומפוננטה אני רץ על כל ההודעות ויוצר את ה UI המתאים לכל הודעה:

<ul>
  {messages.map(msg => (
    <li key={msg.id}>{msg.text}</li>
  ))}
</ul>

וזה מסיים את החלק הראשון. נסכם:

  1. בקשה מגיעה מדפדפן לנתיב hello

  2. ה Controller שולף את כל ההודעות מבסיס הנתונים ושולח אותן ל JavaScript

  3. ה Controller מעביר את השליטה ל View שמציג קובץ HTML בו יש התיחסות לקובץ JS

  4. קוד JavaScript יוצר קומפוננטת ריאקט, שולף את המידע שהגיע מה Controller דרך window.Gon ומציג אותו בתור רשימה על המסך.

6. איך פיניקס מטפל בתקשורת זמן אמת

עכשיו מגיע החלק בו פיניקס מצטיינת, כלומר התקשורת מבוססת Web Sockets. המסלול שלנו מתחיל הפעם בקובץ lib/hello_web/channels/user_socket.ex. זה תוכנו:

defmodule HelloWeb.UserSocket do
  use Phoenix.Socket

  channel "room:*", HelloWeb.RoomChannel

  @impl true
  def connect(_params, socket, _connect_info) do
    {:ok, socket}
  end

  @impl true
  def id(_socket), do: nil
end

כשהשורה שמעניינת אותנו לדוגמה כאן היא:

channel "room:*", HelloWeb.RoomChannel

בפיניקס, סוקט מייצג חיבור ללקוח וערוץ מייצג קטגוריה של הודעות שעוברות בין השרת ללקוח. לכל ערוץ יש "נתיב" או מזהה, בדיוק כמו שהיה לנו ב Router, ומודול שמטפל בתקשורת על ערוץ זה. בדוגמה שלנו הערוץ RoomChannel מטפל בכל הערוצים ששמם מתחיל במילה room ואז נקודותיים ואז עוד משהו.

הערוץ שמור בקובץ שנמצא באותה תיקיה כלומר lib/hello_web/channels/room_channel.ex. זה התוכן:

defmodule HelloWeb.RoomChannel do
  use Phoenix.Channel

  def join("room:lobby", _message, socket) do
    {:ok, socket}
  end

  def handle_in("new_msg", %{"body" => body}, socket) do
    {:ok, msg } = %Hello.Message{text: body} |> Hello.Repo.insert
    broadcast!(socket, "new_msg", %{body: msg})
    {:noreply, socket}
  end
end

למרות שהמודול מטפל בפניות לכל מה שמתחיל ב room, אני מימשתי רק את פונקציית join על חדר מסוים שנקרא room:lobby. זה אומר שכל ניסיון להצטרף לחדר אחר פשוט יחזיר שגיאה.

ברגע שלקוח הצטרף לערוץ הוא יכול להתחיל לשלוח הודעות, וכל הודעה מגיעה לפונקציה handle_in של הערוץ. במימוש שלנו הודעה זה משהו שמשתמש שולח ו-3 שורות מספיקות בשביל לעשות את כל הקסם:

  def handle_in("new_msg", %{"body" => body}, socket) do
    {:ok, msg } = %Hello.Message{text: body} |> Hello.Repo.insert
    broadcast!(socket, "new_msg", %{body: msg})
    {:noreply, socket}
  end

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

7. עדכון מיידי מתוך ריאקט

איך זה עובד ב JavaScript אתם שואלים? נו, זה די פשוט. קודם כל אנחנו צריכים ליצור Socket ולחבר אותו לשרת. זה קורה בקובץ assets/js/socket.js:

import {Socket} from "phoenix"

let socket = new Socket("/socket");
socket.connect()

export default socket

אחרי זה אנחנו צריכים להפעיל את הפונקציה channel של socket כדי לקבל את הערוץ ואז join על הערוץ כדי להתחבר אליו. נרצה גם לטפל בהודעות חדשות שמגיעות בערוץ. בשביל שיהיה קל כתבתי Custom Hook (ואגב יש גם ספריה לא רעה בשם use-phoenix-channel שמימשה משהו דומה אבל יותר מתוחכם). הנה הקוד שלי:

function useChannel(onNewMessage) {
  let channel = socket.channel("room:lobby", {})
  channel.join()
    .receive("ok", resp => { console.log("Joined successfully", resp) })
    .receive("error", resp => { console.log("Unable to join", resp) })

  channel.on("new_msg", payload => {
    console.log(payload);
    onNewMessage(payload.body);
  })

  return channel;
}

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

const channel = useChannel(function(newMessage) {
    setMessages(val => [...val, newMessage]);
});

ועכשיו כל פעם שהודעה חדשה תגיע מהשרת אני מוסיף אותה אוטומטית למערך ההודעות וריאקט דואג לרנדר מחדש את הקומפוננטה שלי ולהציג את רשימת ההודעות המעודכנת על המסך.

שליחת הודעה קורית דרך אותו ערוץ בדיוק עם הקוד הבא:

function sendMessage(e) {
  e.preventDefault();
  const inputElement = e.target.querySelector('input[name="message-text"]');
  const messageText = inputElement.value;

  channel.push("new_msg", {body: messageText})
  inputElement.value = '';
}

8. סיכום

העבודה עם פיניקס מאוד נוחה בהשוואה ל Node.JS ו Express בעיקר בגלל שכל החלקים כבר שם בשבילכם והאקוסיסטם מרגיש יותר יציב, והיא מציעה ביצועים טובים יותר בהשוואה ל ActionCable של ריילס.

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