tag:blogger.com,1999:blog-366288982024-03-08T08:33:12.662+03:00月まで電車アルセニhttp://www.blogger.com/profile/07661163832723079945noreply@blogger.comBlogger8125tag:blogger.com,1999:blog-36628898.post-30361104699264464642012-11-29T18:42:00.000+04:002012-11-29T19:56:46.976+04:00WebSocket-чат на Tornado для вашего Django-проекта<div dir="ltr" style="text-align: left;" trbidi="on">
<img align="right" alt="Tornado" src="http://habrastorage.org/storage2/af3/4b4/0a5/af34b40a5d32825e460d6c52441101a5.jpg" title="Логотип Tornado" />Недавно я запустил сайт <a href="http://backgrounddating.com/">backgrounddating.com</a>, одной из возможностей которого является чат с другими пользователями в режиме реального времени. Документации (как на русском, так и на английском), позволяющей быстро вникнуть в написание подобных приложений с помощью технологии WebSocket пока что мало, поэтому я решил написать данное руководство. Итак, задача состоит в том, чтобы любой пользователь мог отправлять другим пользователям сообщения, и, если у получателя сообщения открыт чат с этим пользователям, то он сразу же видел входящие сообщения (а в ином случае он мог прочитать сообщения позже: то есть при открытии чата загружается история последних сообщений).<br />
<br />
Если вам нужно, чтобы пользователи могли общаться не только вдвоём, а группами из любого количества человек, то сделать это можно почти что элементарно: описанная реализация, по сути, рассчитана на такое расширение функциональности.<br />
<br />
Сразу уточню, что это не единственный способ реализовать подобное. Вы можете использовать другой асинхронный веб-сервер (например node.js), можете использовать другую очередь сообщений (или вообще её <a href="https://github.com/facebook/tornado/blob/master/demos/chat/chatdemo.py">не использовать</a>, если вам подходят особенности такого варианта: с пользователями одного канала обязательно общается один и тот же worker веб-сервера). Я даже не утверждаю, что этот вариант самый лучший (но в данном случае он подошёл лучше всех). В конце концов, мы здесь вообще не будем рассматривать костыли (long polling, Flash) для старых браузеров (а это почти все версии IE, например), не поддерживающих веб-сокеты, и даже не будем рассматривать возможность подключаться из тех браузеров, которые уже поддерживают протокол WebSocket, но не стандартизированную версию (<a href="http://tools.ietf.org/html/rfc6455">RFC 6455</a>), а одну из устаревших. О том, как можно включить поддержку устаревшей версии «draft 76» (она же «hixie-76»), смотрите в <a href="http://www.tornadoweb.org/documentation/websocket.html">документации</a> Tornado.<br />
<a href="http://www.blogger.com/blogger.g?blogID=36628898" name="habracut"></a><br />
Тем не менее, что можно сказать точно — этот способ работает хорошо, причём не в одном проекте, а во многих (описанный способ реализации уже давно применяется, хоть о нём пока и не очень много информации). Например, сервер, на котором работает Background Dating — это на данный момент самый младший VPS от Linode (512 MiB памяти), но нагрузка на процессор не поднималась более 20—40 процентов, а использование оперативной памяти — около 30%. Причём ресурсы используют в основном gunicorn (веб-сервер, на котором работает Django) и PostgreSQL. Но тут нет абсолютно ничего удивительного, поскольку ни для кого не секрет, что Tornado достаточно лёгкий, чтобы не просто работать быстро, но даже справляться с <a href="http://en.wikipedia.org/wiki/C10k_problem">C10k</a> (о чём уже <a href="http://habrahabr.ru/post/145796/">было</a> на Хабрахабре).<br />
<br />
Итак, мы будем использовать <a href="https://djangoproject.com/">Django</a> 1.4.2, <a href="http://tornadoweb.org/">Tornado</a> 2.4, <a href="http://redis.io/">Redis</a> 2.6.5 и <a href="http://postgresql.org/">PostgreSQL</a> 9.2.1 (на самом деле вы можете использовать и другую реляционную СУБД — у Django есть много разных бэкэндов). Для подключения к Redis будет использоваться стандартный для Python клиент <a href="https://github.com/andymccurdy/redis-py">redis-py</a>, а также <a href="https://github.com/evilkost/brukva">brükva</a> (асинхронный Redis-клиент для использования с Tornado). Для того, чтобы развернуть всё это на сервере, мы будем использовать <a href="http://haproxy.1wt.eu/">haproxy</a> и <a href="http://nginx.org/">nginx</a>, для запуска Django-проекта в production будем использовать веб-сервер <a href="http://gunicorn.org/">gunicorn</a>, а управлять запуском веб-серверов для Django и Tornado будет <a href="http://supervisord.org/">Supervisor</a>.<br />
<br />
<a name='more'></a>Если вы чувствуете, что вам не хватает теоретических знаний (например, вы не вполне понимаете, что такое асинхронный неблокирующий веб-сервер, очередь сообщений и так далее), то рекомендую прежде всего прочитать соответствующую документацию, и, если останутся вопросы, то написать в комментариях (или мне по <a href="mailto:aruseni.magiku@gmail.com">почте</a>). Также рекомендую обратить внимание на несколько обсуждений по теме (<a href="http://softwaremaniacs.org/forum/django/32804/">1</a>, <a href="http://softwaremaniacs.org/forum/django/35701/">2</a>, <a href="http://softwaremaniacs.org/forum/django/19643/">3</a>, <a href="http://softwaremaniacs.org/forum/python/22061/">4</a>, <a href="http://softwaremaniacs.org/forum/django/36629/">5</a>, <a href="http://softwaremaniacs.org/forum/python/22160/">6</a>, <a href="http://softwaremaniacs.org/forum/python/39706/">7</a>) на прекрасном <a href="http://softwaremaniacs.org/forum/">форуме</a> Python-гуру Ивана Сагалаева, а также на <a href="http://kmike.ru/files/django-realtime.pdf">слайды</a> доклада «Связываем синхронный фреймворк с асинхронным (на примере django)», который ещё один Python-гуру Михаил Коробов читал на <a href="http://2011.devconf.ru/programm/python_perl">DevConf 2011</a>.<br />
<br />
Исходники рассматриваемого решения присутствуют как в этой статье, <a href="https://github.com/aruseni/chat">так и на GitHub</a>.<br />
<br />
<h3>
Настройка Django</h3>
<br />
Создаём Django-проект и переходим в появившуюся директорию:<br />
<br />
<pre>django-admin.py startproject myproject
cd myproject/
</pre>
<br />
Теперь отредактируем файл myproject/settings.py.<br />
<br />
По-первых, очень хорошей идеей будет сразу переключиться на использование Redis-бэкэнда для хранения информации о сессиях пользователей. Это стоит делать во всех проектах (кроме тех, где вы по какой-то причине не можете использовать Redis, а также тех, где просто не планируется много пользователей). Для этого установите <a href="https://github.com/martinrusev/django-redis-sessions">django-redis-sessions</a> (pip install django-redis-sessions) и просто добавьте в настройках:<br />
<br />
<pre class="brush:python;">SESSION_ENGINE = 'redis_sessions.session'
</pre>
<br />
Поздравляю, вы только что сделали, чтобы количество запросов к базе данных при отправке запросов к сайту уменьшилось на 1 (Redis отвечает на запросы почти мгновенно). :)<br />
<br />
Кстати, такие настройки лучше всего сохранять в отдельном файле, который вы добавляете в gitignore — обычно это local_settings.py.<br />
<br />
Туда же вы можете перенести секретный ключ проекта, например (SECRET_KEY), а также настройки базы данных. Смысл тут в том, что во-первых, вы можете сохранять в local_settings.py настройки, специфические для конкретного сервера (база данных, бэкэнд кэширования, бэкэнд сессий, настройки режима отладки), а во-вторых Git (или другая система контроля версий) не будет хранить информацию о ключах и паролях.<br />
<br />
Соответственно, чтобы добавлять такие настройки в local_settings.py, вам достаточно просто дописать в конце settings.py следующее:<br />
<br />
<pre class="brush:python;">try:
from local_settings import *
except ImportError:
pass
</pre>
<br />
Далее не забудьте настроить базу данных (DATABASES), указать директорию шаблонов, директорию статических файлов и ключ API (Tornado при получении нового сообщения от пользователя будет асинхронно отправлять запрос к Django и Django будет сохранять это сообщение в базе данных), а также URL, по которому нужно отправлять запросы.<br />
<br />
Для того, чтобы при развёртывании сайта на другом сервере (или при размещении проекта в другом месте в ФС) не нужно было менять путь к директориям шаблонов и статических файлов, лучше всего определять местонахождение проекта при запуске, добавив сверху в настройках следующее:<br />
<br />
<pre class="brush:python;">import os
PROJECT_ROOT = os.path.abspath(os.path.dirname(__file__))
</pre>
<br />
А далее, соответственно, получать другие пути на основе PROJECT_ROOT.<br />
<br />
Статические файлы:<br />
<br />
<pre class="brush:python;">STATICFILES_DIRS = (
os.path.join(PROJECT_ROOT, "static"),
)
</pre>
<br />
Шаблоны:<br />
<br />
<pre class="brush:python;">TEMPLATE_DIRS = (
os.path.join(PROJECT_ROOT, "templates"),
)
</pre>
<br />
Ключ API и адрес:<br />
<br />
<pre class="brush:python;">API_KEY = '$0m3-U/\/1qu3-K3Y'
SEND_MESSAGE_API_URL = 'http://127.0.0.1:8000/messages/send_message_api'
</pre>
<br />
Для того, чтобы сгенерировать ключ, можете воспользоваться такой вот несложной строчкой в консоли (на вас оттуда, кстати, смайлик смотрит):<br />
<br />
<pre>< /dev/urandom tr -dc _A-Z-a-z-0-9 | head -c${1:-32};echo;
</pre>
<br />
Теперь остаётся создать в директории myproject (где находятся settings.py и local_settings.py) каталоги static и templates и приступить к написанию приложения-чата.<br />
<br />
<h3>
Чат (Django)</h3>
<br />
Добавим новое приложение:<br />
<br />
<pre>python manage.py startapp privatemessages
</pre>
<br />
И напишем модели (privatemessages/models.py):<br />
<br />
<pre class="brush:python;">from django.db import models
from django.db.models.signals import post_save
from django.contrib.auth.models import User
# Create your models here.
class Thread(models.Model):
participants = models.ManyToManyField(User)
last_message = models.DateTimeField(null=True, blank=True, db_index=True)
class Message(models.Model):
text = models.TextField()
sender = models.ForeignKey(User)
thread = models.ForeignKey(Thread)
datetime = models.DateTimeField(auto_now_add=True, db_index=True)
def update_last_message_datetime(sender, instance, created, **kwargs):
"""
Update Thread's last_message field when
a new message is sent.
"""
if not created:
return
Thread.objects.filter(id=instance.thread.id).update(
last_message=instance.datetime
)
post_save.connect(update_last_message_datetime, sender=Message)
</pre>
<br />
Тут ничего сложного — есть сообщения и треды (нити). Для каждых двух пользователей будет создаваться свой тред, и все их сообщения будут относиться к этому треду. Когда создаётся новое сообщение, то дата и время последнего сообщения для соответствующего треда обновляются.<br />
<br />
Теперь самое время добавить приложение в INSTALLED_APPS в settings.py и синхронизировать модели с базой данных:<br />
<br />
<pre>python manage.py syncdb
</pre>
<br />
Давайте откроем privatemessages/views.py и напишем 4 представления:<br />
<ul>
<li><i>send_message_view</i> — для отправки сообщения через Django (например, если это первое сообщение между двумя людьми)</li>
<li><i>send_message_api_view</i> — для отправки сообщений через Tornado</li>
<li><i>messages_view</i> — для просмотра списка собеседников (с сортировкой по убыванию даты и времени последнего сообщения)</li>
<li><i>chat_view</i> — для чата (Django будет просто возвращать страницу с теми сообщениями, которые уже есть в базе данных, а дальше бразуер будет подключаться к Tornado-серверу через WS и получать/отправлять новые сообщения в реальном времени, без перезагрузки страницы и без дополнительных HTTP-запросов)</li>
</ul>
<br />
<pre class="brush:python;"># Create your views here.
import json
import redis
from django.shortcuts import render_to_response, get_object_or_404
from django.http import HttpResponse, HttpResponseRedirect
from django.template import RequestContext
from django.core.urlresolvers import reverse
from django.utils import timezone
from django.views.decorators.csrf import csrf_exempt
from django.conf import settings
from django.contrib.auth.models import User
from privatemessages.models import Thread, Message
from privatemessages.utils import json_response, send_message
def send_message_view(request):
if not request.method == "POST":
return HttpResponse("Please use POST.")
if not request.user.is_authenticated():
return HttpResponse("Please sign in.")
message_text = request.POST.get("message")
if not message_text:
return HttpResponse("No message found.")
if len(message_text) > 10000:
return HttpResponse("The message is too long.")
recipient_name = request.POST.get("recipient_name")
try:
recipient = User.objects.get(username=recipient_name)
except User.DoesNotExist:
return HttpResponse("No such user.")
if recipient == request.user:
return HttpResponse("You cannot send messages to yourself.")
thread_queryset = Thread.objects.filter(
participants=recipient
).filter(
participants=request.user
)
if thread_queryset.exists():
thread = thread_queryset[0]
else:
thread = Thread.objects.create()
thread.participants.add(request.user, recipient)
send_message(
thread.id,
request.user.id,
message_text,
request.user.username
)
return HttpResponseRedirect(
reverse('privatemessages.views.messages_view')
)
@csrf_exempt
def send_message_api_view(request, thread_id):
if not request.method == "POST":
return json_response({"error": "Please use POST."})
api_key = request.POST.get("api_key")
if api_key != settings.API_KEY:
return json_response({"error": "Please pass a correct API key."})
try:
thread = Thread.objects.get(id=thread_id)
except Thread.DoesNotExist:
return json_response({"error": "No such thread."})
try:
sender = User.objects.get(id=request.POST.get("sender_id"))
except User.DoesNotExist:
return json_response({"error": "No such user."})
message_text = request.POST.get("message")
if not message_text:
return json_response({"error": "No message found."})
if len(message_text) > 10000:
return json_response({"error": "The message is too long."})
send_message(
thread.id,
sender.id,
message_text
)
return json_response({"status": "ok"})
def messages_view(request):
if not request.user.is_authenticated():
return HttpResponse("Please sign in.")
threads = Thread.objects.filter(
participants=request.user
).order_by("-last_message")
if not threads:
return render_to_response('private_messages.html',
{},
context_instance=RequestContext(request))
r = redis.StrictRedis()
user_id = str(request.user.id)
for thread in threads:
thread.partner = thread.participants.exclude(id=request.user.id)[0]
thread.total_messages = r.hget(
"".join(["thread_", str(thread.id), "_messages"]),
"total_messages"
)
return render_to_response('private_messages.html',
{
"threads": threads,
},
context_instance=RequestContext(request))
def chat_view(request, thread_id):
if not request.user.is_authenticated():
return HttpResponse("Please sign in.")
thread = get_object_or_404(
Thread,
id=thread_id,
participants__id=request.user.id
)
messages = thread.message_set.order_by("-datetime")[:100]
user_id = str(request.user.id)
r = redis.StrictRedis()
messages_total = r.hget(
"".join(["thread_", thread_id, "_messages"]),
"total_messages"
)
messages_sent = r.hget(
"".join(["thread_", thread_id, "_messages"]),
"".join(["from_", user_id])
)
if messages_total:
messages_total = int(messages_total)
else:
messages_total = 0
if messages_sent:
messages_sent = int(messages_sent)
else:
messages_sent = 0
messages_received = messages_total-messages_sent
partner = thread.participants.exclude(id=request.user.id)[0]
tz = request.COOKIES.get("timezone")
if tz:
timezone.activate(tz)
return render_to_response('chat.html',
{
"thread_id": thread_id,
"thread_messages": messages,
"messages_total": messages_total,
"messages_sent": messages_sent,
"messages_received": messages_received,
"partner": partner,
},
context_instance=RequestContext(request))
</pre>
<br />
И вот privatemessages/utils.py:<br />
<br />
<pre class="brush:python;">import json
import redis
from django.utils import dateformat
from privatemessages.models import Message
def json_response(obj):
"""
This function takes a Python object (a dictionary or a list)
as an argument and returns an HttpResponse object containing
the data from the object exported into the JSON format.
"""
return HttpResponse(json.dumps(obj), content_type="application/json")
def send_message(thread_id,
sender_id,
message_text,
sender_name=None):
"""
This function takes Thread object id (first argument),
sender id (second argument), message text (third argument)
and can also take sender's name.
It creates a new Message object and increases the
values stored in Redis that represent the total number
of messages for the thread and the number of this thread's
messages sent from this specific user.
If a sender's name is passed, it also publishes
the message in the thread's channel in Redis
(otherwise it is assumed that the message was
already published in the channel).
"""
message = Message()
message.text = message_text
message.thread_id = thread_id
message.sender_id = sender_id
message.save()
thread_id = str(thread_id)
sender_id = str(sender_id)
r = redis.StrictRedis()
if sender_name:
r.publish("".join(["thread_", thread_id, "_messages"]), json.dumps({
"timestamp": dateformat.format(message.datetime, 'U'),
"sender": sender_name,
"text": message_text,
}))
for key in ("total_messages", "".join(["from_", sender_id])):
r.hincrby(
"".join(["thread_", thread_id, "_messages"]),
key,
1
)
</pre>
<br />
О том, что такое tz.activate(), можно почитать в <a href="https://docs.djangoproject.com/en/dev/topics/i18n/timezones/">документации</a> Django по работе с часовыми поясами. Поскольку браузеры не предоставляют информацию о часовом поясе (в отличие от информации о языке, например), нам необходимо самостоятельно создавать на клиентской стороне cookie с названием timezone — таким образом Django сможет отобразить дату и время каждого сообщения в том часовом поясе, в котором на данный момент находится пользователь.<br />
<br />
Для того, чтобы вычислить часовой пояс на клиентской стороне, мы будем использовать библиотеку <a href="http://www.pageloom.com/automatic-timezone-detection-with-javascript">jstz</a> (не забудьте разместить <a href="https://bitbucket.org/pellepim/jstimezonedetect/downloads">jstz.min.js</a> в директории myproject/static). Есть и другие решения, позволяющие определить часовой пояс пользователя: например можно брать наиболее вероятный часовой пояс на основе базы GeoIP или даже сравнивать локальное время на компьютере пользователя с локальным временем на сервере (поскольку нам известно. что на сервере время установлено правильно) — такое решение позволяет узнать фактический часовой пояс пользователя в том случае, если у него в системе выбран какой-то совершенно другой часовой пояс, но время было вручную переведено для соответствия поясному.<br />
<br />
Но в данном случае давайте предположим, что если у пользователя неправильно установлен часовой пояс, то, скорее всего, неточность времени его устраивает (в ином случае это будет дополнительным напоминанием поменять настройки).<br />
<br />
Также обратите внимание, что вам потребуется установить <a href="http://pytz.sourceforge.net/">pytz</a> (pip install pytz) — это нужно для того, чтобы получать часовой пояс из строки в формате базы данных Олсона (<a href="http://www.iana.org/time-zones">IANA time zone database</a>).<br />
<br />
Когда функция send_messages добавляет в базу данных новое сообщение, она также обновляет hash с количеством сообщений для данного треда в Redis. В этом hash инкрементируются два ключа — один из них представляет общее количество сообщений в треде, а другой представляет количество сообщений от данного пользователя. Эти ключи используются при отображении общего количества сообщений, а также количества принятых и отправленных сообщений (количество принятых — это просто общее количество минус количество отправленных).<br />
<br />
Когда пользователь открывает чат и его браузер открывает WS-соединение с Tornado-сервером, Tornado-сервер подписывается на канал треда в Redis, и при появлении новых сообщений тут же отправляет их пользователю.<br />
<br />
Если вы не знакомы с реализацией Pub/Sub в Redis (команды SUBSCRIBE, UNSUBSCRIBE и PUBLISH), то вы можете прочитать о ней в <a href="http://redis.io/topics/pubsub">документации</a> по Redis.<br />
<br />
Когда функцию send_messages вызывает send_message_view и передаёт, в частности, имя отправителя, функция публикует сообщение на канале данного треда в Redis. В случае с send_message_api_view имя отправителя не передаётся, и предполагается, что сообщение уже было отправлено на канал данного треда, ещё до вызова send_message_api_view (так сделано для того, чтобы сообщения максимально быстро передавались между пользователями, общающимися в чате — сохранение в базу данных происходит асинхронно, уже после публикации на канале).<br />
<br />
Теперь создадим файл privatemessages/urls.py и зададим urlpatterns для Django-методов:<br />
<br />
<pre class="brush:python;">from django.conf.urls import patterns, url
urlpatterns = patterns('privatemessages.views',
url(r'^send_message/$', 'send_message_view'),
url(r'^send_message_api/(?P<thread_id>\d+)/$', 'send_message_api_view'),
url(r'^chat/(?P<thread_id>\d+)/$', 'chat_view'),
url(r'^$', 'messages_view'),
)
</pre>
<br />
И, конечно, включим их в <a href="https://docs.djangoproject.com/en/dev/topics/http/urls/">root URLconf</a> (myproject/urls.py):<br />
<br />
<pre class="brush:python;">from django.conf.urls import patterns, include, url
# something else
urlpatterns = patterns('',
# something else
url(r'^messages/', include('privatemessages.urls')),
# something else
)
</pre>
<br />
В директории с шаблонами нужно разместить chat.html и private_messages.html. Также добавим базовый шаблон base.html.<br />
<br />
base.html<br />
<br />
<pre class="brush:xml;"><!DOCTYPE html>
<html>
<head>
<link rel="stylesheet" type="text/css" href="/static/privatemessages.css">
<script type="text/javascript" src="http://yandex.st/jquery/1.8.3/jquery.min.js"></script>
<script type="text/javascript" src="/static/jstz.min.js"></script>
<script type="text/javascript" src="/static/privatemessages.js"></script>
{% block head %}{% endblock %}
<meta http-equiv="Content-Type" content="text/html; charset=utf-8">
<title>{% block title %}Личные сообщения{% endblock title %}</title>
</head>
<body>
{% block content %}{% endblock content %}
</body>
</html>
</pre>
<br />
chat.html<br />
<br />
<pre class="brush:xml;">{% extends "base.html" %}
{% block title %}{{ partner.username }}{% endblock %}
{% block head %}
<script type="text/javascript">
$(document).ready(function() {
activate_chat({{ thread_id }}, "{{ user.username }}", {
"total": {{ messages_total }},
"sent": {{ messages_sent }},
"received": {{ messages_received }}
});
});
</script>
{% endblock %}
{% block content %}
{% load pluralize %}
<div class="chat">
<div class="partner">
<p class="name">{{ partner.username }}</p>
<p class="messages"><span class="total">{{ messages_total }}</span> {{ messages_total|rupluralize:"сообщение,сообщения,сообщений" }} (<span class="received">{{ messages_received }}</span> получено, <span class="sent">{{ messages_sent }}</span> отправлено)</p>
</div>
<div class="conversation">
{% for message in thread_messages reversed %}
<div class="message">
{% if message.sender == user %}<p class="author we"><span class="datetime">{{ message.datetime|date:"d.m.Y H:i:s" }}</span> {{ user.username }}:</p>{% else %}<p class="author partner"><span class="datetime">{{ message.datetime|date:"d.m.Y H:i:s" }}</span> {{ partner.username }}:</p>{% endif %}
<p class="message">{{ message.text|linebreaksbr }}</p>
</div>
{% endfor %}
</div>
<form class="message_form">
<div class="compose">
<textarea rows="1" cols="30" id="message_textarea"></textarea>
</div>
<div class="send">
<button class="btn" type="button">Отправить</button>
<p>Вы также можете отправлять сообщения с помощью клавиш Ctrl + Enter.</p>
</div>
</form>
</div>
{% endblock content %}
</pre>
<br />
private_messages.html<br />
<br />
<pre class="brush:xml;">{% extends "base.html" %}
{% block content %}
{% load pluralize %}
<div class="private_messages">
<h1>Собеседники</h1>
<div class="partners">
{% for thread in threads %}
<p><a href="{% url privatemessages.views.chat_view thread.id %}">{{ thread.partner.username }} ({{ thread.total_messages|default_if_none:"0" }} {{ thread.total_messages|rupluralize:"сообщение,сообщения,сообщений" }})</a></p>
{% empty %}
<p>Пока что собеседников нет.</p>
{% endfor %}
</div>
<h1>Отправить сообщение</h1>
<form action="{% url privatemessages.views.send_message_view %}" method="post" class="new_message">
{% csrf_token %}
<p class="name"><input name="recipient_name" placeholder="Имя получателя"></p>
<p><textarea name="message" placeholder="Сообщение"></textarea></p>
<p><input type="submit" value="Отправить"></p>
</form>
</div>
{% endblock content %}
</pre>
<br />
И ещё создадим каталог privatemessages/templatetags с файлами __init__.py и pluralize.py.<br />
<br />
privatemessages/templatetags/pluralize.py (автор <a href="http://vas3k.ru/dev/django_ru_pluralize/">фильтра</a> — V@s3K):<br />
<br />
<pre class="brush:python;">from django import template
register = template.Library()
@register.filter
def rupluralize(value, arg):
args = arg.split(",")
try:
number = abs(int(value))
except TypeError:
number = 0
a = number % 10
b = number % 100
if (a == 1) and (b != 11):
return args[0]
elif (a >= 2) and (a <= 4) and ((b < 10) or (b >= 20)):
return args[1]
else:
return args[2]
</pre>
<br />
Добавим стили (myproject/static/privatemessages.css):<br />
<br />
<pre class="brush:css;">html, body {
height: 100%;
margin: 0;
}
body {
font-family: Geneva, Arial, Helvetica, sans-serif;
font-size: 14px;
color: #000;
background: #fff;
}
textarea, form p.name input {
border: 1px #d4d4d4 solid;
}
form.new_message p {
margin: 4px 0;
}
form.new_message p.name input, form.new_message textarea {
width: 300px;
}
form.new_message textarea {
height: 100px;
}
textarea:focus, input.name:focus {
border-color: #cacaca;
-webkit-box-shadow: 0 1px 1px rgba(0, 0, 0, 0.075) inset, 0 0 3px rgba(150, 150, 150, 0.5);
-moz-box-shadow: 0 1px 1px rgba(0, 0, 0, 0.075) inset, 0 0 3px rgba(150, 150, 150, 0.5);
box-shadow: 0 1px 1px rgba(0, 0, 0, 0.075) inset, 0 0 3px rgba(150, 150, 150, 0.5);
}
div.private_messages {
padding: 10px;
}
div.private_messages h1 {
margin-top: 0;
}
div.private_messages div.partners {
margin-bottom: 30px;
}
div.chat {
height: 100%;
min-height: 400px;
min-width: 600px;
position: relative;
background-color: #e0e0e0;
}
div.chat div.partner {
height: 50px;
padding: 5px;
}
div.chat div.partner p {
margin: 0;
}
div.chat div.partner p.name {
font-weight: bold;
margin-bottom: 3px;
}
div.chat div.conversation {
position: absolute;
top: 50px;
left: 5px;
right: 5px;
bottom: 140px;
overflow: auto;
padding: 0 5px;
border-style: solid;
border-color: #eee;
border-width: 10px 0;
background-color: #fff;
-webkit-border-radius: 7px;
-moz-border-radius: 7px;
border-radius: 7px;
}
div.chat div.conversation div.message {
padding: 5px 0;
}
div.chat div.conversation div.message p {
margin: 0;
}
div.chat div.conversation div.message p.author.partner {
color: #002c64;
}
div.chat div.conversation div.message p.author.we {
color: #216300;
}
div.chat div.conversation div.message p.author span.datetime {
font-size: 12px;
}
div.chat form.message_form {
position: absolute;
margin: 0;
left: 0;
right: 0;
bottom: 5px;
height: 130px;
}
div.chat form.message_form div.outdated_browser_message {
margin: 10px 5px;
}
div.chat form.message_form div.compose {
float: left;
height: 100%;
width: 80%;
padding: 0 5px;
-webkit-box-sizing: border-box;
-moz-box-sizing: border-box;
box-sizing: border-box;
}
div.chat form.message_form div.compose textarea {
width: 100%;
height: 100%;
margin: 0;
-webkit-box-sizing: border-box;
-moz-box-sizing: border-box;
box-sizing: border-box;
resize: none;
}
div.chat form.message_form div.send {
float: left;
height: 100%;
width: 20%;
min-width: 100px;
padding-right: 5px;
-webkit-box-sizing: border-box;
-moz-box-sizing: border-box;
box-sizing: border-box;
}
div.chat form.message_form div.send p {
margin-top: 5px;
color: #333;
}
div.chat form.message_form div.send button {
width: 100%;
-webkit-box-sizing: border-box;
-moz-box-sizing: border-box;
box-sizing: border-box;
}
</pre>
<br />
И JavaScript (myproject/static/privatemessages.js):<br />
<br />
<pre class="brush:js;">function getCookie(name) {
var cookieValue = null;
if (document.cookie && document.cookie != '') {
var cookies = document.cookie.split(';');
for (var i = 0; i < cookies.length; i++) {
var cookie = jQuery.trim(cookies[i]);
// Does this cookie string begin with the name we want?
if (cookie.substring(0, name.length + 1) == (name + '=')) {
cookieValue = decodeURIComponent(cookie.substring(name.length + 1));
break;
}
}
}
return cookieValue;
}
function setCookie(key, value) {
document.cookie = escape(key) + '=' + escape(value);
}
function getNumEnding(iNumber, aEndings) {
var sEnding, i;
iNumber = iNumber % 100;
if (iNumber>=11 && iNumber<=19) {
sEnding=aEndings[2];
}
else {
i = iNumber % 10;
switch (i)
{
case (1): sEnding = aEndings[0]; break;
case (2):
case (3):
case (4): sEnding = aEndings[1]; break;
default: sEnding = aEndings[2];
}
}
return sEnding;
}
var timezone = getCookie('timezone');
if (timezone == null) {
setCookie("timezone", jstz.determine().name());
}
function activate_chat(thread_id, user_name, number_of_messages) {
$("div.chat form.message_form div.compose textarea").focus();
function scroll_chat_window() {
$("div.chat div.conversation").scrollTop($("div.chat div.conversation")[0].scrollHeight);
}
scroll_chat_window();
var ws;
function start_chat_ws() {
ws = new WebSocket("ws://127.0.0.1:8888/" + thread_id + "/");
ws.onmessage = function(event) {
var message_data = JSON.parse(event.data);
var date = new Date(message_data.timestamp*1000);
var time = $.map([date.getHours(), date.getMinutes(), date.getSeconds()], function(val, i) {
return (val < 10) ? '0' + val : val;
});
$("div.chat div.conversation").append('<div class="message"><p class="author ' + ((message_data.sender == user_name) ? 'we' : 'partner') + '"><span class="datetime">' + time[0] + ':' + time[1] + ':' + time[2] + '</span> ' + message_data.sender + ':</p><p class="message">' + message_data.text.replace(/&/g,'&amp;').replace(/</g,'&lt;').replace(/>/g,'&gt;').replace(/\n/g, '<br />') + '</p></div>');
scroll_chat_window();
number_of_messages["total"]++;
if (message_data.sender == user_name) {
number_of_messages["sent"]++;
} else {
number_of_messages["received"]++;
}
$("div.chat p.messages").html('<span class="total">' + number_of_messages["total"] + '</span> ' + getNumEnding(number_of_messages["total"], ["сообщение", "сообщения", "сообщений"]) + ' (<span class="received">' + number_of_messages["received"] + '</span> получено, <span class="sent">' + number_of_messages["sent"] + '</span> отправлено)');
}
ws.onclose = function(){
// Try to reconnect in 5 seconds
setTimeout(function() {start_chat_ws()}, 5000);
};
}
if ("WebSocket" in window) {
start_chat_ws();
} else {
$("form.message_form").html('<div class="outdated_browser_message"><p><em>Ой!</em> Вы используете устаревший браузер. Пожалуйста, установите любой из современных:</p><ul><li>Для <em>Android</em>: <a href="http://www.mozilla.org/ru/mobile/">Firefox</a>, <a href="http://www.google.com/intl/en/chrome/browser/mobile/android.html">Google Chrome</a>, <a href="https://play.google.com/store/apps/details?id=com.opera.browser">Opera Mobile</a></li><li>Для <em>Linux</em>, <em>Mac OS X</em> и <em>Windows</em>: <a href="http://www.mozilla.org/ru/firefox/fx/">Firefox</a>, <a href="https://www.google.com/intl/ru/chrome/browser/">Google Chrome</a>, <a href="http://ru.opera.com/browser/download/">Opera</a></li></ul></div>');
return false;
}
function send_message() {
var textarea = $("textarea#message_textarea");
if (textarea.val() == "") {
return false;
}
if (ws.readyState != WebSocket.OPEN) {
return false;
}
ws.send(textarea.val());
textarea.val("");
}
$("form.message_form div.send button").click(send_message);
$("textarea#message_textarea").keydown(function (e) {
// Ctrl + Enter
if (e.ctrlKey && e.keyCode == 13) {
send_message();
}
});
}
</pre>
<br />
При развёртывании на сервере не забудьте указать адрес, по которому будет доступен Tornado-сервер (или вообще вынесите в отдельную переменную).<br />
<br />
<h3>
Чат (Tornado)</h3>
<br />
Напишем новое Tornado-приложение и сохраним в privatemessages/tornadoapp.py:<br />
<br />
<pre class="brush:python;">import datetime
import json
import time
import urllib
import brukva
import tornado.web
import tornado.websocket
import tornado.ioloop
import tornado.httpclient
from django.conf import settings
from django.utils.importlib import import_module
session_engine = import_module(settings.SESSION_ENGINE)
from django.contrib.auth.models import User
from privatemessages.models import Thread
c = brukva.Client()
c.connect()
class MainHandler(tornado.web.RequestHandler):
def get(self):
self.set_header('Content-Type', 'text/plain')
self.write('Hello. :)')
class MessagesHandler(tornado.websocket.WebSocketHandler):
def __init__(self, *args, **kwargs):
super(MessagesHandler, self).__init__(*args, **kwargs)
self.client = brukva.Client()
self.client.connect()
def open(self, thread_id):
session_key = self.get_cookie(settings.SESSION_COOKIE_NAME)
session = session_engine.SessionStore(session_key)
try:
self.user_id = session["_auth_user_id"]
self.sender_name = User.objects.get(id=self.user_id).username
except (KeyError, User.DoesNotExist):
self.close()
return
if not Thread.objects.filter(
id=thread_id,
participants__id=self.user_id
).exists():
self.close()
return
self.channel = "".join(['thread_', thread_id,'_messages'])
self.client.subscribe(self.channel)
self.thread_id = thread_id
self.client.listen(self.show_new_message)
def handle_request(self, response):
pass
def on_message(self, message):
if not message:
return
if len(message) > 10000:
return
c.publish(self.channel, json.dumps({
"timestamp": int(time.time()),
"sender": self.sender_name,
"text": message,
}))
http_client = tornado.httpclient.AsyncHTTPClient()
request = tornado.httpclient.HTTPRequest(
"".join([
settings.SEND_MESSAGE_API_URL,
"/",
self.thread_id,
"/"
]),
method="POST",
body=urllib.urlencode({
"message": message.encode("utf-8"),
"api_key": settings.API_KEY,
"sender_id": self.user_id,
})
)
http_client.fetch(request, self.handle_request)
def show_new_message(self, result):
self.write_message(str(result.body))
def on_close(self):
try:
self.client.unsubscribe(self.channel)
except AttributeError:
pass
def check():
if self.client.connection.in_progress:
tornado.ioloop.IOLoop.instance().add_timeout(
datetime.timedelta(0.00001),
check
)
else:
self.client.disconnect()
tornado.ioloop.IOLoop.instance().add_timeout(
datetime.timedelta(0.00001),
check
)
application = tornado.web.Application([
(r"/", MainHandler),
(r'/(?P<thread_id>\d+)/', MessagesHandler),
])
</pre>
<br />
Почти всё в этом приложении выполняется асинхронно, с использованием tornado.ioloop. Исключение — это авторизация пользователя по идентификатору сессии (и получение его имени из базы данных). Это блокирующая операция, то есть в этот момент другие пользователи, подключённые к этому Tornado-серверу, будут ждать. Если вам потребуется, то это легко изменить, но, во-первых, не факт, что потребуется, а во-вторых, подумайте, правильное ли это решение. По этому поводу ребята из Friendfeed (разработчики Tornado) высказывались не раз. Вот, например, цитата из сообщения Бэна Дарнэлла (Ben Darnell):<br />
<br />
<blockquote>
Friendfeed uses mysql, with the standard synchronous MySQLdb module (http://bret.appspot.com/entry/how-friendfeed-uses-mysql). Friendfeed's philosophy is that for things that are fast and under your control (i.e. memcache and database — and if your database isn't fast, rethink how you're querying it), you should just call them synchronously because it's not worth the complexity of managing callbacks. Asynchronous code is for things that are likely to be slow in ways you can't do anything about (e.g. external APIs) or when you want to suspend a request for an indefinite amount of time (long polling). A library like adisp shifts the balance somewhat since it makes async interfaces much simpler to use, although there are still issues like the fact that generator coroutines aren't composable like normal function calls.</blockquote>
<br />
То есть, грубо говоря, если ваша база данных работает медленно — значит сервера в любом случае не справляются с нагрузкой, и асинхронный запуск тут мало чем поможет. Либо, возможно, что вы неправильно структурировали базу данных или делаете слишком медленные запросы — в этом случае будет намного больше пользы, если вы исправите проблему, а не уменьшите симптомы.<br />
<br />
Запускать Tornado-приложение очень удобно через management-команду. В этом случае вам не потребуется <a href="http://stackoverflow.com/questions/2180415/using-django-database-layer-outside-of-django">конфигурировать рабочую среду Django</a> для доступа к ORM и всему остальному.<br />
<br />
Сделать свою management-команду очень легко. Создайте в каталоге privatemessages директорию management, а в ней директорию commands.<br />
<br />
Обязательно добавьте в privatemessages/management и в privatemessages/management/commands по файлу __init__.py (если кто-то вдруг не знает, зачем они используются, то посмотрите в <a href="http://docs.python.org/2/tutorial/modules.html">документации</a>).<br />
<br />
Теперь создадим файл privatemessages/management/commands/starttornadoapp.py:<br />
<br />
<pre class="brush:python;">import signal
import time
import tornado.httpserver
import tornado.ioloop
from django.core.management.base import BaseCommand, CommandError
from privatemessages.tornadoapp import application
class Command(BaseCommand):
args = '[port_number]'
help = 'Starts the Tornado application for message handling.'
def sig_handler(self, sig, frame):
"""Catch signal and init callback"""
tornado.ioloop.IOLoop.instance().add_callback(self.shutdown)
def shutdown(self):
"""Stop server and add callback to stop i/o loop"""
self.http_server.stop()
io_loop = tornado.ioloop.IOLoop.instance()
io_loop.add_timeout(time.time() + 2, io_loop.stop)
def handle(self, *args, **options):
if len(args) == 1:
try:
port = int(args[0])
except ValueError:
raise CommandError('Invalid port number specified')
else:
port = 8888
self.http_server = tornado.httpserver.HTTPServer(application)
self.http_server.listen(port, address="127.0.0.1")
# Init signals handler
signal.signal(signal.SIGTERM, self.sig_handler)
# This will also catch KeyboardInterrupt exception
signal.signal(signal.SIGINT, self.sig_handler)
tornado.ioloop.IOLoop.instance().start()
</pre>
<br />
Если вам требуется, чтобы при завершении процесса через сигналы (например, через сигнал SIGINT, который процесс получает при завершении через Ctrl + C) происходило что-либо ещё, это можно задать в соответствующем обработчике. Почитать немного дополнительной информации на эту тему можно <a href="http://codemehanika.org/blog/2011-10-28-graceful-stop-tornado.html">здесь</a>.<br />
<br />
<h3>
Проверяем</h3>
<br />
Теперь перейдите в корневую директорию проекта (в которой находятся директории myproject и privatemessages, а также файл manage.py) и запустите development-сервер Django и сервер Tornado.<br />
<br />
<pre>python manage.py runserver
python manage.py starttornadoapp
</pre>
<br />
Вы можете запустить эти команды в разных вкладках эмулятора терминала, либо в разных вкладках <a href="http://www.opennet.ru/base/sys/screen_intro.txt.html">screen</a> (если, например, вы подключаетесь к серверу по SSH и вам неудобно открывать ещё одну SSH-сессию).<br />
<br />
Теперь на порту 8000 у вас запущен сервер разработки Django, а на порту 8888 запущен сервер Tornado.<br />
<br />
Список собеседников вы можете посмотреть на следующей странице:<br />
<br />
<a href="http://127.0.0.1:8000/messages/">http://127.0.0.1:8000/messages/</a><br />
<br />
Оттуда же вы можете открыть один из существующих чатов или создать новый (отправив первое сообщение через соответствующую форму).<br />
<br />
Имейте в виду, что вам нужно быть авторизованным на вашем Django-сайте. О том, как написать авторизацию, смотрите в <a href="https://docs.djangoproject.com/en/dev/topics/auth/">документации</a>. Решение для ленивых — просто <a href="https://docs.djangoproject.com/en/dev/ref/contrib/admin/">включите административный интерфейс</a> и авторизируйтесь через него.<br />
<br />
<h3>
Deployment</h3>
<br />
Для того, чтобы запустить ваше приложение на сервере мы можем воспользоваться следующим ПО:<br />
<br />
<ul>
<li><a href="http://haproxy.1wt.eu/">haproxy</a> — балансировщик нагрузки, будет работать на порту 80</li>
<li><a href="http://nginx.org/">nginx</a> — раздача статических файлов + реверс-прокси для gunicorn, будет работать на порту 8100</li>
<li><a href="http://supervisord.org/">Supervisor</a> — запуск сервера Tornado на портах 8000—8003 (4 процесса) и сервера Django (можно использовать gunicorn) на порту 8150</li>
</ul>
<br />
Прежде всего настроим Supervisor. Для этого (на примере Supervisor из репозиториев Ubuntu) создадим в /etc/supervisor/conf.d файлы django.conf и tornadoapp.conf. Убедитесь, что Supervisor будет читать эти файлы (в основном конфигурационном файле должна присутствовать секция include со строкой files = /etc/supervisor/conf.d/*.conf).<br />
<br />
django.conf<br />
<br />
<pre class="brush:plain;">[program:django]
command=gunicorn_django --workers 4 -b 127.0.0.1:8150
directory=/home/yourusername/myproject
user=yourusername
autostart=true
autorestart=true
</pre>
<br />
tornadoapp.conf<br />
<br />
<pre class="brush:plain;">process_name = tornado-%(process_num)s
user = yourusername
directory = /home/yourusername/myproject
command = python manage.py starttornadoapp %(process_num)s
# Increase numprocs to run multiple processes on different ports.
# Note that the chat demo won't actually work in that configuration
# because it assumes all listeners are in one process.
numprocs = 4
numprocs_start = 8000
autostart=true
autorestart=true
</pre>
<br />
Теперь запустите supervisor и посмотрите вывод команды supervisorctl status.<br />
<br />
Убедитесь, что у всех 5 процессов (gunicorn будет отображаться как один процесс) выводится состояние RUNNING и uptime совпадает с тем, сколько времени прошло с момента запуска Supervisor.<br />
<br />
Также я рекомендую убедиться в том, что и Django-серверы, и Tornado-серверы работают. Для этого мы можете открыть SSH-туннель до сервера и просто попробовать открыть ваш сайт в браузере, обращаясь при этом к выбранному порту на локальном адресе.<br />
<br />
То, есть, например:<br />
<br />
<pre>ssh -L 8000:localhost:8150 someverycoolserver.com
</pre>
<br />
После чего просто открываете в браузере 127.0.0.1:8000 (SSH-сервер передаст ваш запрос на 127.0.0.1:8150 в своей сети — где запущен веб-сервер, который вы хотите проверить).<br />
<br />
Аналогичным образом вы можете проверить Tornado-серверы. Они находятся на портах 8000—8003.<br />
<br />
Если серверы Django и Tornado работают, то вам остаётся только настроить nginx и haproxy.<br />
<br />
Пример конфигурационного файла для nginx:<br />
<br />
<pre class="brush:plain;">server {
listen 127.0.0.1:8100;
server_name someverycoolserver.com;
# no security problem here, since / is always passed to upstream
root /home/yourusername/myproject/myproject/static/;
## Compression
# src: http://www.ruby-forum.com/topic/141251
# src: http://wiki.brightbox.co.uk/docs:nginx
gzip on;
gzip_http_version 1.0;
gzip_comp_level 2;
gzip_proxied any;
gzip_min_length 1100;
gzip_buffers 16 8k;
gzip_types text/plain text/html text/css application/x-javascript text/xml application/xml application/xml+rss text/javascript;
# Some version of IE 6 don't handle compression well on some mime-types, so just disable for them
gzip_disable "MSIE [1-6].(?!.*SV1)";
# Set a vary header so downstream proxies don't send cached gzipped content to IE6
gzip_vary on;
## /Compression
location /static/admin/ {
# this changes depending on your python version
root /usr/local/lib/python2.7/dist-packages/django/contrib/admin/;
}
location /robots.txt {
alias /home/yourusername/myproject/myproject/robots.txt;
}
location /favicon.ico {
alias /home/yourusername/myproject/myproject/img/favicon.ico;
expires 3d;
}
location /static/ {
root /home/yourusername/myproject/myproject/;
expires 3d;
}
location / {
proxy_pass_header Server;
proxy_set_header Host $http_host;
proxy_redirect off;
proxy_set_header X-Real-IP $remote_addr;
proxy_set_header X-Scheme $scheme;
proxy_connect_timeout 10;
proxy_read_timeout 10;
proxy_pass http://localhost:8150/;
}
}
</pre>
<br />
Обратите внимание, что тут выбрано кэширование статических файлов в течение трёх дней. Делать так стоит либо в том случае, если вы точно знаете, что ничего не будете там менять (а обычно менять всё-таки нужно), либо если вы для всех статических файлов используете так называемую технику static files versioning (обычно это означает, что при запросе статических файлов к их адресу добавляется query string, и если серверу известно, что файл изменился на диске, то query string меняется). В частности, это отлично подходит, если вы используете автоматическое объединение и мимификацию (минификацию) CSS и JS — например, <a href="https://github.com/miracle2k/django-assets">django-assets</a>.<br />
<br />
Пример конфигурационного файла haproxy:<br />
<br />
<pre class="brush:plain;">global
maxconn 10000 # Total Max Connections. This is dependent on ulimit
nbproc 2
defaults
mode http
option redispatch
maxconn 2000
contimeout 5000
clitimeout 50000
srvtimeout 50000
option httpclose
frontend all 0.0.0.0:80
timeout client 86400000
acl is_chat hdr_beg(host) -i chat
use_backend socket_backend if is_chat
default_backend www_backend
backend www_backend
option forwardfor # This sets X-Forwarded-For
timeout server 30000
timeout connect 4000
server server1 localhost:8100
backend socket_backend
balance roundrobin
option forwardfor # This sets X-Forwarded-For
no option httpclose # To match the `Connection` header for the websocket protocol rev. 76
option http-server-close
option http-pretend-keepalive
timeout queue 5000
timeout server 86400000
timeout connect 86400000
server server1 localhost:8000 weight 1 maxconn 5000 check
server server2 localhost:8001 weight 1 maxconn 5000 check
server server2 localhost:8002 weight 1 maxconn 5000 check
server server2 localhost:8003 weight 1 maxconn 5000 check
</pre>
<br />
Здесь мы указываем, что хотим перенаправлять все запросы, Host в которых начинается с chat, на сервера localhost:8000, localhost:8001, localhost:8002 и localhost:8003, а все остальные запросы перенаправлять на localhost:8100.<br />
<br />
После запуска haproxy будет работать на 80-м порту на всех имеющихся IPv4-адресах. Если вы хотите поддерживать IPv6 (а это отличная идея, если его поддерживает ваш хостинг/провайдер), то добавьте в секции frontend конфигурационный параметр bind с вашим IPv6-адресом и 80-м портом через двоеточие.<br />
<br />
Имейте в виду, что у некоторых пользователей ваш IPv6-адрес может определяться (если, конечно, вы добавите AAAA-запись в DNS), но при этом не работать. Такое бывает в том случае, если системные администаторы их провайдера всё сделали неправильно. Варианты решения — отдельный поддомен для IPv6, либо так называемый <a href="http://en.wikipedia.org/wiki/IPv6_brokenness_and_DNS_whitelisting">DNS whitelisting</a>. Так <a href="http://events.yandex.ru/talks/325/">делает</a>, например, Яндекс — потому что они с одной стороны любят и хотят поддерживать IPv6, но с другой стороны они не могут позволить себе терять 1—2 процента пользователей (для Яндекса это очень много).<br />
<br />
Кстати, Tornado-сервер доступен на отдельном поддомене, а не на отдельном порту не просто так — в некоторых компаниях работают очень странные системные администраторы, которые попросту закрывают доступ к любым альтернативным портам (не 80 и не 443).<br />
<br />
Вы также можете настроить haproxy таким образом, чтобы он выбирал backend не по заголовку Host, а <a href="https://gist.github.com/3767467">по наличию заголовка Upgrade</a> — тогда получится, что у вас и Django, и Tornado работают на одном и том же домене (вообще без поддоменов), но WS-соединения открываются с Tornado-серверами, а все остальные — с Django-серверами.<br />
<br />
Как видите, в целом всё довольно просто. Чтобы разобраться в новой теме, достаточно читать документацию, а если в документации описано не всё, то читать исходники (в случае с Django и Tornado о многих вещах проще всего узнать именно из исходников), форумы, а также материалы с тематических докладов. Совершенствуйтесь и работайте над интересными проектами! Всем удачи и приятного программирования.</div>アルセニhttp://www.blogger.com/profile/07661163832723079945noreply@blogger.com1tag:blogger.com,1999:blog-36628898.post-13398321355844948742011-12-17T16:14:00.000+04:002011-12-17T16:15:19.787+04:00Автоматическая подсветка символов из другого языка в текстеКогда кто-то при написании текста пользуется одновременно русской и английской раскладкой, это в некоторых случаях может приводить к тому, что в, например, русских словах текста появляются английские буквы. Поскольку вид некоторых букв в английском и русском пересекается, подобное может остаться незамеченным.<br /><br />Я написал <a href="http://aruseni.alwaysdata.net/layouthighlight/">небольшую веб-страницу</a>, где можно набрать (или вставить) текст, и выбрать, на каком он языке — на русском или на английском. Страница тут же подсвечивает символы английского языка, если текст русский, и, наоборот, символы русского языка, если текст на английском.<br /><br /><a href="http://aruseni.alwaysdata.net/layouthighlight/"><img src="http://habrastorage.org/storage1/1df43a6c/aad7d82c/a0aa46ae/9f9c3ec0.png" alt="Скриншот"></a><br /><br />Очевидное развлечение (не претендующее, впрочем, на практическую ценность) — проверять тексты, написанные разными людьми. В зависимости от того, насколько внимательным был автор, выделение будет чаще или реже встречаться в русских словах (где могут присутствовать английские буквы).<br /><br />Практическое же применение может быть, например, если есть какой-то код авторизации, и имеется подозрение, что часть символов в нём может быть на русском (и поэтому код не работает). Эта веб-страница, соответственно, позволяет проверить, так ли это.アルセニhttp://www.blogger.com/profile/07661163832723079945noreply@blogger.com0tag:blogger.com,1999:blog-36628898.post-33141473174786432702011-01-07T00:05:00.004+03:002012-11-29T19:29:14.027+04:00testcreator — автоматизация тестирования студентов<div dir="ltr" style="text-align: left;" trbidi="on">
Вчера я дописал небольшую программу с веб-интерфейсом; она написана на Django (Python) и позволяет несколько автоматизировать процесс тестирования студентов.<br />
<br />
Преподаватель может создать тест — например, по русскому языку или математике. После этого он может добавить к нему нужное количество вопросов и к каждому из вопросов заполнить несколько ответов, при этом он может выбрать один или несколько ответов как «правильные».<br />
<br />
После того, как тест будет наполнен вопросами, можно экспортировать необходимое количество случайно выбранных вопросов в PDF, причём сразу в двух вариантах — один вариант для студента, для заполнения, а другой для преподавателя — для проверки теста (в варианте для преподавателя указаны правильные ответы).<br />
<br />
Таким образом можно, например, сделать тест с двумя сотнями вопросов и выдать каждому студенту совершенно индивидуальный вариант с 20 вопросами.<br />
<br />
<a href="http://img84.imageshack.us/img84/1858/testcreator1.png"><img alt="Скриншот" src="http://img84.imageshack.us/img84/1858/testcreator1.th.png" /></a><br />
<br />
<a href="http://img840.imageshack.us/img840/1285/testcreator2.png"><img alt="Скриншот" src="http://img840.imageshack.us/img840/1285/testcreator2.th.png" /></a><br />
<br />
<a href="http://img585.imageshack.us/img585/691/testcreator3.png"><img alt="Скриншот" src="http://img585.imageshack.us/img585/691/testcreator3.th.png" /></a><br />
<br />
<a href="http://img830.imageshack.us/img830/1391/testcreator4.png"><img alt="Скриншот" src="http://img830.imageshack.us/img830/1391/testcreator4.th.png" /></a><br />
<br />
<a href="http://img257.imageshack.us/img257/3825/testcreator5.png"><img alt="Скриншот" src="http://img257.imageshack.us/img257/3825/testcreator5.th.png" /></a><br />
<br />
<a name='more'></a>
Теперь опишу установку (для Ubuntu 10.10).<br />
<br />
Скачать <a href="http://narod.ru/disk/19976975001/testcreator.tar.bz2.html">файл</a>, распаковать его к себе (например, в домашний каталог).<br />
<br />
Открыть консоль, выполнить sudo su.<br />
<br />
apt-get update<br />
apt-get install python-django<br />
apt-get install python-setuptools<br />
apt-get install python-all-dev<br />
easy_install pisa<br />
easy_install reportlab<br />
easy_install html5lib<br />
<br />
Перейти в каталог с программой и выполнить syncdb — при этом надо<br />
будет создать суперпользователя, которого можно использовать для входа<br />
в админку, если она для чего-нибудь понадобится (/admin/).<br />
<br />
cd testcreator<br />
python manage.py syncdb<br />
<br />
И запустить сервер. Он вполне подходит для обычного<br />
использования, но для использования несколькими людьми и вообще<br />
продакшена настоятельно рекомендую обратить внимание в сторону<br />
gunicorn + nginx.<br />
<br />
python manage.py runserver<br />
<br />
Всё, программа должна быть доступна по следующему адресу.<br />
<br />
<a href="http://127.0.0.1:8000/">http://127.0.0.1:8000/</a><br />
<br />
Кстати, там используется unobtrusive javascript. То есть если<br />
JavaScript у пользователя по каким-то причинам выключен, и модные<br />
анимированные эффекты динамически меняющихся страниц у него не<br />
отобразить, то программа у него всё равно работает — просто происходит<br />
дополнительный переход по ссылке.<br />
<br />
Вот такая программа получилась. Если у вас есть какие-нибудь мысли по поводу этой программы, буду рад их услышать.</div>
アルセニhttp://www.blogger.com/profile/07661163832723079945noreply@blogger.com2tag:blogger.com,1999:blog-36628898.post-15979817644841212652010-11-06T05:41:00.005+03:002010-11-07T00:07:32.751+03:00FLAC encoding to MP3 and OggOkay, so you have a collection of amazing music in FLAC. But your portable media player only supports MP3. That means you have to convert the files before you can put them on your player. And it would be nice to convert whole albums.<br /><br />If your player supports Ogg, you can simply execute this. It converts each of the .flac files in the current directory to Ogg.<br /><br />oggenc -q7 *.flac<br /><br />But how about MP3? LAME is a terrific MP3 encoder, but it doesn’t have an option to convert FLAC files.<br /><br />Well, fortunately, Linux provides all the needed tools to get the needed combination of programs working together to do something.<br /><br />for i in *.flac ; do flac -dc "${i}" | lame -b 320 --alt-preset insane - "/home/ml/mp3/${i%.flac}.mp3" ; done<br /><br />Here we use the <a href="http://tldp.org/HOWTO/Bash-Prog-Intro-HOWTO-7.html">“for” cycle of bash</a> to run the “flac” command for each of the .flac files in the current directory. Option -d tells flac to decode the given file and -c tells to write output to the standard output (stdout). LAME has two required arguments: input file and output file. But we can use a hyphen (“-”) for input file to use stdin and for output file to use stdout. In this case LAME has stdin received from flac. To transmit flac’s output to lame’s input we used <a href="http://en.wikipedia.org/wiki/Pipeline_%28Unix%29">pipes</a>. Finally, ${i%.flac}.mp3 is a simple <a href="http://www.purinchu.net/wp/2005/06/11/advanced-parameter-substitution-with-bash/">bash parameter substitution</a> example. It takes the contents of the i variable, but without the ending “.flac”. Then you can just put .mp3 to the end of the string to make a new file extension.アルセニhttp://www.blogger.com/profile/07661163832723079945noreply@blogger.com0tag:blogger.com,1999:blog-36628898.post-51463711120699823132010-07-21T23:48:00.003+04:002012-11-29T19:31:39.094+04:00Recording and editing screencasts with recordMyDesktop and Audacity<div dir="ltr" style="text-align: left;" trbidi="on">
RecordMyDesktop is a nice tool for screencasting. It allows you to record the computer’s screen and your voice. But what if you want a little more? In my case, I wanted to edit the audio track to include music and change volume on my voice in some places.<br />
<br />
Before running recordMyDesktop and recording the video, make sure that you have enough disk space available in /tmp. If you don’t, choose a different working directory using the -workdir option.<br />
<br />
This runs the recording after waiting for 5 seconds, and uses /home/kasi_sona/tmp as a temporary files directory.<br />
<br />
<span style="font-weight: bold;">recordmydesktop --delay 5 --workdir /home/kasi_sona/tmp</span><br />
<br />
To stop the recording, you can use Ctrl + Left Alt + s.<br />
<br />
<a name='more'></a>
When the video is recorded, it converts to Ogg (Vorbis and Theora). Before the conversion completes, you can go to the temporary files directory and copy the PCM audio file somewhere to edit it with Audacity (you can just make a hardlink using the ln command — a file is deleted only when the last hardlink is deleted).<br />
<br />
Then edit the file with Audacity and export it to Ogg Vorbis (you can also select the quality — I like to use 7).<br />
<br />
Okay. So, now we have two files. A video file (which includes the original audio track) and an audio file (which we want to use as the new audio track). So, we just want to replace an audio track. All is encoded already, the task seems very simple. And it <span style="font-style: italic;">is</span> very simple, but before I found a solution, I tried a lot of programs (ffmpeg, mencoder, vlc) and none of them worked.<br />
<br />
The program that brings a simple, beautiful solution, is called oggzmerge. It is a part of oggz-tools.<br />
<br />
This merges out.ogv and voice_with_music.ogg to programming_an_image_hosting_application_with_django.ogg.<br />
<br />
<span style="font-weight: bold;">oggzmerge -o programming_an_image_hosting_application_with_django.ogg out.ogv voice_with_music.ogg</span><br />
<br />
If you want to have two audio tracks in your video (the user can choose from them), this is the complete solution. For example, if you only included music in your audio track, this allows a user to choose whether he or she wants to hear the background music.<br />
<br />
But what if you want to replace the old audio track with a new one? Then you will need to separate audio from video in your original video file. And here is how.<br />
<br />
Get oggsplit.<br />
<br />
<span style="font-weight: bold;">svn checkout http://svn.xiph.org/trunk/ogg-tools/oggsplit/</span><br />
<br />
Of course, you need Subversion for this.<br />
<br />
<span style="font-weight: bold;">cd oggsplit</span><br />
<span style="font-weight: bold;">./autogen.sh</span><br />
<span style="font-weight: bold;">make</span><br />
<br />
Now split your original video file with oggsplit.<br />
<br />
<span style="font-weight: bold;">./oggsplit /moar/video/software/django/programming_an_image_hosting_application_with_django/out.ogv</span><br />
<br />
In my case, it created 3 streams: out.c01.g01.ogv, out.c01.g02.ogv, out.c01.g03.ogv.<br />
<br />
Move the video stream to your screencast directory and delete other streams.<br />
<span style="font-weight: bold;">mv out.c01.g02.ogv /moar/video/software/django/programming_an_image_hosting_application_with_django/only_video.ogv</span><br />
<span style="font-weight: bold;">rm out.c01*</span><br />
<br />
Now go to your screencast’s directory and merge the audio track and the video track.<br />
<br />
<span style="font-weight: bold;">oggzmerge -o programming_an_image_hosting_application_with_django.ogg only_video.ogv voice_with_music.ogg</span><br />
<br />
Great! Finally, let’s split the video into parts of the needed length. It is useful if you want to upload your screencast on YouTube, but it is longer than 10 minutes.<br />
<br />
<span style="font-weight: bold;">oggz-chop -o part1.ogg -s 0:0:0 -e 0:10:0 programming_an_image_hostin<br />g_application_with_django.ogg</span><br />
<br />
Just like oggzmerge, oggz-chop is a part of oggz-tools.<br />
<br />
So, here it is. Come on, go record a nice screencast!<br />
<br />
Have fun!</div>
アルセニhttp://www.blogger.com/profile/07661163832723079945noreply@blogger.com0tag:blogger.com,1999:blog-36628898.post-51096846355390440472010-07-18T09:28:00.000+04:002012-11-29T19:33:59.364+04:00Смотрим кино с субтитрами сразу на двух языках<div dir="ltr" style="text-align: left;" trbidi="on">
Увидев только что на Хабрахабре вот <a href="http://mishellr.habrahabr.ru/blog/99185/" title="Учим английский вместе с TheKMPlayer">эту</a> блогозапись, подумал, а нельзя ли сделать так в Линуксе. Оказалось — можно. Причём метод не зависит от проигрывателя видео, главное, чтобы программа поддерживала субтитры в формате <a href="http://en.wikipedia.org/wiki/Advanced_SubStation_Alpha#Advanced_SubStation_Alpha" title="Advanced SubStation Alpha в статье SubStation Alpha в английском разделе Википедии">ASS</a>.<br />
<br />
<img alt="Скриншот" src="http://img232.imageshack.us/img232/6783/mplayer.png" /><br />
<br />
<blockquote>
if anyone needs this, i created a bash script allowing you to<br />
watch/convert movies with two subtitles shown at the same time. you<br />
may use this if a foreign friend comes to visit you and you both would<br />
like to watch a movie with subtitles in your mother language, for<br />
instance.</blockquote>
<blockquote>
Если кому-то надо, я создал скрипт на bash'е, позволяющий смотреть/конвертировать фильмы так, чтобы одновременно показывались две дорожки субтитров. Вы можете им пользоваться, если, скажем, к вам в гости приезжает друг из другой страны, и вы бы оба хотели посмотреть фильм с субтитрами на вашем родном языке.</blockquote>
<br />
<a name='more'></a>
<a href="http://lists.mplayerhq.hu/pipermail/mplayer-users/2007-August/068496.html">[MPlayer-users] two subtitles at one time (script)</a><br />
<br />
Я сделал в скрипте изменения в двух строчках, добавив опцию -subcp utf-8 в команду конвертации SRT-файлов.<br />
<br />
Зависимости скрипта — mplayer (в случае, если ваши файлы субтитров нужно сконвертировать в srt) и gnu utils (по поводу присутствия gnu utils на компьютере, что-то мне подсказывает, можно не беспокоиться).<br />
<br />
Сохраним скрипт <a href="http://narod.ru/disk/22807110000/merge2ass.sh.html">merge2ass.sh</a> к себе на компьютер (ссылка выше).<br />
<br />
Теперь откроем консоль и перейдём в каталог, в котором находится скрипт.<br />
<br />
Например так:<br />
cd scripts/ (находясь в домашнем каталоге)<br />
Или так:<br />
cd ~/scripts/ (находясь где-то ещё)<br />
<br />
Установим скрипту права на исполнение (делается один раз):<br />
chmod +x merge2ass.sh<br />
<br />
Теперь посмотрим, какие аргументы принимает скрипт.<br />
<br />
1. Файл фильма<br />
2. Одни субтитры<br />
2. Вторые субтитры<br />
4. Необязательный аргумент, может быть «-pm» или «--play-movie» для мгновенного перехода к просмотру фильма<br />
<br />
Итак, скачиваем русские и английские субтитры.<br />
<br />
Русские субтитры, скорее всего, в кодировке CP1251. Меняем кодировку на UTF-8.<br />
<br />
iconv -f cp1251 -t utf-8 movies/lie_to_me-s02e11-russian.srt > movies/lie_to_me-s02e11-russian-utf-8.srt<br />
<br />
И запускаем скрипт.<br />
<br />
./merge2ass.sh movies/lie_to_me-s02e11.avi movies/lie_to_me-s02e11-english.srt movies/lie_to_me-s02e11-russian-utf-8.srt<br />
<br />
Скрипт создаёт файл lie_to_me-s02e11-bilingual.ass. Остаётся запустить кино.<br />
<br />
mplayer -noautosub -ass movies/lie_to_me-s02e11.avi -sub movies/lie_to_me-s02e11-bilingual.ass -subcp utf-8<br />
<br />
Приятного просмотра и, конечно же, изучения языков!</div>
アルセニhttp://www.blogger.com/profile/07661163832723079945noreply@blogger.com0tag:blogger.com,1999:blog-36628898.post-38588063005819100212009-10-22T19:31:00.004+04:002012-11-29T19:34:54.748+04:00Linux (или компьютер) совсем для всех<div dir="ltr" style="text-align: left;" trbidi="on">
<b>Задача.</b> Обучить (с нуля) 60-летнюю маму пользоваться компьютером, Ubuntu Linux и электронной почтой Gmail.<br />
<br />
<b>Ресурс.</b> Все необходимые детали, чтобы собрать компьютер, загрузочная флэшка с Ubuntu, вечер на сборку компьютера и установку ОС, несколько часов на написание руководства, полчаса на обучение.<br />
<br />
<b>Результат.</b> Мы переехали в другую страну, а тот компьютер работает и позволяет его пользователю обмениваться письмами по электронной почте без чьей-либо помощи.<br />
<br />
Был собран компьютер — процессор Pentium D, 512 MiB оперативной памяти, встроенные в материнскую плату графика и звук, винчестер. Никаких наворотов вроде оптического привода или флоппи-дисковода — в 21-м веке оптические диски (не говоря уж о дискетах) устарели, да и какое отношение они имеют к электронной почте?<br />
<br />
Далее на этот компьютер была установлена Ubuntu 9.04. Почти никаких особенностей. Раздел подкачки 1 GiB. Пришлось, правда, сделать небольшую лоботомию при установке — форсировать загрузку Live с графическим драйвером vesa (кажется, параметром vga=788 или как-то так). Очевидно, Ubuntu пробовала какой-то другой драйвер, который, впрочем, успешно вешал компьтер. Такую же проблему я уже встречал ранее на другом компьютере (там тоже было видео ATi, но не встроенное) — там умный подбор «подходящего» драйвера приводил к kernel panic. Впрочем, на большинстве компьютеров, которые я видел, установка Ubuntu проходит вообще без проблем.<br />
<br />
После установки всё работало отлично. Эффекты были отключены для увеличения производительности. Звук не работал — нужно было всего-то снести pulseaudio (к этому я уже привык, я вообще считаю, что разработчики Ubuntu очень странные люди в этом плане: зачем встраивать в стабильный дистрибутив то, что работает только на некоторых компьютерах?), после этого всё стало замечательно.<br />
<br />
<a name='more'></a>
Итак, руководство. При использовании настоятельно рекомендую произвести в нём изменения для конкретного компьютера, конкретной установки Ubuntu и, возможно, адреса электропочты (можно переделать скриншоты).<br />
<br />
Например изменения под нюансы компьютера на первой странице руководства:<br />
<br />
<i>- нажмите на кнопку включения на корпусе<br />+ нажмите на кнопку включения на корпусе (она большая)<br /><br />+ Ваш компьютер имеет нюанс — его включение остановится на определённом этапе. При этом на экране (внизу) появится сообщение: «Intel CPU uCode loading error. Press F1 to Resume». Просто нажмите на клавиатуре клавишу F1 (верхний ряд, вторая слева) и загрузка продолжится.</i><br />
<br />
Ещё неплохо на распечатанном руководстве сделать заметки ручкой — как переключить раскладку клавиатуры, какие логин и пароль вводить. Желательно на соответствующих страницах, чтобы их было просто найти.<br />
<br />
<b>Определения</b><br />
Этот раздел, в принципе, можно не читать. Он содержит всякую занудную компьютерщину, не особо интересную нормальным людям.<br />
<br />
<b>Что будет использоваться</b><br />
Просто перечисление используемых программ.<br />
<br />
<b>Приступим</b><br />
Раздел, показывающий пользователю самые азы. Как включить компьютер? Как запустить веб-браузер? Как ввести текст в поле ввода? Как воспользоваться Гуглом?<br />
<br />
<b>Как проверить почту</b><br />
Тут описывается, как войти в Gmail и проверить почту.<br />
<br />
<b>Выключение компьютера</b><br />
Как правильно выключить компьютер из операционной системы.<br />
<br />
<b>Содержание</b><br />
Содержание руководства.<br />
<br />
<a href="http://files.tinycmd.org/docs/ubuntu_manual.pdf">PDF</a>, 5.7 MiB<br />
<a href="http://files.tinycmd.org/docs/ubuntu_manual.odt">ODT</a>, 5.6 MiB</div>
アルセニhttp://www.blogger.com/profile/07661163832723079945noreply@blogger.com1tag:blogger.com,1999:blog-36628898.post-42051362997596229102008-12-27T22:14:00.002+03:002012-11-29T19:40:46.968+04:00Легенда о восстановлении Unix<div dir="ltr" style="text-align: left;" trbidi="on">
Сейчас много мыслей о том, что ждёт нас в будущем, в 2009 году, да и после. Но почему бы на мгновение не вернутсья в прошлое и не восхититься тем, как хардкорные юниксоиды того времени выкручивались, восстанавливая систему?<br />
<br />
Это — перевод <a href="http://www.ee.ryerson.ca/~elf/hack/recovery.html">статьи</a> <a href="http://www.wolczko.com/">Mario Wolczko</a>, опубликованной в Usenet в 1986.<br />
<br />
Бывало ли когда-нибудь, что ты оставлял терминал залогиненным, просто чтобы вернуться и увидеть, как (предполагаемый) друг написал в нём <b>rm -rf ~/*</b> и стоит возле клавиатуры: — Одолжи мне пятёрку до четверга, или я нажимаю «энтер».<br />
<br />
Без всякого сомнения, этот человек не понимает, какую травму он может нанести, и воспринимает всё как милую шутку.<br />
<br />
Это была тихая среда. Если быть точным — среда 1-го октября, 15:15 по британскому летнему времени. Питэр, мой коллега, отошёл от своего терминала и сказал мне: — Марио, у меня тут небольшая проблема с отправкой почты.<br />
<br />
Понимая, что такое сообщение может сбить с толку кого угодно, я решил прогуляться до его терминала, чтобы посмотреть, что не так.<br />
<br />
В терминале было странное сообщение об ошибке, примерно такое (я уже не помню всех деталей): <b>cannot access /foo/bar for userid 147</b><br />
<br />
<a name='more'></a>Сначала я подумал: у кого userid 147? Отправитель сообщения, получатель или ещё что-то? Тогда я перешёл к другому, уже залогиненному терминалу, и набрал<br />
<br />
<pre>grep 147 /etc/passwd</pre>
<br />
просто, чтобы получить ответ<br />
<br />
<pre>/etc/passwd: No such file or directory. </pre>
<br />
Тут же я предположил, что чего-то нет. Всё подтвердилось, в ответ на<br />
<br />
<pre>ls /etc</pre>
<br />
Я получил<br />
<br />
<pre>ls: not found. </pre>
<br />
Я посоветовал Питэру, что хорошей идеей будет ничего сейчас не трогать, и пошёл искать нашего системного администратора.<br />
<br />
Когда я пришёл к нему в офис, его дверь была приоткрыта, и в течение 10 секунд я понял, что у нас за проблема. Джэймс, наш менеджер, сидел с головой в руках, руками между коленями, как человек, мир которого только что рухнул. Наш недавно назначенный системный программист, Нэйл, стоял сзади него и пристально, вяло наблюдал за терминалом у него на экране. А я подсмотрел вверху экрана следующее:<br />
<br />
<pre># cd
# rm -rf *</pre>
<br />
Вот дерьмо, подумал я. И это ведь всё объясняет.<br />
<br />
Я даже не помню, что происходило в следующие минуты; моя память словно размыта. Я помню только, что мы пробовали <b>ls</b> (снова), <b>ps</b>, <b>who</b> и, может, ещё несколько команд — всё бесполезно. Следующее, что я помню: я снова у моего терминала (многооконный графический терминал), набираю<br />
<br />
<pre>cd /
echo *</pre>
<br />
Я должен выразить благодарность Дэвиду Корну, ведь он сделал <b>echo</b> встроенной внутрь командной оболочки; не нужно и говорить, что бинарный файл <b>/bin/echo</b> за компанию со всем <b>/bin</b> был удалён. Что прояснилось в следующие несколько минут, так это то, что <b>/dev</b>, <b>/etc</b> и<br />
<b>/lib</b> неразлучимо исчезли, но, к счастью, Нэйл прервал <b>rm</b> в тот момент, когда она была где-то между <b>/news</b> и <b>/tmp</b>; <b>/usr</b> и<br />
<b>/users</b> остались нетронуты.<br />
<br />
Тем временем Джэймс добрался до нашего шкафа с кассетами и вытащил что-то с надписью о том, что это бэкап корневой файловой системы, сделанный четыре недели назад. В воздухе витал вопрос: — Как же нам восстановить содержание кассеты?<br />
<br />
Мы ведь потеряли не только <b>/etc/restore</b> — все файлы устройств контроллера ленточных накопителей были стёрты. А где живёт <b>mknod</b>? Правильно, <b>/etc</b>. Как насчёт восстановить любой из них по Ethernet с другого VAX? Понятное дело, <b>/bin/tar</b> пропал, а <b>rcp</b> люди из Беркли заботливо положили в<br />
<b>/bin</b> в дистрибутиве 4.3. Кроме того, для работы сети нам нужен как минимум <b>/etc/hosts</b>. Версию <b>cpio</b> мы нашли в <b>/usr/local</b>, но без контроллера ленточных накопителей это, к сожалению, бесполезно.<br />
<br />
В качестве альтернативы мы могли бы вытащить загрузочную ленту и пересобрать корневую файловую систему, но ни Джэймс, ни Нэйл никогда не делали этого прежде, и мы не были уверены, что это и есть то, что нам нужно — полностью переформатированный диск и потеря всех наших пользовательских файлов (мы делаем бэкапы пользовательских файлов каждый четверг; по закону Мёрфи всё и должно было случиться именно в среду).<br />
Ещё решение — позаимствовать диск от другого VAX, загрузиться с него, и уже потом разбираться; но тогда бы пришлось звать DEC-инженера — это в самом лучшем случае. У нас было много пользователей, в муках завершающих свои кандидатские диссертации, и потеря возможно недельной работы была немыслима.<br />
<br />
Так что же делать? Следующей идеей было написать программу, которая бы создала дескриптор устройства для контроллера ленточных накопителей, но все мы знаем, где живут <b>cc</b>, <b>as</b> и <b>ld</b>. Или, может, сделать минимального вида <b>/etc/passwd</b>, <b>/etc/hosts</b> и прочее, чтобы <b>/usr/bin/ftp</b> смог работать. По счастливой случайности, у меня оказался всё ещё открытый в одном из моих окон <b>gnuemacs</b> — мы могли бы воспользоваться им, чтобы создать <b>passwd</b> и всё остальное, но первым шагом нужно создать директорию, чтобы поместить их туда. Разумеется, был удалён <b>/bin/mkdir</b>, то же самое произошло с <b>/bin/mv</b>, так что мы не могли переименовать <b>/tmp</b> в <b>/etc</b>. Однако это явно была правильная линия для атаки.<br />
<br />
К тому моменту к нам присоединился Alasdair, наш местный UNIX-гуру, как оказалось, знающий ассемблер VAX. Так что наш план стал таким:<br />
<ol>
<li>написать на ассемблере программу, которая бы могла либо переименовать <b>/tmp</b> в <b>/etc</b>, либо создать <b>/etc</b>;</li>
<li>заассемблировать её на другом VAX, сделать <b>uuencode</b>;</li>
<li>записать её в в uu-закодированный файл, используя мой GNU, и сделать <b>uudecode</b> (какой-то умный человек догадался поместить <b>uudecode</b> в <b>/usr/bin</b>).</li>
</ol>
Остаётся запустить программу. Ещё одно чудо: терминал, который использовался для нанесения ущерба, всё ещё был суперпользовательским после <b>su</b> (достаточно вспомнить, что <b>su</b> находится в <b>/bin</b>), так что у нас хотя бы появился шанс, что всё это заработает.<br />
<br />
И вот, мы уже стоим на очаровательном пути к успеху. Потратив всего час, мы состряпали примерно дюжину строчек на ассемблере для создания <b>/etc</b>. Обрезанный бинарный файл оказался длиной всего 76 байт, так что мы сконвертировали его в HEX (читается немного лучше, чем вывод <b>uuencode</b>), и записали, используя мой редактор. Если у вас вдруг когда-нибудь возникнет такая проблема, вот HEX на будущее:<br />
<br />
<pre>070100002c000000000000000000000000
0000000000000000000000000000000000
dd8fff010000dd8f27000000fb02ef0700
0000fb01ef070000000000bc8f88000400
00bc012f65746300</pre>
<br />
У меня была подручная программа (а что, у кого-то не было?) для конвертирования ASCII HEX в двоичный код, и вывод <b>/usr/bin/sum</b> совпадал с нашим оригинальным бинарным файлом. Но стоп, секундочку — как же ты установишь права на выполнение без <b>/bin/chmod</b>? За несколько секунд сформированная мысль (которая, как обычно, завершает пару минут раздумий) принесла решение проблемы: нужно записать бинарный файл поверх уже существующего файла, для которого я являюсь владельцем. Вот и хорошо.<br />
<br />
Мы вернулись к терминалу с правами суперпользователями, с осторожностью вспомнили поставить umask на 0 (чтобы я мог создавать внутри файлы, используя свой GNU), и запустили бинарный файл. Теперь у нас был <b>/etc</b>, с доступом на запись для всех. Теперь оставалось всего несколько шагов: нужно было создать <b>passwd</b>, <b>hosts</b>, <b>services</b>, <b>protocols</b>, (etc), после чего <b>ftp</b> был готов к работе. Потом мы восстановили содержимое <b>/bin</b> по сети (это невероятно, как тебе начинает жутко не хватать <b>ls</b> после всего нескольких коротких часов без него) и взяли файлы из <b>/etc</b>. Ключевым файлом был <b>/etc/rrestore</b>, с его помощью мы восстановили <b>/dev</b> с бэкап-кассеты. Тут и сказочке конец.<br />
<br />
А вот теперь мы задаём себе вопрос, в чём же мораль этой истории. Ну, для начала, стоит хорошо запомнить вечные слова: <b>не паниковать</b>. Мы ведь сначала и хотели просто перезагрузить компьютер и попробовать всё как single-пользователь, но, к сожалению, система при загрузке не нашла бы <b>/etc/init</b> и <b>/bin/sh</b>. Здравое мышление спасло нас от таких действий.<br />
<br />
Следующая вещь, которую стоит запомнить, это то, что утилиты UNIX могут использоваться с действительно нетипичными для них целями. Даже без моего <b>gnuemacs</b> мы бы могли выжить, используя, скажем, <b>/usr/bin/grep</b> как замену для <b>/bin/cat</b>.<br />
<br />
И последняя вещь. Это невероятно, насколько громадную часть системы можно удалить, не загубив её окончательно. Несмотря на тот факт, что никто бы не смог войти в систему (<b>/bin/login</b>?), и почти все нужные команды пропали, всё остальное выглядело нормально. Естественно, некоторые вещи не могут оставаться живы без, скажем, <b>/etc/termcap</b>, <b>/dev/kmem</b> или <b>/etc/utmp</b>, но, в конечном итоге, всё работает в мире и согласии.<br />
<br />
Я оставляю вопрос: оказавшись в такой же ситуации, с возможностью мыслить, учитывая приобретённый теперь опыт, можно было бы решить эту проблему проще?</div>
アルセニhttp://www.blogger.com/profile/07661163832723079945noreply@blogger.com11