Ok here is an overview of what's going on:
M <-- Message with unique id of 1234
|
+-Start Queue
|
|
| <-- Exchange
/|\
/ | \
/ | \ <-- bind to multiple queues
Q1 Q2 Q3
\ | / <-- start of the problem is here
\ | /
\ | /
\|/
|
Q4 <-- Queues 1,2 and 3 must finish first before Queue 4 can start
|
C <-- Consumer
So I have an exchange that pushes to multiple queues, each queue has a task, once all tasks are completed, only then can Queue 4 start.
So message with unique id of 1234 gets sent to the exchange, the exchange routes it to all the task queues ( Q1, Q2, Q3, etc... ), when all the tasks for message id 1234 have completed, run Q4 for message id 1234.
How can I implement this?
Using Symfony2, RabbitMQBundle and RabbitMQ 3.x
Resources:
http://www.rabbitmq.com/tutorials/amqp-concepts.html
http://www.rabbitmq.com/tutorials/tutorial-six-python.html
UPDATE #1
Ok I think this is what I'm looking for:
https://github.com/videlalvaro/Thumper/tree/master/examples/parallel_processing
RPC with Parallel Processing, but how do I set the Correlation Id to be my unique id to group the messages and also identify what queue?
You need to implement this: http://www.eaipatterns.com/Aggregator.html but the RabbitMQBundle for Symfony doesn't support that so you would have to use the underlying php-amqplib.
A normal consumer callback from the bundle will get an AMQPMessage. From there you can access the channel and manually publish to whatever exchanges comes next in your "pipes and filters" implementation
In the RPC tutorial at RabbitMQ's site, there is a way to pass around a 'Correlation id' that can identify your messages to users in the queue.
I'd recommend using some sort of id with your messages into the first 3 queues and then have another process to dequeue messages from the 3 into buckets of some sort. When those buckets receive what I'm assuming is the completion of there 3 tasks, send the final message off to the 4th queue for processing.
If you are sending more than 1 work item to each queue for one user, you might have to do a little preprocessing to find out how many items a particular user placed into the queue so the process dequeuing before 4 knows how many to expect before queuing up.
I do my rabbitmq in C#, so sorry my pseudo code isn't in php style
// Client
byte[] body = new byte[size];
body[0] = uniqueUserId;
body[1] = howManyWorkItems;
body[2] = command;
// Setup your body here
Queue(body)
// Server
// Process queue 1, 2, 3
Dequeue(message)
switch(message.body[2])
{
// process however you see fit
}
processedMessages[message.body[0]]++;
if(processedMessages[message.body[0]] == message.body[1])
{
// Send to queue 4
Queue(newMessage)
}
Response to Update #1
Instead of thinking of your client as a terminal, it might be useful to think of the client as a process on a server. So if you setup an RPC client on a server like this one, then all you need to do is have the server handle the generation of a unique id of a user and send the messages to the appropriate queues:
public function call($uniqueUserId, $workItem) {
$this->response = null;
$this->corr_id = uniqid();
$msg = new AMQPMessage(
serialize(array($uniqueUserId, $workItem)),
array('correlation_id' => $this->corr_id,
'reply_to' => $this->callback_queue)
);
$this->channel->basic_publish($msg, '', 'rpc_queue');
while(!$this->response) {
$this->channel->wait();
}
// We assume that in the response we will get our id back
return deserialize($this->response);
}
$rpc = new Rpc();
// Get unique user information and work items here
// Pass even more information in here, like what queue to use or you could even loop over this to send all the work items to the queues they need.
$response = rpc->call($uniqueUserId, $workItem);
$responseBuckets[array[0]]++;
// Just like above code that sees if a bucket is full or not
I am a little unclear on what you are trying to achieve here. But I would probably alter the design somewhat so that once all messages are cleared from the queues you publish to a separate exchange which publishes to queue 4.
In addition to my RPC based answer I want to add another one which is based on EIP aggregator pattern.
The idea is next: Everything is async, no RPC or other sync things. Every task sends an even when it is done, The aggregator is subscribed to that event. It basically counts tasks and sends task4 message when the counter reaches expected number (in our case 3). I choose a filesystem as a storage for counters for the Sake of simplicity. You can use a database there.
The producer looks simpler. It just fires and forgets
<?php
use Enqueue\Client\Message;
use Enqueue\Client\ProducerInterface;
use Enqueue\Util\UUID;
use Symfony\Component\DependencyInjection\ContainerInterface;
/** #var ContainerInterface $container */
/** #var ProducerInterface $producer */
$producer = $container->get('enqueue.client.producer');
$message = new Message('the task data');
$message->setCorrelationId(UUID::generate());
$producer->sendCommand('task1', clone $message);
$producer->sendCommand('task2', clone $message);
$producer->sendCommand('task3', clone $message);
The task processor has to send an event once its job is done:
<?php
use Enqueue\Client\CommandSubscriberInterface;
use Enqueue\Client\Message;
use Enqueue\Client\ProducerInterface;
use Enqueue\Psr\PsrContext;
use Enqueue\Psr\PsrMessage;
use Enqueue\Psr\PsrProcessor;
class Task1Processor implements PsrProcessor, CommandSubscriberInterface
{
private $producer;
public function __construct(ProducerInterface $producer)
{
$this->producer = $producer;
}
public function process(PsrMessage $message, PsrContext $context)
{
// do the job
// same for other
$eventMessage = new Message('the event data');
$eventMessage->setCorrelationId($message->getCorrelationId());
$this->producer->sendEvent('task_is_done', $eventMessage);
return self::ACK;
}
public static function getSubscribedCommand()
{
return 'task1';
}
}
And the aggregator processor:
<?php
use Enqueue\Client\TopicSubscriberInterface;
use Enqueue\Psr\PsrContext;
use Enqueue\Psr\PsrMessage;
use Enqueue\Psr\PsrProcessor;
use Symfony\Component\Filesystem\LockHandler;
class AggregatorProcessor implements PsrProcessor, TopicSubscriberInterface
{
private $producer;
private $rootDir;
/**
* #param ProducerInterface $producer
* #param string $rootDir
*/
public function __construct(ProducerInterface $producer, $rootDir)
{
$this->producer = $producer;
$this->rootDir = $rootDir;
}
public function process(PsrMessage $message, PsrContext $context)
{
$expectedNumberOfTasks = 3;
if (false == $cId = $message->getCorrelationId()) {
return self::REJECT;
}
try {
$lockHandler = new LockHandler($cId, $this->rootDir.'/var/tasks');
$lockHandler->lock(true);
$currentNumberOfProcessedTasks = 0;
if (file_exists($this->rootDir.'/var/tasks/'.$cId)) {
$currentNumberOfProcessedTasks = file_get_contents($this->rootDir.'/var/tasks/'.$cId);
if ($currentNumberOfProcessedTasks +1 == $expectedNumberOfTasks) {
unlink($this->rootDir.'/var/tasks/'.$cId);
$this->producer->sendCommand('task4', 'the task data');
return self::ACK;
}
}
file_put_contents($this->rootDir.'/var/tasks/'.$cId, ++$currentNumberOfProcessedTasks);
return self::ACK;
} finally {
$lockHandler->release();
}
}
public static function getSubscribedTopics()
{
return 'task_is_done';
}
}
I can show you how you can do it with enqueue-bundle.
So install it with composer and register as any other bundle. Then configure:
// app/config/config.yml
enqueue:
transport:
default: 'amnqp://'
client: ~
This approach is based on RPC. Here's how you do it:
<?php
use Enqueue\Client\ProducerInterface;
use Symfony\Component\DependencyInjection\ContainerInterface;
/** #var ContainerInterface $container */
/** #var ProducerInterface $producer */
$producer = $container->get('enqueue.client.producer');
$promises = new SplObjectStorage();
$promises->attach($producer->sendCommand('task1', 'the task data', true));
$promises->attach($producer->sendCommand('task2', 'the task data', true));
$promises->attach($producer->sendCommand('task3', 'the task data', true));
while (count($promises)) {
foreach ($promises as $promise) {
if ($replyMessage = $promise->receiveNoWait()) {
// you may want to check the response here
$promises->detach($promise);
}
}
}
$producer->sendCommand('task4', 'the task data');
The consumer processor looks like this:
use Enqueue\Client\CommandSubscriberInterface;
use Enqueue\Consumption\Result;
use Enqueue\Psr\PsrContext;
use Enqueue\Psr\PsrMessage;
use Enqueue\Psr\PsrProcessor;
class Task1Processor implements PsrProcessor, CommandSubscriberInterface
{
public function process(PsrMessage $message, PsrContext $context)
{
// do task job
return Result::reply($context->createMessage('the reply data'));
}
public static function getSubscribedCommand()
{
// you can simply return 'task1'; if you do not need a custom queue, and you are fine to use what enqueue chooses.
return [
'processorName' => 'task1',
'queueName' => 'Q1',
'queueNameHardcoded' => true,
'exclusive' => true,
];
}
}
Add it to your container as a service with a tag enqueue.client.processor and run command bin/console enqueue:consume --setup-broker -vvv
Here's the plain PHP version.
Related
I'm stuck on something, and it seems internet haven't had this problem (or i haven't got the right keyword to find the answer)
Keep in mind that I'm still learning Symfony 6 and I'm a bit by myself for now ... So I'm open if you tell me that everything I did is garbage.
I'm creating an application to export datas in excels for our customers.
I create a call on a database, with a specific SQL request to get my datas. Then i send the datas in a SpreadsheetService to create my spreadsheet and launch the download for the user.
<?php
namespace App\Service;
use PhpOffice\PhpSpreadsheet\Spreadsheet;
use PhpOffice\PhpSpreadsheet\Writer\Xlsx;
use Symfony\Component\HttpFoundation\StreamedResponse;
class SpreadsheetService {
public function export(string $title, $datas, $rsm) {
$streamedResponse = new StreamedResponse();
$streamedResponse->setCallback(function () use ($title, $datas, $rsm) {
// Generating SpreadSheet
$spreadsheet = new Spreadsheet();
$sheet = $spreadsheet->getActiveSheet();
$sheet->setTitle($title);
// Generating First Row with column name
$sheet->fromArray($rsm->scalarMappings);
// Generating other rows with datas
$count = 2;
foreach ($datas as $data) {
$sheet->fromArray($data, null, 'A' . $count);
$count++;
}
// Write and send created spreadsheet
$writer = new Xlsx($spreadsheet);
$writer->save('php://output');
// This exit(); is required to prevent errors while opening the generated .xlsx
exit();
});
// Puting headers on response and sending it
$streamedResponse->setStatusCode(Response::HTTP_OK);
$streamedResponse->headers->set('Content-Type', 'application/vnd.openxmlformats-officedocument.spreadsheetml.sheet');
$streamedResponse->headers->set('Content-Disposition', 'attachment; filename="' . $title . '.xlsx"');
$streamedResponse->send();
return;
}
So, this is working like a charm. BUT, my chief want it to be asynchronous.
After some research on Symfony 6 and async in Symfony, I happened to find something called symfony/messenger, which at first sounded like it was only for send messages (mail, chat, sms ...) but after some reading, sounded like the async library for Symfony 6.
So, i tried by following step by step to setup the async.
First, i created an ExportMessage.php
<?php
namespace App\Message;
class ExportMessage {
// I need a slug to know for which customer i want the export
private string $slug;
// I need this string to know if it is an export for their clients, or their candidats etc ... (Wanted to setup an enum, but failed trying for now ...)
private string $typeExport;
public function __construct(string $slug, string $typeExport) {
$this->slug = $slug;
$this->typeExport = $typeExport;
}
/**
* Get the value of slug
*/
public function getSlug() {
return $this->slug;
}
/**
* Get the value of typeExport
*/
public function getTypeExport() {
return $this->typeExport;
}
}
Then i created an ExportHandler.php that will do some work when i send an ExportMessage (They go together)
<?php
namespace App\MessageHandler;
use App\Message\ExportMessage;
use App\Service\ClientRequestService;
use Symfony\Component\Messenger\Handler\MessageHandlerInterface;
class ExportHandler implements MessageHandlerInterface {
private ClientRequestService $clientRequestService;
// clientRequestService is the service that send the sql request and then call the SpreadsheetService to create the excel file
public function __construct(ClientRequestService $clientRequestService) {
$this->clientRequestService = $clientRequestService;
}
public function __invoke(ExportMessage $message) {
return $this->clientRequestService->export($message->getSlug(), $message->getTypeExport());
}
}
Finally, in my Controller, I don't call clientRequestService->export anymore, i create a messageBus that will keep track of any messages i send, and will process them correctly (Something like that, I didn't understand every aspect of it for now I think)
class DashboardController extends AbstractController {
private MessageBusInterface $messageBus;
public function __construct([...], MessageBusInterface $messageBus) {
[...]
$this->messageBus = $messageBus;
}
[...]
#[Route('{slug}/export-candidats', name: 'app_export_candidats')]
public function exportCandidats(string $slug) {
$this->messageBus->dispatch(new ExportMessage($slug, 'candidats'));
// Not anymore --> $this->requestService->export($slug, 'candidats');
return $this->redirectToRoute('app_dashboard', ['slug' => $slug]);
}
[...]
And just for the sake of it, here's the clientRequestService.php in case
<?php
namespace App\Service;
use App\Service\MappingService;
use App\Service\SpreadsheetService;
use App\Factory\EntityManagerFactory;
use App\Repository\Istrator\DatabaseGroupRepository;
class ClientRequestService {
private $factory;
private $databaseGroupRepository;
private $mappingService;
private $spreadsheetService;
private $rootpath_sql_request;
public function __construct(EntityManagerFactory $factory, DatabaseGroupRepository $databaseGroupRepository, MappingService $mappingService, SpreadsheetService $spreadsheetService, string $rootpath_sql_request) {
$this->factory = $factory;
$this->databaseGroupRepository = $databaseGroupRepository;
$this->mappingService = $mappingService;
$this->spreadsheetService = $spreadsheetService;
$this->rootpath_sql_request = $rootpath_sql_request;
}
public function export(string $slug, $export) {
$databaseGroup = $this->databaseGroupRepository->findBySlug($slug);
$entityManager = $this->factory->createManager($databaseGroup->getIdDb());
switch ($export) {
case 'candidats':
$rsm = $this->mappingService->getMappingExportCandidats($entityManager);
$query = file_get_contents($this->rootpath_sql_request . "export_candidats.sql");
break;
case 'clients':
$rsm = $this->mappingService->getMappingExportClients($entityManager);
$query = file_get_contents($this->rootpath_sql_request . "export_clients.sql");
break;
case 'pieces_jointes':
$rsm = $this->mappingService->getMappingPiecesJointes($entityManager);
$query = file_get_contents($this->rootpath_sql_request . "export_noms_pj.sql");
break;
case 'notes_suivi':
$rsm = $this->mappingService->getMappingNotesSuivi($entityManager);
$query = file_get_contents($this->rootpath_sql_request . "export_notes_suivi.sql");
break;
default:
return;
}
$results = $entityManager->createNativeQuery($query, $rsm)->execute();
$this->spreadsheetService->export($export, $results, $rsm);
}
}
It seems to be okay, but this doesn't trigger the download ...
Can someone help me understand this problem ?
EDIT 1:
After some research, i found out that the Handler isn't even called.
I tried some thing :
In the messenger.yaml i defined my ExportMessage as async :
framework:
messenger:
failure_transport: failed
transports:
# https://symfony.com/doc/current/messenger.html#transport-configuration
async:
dsn: '%env(MESSENGER_TRANSPORT_DSN)%'
options:
use_notify: true
check_delayed_interval: 60000
retry_strategy:
max_retries: 3
multiplier: 2
failed: 'doctrine://default?queue_name=failed'
# sync: 'sync://'
routing:
Symfony\Component\Mailer\Messenger\SendEmailMessage: async
Symfony\Component\Notifier\Message\ChatMessage: async
Symfony\Component\Notifier\Message\SmsMessage: async
// --------------- Here ----------------
App\Message\ExportMessage: async
And then in my services.yaml I defined my handler as a Service
# This file is the entry point to configure your own services.
# Files in the packages/ subdirectory configure your dependencies.
# Put parameters here that don't need to change on each machine where the app is deployed
# https://symfony.com/doc/current/best_practices.html#use-parameters-for-application-configuration
parameters:
services:
# default configuration for services in *this* file
_defaults:
autowire: true # Automatically injects dependencies in your services.
autoconfigure: true # Automatically registers your services as commands, event subscribers, etc.
# makes classes in src/ available to be used as services
# this creates a service per class whose id is the fully-qualified class name
App\:
resource: '../src/'
exclude:
- '../src/DependencyInjection/'
- '../src/Entity/'
- '../src/Kernel.php'
App\MessageHandler\ExportHandler:
tags: [messenger.message_handler]
[...]
Maybe this can narrow down some problems. My handlers isn't called and i don't understand why.
My message is sent (It's created in the database)
| 30 | O:36:\"Symfony\\Component\\Messenger\\Envelope\":2:{s:44:\"\0Symfony\\Component\\Messenger\\Envelope\0stamps\";a:1:{s:46:\"Symfony\\Component\\Messenger\\Stamp\\BusNameStamp\";a:1:{i:0;O:46:\"Symfony\\Component\\Messenger\\Stamp\\BusNameStamp\":1:{s:55:\"\0Symfony\\Component\\Messenger\\Stamp\\BusNameStamp\0busName\";s:21:\"messenger.bus.default\";}}}s:45:\"\0Symfony\\Component\\Messenger\\Envelope\0message\";O:25:\"App\\Message\\ExportMessage\":2:{s:31:\"\0App\\Message\\ExportMessage\0slug\";s:4:\"toma\";s:37:\"\0App\\Message\\ExportMessage\0typeExport\";s:9:\"candidats\";}} | [] | default | 2022-04-26 14:36:53 | 2022-04-26 14:36:53 | NULL |
I continue to work on it.
EDIT 2 :
Ok, so I didn't understand how asynchrones buses worked in php, because I'm from Typescript, and asynchronous process in Typescript are really different compared to this.
I needed a consumers that will listen when there is a message pushed in the bus, and consume it, and send it to the handler...
The documentation explained that, but i didn't understand :
https://symfony.com/doc/current/the-fast-track/en/18-async.html#running-workers-in-the-background
So now, I can generate my excel file asynchronously. I just have to create something to watch for it to be created, and give a link to download it.
Hope this thread can help some people who, like me, didn't quite understand the bus mecanic.
So I have a complicated onboarding process that does several steps. I created a class that handles the process but I've added a few more steps and I'd like to refactor this into something a bit more manageable. I refactored to use Laravel's pipeline, but feel this may not be the best refactor due to the output needing to be modified before each step.
Here is an example before and after with some pseudo code.
before
class OnboardingClass {
public $user;
public $conversation;
public function create($firstName, $lastName, $email){
// Step 1
$user = User::create();
// Step 2
$conversation = Conversation::create(); // store information for new user + existing user
// Step 3
$conversation->messages()->create(); // store a message on the conversation
// Step 4
// Send api request to analytics
// Step 5
// Send api request to other service
return $this;
}
}
after
class OnboardingClass{
public $user;
public $conversation;
public function create($firstName, $lastName, $email){
$data = ['first_name' => $firstName, ...]; // form data
$pipeline = app(Pipeline::Class);
$pipeline->send($data)
->through([
CreateUser::class,
CreateNewUserConversation::class,
AddWelcomeMessageToConversation::class,
...
])->then(function($data){
// set all properties returned from last class in pipeline.
$this->user = $data['user'];
$this->conversation = $data['conversation'];
});
return $this;
}
}
Now within each class I modify the previous data and output a modified version something like this
class CreateUser implements Pipe {
public function handle($data, Closure $next) {
// do some stuff
$user = User::create():
return $next([
'user' => $user,
'other' => 'something else'
]);
}
}
In my controller I am simply calling the create method.
class someController() {
public function store($request){
$onboarding = app(OnboardingClass::class);
$onboarding->create('John', 'Doe', 'john#example.com');
}
}
So the first pipe receives the raw form fields and outputs what the second pipe needs to get the job done in its class, then the next class outputs the data required by the next class, so on and so forth. The data that comes into each pipe is not the same each time and you cannot modify the order.
Feels a bit weird and I'm sure there is a cleaner way to handle this.
Any design pattern I can utilize to clean this up a bit?
I think you could try using Laravel Service Provider, for example, you could build a login service provider; or Event & Listener, for example, you could build an listener for login and triggers a event to handle all the necessary logics. Can't really tell which one is the best since outcome is the same and it makes same amount of network requests, but it's more on personal preferences
I have a poll route on an API on Laravel 5.7 server, where the api user can request any information since the last poll.
The easy part is to respond immediately to a valid request if there is new information return $this->prepareResult($newData);
If there is no new data I am storing a poll request in the database, and a cron utility can then check once a minute for all poll requests and respond to any polls where data has been updated. Alternatively I can create an event listener for data updates and fire off a response to the poll when the data is updated.
I'm stuck with how to restore each session to match the device waiting for the update. I can store or pass the session ID but how do I make sure the CRON task / event processor can respond to the correct IP address just as if it was to the original request. Can php even do this?
I am trying to avoid websockets as will have lots of devices but with limited updates / interactions.
Clients poll for updates, APIs do not push updates.
REST API's are supposed to be stateless, so trying to have the backend keep track goes against REST.
To answer your question specifically, if you do not want to use websockets, the client app is going to have to continue to poll the endpoint till data is available.
Long poll is a valid technique. i think is a bad idea to run poll with session. since session are only for original user. you can run your long poll with php cli. you can check on your middleware to allow cli only for route poll. you can use pthreads
to run your long poll use pthreads via cli. and now pthreads v3 is designed safely and sensibly anywhere but CLI. you can use your cron to trigger your thread every one hour. then in your controller you need to store a $time = time(); to mark your start time of execution. then create dowhile loop to loop your poll process. while condition can be ($time > time()+3600) or other condition. inside loop you need to check is poll exist? if true then run it. then on the bottom of line inside loop you need to sleep for some second, for example 2 second.
on your background.php(this file is execute by cron)
<?php
error_reporting(-1);
ini_set('display_errors', 1);
class Atomic extends Threaded {
public function __construct($data = NULL) {
$this->data = $data;
}
private $data;
private $method;
private $class;
private $config;
}
class Task extends Thread {
public function __construct(Atomic $atomic) {
$this->atomic = $atomic;
}
public function run() {
$this->atomic->synchronized(function($atomic)
{
chdir($atomic->config['root']);
$exec_statement = array(
"php7.2.7",
$atomic->config['index'],
$atomic->class,
$atomic->method
);
echo "Running Command".PHP_EOL. implode(" ", $exec_statement)." at: ".date("Y-m-d H:i:s").PHP_EOL;
$data = shell_exec(implode(" ", $exec_statement));
echo $data.PHP_EOL;
}, $this->atomic);
}
private $atomic;
}
$config = array(
"root" => "/var/www/api.example.com/api/v1.1",
"index" => "index.php",
"interval_execution_time" => 200
);
chdir($config['root']);
$threads = array();
$list_threads = array(
array(
"class" => "Background_workers",
"method" => "send_email",
"total_thread" => 2
),
array(
"class" => "Background_workers",
"method" => "updating_data_user",
"total_thread" => 2
),
array(
"class" => "Background_workers",
"method" => "sending_fcm_broadcast",
"total_thread" => 2
)
);
for ($i=0; $i < count($list_threads); $i++)
{
$total_thread = $list_threads[$i]['total_thread'];
for ($j=0; $j < $total_thread; $j++)
{
$atomic = new Atomic();
$atomic->class = $list_threads[$i]['class'];
$atomic->method = $list_threads[$i]['method'];
$atomic->thread_number = $j;
$atomic->config = $config;
$threads[] = new Task($atomic);
}
}
foreach ($threads as $thread) {
$thread->start();
usleep(200);
}
foreach ($threads as $thread)
$thread->join();
?>
and this on your controller
<?php
defined('BASEPATH') OR exit('No direct script access allowed');
class Background_workers extends MX_Controller {
public function __construct()
{
parent::__construct();
$this->load->database();
$this->output->enable_profiler(FALSE);
$this->configuration = $this->config->item("configuration_background_worker_module");
}
public function sending_fcm_broadcast() {
$time_run = time();
$time_stop = strtotime("+1 hour");
do{
$time_run = time();
modules::run("Background_worker_module/sending_fcm_broadcast", $this->configuration["fcm_broadcast"]["limit"]);
sleep(2);
}
while ($time_run < $time_stop);
}
}
this is a sample runing code from codeigniter controller.
Long polling requires holding the connection open. That can only happen through an infinite loop of checking to see if the data exists and then adding a sleep.
There is no need to revitalize the session as the response is fired only on a successful data hit.
Note that this method is very CPU and memory intensive as the connection and FPM worker will remain open until a successful data hit. Web sockets is a much better solution regardless of the number of devices and frequency of updates.
You can use notifications. "browser notification" for web clients and FCM and APN notification for mobile clients.
Another option is using SSE (server sent events). It's a connection like socket but over http. Client sends a normal request, and server can just respond to client multiple times and any time if client is available (In the same request that has been sent).
I have been following along with the tutorials here and got the ratchet server working.
My chat class is the same as the tutorial more or less at the moment, so no point in showing that here yet since my question is more about implementation strategy.
In the question I attached the user was looking how to get the connection object of a specific user. In the top answer solution keeping track of the resource IDs seems to be the way to do this.
For example when the connection is created there is this code.
public function onOpen(ConnectionInterface $conn) {
// Store the new connection to send messages to later
$this->clients[$conn->resourceId] = $conn;
echo "New connection! ({$conn->resourceId})\n";
}
This creates a member variable clients to store all the connections and you simply reference it now by ID to send a message. This clients however is an instance of ConnectionInterface $conn
Then to send a message you simply use the code below entering as the array key the id of the client. Very simple.
$client = $this->clients[{{insert client id here}}];
$client->send("Message successfully sent to user.");
As we know ratchet runs as a script on the server in an event loop never ending.
I'm running a Symfony project in which outside of the server instance running the ratchet code when a user does a certain action in the system I need it to send a message to a particular client connected to the server.
I'm not sure how to do this since the clients are instances of ConnectionInterface and are created when the users first connect via WebSockets. How do I send a message to a particular client in this way?
Here is a visual of what I'm trying to achieve.
References:
how to get the connection object of a specific user?
The solution I am about to post covers the entire process of communicating from server to the client on the web browser including a way to make the Websocket server run in the background (with and without docker).
Step 1:
Assuming you have ratchet installed via composer, create a folder in your project called bin and name the file "startwebsocketserver.php" (or whatever you want)
Step 2:
Copy the following code into it.
<?php
use Ratchet\Server\IoServer;
use Ratchet\Http\HttpServer;
use Ratchet\WebSocket\WsServer;
use React\Socket\Server;
use React\EventLoop\Factory;
use WebSocketApp\Websocketserver;
use WebSocketApp\Htmlserver;
use WebSocketApp\Clientevent;
use Symfony\Component\EventDispatcher\EventDispatcher;
use Ratchet\App;
require dirname(__DIR__) . '/vendor/autoload.php';
require_once dirname(__DIR__) . '/bootstrap/bootstrap.php';
$websocketserver = new Websocketserver();
$dispatcher = new EventDispatcher(); //#JA - This is used to maintain communication between the websocket and HTTP Rest API Server
$dispatcher->addListener('websocketserver.updateclient', array($websocketserver, 'updateClient'));
//// 1. Create the event loop
$loop = Factory::create();
//// 2. Create websocket servers
$webSock = new Server($loop);
new IoServer(
new HttpServer(
new WsServer( $websocketserver )
),
$webSock
);
$webSock->listen('8080', '0.0.0.0');
$app = new App( 'localhost', 6677, '0.0.0.0',$loop );
$app->route( '/', new Htmlserver(), [ '*' ] );//#JA - Allow any origins for last parameter
$app->run();
Note that in my example I am using a bootstrap file to load the database. If you are not using a database or some other method than ignore that. For the purposes of this answer I will be assuming Doctrine 2 as the database.
What this code does is creates an HTTP server & a WebSocket server within the same code base and at the same time. I'm using the $app->route approach since you can add further routing for the HTTP server to organize API Calls to talk to the WebSocket Server from your PHP Web Server.
The $loop variable includes the Websocket server in the application loop along with the HTTPServer.
Step 3:
In your project directory create a folder called websockets. Inside that create another folder called WebSocketApp. Inside that create 3 empty files for now.
Clientevent.php
Htmlserver.php
Websocketserver.php
We will go into each of these files 1 by 1 next. Failure to create these directories in this order will cause composer Autoload PSR-0 to fail to find them.
You can change the names but make sure you edit your composer file accordingly.
Step 4:
In your composer.json file make sure it looks something like this.
{
"require": {
"doctrine/orm": "^2.5",
"slim/slim": "^3.0",
"slim/twig-view": "^2.1",
"components/jquery": "*",
"components/normalize.css": "*",
"robloach/component-installer": "*",
"paragonie/random_compat": "^2.0",
"twilio/sdk": "^5.5",
"aws/aws-sdk-php": "^3.22",
"mailgun/mailgun-php": "^2.1",
"php-http/curl-client": "^1.7",
"guzzlehttp/psr7": "^1.3",
"cboden/ratchet": "^0.3.6"
},
"autoload": {
"psr-4": {
"app\\":"app",
"Entity\\":"entities"
},
"psr-0": {
"WebSocketApp":"websockets"
},
"files": ["lib/utilities.php","lib/security.php"]
}
}
In my case I'm using doctrine & slim, the important part is the "autoload" section. This section in particular is important.
"psr-0": {
"WebSocketApp":"websockets"
},
This will autoload anything in the folder websockets in the namespace of WebSocketApp. psr-0 assumed that code would be organized by folders for namespaces which is why we had to add another folder called WebSocketApp inside of websockets.
Step 5:
In the htmlserver.php file put this...
<?php
namespace WebSocketApp;
use Guzzle\Http\Message\RequestInterface;
use Guzzle\Http\Message\Response;
use Guzzle\Http\Message\Request;
use Ratchet\ConnectionInterface;
use Ratchet\Http\HttpServerInterface;
class Htmlserver implements HttpServerInterface {
protected $response;
public function onOpen( ConnectionInterface $conn, RequestInterface $request = null ) {
global $dispatcher;
$this->response = new Response( 200, [
'Content-Type' => 'text/html; charset=utf-8',
] );
$query = $request->getQuery();
parse_str($query, $get_array);//#JA - Convert query to variables in an array
$json = json_encode($get_array);//#JA - Encode to JSON
//#JA - Send JSON for what you want to do and the token representing the user & therefore connected user as well.
$event = new ClientEvent($json);
$dispatcher->dispatch("websocketserver.updateclient",$event);
$this->response->setBody('{"message":"Successfully sent message to websocket server")');
echo "HTTP Connection Triggered\n";
$this->close( $conn );
}
public function onClose( ConnectionInterface $conn ) {
echo "HTTP Connection Ended\n";
}
public function onError( ConnectionInterface $conn, \Exception $e ) {
echo "HTTP Connection Error\n";
}
public function onMessage( ConnectionInterface $from, $msg ) {
echo "HTTP Connection Message\n";
}
protected function close( ConnectionInterface $conn ) {
$conn->send( $this->response );
$conn->close();
}
}
The purpose of this file is to make communication to the WebSocket server simple through basic HTTP which I will show a demo of later using cURL from the PHP Web Server. I designed this to propagate messages to the WebSocket server using Symfony's Event system and by looking at the Query String and converting it to a JSON string. It could have also been kept as an array if you wish, but in my case I needed the JSON string.
Step 6:
Next in the clientevent.php put this code...
<?php
namespace WebSocketApp;
use Symfony\Component\EventDispatcher\Event;
use Entity\User;
use Entity\Socket;
class Clientevent extends Event
{
const NAME = 'clientevent';
protected $user; //#JA - This returns type Entity\User
public function __construct($json)
{
global $entityManager;
$decoded = json_decode($json,true);
switch($decoded["command"]){
case "updatestatus":
//Find out what the current 'active' & 'busy' states are for the userid given (assuming user id exists?)
if(isset($decoded["userid"])){
$results = $entityManager->getRepository('Entity\User')->findBy(array('id' => $decoded["userid"]));
if(count($results)>0){
unset($this->user);//#JA - Clear the old reference
$this->user = $results[0]; //#JA - Store refernece to the user object
$entityManager->refresh($this->user); //#JA - Because result cache is used by default, this will make sure the data is new and therefore the socket objects with it
}
}
break;
}
}
public function getUser()
{
return $this->user;
}
}
Note that the User and Socket entities are entities I created from Doctrine 2. You can use whatever database you prefer. In my case I am needing to send messages to particular users from the PHP Web Server based on their login tokens from the database.
Clientevent assumes JSON string of '{"command":"updatestatus","userid":"2"}'
You can set it up however you like though.
Step 7:
In the Websocketserver.php file put this...
<?php
namespace WebSocketApp;
use Ratchet\MessageComponentInterface;
use Ratchet\ConnectionInterface;
use Symfony\Component\EventDispatcher\Event;
use Entity\User;
use Entity\Authtoken;
use Entity\Socket;
class Websocketserver implements MessageComponentInterface {
protected $clients;
public function updateClient(Event $event)
{
$user = $event->getUser();//#JA - Get reference to the user the event is for.
echo "userid=".$user->getId()."\n";
echo "busy=".($user->getBusy()==false ? "0" : "1")."\n";
echo "active=".($user->getActive()==false ? "0" : "1")."\n";
$json["busy"] = ($user->getBusy()==false ? "0" : "1");
$json["active"] = ($user->getActive()==false ? "0" : "1");
$msg = json_encode($json);
foreach($user->getSockets() as $socket){
$connectionid = $socket->getConnectionid();
echo "Sending For ConnectionID:".$connectionid."\n";
if(isset($this->clients[$connectionid])){
$client = $this->clients[$connectionid];
$client->send($msg);
}else{
echo "Client is no longer connected for this Connection ID:".$connectionid."\n";
}
}
}
public function __construct() {
$this->clients = array();
}
public function onOpen(ConnectionInterface $conn) {
// Store the new connection to send messages to later
$this->clients[$conn->resourceId] = $conn;
echo "New connection! ({$conn->resourceId})\n";
}
public function onMessage(ConnectionInterface $from, $msg) {
global $entityManager;
echo sprintf('Connection %d sending message "%s"' . "\n", $from->resourceId, $msg);
//#JA - First step is to decode the message coming from the client. Use token to identify the user (from cookie or local storage)
//#JA - Format is JSON {token:58d8beeb0ada3:4ffbd272a1703a59ad82cddc2f592685135b09f2,message:register}
$json = json_decode($msg,true);
//echo 'json='.print_r($json,true)."\n";
if($json["message"] == "register"){
echo "Registering with server...\n";
$parts = explode(":",$json["token"]);
$selector = $parts[0];
$validator = $parts[1];
//#JA - Look up records in the database by selector.
$tokens = $entityManager->getRepository('Entity\Authtoken')->findBy(array('selector' => $selector, 'token' => hash('sha256',$validator)));
if(count($tokens)>0){
$user = $tokens[0]->getUser();
echo "User ID:".$user->getId()." Registered from given token\n";
$socket = new Socket();
$socket->setUser($user);
$socket->setConnectionid($from->resourceId);
$socket->setDatecreated(new \Datetime());
$entityManager->persist($socket);
$entityManager->flush();
}else{
echo "No user found from the given cookie token\n";
}
}else{
echo "Unknown Message...\n";
}
}
public function onClose(ConnectionInterface $conn) {
global $entityManager;
// The connection is closed, remove it, as we can no longer send it messages
unset($this->clients[$conn->resourceId]);
//#JA - We need to clean up the database of any loose ends as well so it doesn't get full with loose data
$socketResults = $entityManager->getRepository('Entity\Socket')->findBy(array('connectionid' => $conn->resourceId));
if(count($socketResults)>0){
$socket = $socketResults[0];
$entityManager->remove($socket);
$entityManager->flush();
echo "Socket Entity For Connection ID:".$conn->resourceId." Removed\n";
}else{
echo "Was no socket info to remove from database??\n";
}
echo "Connection {$conn->resourceId} has disconnected\n";
}
public function onError(ConnectionInterface $conn, \Exception $e) {
echo "An error has occurred: {$e->getMessage()}\n";
$conn->close();
}
}
This is the most complicated file to explain. To start there is a protected variable clients that stores every connection made to this ratchet websocket server. Its created in the onOpen event.
Next the onMessage event is where the web browser clients will register themselves for receiving messages. I did this using a JSON protocol. An example is in the code of the format I used in particular in which I used the token from their cookie to identify what user it was in my system along with a simple register message.
I simple look in the database in this function to see if there is an authToken to go along with the cookie.
If there is write to the Socket table in your database the $from->resourceId
This is the number that ratchet uses to keep track of that particular connection number.
Next in the onClose method note that we have to make sure to remove the entries we created when the connection closes so the database doesn't get filled with unnecessary and extra data.
Finally note that the updateClient function is a symfony Event that is triggered from the HtmlServer we did earlier.
This is what actually sends the message to the client web browser. First in case that user has many web browsers open creating different connections we loop through all known sockets related to that user. Doctrine makes this easy with $user->getSockets(), you will have to decide best way to do this.
Then you simply say $client->send($msg) to send the message to the web browser.
Step 8:
Finally in your javascript for your webbrowser put something like this.
var hostname = window.location.hostname; //#JA - Doing it this way will make this work on DEV and LIVE Enviroments
var conn = new WebSocket('ws://'+hostname+':8080');
conn.onopen = function(e) {
console.log("Connection established!");
//#JA - Register with the server so it associates the connection ID to the supplied token
conn.send('{"token":"'+$.cookie("ccdraftandpermit")+'","message":"register"}');
};
conn.onmessage = function(e) {
//#JA - Update in realtime the busy and active status
console.log(e.data)
var obj = jQuery.parseJSON(e.data);
if(obj.busy == "0"){
$('.status').attr("status","free");
$('.status').html("Free");
$(".unbusy").css("display","none");
}else{
$('.status').attr("status","busy");
$('.status').html("Busy");
$(".unbusy").css("display","inline");
}
if(obj.active == "0"){
$('.startbtn').attr("status","off");
$('.startbtn').html("Start Taking Calls");
}else{
$('.startbtn').attr("status","on");
$('.startbtn').html("Stop Taking Calls");
}
};
My demo here shows simple way to pass information back and forth with JSON.
Step 9:
To send messages from the PHP Web server I did something like this in a helper function.
function h_sendWebsocketNotificationToUser($userid){
//Send notification out to Websocket Server
$ch = curl_init();
curl_setopt($ch, CURLOPT_URL, "http://localhost/?command=updatestatus&userid=".$userid);
curl_setopt($ch, CURLOPT_PORT, 6677);
curl_setopt($ch, CURLOPT_RETURNTRANSFER, 1);
$output = curl_exec($ch);
curl_close($ch);
}
This would attempt to send the updateStatus message for a particular user at anytime.
Step 10:
There is no step 10 you are done! Well okay not quite... To run the webserver in the background I use Docker which makes it easy. Simply execute the webserver with the following command.
docker exec -itd draftandpermit_web_1 bash -c "cd /var/www/callcenter/livesite; php bin/startwebsocketserver.php"
or something of this equivlent for your situation. Key here is the -d option I'm using which runs it in the background. Even if you run the command again it will NOT spawn two instances which is nifty. Shutting down the server is outside the scope of this but if you find a nice way to do this please amend or comment on this answer.
Also don't forget to open the ports correctly on your docker-compose file. I did something like this for my project.
ports:
- "80:80"
- "8080:8080"
- "6060:80"
- "443:443"
- "6677:6677"
#This is used below to test on local machines, just portforward this on your router.
- "8082:80"
Just remember 8080 is used by the WebSockets so it has to pass through completely.
In case you are curious about entity and database structure and what I used here is an attached image.
I took a look at this other question. I am looking for a way to do what the OP of that question wants as well, and that is to continue processing php after sending http response, but in Symfony2.
I implemented an event that fires after every kernel termination. So far so good, but what I want is for it to fire after CERTAIN terminations, in specific controller actions, for instance after a form was sent, not every single time at every request. That is because I want to do some heavy tasks at certain times and don't want the end user to wait for the page to load.
Any idea how can I do that?
<?php
namespace MedAppBundle\Event;
use JMS\DiExtraBundle\Annotation\InjectParams;
use JMS\DiExtraBundle\Annotation\Service;
use JMS\DiExtraBundle\Annotation\Tag;
use Psr\Log\LoggerInterface;
use Symfony\Component\DependencyInjection\ContainerInterface;
use Symfony\Component\HttpKernel\KernelEvents;
use Symfony\Component\Console\ConsoleEvents;
use Symfony\Component\EventDispatcher\EventSubscriberInterface;
use JMS\DiExtraBundle\Annotation\Inject;
/**
* Class MedicListener
* #package MedAppBundle\EventListener
* #Service("medapp_test.listener")
* #Tag(name="kernel.event_subscriber")
*/
class TestListener implements EventSubscriberInterface
{
private $container;
private $logger;
/**
* Constructor.
*
* #param ContainerInterface $container A ContainerInterface instance
* #param LoggerInterface $logger A LoggerInterface instance
* #InjectParams({
* "container" = #Inject("service_container"),
* "logger" = #Inject("logger")
* })
*/
public function __construct(ContainerInterface $container, LoggerInterface $logger = null)
{
$this->container = $container;
$this->logger = $logger;
}
public function onTerminate()
{
$this->logger->notice('fired');
}
public static function getSubscribedEvents()
{
$listeners = array(KernelEvents::TERMINATE => 'onTerminate');
if (class_exists('Symfony\Component\Console\ConsoleEvents')) {
$listeners[ConsoleEvents::TERMINATE] = 'onTerminate';
}
return $listeners;
}
}
So far I've subscribed the event to the kernel.terminate one, but obviously this fires it at each request. I made it similar to Swiftmailer's EmailSenderListener
It feels kind of strange that the kernel must listen each time for this event even when it's not triggered. I'd rather have it fired only when needed, but not sure how to do that.
In the onTerminate callback you get an instance of PostResponseEvent as first parameter. You can get the Request as well as the Response from that object.
Then you should be able to decide if you want to run the actual termination code.
Also you can store custom data in the attributes bag of the Request. See this link: Symfony and HTTP Fundamentals
The Request class also has a public attributes property, which holds special data related to how the application works internally. For the Symfony Framework, the attributes holds the values returned by the matched route, like _controller, id (if you have an {id} wildcard), and even the name of the matched route (_route). The attributes property exists entirely to be a place where you can prepare and store context-specific information about the request.
Your code could look something like this:
// ...
class TestListener implements EventSubscriberInterface
{
// ...
public function onTerminate(PostResponseEvent $event)
{
$request = $event->getRequest();
if ($request->attributes->get('_route') == 'some_route_name') {
// do stuff
}
}
// ...
}
Edit:
The kernel.terminate event is designed to run after the response is sent. But the symfony documentation is saying the following (taken from here):
Internally, the HttpKernel makes use of the fastcgi_finish_request PHP function. This means that at the moment, only the PHP FPM server API is able to send a response to the client while the server's PHP process still performs some tasks. With all other server APIs, listeners to kernel.terminate are still executed, but the response is not sent to the client until they are all completed.
Edit 2:
To use the solution from here, you could either directly edit the web/app.php file to add it there (but this is some kind of "hacking core" imo, even though it would be easier to use than the following). Or you could do it like this:
Add a listener to kernel.request event with a high priority and start output buffering (ob_start).
Add a listener to kernel.response and add the header values to the response.
Add another listener with highest priority to kernel.terminate and do the flushing (ob_flush, flush).
Run your code in a separate listener with lower priority to kernel.terminate
I did not try it, but it should actually work.
To solve this issue for some of my use cases I simply create symfony commands to do the heavy tasks, and call them via exec() to make them run in a separate process.
I used these answers to write a Response class that has this functionality:
https://stackoverflow.com/a/28738208/1153227
This implementation will work on Apache and not just PHP FPM. However, to make this work we must prevent Apache from using gzip (by using an invalid Content-Encoding) so it makes sense to have a custom Response class to specify exactly when having an early response is more important than compression.
use Symfony\Component\HttpFoundation\Response;
class EarlyResponse extends Response
{
// Functionality adapted from this answer: https://stackoverflow.com/a/7120170/1153227
protected $callback = null;
/**
* Constructor.
*
* #param mixed $content The response content, see setContent()
* #param int $status The response status code
* #param array $headers An array of response headers
*
* #throws \InvalidArgumentException When the HTTP status code is not valid
*/
public function __construct($content = '', $status = 200, $headers = array(), $callback = null)
{
if (null !== $callback) {
$this->setTerminateCallback($callback);
}
parent::__construct($content, $status, $headers);
}
/**
* Sets the PHP callback associated with this Response.
* It will be called after the terminate events fire and thus after we've sent our response and closed the connection
*
* #param callable $callback A valid PHP callback
*
* #throws \LogicException
*/
public function setTerminateCallback($callback)
{
//Copied From Symfony\Component\HttpFoundation\StreamedResponse
if (!is_callable($callback)) {
throw new \LogicException('The Response callback must be a valid PHP callable.');
}
$this->callback = $callback;
}
/**
* #return Current_Class_Name
*/
public function send() {
if (function_exists('fastcgi_finish_request') || 'cli' === PHP_SAPI) { // we don't need the hack when using fast CGI
return parent::send();
}
ignore_user_abort(true);//prevent apache killing the process
if (!ob_get_level()) { // Check if an ob buffer exists already.
ob_start();//start the output buffer
}
$this->sendContent(); //Send the content to the buffer
static::closeOutputBuffers(1, true); //flush all but the last ob buffer level
$this->headers->set('Content-Length', ob_get_length()); // Set the content length using the last ob buffer level
$this->headers->set('Connection', 'close'); // Close the Connection
$this->headers->set('Content-Encoding', 'none');// This invalid header value will make Apache not delay sending the response while it is
// See: https://serverfault.com/questions/844526/apache-2-4-7-ignores-response-header-content-encoding-identity-instead-respect
$this->sendHeaders(); //Now that we have the headers, we can send them (which will avoid the ob buffers)
static::closeOutputBuffers(0, true); //flush the last ob buffer level
flush(); // After we flush the OB buffer to the normal buffer, we still need to send the normal buffer to output
session_write_close();//close session file on server side to avoid blocking other requests
return $this;
}
/**
* #return Current_Class_Name
*/
public function callTerminateCallback() {
if ($this->callback) {
call_user_func($this->callback);
}
return $this;
}
}
You also need to add a method to your AppKernel.php to make this work (don't forget to add a use statement for your EarlyResponse class)
public function terminate(Request $request, Response $response)
{
ob_start();
//Run this stuff before the terminate events
if ($response instanceof EarlyResponse) {
$response->callTerminateCallback();
}
//Trigger the terminate events
parent::terminate($request, $response);
//Optionally, we can output the beffer that will get cleaned to a file before discarding its contents
//file_put_contents('/tmp/process.log', ob_get_contents());
ob_end_clean();
}