Notes about Elixir Phoneix
1. How to use Plug
- ref: https://hexdocs.pm/phoenix/plug.html
- For example, without using Plug, if we want to validate something in an controller, we will use nested blocks of code.
After using Plug, we could replace those nested block of code wit ha flattened series of plug transformations. - The
halt(conn)
the is key. It tells Plug that the next plug should not be invoked.
- For example, without using Plug, if we want to validate something in an controller, we will use nested blocks of code.
1.1. How authentication is implemented in Rumbl application as plug
- In Rumbl, authentication is implemented with two plugs:
One is type of function plug,
def authenticate_user(conn, _opts)
. It is used inuser_controller
module for action [:index, :show].
plug :authenticate_user when action in [:index, :show]
Another is type of module plug, the
RumblWeb.Auth
. It is used in pipelinebrowser
.
pipeline :browser do plug :accepts, ["html"] ... # our own plug plug RumblWeb.Auth end
- The module plug makes sure the conn’s
current_user
property exist with value equals touser
ornil
. This is for every connection. - The function plug makes sure request to UserController’s index and show path must contains
user
.
1.2. Controller are module plug to be used as action fallback
- Action fallback allows us to centralize error handling code in plugs which are called when a controller action fails to return a
%Plug.Conn{}
struct.
2. Pipelines and Plugs
- Pipelines are a series of plugs that can be attached to specific scopes.
- Routes are defined inside scopes and scopes may pipe through multiple pipelines.
- The router invokes a pipeline on a route defined within a scope. Routes outside of a scope have no pipelines.
2.1. How to create pipelines
- Define custom pipelines anywhere in the router module.
3. How to redirect a request
For example, we want to redirect user after he/she created account.
def create(conn, %{"user" => user_params}) do case Accounts.register_user(user_params) do # Do login if we insert the newly registered user in database. {:ok, user} -> conn |> RumblWeb.Auth.login(user) |> put_flash(:info, "#{user.name} created!") |> redirect(to: Routes.user_path(conn, :index)) {:error, %Ecto.Changeset{} = changeset} -> render(conn, "new.html", changeset: changeset) end end
- Notice: Route helper function
Routes.user_path
is used to make sure the redirect path is correct.
- Notice: Route helper function
4. Controller
4.1. How to pass multiple values when render a template
- For one value, we could use key: value pair as the third argument. Such as:
render(conn, "show.html", messenger: messenger)
. For multiple values, we could use
Plug.Conn.assign
:
def show(conn, %{"messenger" => messenger}) do conn |> assign(:messenger, messenger) |> assign(:receiver, "Dweezil") |> render("show.html") end
5. Ecto
5.1. How to create models (the data in DB)
use phx.gen.schema
mix phx.gen.schema User users name:string email:string \ number_of_pets:integer
- This will generate two files, one is the
user.ex
model with schema, another is thexxx_create_user.exs
which contains the migration for our DB. - After modifying them to perfectly suit our need, we run
mix ecto.migrate
.
- This will generate two files, one is the
5.2. About changeset
- What is changeset?
- A changeset defines some contains/validation for our data.
- We use it by
import Ecto.Changeset
and it defines a lot of useful functions. - Use that module, we could validate our data which we want to insert into our db.
- It will return a result with true or false, plus the changes (our validated/filtered data).
- A changeset defines some contains/validation for our data.
- How template and changeset are binded?
- A changeset could be passed into template like this:
render(conn, "new.html", changeset: changeset)
.
- A changeset could be passed into template like this:
- What is the differences between validation and constraint
- Validation executed before touching the db. And constraint rely on the database.
- Therefore, validation happends before constraint.
- Validation executed before touching the db. And constraint rely on the database.
6. Channels
6.1. About channels
- Components
Endpoint, in your Phoenix app’s Endpoint module
socket "/socket", RumblWeb.UserSocket, websocket: true, longpoll: false
- Socket Handler
On client side, we establish socket connection to the route
import {Socket} from "phoenix" let socket = new Socket("/socket", { params: {token: window.userToken}, logger: (kind, msg, data) => {console.log(`${kind}: ${msg}`, data)} }) export default socket
- The point here is to create websocket connection to connect to
/socket
defined in RumblWeb.Endpoint module.
- The point here is to create websocket connection to connect to
On server side, inside RumblWeb.UserSocket module
defmodule RumblWeb.UserSocket do use Phoenix.Socket channel "videos:*", RumblWeb.VideoChannel def connect(_params, socket, _connect_info) do {:ok, socket} end def id(_socket) do nil end end
- Whenever a client sends a message whose topic starts with “videos:”, it will be routed to our
VideoChannel
. - Implemented two callbacks.
- Whenever a client sends a message whose topic starts with “videos:”, it will be routed to our
Define
VideoChannel
module to manage messages.
defmodule RumblWeb.VideoChannel do use RumblWeb, :channel # "videos:" <> video_id will match all topics starting with "videos:" # and assign the rest of the topic to the video_id variable def join("videos:" <> video_id, _params, socket) do {:ok, assign(socket, :video_id, String.to_integer(video_id))} end end
- Let clients to join a given topic.
- Let clients to join a given topic.
- Get the client and server talking
Create socket and join a channel.
socket.connect() let vidChannel = socket.channel("videos:" + videoId) vidChannel.join() .receive("ok", resp => console.log("joined the video channel", resp)) .receive("error", reason => { console.log("join failed", reason) })
- Sending and Receiving events
On client
vidChannel.on("ping", ({count} => console.log("PING", count)))
On server
defmodule RumblWeb.VideoChannel do use RumblWeb, :channel # "videos:" <> video_id will match all topics starting with "videos:" # and assign the rest of the topic to the video_id variable def join("videos:" <> video_id, _params, socket) do # {:ok, assign(socket, :video_id, String.to_integer(video_id))} :timer.send_interval(5000, :ping) {:ok, socket} end def handle_info(:ping, socket) do count = socket.assigns[:count] || 1 push(socket, "ping", %{count: count}) {:noreply, assign(socket, :count, count + 1)} end end
- Our server send to “videos:” channel a message for every 5 second.
handle_info
callback is invoked whenever an elixir message reaches the channel.
- Our server send to “videos:” channel a message for every 5 second.
- In general,
- On server
- Define a socket module and mount it as a socket endpoint. Inside this socket module:
- Implement
id/1
andconnect/3
methods. - Define channel routes with its channel module.
- Implement
- Define a channel module
- Define how client join a given topic by implementing
join/3
. - Handle incoming events by using
handle_in
handle_out
handle_info
- Define how client join a given topic by implementing
- Define a socket module and mount it as a socket endpoint. Inside this socket module:
- On client
- Import socket, connect to an socket endpoint defined by server.
- Create channel using
socket.channel
. After that join the channel(someChannel.join
) and receive message (someChannel.receive
). - Handle channel’s event like
someChannel.on("someEvent", callback)
.
- Import socket, connect to an socket endpoint defined by server.
- On server
6.2. TODO General steps recorded from Rumbl application
In this application, we are using channels to build video annotation in real time.
Confirm we could establish the websocket connection. Here, we prepare the client and server for the channel.
- Use JavaScript to build Phoenix client.
- Create a Video module in
assets/js/video.js
. - Initialize it from
assets/js/app.js
.
- Create a Video module in
- In
assets/js/user-socket.js
, we create socket and export this module. - The
rumbl_web/channels/user_socket.ex
defines theUserSocket
module which serves as the starting point for all socket connections.
At this point, you should be able to connect to socket.
- Use JavaScript to build Phoenix client.
- Create the channel
Define the channel the client will join: match a topic with a channel module.
channel "videos:*", RumblWeb.VideoChannel
Implement the VideoChannel module. It will alow connections through
join
function.
def join("videos:" <> video_id, _params, socket) do ... end
- Notice the pattern matching: it will match all topics starting with “videos:”, and assign the rest of the topic to the
video_id
.
- Notice the pattern matching: it will match all topics starting with “videos:”, and assign the rest of the topic to the
- Modify the
video.js
to
- Create channel from the socket and give it our topic.
- Join the channel with
receive
.
- Create channel from the socket and give it our topic.
- Sending and receiving events (socket(different endpoint) –> channel(different topic) –> different event)
- On server side, in our
RumblWeb.VideoChannel
module, we implement handlein, handleout, and handleinfo.
- Conceptually, we are taking a socket and returning a transformed socket.
- We could
push
some event.
- Conceptually, we are taking a socket and returning a transformed socket.
- On client side, receive that message with
channel.on(event, callback)
. - Modify server side
- Handle the specific event(newannotation) and broadcast it to all connected socket with event type: “newannotation”.
- Handle the specific event(newannotation) and broadcast it to all connected socket with event type: “newannotation”.
Modify client side
- When user click a button, we push annotation to channel with “newannotation” event type with payload.
- Listen on “newannotation” event and render the received message.
- Render annotation is done by creating a div with content and append that div to some element.
So, user click, push event –> server, receive it and broadcast it back to all client –> client handle event and display.
- When user click a button, we push annotation to channel with “newannotation” event type with payload.
- On server side, in our
- Socket authentication
Expose the token to the client side in “lib/rumblweb/templates/layout/app.html.heex”.
<script>window.userToken = "<%= assigns[:user_token] %>"</script>
- Add the
:user_token
toconn.assigns
whenever we have a current user. - Pass the user token to the
Socket
constructor on the client - Update RumblWeb.UserSocket module using pattern matching for
connect/2
function.
- Persisting annotation
Extend multimedia context to attach those annotations to videos and users in DB.
Use ecto to generate schema
mix phx.gen.schema Multimedia.Annotation annotations body:text \ at:integer user_id:references:users video_id:references:videos mix ecto.migrate
- Wire up new relationships to Accounts.User and Multimedia.Video schemas. (Make choices!)
- Don’t expose every association between modules. For example, here we don’t want Accounts.User schema know about Multimedia.Annotations. (May revisit later)
Add
has_many
to Multimedia.Video
has_many :annotations, Rumbl.Multimedia.Annotation
- Don’t expose every association between modules. For example, here we don’t want Accounts.User schema know about Multimedia.Annotations. (May revisit later)
- Update generated Annotation schema: make :user, :video fields “belongsto”.
- Implement read and write video annotations features from Multimedia context rather than the schema.
- For example:
annotation_video
which create annotation for a user and video.list_annotation
which list all annotations for a given video.
- Head back to VideoChannel module to intergrate those features.
- For example:
- If we refresh page the messages are gone even they are in the DB. We need to pass the messages to the client when a user joins the channel.
- Update RumblWeb.VideoChannel’s
join
function to pass down a list of annotations. (server side) - Update
vidChannel.join()
fromvideo.js
(client side)
- Update RumblWeb.VideoChannel’s
- Schedule the annotations to appear synced up with the video playback.
- Instead of rendering all annotations immediately on join, we schedule them to render based on the current player time.
- Instead of rendering all annotations immediately on join, we schedule them to render based on the current player time.
- Handling disconnects
We need to prevent client to see duplicated message when it rejoin the channel after an unstable network.
- General idea:
- The idea is we track a
last_seen_id
on the client. Then whenever we rejoin a crash or disconnect, we send ourlast_seen_id
to server. - That way server could just send the data we missed.
- The idea is we track a
- On server side, we need to return annotations after the
last_seen_id
.
- So, we need to modify
list_annotations
function. - Also, modify the place in VideoChannel module where calls
list_annotations
.
- So, we need to modify
- On client side
- We need to create and pass the
last_seen_id
to video channel, so it could be extract and used from server side. This is done from the second argument ofsocket.channel
. Phoenix will send those custom params when a user joins the channel. - Track this
last_seen_id
:
- During join/rejoin channel: compute this last seen id from all annotations’s ids.
- During an “newannotation” event, just use this new arrived resp’s id which is the last annotation’s id.
- During join/rejoin channel: compute this last seen id from all annotations’s ids.
- We need to create and pass the
- General idea:
- Tracking presence on a channel
Generate a presence module
mix phx.gen.presence
- The generated
lib/rumbl_web/channels/presence.ex
defines the functions we required for tracking presence on a channel. - Add this module to supervisor tree in
lib/rumbl/application.ex
.
- The generated
- On server, we ask Phoenix to track broadcast messages to our socket’s topic about users coming and going. So, make changes on VideoChannel module
- Send self a message when join in
join
. - In
handle_info
, send message to all presence socket and ask RumblWeb.Presence module to track user.
- Send self a message when join in
- On client
- Create element to hold user-lists.
- In video.js
import {Presence} from "phoenix"
and define callback function forpresence.onSync
in which we update the user-lists’ content.
- Create element to hold user-lists.
- To display online users not just showing ID, we will build a context function to fetch the usernames for a list of ids.
- Build
list_users_with_ids/1
function in Rumbl.Accounts module. - Use that function in RumblWeb.Presence module to decorate our presence information in
fetch
callback. - Don’t forget to do modification in
presence.onSync
in video.js.
- Build
7. Ecto Queries and Constraints
7.1. Select
Find the user whose id == 1 from User table
Rumbl.Repo.get_by(User, id: 1)
8. Other what/how
8.1. How to construct js code for channels
- Our js code will follow these patterns
- Use “let Something = {}” to include all the code in it, then export it as “export default Something”.
- It will contain “init” method as constructor.
- Other functions will be binded to it as “this.someFunction”.
- Use “let Something = {}” to include all the code in it, then export it as “export default Something”.