מדריך קוד: בואו נכתוב אפליקציית הודעות בזמן אמת עם Pheonix ו React
פיניקס היא פריימוורק צד השרת האהובה עליי בימים אלה. היא לקחה הרבה השראה מ 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>
וזה מסיים את החלק הראשון. נסכם:
בקשה מגיעה מדפדפן לנתיב hello
ה Controller שולף את כל ההודעות מבסיס הנתונים ושולח אותן ל JavaScript
ה Controller מעביר את השליטה ל View שמציג קובץ HTML בו יש התיחסות לקובץ JS
קוד 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 שצריכים ביצועים טובים, נוחות קידוד וחיבור טוב בין קוד צד שרת לצד לקוח.