اسکریپت Chat با COMET (+ دمو)

سلام.چند سال پیش این اسکریپت رو با استفاده از تکنولوژی COMET نوشتم حالا گفتم با ویرگولی ها هم به اشتراکش بذارم. در نوع خودش سبک جالبیه.قبل از توضیحات اسکریپت میتونید دمو رو ببینید. ازینجا هم اسکریپتو دانلود کنید.ببینید به طور کلی سه روش وجود داره برای اینکه client و server بصورت real-time باهم تعامل داشته باشن:Long PollingWebsocketsServer-Sent Events (SSE)من دراین اسکریپت اومدم از مکانیسم HTTP Long Polling استفاده کردم. یعنی درواقع تکنولوژی Comet رو میتونیم به دو طریق پیاده کنیم: 1. Streaming و 2. Long Pollingبرای پیاده سازی این سیستم میتونیم از ajax استفاده کنیم و هر یک یا چند ثانیه با توابعی مثل setinerval یا settimeout عمل آجاکس رو تکرار کنیم و سمت سرور بریم و چک کنیم اگر پیغام جدیدی اومده بود برای کاربر ارسال بشه. ولی تعداد درخواست های زیادی که از سمت کلاینت فرستاده میشه بار زیادی رو سرور میزاره که باعث اتلاف منابع سرور و عدم کارایی سیستم میشه. یکی از راه حل های این موضوع تکنولوژی Comet هست.در کامت وقتی درخواستی با توابع ajax به سمت سرور از سمت کلاینت فرستاده میشه, در سمت سرور فورا پاسخی داده نمیشه و ارتباط باز میمونه و تا زمانی که نتیجه ای رو دریافت کنه این ارتباطو نگه میداریم.این کارو با یک حلقه ی بینهایت میتونیم اجرا کنیم.البته در هاست های اشتراکی بعد از 30 ثانیه timeout میشه و ما ارتباطو در حد چند ثانیه باز نگه میداریم. کلیت قضیه این بود.حالا یکی یکی با کد میرم جلو.واسه سند یک پیام نیازی به comet نیست و از ajax استفاده میکنیم.چون قراره هر وقت دکمه ی اینتر و یا دکمه ی سند کلیک شد یک پیام در دیتابیس ثبت شود که این اتفاق زمان کلیک کردن کاربر روی دکمه بوجود میاد بنابراین از ajax استفاده میکنیم.ارسال پیام: (فایل send.js)تابع sendMessage با فشردن دکمه ی enter اجرا میشه:function sendMessage() { var qt = ''; var txt = text = $('#txtedit').val(); to = 0; if(typeof quote != 'undefined' && quote.trim() != '') { if(text.match(/\[:::\]_.+_\[:::\]/g)) { var rx = /\[:::\]_(.+)_\[:::\]/g; var arr = rx.exec(text)[1]; arr = arr.split('-'); to = arr[1]; messageID = arr[2]; txt = text = text.replace(/\[:::\]_.+_\[:::\]/g, ''); } qt = quote.split(':'); if(qt[1].trim() != '') { text += '[_::_]' + quote; qt = '<div class=&quotfrom&quot '+ colorQuote +'&quot>'+ htmlEntities(qt[0]) +':</div>'+ htmlEntities(qt[1]) +'</div>'; } else { qt = ''; } quote = undefined; } $('#messageBox').text(''); $('#txtedit').val(''); if($('.context').scrollTop() + $('.context').innerHeight() >= $('.context')[0].scrollHeight) { $(&quot.context&quot).animate({ scrollTop: $('.context')[0].scrollHeight}, 1000); } if(txt.trim() != '') { color = $('.me').attr('color'); txt = htmlEntities(txt); txt = convertToImg(txt); $('.context').append('<div class=&quotinner quote lfloat&quot from=&quotno&quot>'+ qt.trim() +'<div class=&quotfrom&quot style=&quotcolor: '+ color +' !important;&quot>Me:</div>'+ txt +'</div>'); $('#send').attr('disabled', 'disabled'); $.ajax({ url: 'http://localhost/chat/display', async: true, type: &quotPOST&quot, data: 'text=' + text.trim() + '&to=' + to, cache: false, //dataType: 'json', success: function (msg) { if (msg == 1) { $('#send').removeAttr('disabled'); } }, error: function (msg) { alert('app has an error!'); } }); } }این تابع شرو میکنه به دریافت مقادیر مورد نیاز از textbox و چک کردن وجود نقل قول و ... تا به ارسال پیام میرسه.قبل از ارسال پیام من پیغام کاربرو در صفحه ی خودش چاپ کردم:$('.context').append('<div class=&quotinner quote lfloat&quot from=&quotno&quot>'+ qt.trim() +'<div class=&quotfrom&quot style=&quotcolor: '+ color +' !important;&quot>Me:</div>'+ txt +'</div>');بعد هم بخش اصلی تابع که عمل سند پیام رو انجام میده این قسمت هست:$.ajax({ url: 'http://localhost/chat/display', async: true, type: &quotPOST&quot, data: 'text=' + text.trim() + '&to=' + to, cache: false, //dataType: 'json', success: function (msg) { if (msg == 1) { $('#send').removeAttr('disabled'); } }, error: function (msg) { alert('app has an error!'); } });text حاوی پیام کاربره و to مشخص میکنه به چه کاربری داره ارسال میشه یعنی اگر صفر نباشد آیدی کاربری که پاسخش رو دادیم ارسال میکنه.نمایش پیام کاربرو قبل از آجاکس انجام دادم و append کردم.اولش این عمل نمایش پیامو داخل رویداد success گذاشته بوده ولی چون ارتباط comet باز بود و درخواست کاربر pending میموند تا برگرده و این عمل سند انجام بشه که به همین علت مدت زمانی طول میکشید و نمایش پیامی که سند کرده به تاخیر می افتاد, بخاطر همین قبل از ajax قرار دادم که به محض دکمه ی enter روی صفحه ی کاربر پیامش نمایش داده بشه.در ادامه , تابع ajax درخواستی رو برای آدرس display در صفحه ی index.php ارسال میکنه و در صفحه ی ایندکس این کد اومده:if (isset($parts[0])) { switch ($parts[0]) { case 'save': $obj = new Save(); echo $obj->save(); break; case 'display': $obj = new Message(true); echo $obj->display(); break; case 'logout': session_destroy(); Base::redirect($config->baseUrl . '/login'); break; case '404': exit('Pooof...'); break; default: Base::redirect($config->baseUrl . '/404'); break; }وقتی درخواست ارسال میشه به این خط کد مرسه و آبجکتی از کلاس message ساخته میشه و متد display صدا زده میشه:case 'display': $obj = new Message(true); echo $obj->display(); break;نکته: شرط (case) اول , که save هست اضافیه. یادم رفته پاکش کنم.(چون اول دریافت و ارسال در دو کلاس مختلف انجام میشد و به دلیل اینکه درخواست دریافت پیام ها به مدت چند ثانیه pending میموند و عملیات ارسال هم باید منتظر میشد , تصمیم گرفتم عمل دریافت و ارسال رو در یک کلاس و در یک متد به نام display پیاده کنم.بنظرم خوبه اینجا یه stop به درخواست pending بدیم تا درخواست ارسال پیام اجرا بشه Surprised ) - (فایل دانلود اصلاح شد.)در ادامه وارد متد display میشه :public function display() { if(isset($_POST['text'])) { $text = DB::Escape($_POST['text']); $time = microtime(true); $to_message_id = (isset($_POST['to']) && $_POST['to'] > 0 ? intval($_POST['to']) : 0); DB::Query(&quotINSERT INTO `messages` (`user_id`, `to_message_id`, `text`, `timestamp`) VALUES ('{$_SESSION['id']}', '{$to_message_id}', '{$text}', '{$time}')&quot); if(DB::AffectedRows() > 0) { return 1; } else { return DB::LastError(); } } if(isset($_POST['time'])) { $time = floatval($_POST['time']); $current = time(); $list = ''; while (time() - $current < 100) { $result = self::findAll(&quotWHERE (`timestamp` > '{$time}' AND `user_id` != '{$_SESSION['id']}')&quot); if(count($result) > 0) { $result = array_reverse($result); $html = ''; $to = ''; foreach ($result as $obj) { $qt = ''; $text = ''; if(strpos($obj->text, '[_::_]')) { $array = explode('[_::_]', $obj->text); $text = $array[0]; $q = explode(&quot:&quot, $array[1]); $qt = ' <div class=&quotfrom&quot '. ($_SESSION['username'] == trim($q[0]) ? $_SESSION['userColor'] : '#000') .' !important;&quot>' . ($_SESSION['username'] == trim($q[0]) ? 'Me' : Base::HtmlEscape($q[0])) .':</div>' . Base::HtmlEscape($q[1]) .'</div>'; } else { $text = $obj->text; } $html .= '<div class=&quotinner quote&quot from=&quot' . ($_SESSION['id'] == $obj->user_id ? &quotno&quot : $obj->user_id . '-' . $obj->message_id) .'&quot>'. $qt .'<div class=&quotfrom&quot style=&quotcolor: '. $obj->color .' !important;&quot>' . Base::HtmlEscape($obj->username) .':</div>'. Base::convertToImg(Base::HtmlEscape($text)); $html .= '<div style=&quotheight: 20px;&quot></div>'; $html .= '<div class=&quottime&quot>'. Jdf::jdate('H:i:s | l , j F Y', (explode('.', $obj->timestamp)[0])). '</div>'; $html .= '</div>'; if ($_SESSION['id'] == $obj->to_message_id) { $to .= '<div class=&quotinnerMyMessages quote&quot from=&quot' . $obj->user_id . '-' . $obj->message_id .'&quot>'. $qt .'<div class=&quotfrom&quot style=&quotcolor: '. $obj->color .' !important;&quot>'. Base::HtmlEscape($obj->username) .':</div>'. Base::convertToImg(Base::HtmlEscape($text)) .'</div>'; } } return json_encode(array('message' => $html, 'to' => $to , 'time' => $obj->timestamp, 'onlineUsers' => substr($list, 0, -1))); } else { sleep(2); } $sess = Loader::load('Session'); $onlineUsers = $sess->onlineUsers(); foreach ($onlineUsers as $user) { $list .= $user . '.'; } } return json_encode(array('onlineUsers' => substr($list, 0, -1))); } }دو تا شرط وجود داره.یکی وجود متغیر text و شرط دیگه وجود متغیر time. اگر متغیر تکست وجود داشت یعنی کاربر پیامی ثبت کرده.در قسمت دیتای تابع ajax , ما text رو ارسال کردیم.پس وارد این قسمت از متد میشه:if(isset($_POST['text'])) { $text = DB::Escape($_POST['text']); $time = microtime(true); $to_message_id = (isset($_POST['to']) && $_POST['to'] > 0 ? intval($_POST['to']) : 0); DB::Query(&quotINSERT INTO `messages` (`user_id`, `to_message_id`, `text`, `timestamp`) VALUES ('{$_SESSION['id']}', '{$to_message_id}', '{$text}', '{$time}')&quot); if(DB::AffectedRows() > 0) { return 1; } else { return DB::LastError(); } }و عملیات ثبت پیام در دیتابیس انجام میشه.بهتر بود بعد از عملیات insert , بجای return 1 عمل سلکت هم انجام میدادم و نتایج جدید return میکردم. کل ماجرای سند پیام جدید اینه.بقیه ی کدهایی که در فایل send.js اومده , تجزیه و بررسی متن وارد شده ی کاربره.دریافت پیام کاربران در حین چت: (فایل display.js)بعد از ورد به صفحه ی چت و لود صفحه تابع displayMessage اجرا میشه:$(document).ready(function() { $(window).load(displayMessage(lastTimestamp)); });ین تابع یک درخواست به سمت سرور ارسال میکنه و ما در سمت سرور تا زمانی که رکورد جدیدی از دیتابیس پیدا نکنیم حلقه ای رو سمت سرور تکرار میکنیم و پاسخی به سمت کلاینت نمیفرستیم.به محض دریافت رکورد جدید , پاسخ به سمت کاربر ارسال میشه و پاسخ دریافت شده در رویداد success بعد از چند بررسی به کاربر نمایش داده میشود و بعد از نمایش پیام های جدید یک وقفه ی چند ثانیه با تابع settimeout ایجاد میکنیم و دوباره تابع displayMessage رو صدا میزنیم تا همین مرحله ها از ابتدا تکرار شوند و درخواستی سمت سرور ارسال شود.یعنی با کامت request ها و response ها رو به حداقل رسوندیم.تابع displayMessage:function displayMessage(time) { $.ajax({ url: 'http://localhost/chat/display', async: true, type: &quotPOST&quot, data: 'time=' + time, cache: false, dataType: 'json', success: function (msg) { //alert(msg['message']);return; if(msg['message']) { if($('.context').scrollTop() + $('.context').innerHeight() >= $('.context')[0].scrollHeight) { $(&quot.context&quot).animate({ scrollTop: $('.context')[0].scrollHeight}, 1000); } $('.context').append(msg['message']); if(msg['to'] != '') { $('.my-messages').append(msg['to']); } time = msg['time']; //alert(time) } if(msg['onlineUsers']) { var users = msg['onlineUsers'].split('.'); // point var dvUsers = ''; users = array_unique(users); $('.users .innerUsers').each(function() { dvUsers += $(this).text().trim() + ','; }); dvUsers = dvUsers.split(','); dvUsers = dvUsers.filter(function(a){return a != '';}); for (var i = 0; i < users.length; i++) { if($('.users').html().indexOf(users[i]) == -1) { $('<div class=&quotinnerUsers '+ users[i] +'&quot>'+ users[i] +'</div>').appendTo('.users').fadeIn(1000); } if(dvUsers.indexOf(users[i]) != -1) { delete dvUsers[dvUsers.indexOf(users[i])]; dvUsers = dvUsers.filter(function(a){return typeof a !== 'undefined';}); } }; if(dvUsers.length > 0) { $.each(dvUsers, function(index, value) { $('.' + value).remove(); }); } } setTimeout(displayMessage, 2000, time); }, error: function (msg) { displayMessage(time); } }); }این تابع یک time میگیرد.در واقع مقدار time برابر time آخرین رکوردیست که از دیتابیس فراخوانی شده و نمایش داده شده. زمانیکه پیج چت را باز میکنیم , در صفحه ی main.php گفته شده 100 پیام آخر داخل دیتابیس , در صفحه نمایش داده شود.و در ادامه ی همین کدهای main.php این کدو گذاشتم: var lastTimestamp = <?php echo (isset($obj) ? $obj->timestamp : 0); ?>; متغیری در جاواسکریپت تعریف کردم و مقدارشو برابر timestamp آخرین رکورد fetch شده قرار دادم.این timestamp بعنوان اولین مقدار time و در اولین اجرای تابع displayMessage قرار میگیره.در فراخوانی های بعدی تابع این مقدار جایگزین میشه. بعد از اجرای displayMessage آجاکس فراخوانی میشه و در خواستی به همان آدرس قبلی یعنی display به صفحه ی index.php ارسال میشه که حاوی دیتای time است.در صفحه ی index.php و شرط switch بازهم شرط display اجرا میشود. متد display فراخوانی میشود و از بین دو شرطی که در پست قبل گفتیم , کد شرط وجود time اجرا میشود:if(isset($_POST['time'])) { $time = floatval($_POST['time']); $current = time(); $list = ''; while (time() - $current < 100) { $result = self::findAll(&quotWHERE (`timestamp` > '{$time}' AND `user_id` != '{$_SESSION['id']}')&quot); if(count($result) > 0) { $result = array_reverse($result); $html = ''; $to = ''; foreach ($result as $obj) { $qt = ''; $text = ''; if(strpos($obj->text, '[_::_]')) { $array = explode('[_::_]', $obj->text); $text = $array[0]; $q = explode(&quot:&quot, $array[1]); $qt = '<div class=&quotfrom&quot color: ' . ($_SESSION['username'] == trim($q[0]) ? $_SESSION['userColor'] : '#000') .' !important;&quot>'. ($_SESSION['username'] == trim($q[0]) ? 'Me' : Base::HtmlEscape($q[0])) .':</div>'. Base::HtmlEscape($q[1]) .'</div>'; } else { $text = $obj->text; } $html .= '<div class=&quotinner quote&quot from=&quot' . ($_SESSION['id'] == $obj->user_id ? &quotno&quot : $obj->user_id . '-' . $obj->message_id) .'&quot>'. $qt .'<div class=&quotfrom&quot style=&quotcolor: '. $obj->color .' !important;&quot>' . Base::HtmlEscape($obj->username) .':</div>' . Base::convertToImg(Base::HtmlEscape($text)); $html .= '<div style=&quotheight: 20px;&quot></div>'; $html .= '<div class=&quottime&quot>' . Jdf::jdate('H:i:s | l , j F Y', (explode('.', $obj->timestamp)[0])). '</div>'; $html .= '</div>'; if ($_SESSION['id'] == $obj->to_message_id) { $to .= '<div class=&quotinnerMyMessages quote&quot from=&quot' . $obj->user_id . '-' . $obj->message_id .'&quot>'. $qt .'<div class=&quotfrom&quot style=&quotcolor: '. $obj->color .' !important;&quot>' . Base::HtmlEscape($obj->username) .':</div>' . Base::convertToImg(Base::HtmlEscape($text)) .'</div>'; } } return json_encode(array('message' => $html, 'to' => $to , 'time' => $obj->timestamp, 'onlineUsers' => substr($list, 0, -1))); } else { sleep(2); } $sess = Loader::load('Session'); $onlineUsers = $sess->onlineUsers(); foreach ($onlineUsers as $user) { $list .= $user . '.'; } } return json_encode(array('onlineUsers' => substr($list, 0, -1))); }کل کدها به کنار قسمت اصلی کد , حلقه ی while با تکرار به مدت 100 ثانیه هست:(این مقدار بسته به منابع هاست میتونه کم یا زیاد بشه!)while (time() - $current < 100)هر بار که این حلقه اجرا میشود , یک کوئری اجرا میشود:$result = self::findAll(&quotWHERE (`timestamp` > '{$time}' AND `user_id` != '{$_SESSION['id']}')&quot);این کوئری تمام رکوردهایی که timestamp اشون بزرگتر از time ارسالی توسط آجاکس(یعنی time آخرین رکورد نمایش داده شده در صفحه ی کاربر) است رو واکشی میکنه.البته این شرط هم وجود داره که پیام هایی واکشی بشن که فرستندشون خود کاربر نباشن.(چون پیام های خود کاربر به محض سند شدن رو صفحه ی خودش یکبار نمایش داده شده.اینجا فرضو براین گرفتم که یک یوزر با یک مرورگر قراره باز بشه و بصورت همزمان کسی نباید باز کنه.)میشد شرطو جابجا کرد و اول user_id چک بشه و بعد AND بشه با شرط timestamp. چون اگه شرط user_id برقرار نبود دیگه شرط timestamp اجرا نشه و باعث سرعت بیشتری در کوئری بشه.خلاصه بعد از عمل fetch کردن اگر رکوردی پیدا شد if اجرا میشه و تمام پیام های جدید واکشی شده بسمت کلاینت return میشن و در success دریافت و نمایش داده میشن و بعد از چند ثانیه دوباره یک درخواست جدید بهمراه آخرین timestamp بسمت سرور فرستاده میشه.اما اگر رکوردی یافت نشد یعنی پیغامی ثبت نشده , بنابراین else اجرا میشود. در قسمت else :else { sleep(2); }به مدت دوثانیه وقفه ایجاد کردیم تا به سرور فشار زیادی وارد نشه. و بعد از else دوباره این حلقه اجرا میشه.این حلقه در مدت 100 ثانیه ای که تکرار میشه همین روالو ادامه میده.اگر رکوردی پیدا شد ارتباط قطع میشه قطع میشه و پاسخ به سمت کلاینت فرستاده میشود. اما اگر در مدت 100 ثانیه رکوردی پیدا نشد این اسکریپت به پایان میرسه و تنها تعداد کاربران آنلاینو return میکنه.یک نکته در مورد time: در اول کار , زمان رو برحسب ثانیه وارد دیتابیس میکردم.ولی در تست هایی که انجام دادم , وقتی سریع و پشت سرهم مثلا عدد 1 تا 9 ارسال میکردم , در نمایش پیام ها قاطی میکرد.بعضی هارو هم جا مینداخت. یعنی در دیتابیس درست درج شده بود ولی در نمایش به مشکل میخورد. واسه همین فیلد timestamp رو double گذاشتم و مقدار تایم هر پیام رو با microtime ذخیره کردم.توجه: این اسکریپت 6-7 سال پیش نوشته شده و اصلاً مبنای گرافیکی و طراحی نداشته و فقط بر پایه ی پیاده سازی سیستمی با تکنولوژی های متفاوت بوده است.(که هنوز هم ازین تکنولوژی میتوان استفاده کرد.)برنامه نویس: محسن موحد