UP | HOME
Land of Lisp

Zhao Wei

How can man die better than facing fearful odds, for the ashes of his fathers and the temples of his Gods? -- By Horatius.

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.

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 in user_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 pipeline browser.

      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 to user or nil. 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.

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 the xxx_create_user.exs which contains the migration for our DB.
    • After modifying them to perfectly suit our need, we run mix ecto.migrate.

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).
  • How template and changeset are binded?
    • A changeset could be passed into template like this: render(conn, "new.html", changeset: changeset).
  • 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.

6. Channels

6.1. About channels

  • Components
    1. Endpoint, in your Phoenix app’s Endpoint module

      socket "/socket", RumblWeb.UserSocket,
        websocket: true,
        longpoll: false
      
    2. Socket Handler
      1. 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.
      2. 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.
    3. 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.
    4. Get the client and server talking
      1. 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)
          })
        
      2. 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.
  • In general,
    • On server
      • Define a socket module and mount it as a socket endpoint. Inside this socket module:
        • Implement id/1 and connect/3 methods.
        • Define channel routes with its channel module.
      • 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
    • 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).

6.2. TODO General steps recorded from Rumbl application

In this application, we are using channels to build video annotation in real time.

  1. Confirm we could establish the websocket connection. Here, we prepare the client and server for the channel.

    1. Use JavaScript to build Phoenix client.
      • Create a Video module in assets/js/video.js.
      • Initialize it from assets/js/app.js.
    2. In assets/js/user-socket.js, we create socket and export this module.
    3. The rumbl_web/channels/user_socket.ex defines the UserSocket module which serves as the starting point for all socket connections.

    At this point, you should be able to connect to socket.

  2. Create the channel
    1. Define the channel the client will join: match a topic with a channel module.

      channel "videos:*", RumblWeb.VideoChannel
      
    2. 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.
    3. Modify the video.js to
      • Create channel from the socket and give it our topic.
      • Join the channel with receive.
  3. Sending and receiving events (socket(different endpoint) –> channel(different topic) –> different event)
    1. 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.
    2. On client side, receive that message with channel.on(event, callback).
    3. Modify server side
      • Handle the specific event(newannotation) and broadcast it to all connected socket with event type: “newannotation”.
    4. 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.

  4. Socket authentication
    1. Expose the token to the client side in “lib/rumblweb/templates/layout/app.html.heex”.

      <script>window.userToken = "<%= assigns[:user_token] %>"</script>
      
    2. Add the :user_token to conn.assigns whenever we have a current user.
    3. Pass the user token to the Socket constructor on the client
    4. Update RumblWeb.UserSocket module using pattern matching for connect/2 function.
  5. Persisting annotation
    Extend multimedia context to attach those annotations to videos and users in DB.
    1. 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
      
    2. 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
        
    3. Update generated Annotation schema: make :user, :video fields “belongsto”.
    4. 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.
    5. 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() from video.js (client side)
    6. 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.
  6. 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 our last_seen_id to server.
      • That way server could just send the data we missed.
    • 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.
    • 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 of socket.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.
  7. Tracking presence on a channel
    1. 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.
    2. 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.
    3. On client
      • Create element to hold user-lists.
      • In video.js import {Presence} from "phoenix" and define callback function for presence.onSync in which we update the user-lists’ content.
    4. 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.

7. Ecto Queries and Constraints

7.1. Select

  1. 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”.