This is a post that, in a way, piggybacks on a previous posting I did last week. I'd done a capstone project at Flatiron a few weeks back that used P5.js and websockets to create a collaborative canvas for users to build an audio visualizer together. If you're interested, here's the link.
Anyways, throughout the project I'd found that the easy solution of borrowing someone else's prewritten components or React classes didn't suit the relatively specific way in which my project worked. Specifically, I needed a lot of control over the behavior of the P5 sketch and also the websocket since they were the bread and butter of my project. I needed an open connection to all the users on the same canvas and when one user edits a P5 shape, that edit will render across all the different users or "subscribers" in ActionCable jargon. Of course you could have the users constantly request updates from the server through HTTP requests, otherwise known as polling, but websockets allow the connection between the client and server to remain open such that information can flow freely back and forth. Polling would mean my server would go on overdrive with request pings.
Also, before I get into it. This isn't going to be an extremely thorough explanation of how websockets work in rails, more my implementation. Jennifer Ingram did a fantastic job explaining them in her post here and I would definitely recommend you check that out. My project is a bit more of a specific use case of ActionCable, so thought it'd be valuable to share regardless.
Now to the Code!
Rails has made it super handy to do really interesting things with ActionCable, allowing for easy streaming connected to specific models. In my case, I had a Pictures model (I tried to make it called "canvas" but rails consistently got confused, thinking the singular form of canvas was "canva." Super annoying) that had its own channel to stream from. To generate this all you need is:
rails g channel [channel_name]
This will build a file for you that acts like a controller would for HTTP. Mine looked like:
class PicturesChannel < ApplicationCable::Channel
def subscribed
# stream_from "some_channel"
end
def unsubscribed
# Any cleanup needed when channel is unsubscribed
end
end
In other examples, users will generate multiple channels (e.g. one for chatrooms and one for messages that will stream to a chatroom model) and this can be a good way to segment and control the flow of data. For me, I wanted the code to be as simple as possible so as to not get too in the weeds, hence I stuck with just one channel.
There's really only one more crucial component to add, and that's adding one line to your routes.
Rails.application.routes.draw do
...
mount ActionCable.server => '/cable'
end
This just establishes where you'll tell your frontend to mount the cable. Essentially, you're good to go at this point, by filling in the necessary information in your subscription method. As the comments under the subscribe method indicate, you'll provide a string for which subscribers or clients on the front end will link up to. Think of it like the name of a tv station and we have to make sure our users all have the right channel at the same time. E.g.
class PicturesChannel < ApplicationCable::Channel
def subscribed
# stream_from "some_channel"
stream_from "pictures_channel_#{params[:id]}"
end
def unsubscribed
# Any cleanup needed when channel is unsubscribed
end
end
I fed in a template literal so that each channel corresponded to specific pictures (or canvases) based on their id's. This becomes clear when we get to React but we can access values fed to the backend using params just like our controllers.
Now to React
To make sure the whole picture emerges, I'm jumping to React such that we won't get mired in the backend. The first thing you need is ActionCable and you can import this in two ways. As a node package:
npm install actioncable --save
or as a requirement in the very component you need to mount with:
const actioncable = require("actioncable")
I went with the requirement because I'd followed an example I saw online, but Jennifer Ingram does it as a node package so refer to her if you want it that way.
Next we'll need to mount the cable and we'll just feed in our route established on the backend:
class Canvas extends React.Component {
...
componentDidMount() {
...
this.cable = actioncable.createConsumer('ws://localhost:3000/cable');
}
I'd learned that fetching data is best on component mounting so mounting the cable seemed to make sense in this lifecycle method. This just mounts our cable in the right direction. It's almost like we're turning on the TV, and just need to put on the right channel. That's done with this:
this.canvasChannel = this.cable.subscriptions.create({
channel: `PicturesChannel`,
id: this.props.paramsId
},{
connected: () => {
console.log("connected!")
},
disconnected: () => {},
received: data => {}
})
This bit of code establishes where it is we're subscribing to through feeding in a hash that specifies a string for the channel key. I fed in an id key so that I could know hook the user up to the right canvas, using some props I fed it. Note, the string we feed into channel is crucially important and needs to match up with the name of the channel we generated, NOT the string we fed into the stream_from function above. You can create this channel object anywhere you'd like, I had mine actually inside my sketch for P5 because the received data had to alter the P5 sketch while it was running. Thus, the recieved callback had to have the appropriate scope to access those variables. A more general case would look like:
this.canvasChannel = this.cable.subscriptions.create({
channel: [name_of_channel]
},{
connected: () => {},
disconnected: () => {},
received: data => {}
})
In the second argument of the connected, disconnected, and received keys are just callback functions that you can specify. Such as if you wanted things to trigger like a green light to turn on or off if connected or disconnected, you'd use the connected and disconnected callbacks to toggle it. The received callback is super important though and going to be run every time data travels down the channel from the backend to the front.
Surprisingly, this is all that's needed on the front end to have your cable up and running. In my case, the "cable.subscriptions.create" method will hook up to the backend PicturesChannel, run the subscribe method, and use the id key to create a channel name that the backend will use to broadcast to and the frontend will be subscribed to. The steps so far have been:
Rails
-Generate a channel
-Setup the websocket route
-Give stream_from a string for rails to know which channel to broadcast to
React
-Mount the cable
-Create a subscription to the correct channel
This is as general as I can make to so that the point comes across. I'll now take you through an example where I wanted a user to click on some part of the canvas and that click would send the clicks position through the channel, broadcast that to all subscribers of that channel, and then have each client receive and draw on all the clients canvases.
So when a user clicks on a part of the canvas, that can then trigger a callback that sends its position to the back end. We can do this with one of the methods on our channel object:
p.mouseClicked = () => {
this.canvasChannel.send({
canvas_id: this.props.paramsId,
burst: {
user_id: [user_id],
tune : {
x: p.winMouseX,
y: p.winMouseY
}
}
})
}
mouseClicked is a nice P5 function that responds every time a user clicks on our canvas and also has nice variables that hold where the mouse was at this time, "winMouseX" and "winMouseY". Refer to my P5 blog post for explanation of the funky "p." notation but it's just a P5 object. canvasChannel.send send this data to the backend through our websocket. Now for the channel to receive this, we have to add a method in the PicturesChannel:
class PicturesChannel < ApplicationCable::Channel
...
def receive(data)
ActionCable.server.broadcast("pictures_channel_#{data['canvas_id']}", data)
end
end
This receive method takes an argument "data" and will be run whenever data is sent up through the channel to the backend. The line underneath just broadcasts that data to the correct channel. In this case, we're passing it data of who clicked, where they clicked, and what canvas they clicked on. Rails uses the information to broadcast to the correct channel name which is the exact string we used in our subscription method "pictures_channel_#{data['canvas_id']}".
Then the second argument is the actual data that we passed through. All this method is doing is catching what we send through, and directing it to the proper place. Note, the data that we feed into this broadcast method must be JSON. If you want to broadcast data elsewhere, like in one of the controllers, you have to make sure it's a hash. It does not automatically serialize it for you if you feed in an instance of a model.
Once this is done, the data is broadcast back down the channel to all the subscribers and reaches our frontend channel subscription object, where the received method is run. Then all that's needed is to take the position of the click we sent through and run a method that displays it on all the clients:
this.canvasChannel = this.cable.subscriptions.create({
channel: `PicturesChannel`,
id: this.props.paramsId
},{
connected: () => {},
disconnected: () => {},
received: data => {
this.handleRecievedClick(data)
}
})
In P5 this can mean having a shape appear at that location, or have a burst explode in that location (using Mo.js). Once everything is set up, you really have the freedom to send over anything you want. You could have data sent through that changes renders state changes like the color of components, what's displayed, etc. etc.. There's a ton of things you can do with websockets outside of just a chatroom.
I hope this helped and wasn't too long. Feel free to reach out if you have any questions.
Top comments (0)