Session notes

José Barrera edited this page May 21, 2018 · 7 revisions

BrightonPHP - Broadcasting with Laravel, Redis, VueJs and Socket.io

Introduction

The aim of this session is to creae a simple web system that allows you to execute external commands using the Process Symfony library and then broadcasting the output of those external commands via websockets to display them to all the subscribers in their web browsers.

The app would perform a couple of simple commands like:

  • clone a public repo for a sample PHP project
  • run php -l on the files

The sample app would be able to display the output from running those commands which would be broadcasted using Laravel broadcasting system and websockets.

I decided to use socket.io instead of Pusher just because I wanted full control of the whole app and not having to rely on third party services. It is a simplified version of a similar system I had to build for work. I'm by no means an expert on VueJS, so I have taken the necessary steps to make my example work but that doesn't mean that there aren't better ways of achieving the same result.

In Laravel there are public, private and presence channels. Due to time limitations I didn't manage to get private channels to work, so for this talk I will focus only on public channels. Most of the private channel examples out there use Pusher and I wanted to use socket.io and laravel-echo-server; maybe that made it harder for me to find a working sample.

Things to do in advance of the talk

  1. Clone Laradock
$ cd ~/projects/
$ git clone https://github.com/Laradock/laradock.git
  1. Create .env file and add relevant settings to it
$ cp env-example .env
$ vim .env

Ensure that it contains the following values:

APPLICATION=../BrightonPHPBroadcasting/
WORKSPACE_INSTALL_NODE=true
  1. Clone the initial BrightonPHPBroadcasting github repo:
$ cd ~/projects/
$ git clone https://github.com/josefbarrera/BrightonPHPBroadcasting.git
  1. Install Laravel's composer dependencies:
$ docker-compose up -d mysql nginx
$ docker-compose exec workspace bash
root@48aeeac0a287:/var/www# composer install
  1. Set the appropriate values in Laravel's .env file (make sure that the values in Laravel's .env match the values for MYSQL in laradock's .env):
DB_HOST=mysql
DB_DATABASE=broadcasting
DB_USERNAME=default
DB_PASSWORD=secret
...
BROADCAST_DRIVER=redis
QUEUE_DRIVER=redis
REDIS_HOST=redis
  1. Ensure that the php-fpm container has git installed. We need to modify laradock/php-fpm/Dockerfile-72 in the laradock code base, and add git to be installed. Add this near the top of the file:
RUN apt-get update -yqq && \
apt-get -y install git

Then rebuild the container:

$ docker-compose up -d --build php-fpm

Broadcasting smoke test

  1. Bring up all the containers that will be used
$ docker-compose up -d nginx mysql redis laravel-echo-server
  1. Install predis library and install node dependencies, including socket.io-client and laravel-echo
$ docker-compose exec workspace bash
root@c71e7d2938ad:/var/www# composer require predis/predis
root@c71e7d2938ad:/var/www# npm install
root@c71e7d2938ad:/var/www# npm install --save socket.io-client laravel-echo
npm notice created a lockfile as package-lock.json. You should commit this file.
+ socket.io-client@2.1.0
added 30 packages in 3.29s
  1. Enable the broadcast service provider, in config/app.php uncomment the line
// App\Providers\BroadcastServiceProvider::class,
  1. Ensure that the csrf_token is present in the home blade template resources/views/welcome.blade.php:
<meta name="csrf-token" content="{{ csrf_token() }}">
  1. In resources/assets/js/bootstrap.js ensure that the following lines are present (uncomment the lines at the end of the file and edit as appropriate)
import Echo from 'laravel-echo'

window.io = require('socket.io-client');

window.Echo = new Echo({
    broadcaster: 'socket.io',
    host: window.location.hostname + ':6001'
});
  1. Generate a failed_jobs table
$ docker-compose exec workspace bash
root@c71e7d2938ad:/var/www# php artisan queue:failed-table
Migration created successfully!
root@c71e7d2938ad:/var/www# php artisan migrate
  1. Start the queue worker process in a workspace bash session (they need to be restarted to pick up code changes)
$ docker-compose exec workspace bash
root@c71e7d2938ad:/var/www# php artisan queue:work
  1. Start another process to watch changes to js and css resources and compile them
$ docker-compose exec workspace bash
root@c71e7d2938ad:/var/www# npm run watch-poll
  1. Ensure that app/Providers/EventServiceProvider.php contains these lines:
protected $listen = [
    'App\Events\EmitScriptOutput' => [
        'App\Listeners\StoreEmittedOutput',
    ],
];
  1. Generate the event and event listener classes:
$ docker-compose exec workspace bash
root@c71e7d2938ad:/var/www# php artisan event:generate
Events and listeners generated successfully!
  1. Generate a model and migration to store the emitted output in the database
$ mkdir app/Models
$ docker-compose exec workspace bash
root@c71e7d2938ad:/var/www# php artisan make:model -m Models/ScriptOutput
Model created successfully.
Created Migration: 2018_04_16_065212_create_script_outputs_table
  1. Edit the generated migration file to add the appropriate columns to the model's table; ensure that the up() function looks like this:
public function up()
{
    Schema::create('script_outputs', function (Blueprint $table) {
        $table->increments('id');
        $table->timestamps();
        $table->mediumText('data');
    });
}
  1. Execute the database migration:
$ docker-compose exec workspace bash
root@c71e7d2938ad:/var/www# php artisan migrate
Migrating: 2018_04_16_065212_create_script_outputs_table
Migrated:  2018_04_16_065212_create_script_outputs_table
  1. Ensure that the class app/Events/EmitScriptOutput.php contains these lines:
...

class EmitScriptOutput implements ShouldBroadcast
{

    ...

    public $data = [];

    /**
     * Create a new event instance.
     *
     * @return void
     */
    public function __construct($data = [])
    {
        $this->data = $data;
    }

    ...

    public function broadcastOn()
    {
        return new Channel('public_messages');
    }
}
  1. Add a controller for the externals script actions
$ docker-compose exec workspace bash
root@48aeeac0a287:/var/www# php artisan make:controller ScriptsController
Controller created successfully.
  1. Add a route for triggering the external script to routes/web.php
Route::post('/scan', 'ScriptsController@scanRepo');
  1. Add action to the ScriptsController
// In the use section of the class add:
use App\Events\EmitScriptOutput;

// Then:
  public function scanRepo()
  {
      $data = json_encode(['message' => 'triggered EmitScriptOutput']);
      event(new EmitScriptOutput($data));
      return "ok";
  }
  1. Add a simple form to call the trigger action to resources/views/welcome.blade.php
<form method="post" action="/scan" id="scan-repo">
    @csrf
    <button>Start scan</button>
</form>
  1. Include the javascript in resources/views/welcome.blade.php:
<!-- Scripts -->
<script src="{{ asset('js/app.js') }}" defer></script>
  1. Add the necessary javascript in resources/assets/js/app.js to handle the above form's submission via ajax and to subscribe to the new public channel
var form = document.getElementById('scan-repo');
form.addEventListener('submit', function(e) {
    e.preventDefault();
    window.axios.post(form.action);
});

Echo.channel(`public_messages`)
    .listen('EmitScriptOutput', (e) => {
        console.log(e.data);
    });

Also comment out these lines:

//Vue.component('example-component', require('./components/ExampleComponent.vue'));
//
//const app = new Vue({
//    el: '#app'
//});
  1. Test in the browser

Execute external script and broadcast its output

Explain what the external_scripts/clone_repo.sh file do

  1. Save script outputs to database. Add the following to app/Listeners/StoreEmittedOutput.php
...
use App\Models\ScriptOutput;

...
public function handle(EmitScriptOutput $event)
{
    $output = new ScriptOutput();
    $output->data = json_encode(
        $event->data,
        JSON_UNESCAPED_UNICODE | JSON_UNESCAPED_SLASHES | JSON_NUMERIC_CHECK
    );
    $output->save();
}
  1. Ensure app.css is included in resources/views/welcome.blade.php. Add this above the <style> tag in the head
<!-- Styles -->
<link href="{{ asset('css/app.css') }}" rel="stylesheet">
  1. Add vue component for messages list. Create a file resources/assets/js/components/MessagesBoard.vue with these contents:
<template>
    <div class="card">
        <div class="card-header">Messages</div>

        <div class="card-body">
            <ul class="messages-list list-group">
                <li class="list-group-item" v-for="message in messages">
                    <span class="badge badge-secondary">{{ message.date}}</span> {{ message.content.trim() }}
                </li>
            </ul>
        </div>
    </div>
</template>

<script>
    export default {
        props: ['messages'],
    }
</script>
  1. For styling of the messages list, add this to resources/assets/sass/app.scss and ensure it is compiled:
.messages-list {
    max-height: 500px;
    overflow-y: auto;
}
  1. Add logic to resources/assets/js/app.js to include vue component and initialise vue app
Vue.component('messages-board', require('./components/MessagesBoard.vue'));

const app = new Vue({
    el: '#messages-board',
    data: {
        messages: [],
        disabled: null,
    },
    mounted: function () {
        Echo.channel(`public_messages`)
            .listen('EmitScriptOutput', (e) => {
                this.messages.push(e.data);
            });
   },
});

And remove the js previously added, also in app.js

Echo.channel(`public_messages`)
    .listen('EmitScriptOutput', (e) => {
        console.log(e.data);
    });
  1. Add the code to display the Vue component and a div#message-board to attach the above vue app to to resources/views/welcome.blade.php
<div id="messages-board" class="content">
...
    <div class="container">
        <messages-board
            v-bind:messages="messages">
        </messages-board>
    </div>
...
</div>
  1. Create a model to hold our logic to execute external bash scripts
$ docker-compose exec workspace bash
root@bc8e0cf3637b:/var/www# php artisan make:model Models/BashScript
Model created successfully.
  1. Add logic to that model. Ensure app/Models/BashScript/php looks like this:
<?php

namespace App\Models;

use App\Events\EmitScriptOutput;
use Illuminate\Database\Eloquent\Model;
use Symfony\Component\Process\Process;
use Symfony\Component\Process\Exception\ProcessFailedException;

class BashScript extends Model
{
    public function execute()
    {
        $this->emitEvent('start', 'Scan started');
        $script_path = base_path('external_scripts');
        $process = new Process("sh $script_path/clone_repo.sh");

        try {
            $process->mustRun(function ($type,$buffer) {
                if (Process::ERR === $type) {
                    $message_type = 'error';
                } else {
                    $message_type = 'info';
                }
                $this->emitEvent($message_type, $buffer);
            });
        } catch (ProcessFailedException $e) {
            $this->emitEvent('error', $e->getMessage());
        }
        $this->emitEvent('end', 'Scan finished');
    }

    private function emitEvent($type, $content)
    {
        $data = [
            'type' => $type,
            'date' => date('Y-m-d H:i:s'),
            'content' => $content
        ];
        event(new EmitScriptOutput($data));
    }
}
  1. Change logic in app/Http/Controllers/ScriptController.php to execute the external script
...
use App\Models\BashScript;
...
public function scanRepo()
{
    $script = new BashScript();
    $script->execute();
    return "ok";
}
  1. Test if the messages are being displayed in the browser

  2. Add action to retrieve the last 15 messages and display them upon page load and add logic in app.js to retrieve them via ajax and add them to the vue instance

Add this to ScriptsController.php

...
use App\Models\ScriptOutput;
...
public function getLatestMessages()
{
    // Get the latest 15 items and sort them by id
    $output = ScriptOutput::latest()->take(15)->get();
    $output = $output->sortBy('id');

    $messages = [];
    foreach ($output as $message) {
        $messages[] = json_decode($message->data);
    }

    return response()->json($messages);
}
  1. Add Ajax call to vue component to obtain the last 15 messages, add this to resources/assets/js/app.js inside the mounted member of the vue instance
window.axios.get('/messages')
    .then(function (response) {
        app.messages = response.data;
     });
  1. Add route for the getLatestMessages action to routes/web.php
Route::get('/messages', 'ScriptsController@getLatestMessages');
  1. Add method in MessagesBoard.vue to determine the class for the list items based on the message type
        methods: {
          alertType: function(type) {
              var type_class = "list-group-item-";
              switch (type) {
                  case "start":
                      type_class += "info";
                      break;
                  case "end":
                      type_class += "success";
                      break;
                  case "error":
                      type_class += "danger";
                      break;
                  default:
                      type_class += "primary";
              }
              return type_class;
          }
      }

Call the newly defined method in the component MessagesBoard.vue to set each list item's class correctly:

<li class="list-group-item" :class="alertType(message.type)" v-for="message in messages">

Demonstrate what it looks like for two users using an incognito window

  1. Add logic in app.js to disable submit button whilst the process is executing
...
.listen('EmitScriptOutput', (e) => {
    this.messages.push(e.data);
    switch (e.data.type) {
        case "end":
        case "error":
            this.disabled = null;
            break;
        default:
            this.disabled = 1;
    }
});
...

Add :disabled directive to submit button in form in welcome.blade.php

...
<button :disabled="disabled">Start scan</button>
...
  1. To do autoscrolling use vanilla javascript with MutationObserver object.
var messages_list = document.querySelector('.messages-list');
var observer = new MutationObserver(scrollToBottom);
// Tell observer to look for new children that will change the height.
var config = {childList: true};
observer.observe(messages_list, config);

function scrollToBottom() {
    messages_list.scrollTop = messages_list.scrollHeight;
}

Optional, if there is time, show how errors found by the php linter would be displayed.

Resources

  1. Session repo: https://github.com/josefbarrera/BrightonPHPBroadcasting
  2. Laravel Broadcasting: https://laravel.com/docs/5.6/broadcasting
  3. VueJS: https://vuejs.org/
  4. Laradock: http://laradock.io/
  5. Symfony’s Process component: http://symfony.com/doc/current/components/process.html
Clone this wiki locally
You can’t perform that action at this time.
You signed in with another tab or window. Reload to refresh your session. You signed out in another tab or window. Reload to refresh your session.
Press h to open a hovercard with more details.