Пишем свой URL Shortener

image

Статья описывает создание простейшей сокращалки ссылок, вроде bit.ly или goo.gl.



Итак, заходим в директорию с вашим проектом и создаём приложение. Пусть оно носит имя 'shortener'.
$ django-project startapp shortener

Для начала опишем несложную конфигурацию URL, которая будет определять, что нужно пользователю в соответствии с его запросом.

# urls.py 
from
django.conf.urls.defaults import *
 
urlpatterns = patterns('',
   (r'^$', 'views.index'),
   (r'^(?P<key>.{3})$', 'views.redirect'),
)

Мы будем рассматривать случай, когда для сокращения используется адрес example.com, то есть представленная выше конфигурация является единственной (не забудьте указать ROOT_URLCONF в settings.py). Если же вы используете адрес вроде example.com/shortener/, не забудьте подключить свою конфигурацию в основной с помощью механизма include.

Теперь при обращении к example.com будет вызывать функция представления index из файла views.py, остальные же адреса, длина которых 3 три знака (без домена), мы будем пытаться «развернуть» помощью функции redirect из того же файла. Для сокращения ссылок мы будем использовать латинские буквы в различном регистре и цифры. Длина «ключа» будет три символа, этого вполне достаточно для не очень массовых сервисов (62^3 варианта).

Следующим нашим шагом будет определение модели, то есть описание данных на уровне БД.

# models.py 
from django.db import models
 
from datetime import datetime
from random import choice
import string
 
 
def generate_key():
        chars = string.digits + string.letters
        return ''.join(choice(chars) for _ in range(3))
 
class ShortUrl(models.Model):
 
    key = models.CharField(max_length=3, primary_key=True, default=generate_key)
    target = models.URLField(verify_exists=False, unique=True)
    added = models.DateTimeField(auto_now_add=True, editable=False)
 
    def __unicode__(self):
        return '%s  %s' % (self.target, self.key)
 
 
class Hit(models.Model):
 
    target = models.ForeignKey(ShortUrl)
    time = models.DateTimeField(auto_now_add=True, editable=False)
    referer = models.URLField(blank=True, verify_exists=False)
    ip = models.IPAddressField(blank=True)
    user_agent = models.CharField(blank=True, max_length=100)

После импорта нужных функций и моделей мы реализуем функцию, которая будет генерировать случайный ключ из заданных символов. Далее описывается класс ShortUrl, который отвечает за представление нашей сокращённой ссылки в базе данных. Каждый объект этого класса имеет уникальный атрибут key, поле, в котором хранится «длинная» ссылка, а также дату создания ссылки. Затем идет класс Hit. С помощью него мы будет хранить информацию о клике по укороченной ссылке, а именно время клика, IP кликнувшего, его User Agent и Referer, ну и «длинную» ссылку.

Обратите внимание на аргументы полей, впоследствии они будут очень важны.

Наша страница, на которой пользователь сможет укоротить свою ссылку, будет очень минималистичной — одна форма и одна кнопка (она показаны на первой картинке). Давайте опишем эту небольшую форму, её код будет содержаться в forms.py.

#forms.py
from django import forms
 
class UrlForm(forms.Form): 
    url = forms.URLField(label='url', verify_exists=False)

Здесь всё очень просто — одно поле, с помощью которого мы будем обрабатывать отправленную пользователем «длинную» ссылку.

Пришло время написать представление, которое будет обрабатывать данные, переданные нашей конфигурацией URL. Первой опишем функцию make_short_url и импортируем нужные модули и функции.

#views.py
from django.http import HttpResponseRedirect
from django.shortcuts import render_to_response, get_object_or_404
from django.template import RequestContext
 
from forms import UrlForm
from models import ShortUrl, Hit
 
 
def make_short_url(url):
    short_url = ShortUrl.objects.get_or_create(target=url)[0]
    short_url.save()
    return 'http://example.com/%s' % (short_url.key)

После импортов реализуется функция, которая принимает некую ссылку, создаёт соответствующий ей объект класса ShortUrl и возвращает уже укороченную ссылку (она была сгенерирована во время создания объекта). Возможно также использование Site.objects.get_current().domain из django.contrib.sites.models.

Теперь нам нужно написать функции, которые обрабатывают форму и реализуют разворачивание «короткой» ссылки, то есть редирект.

#views.py (continuation)
def index(request):
    if request.method == 'POST':
        form = UrlForm(request.POST)
        if form.is_valid():
            url = form.cleaned_data.get('url')
            url = make_short_url(url)
            return render_to_response('shortener.html', {'url':url})
    else:
        form = UrlForm(label_suffix='')
    return render_to_response('shortener.html', {'form': form, 'url': ''})
 
 
def redirect(request, key):
    target = get_object_or_404(ShortUrl, key=key)
 
    try:
        hit = Hit()
        hit.target = target
        hit.referer = request.META.get("HTTP_REFERER", "")
        hit.ip = request.META.get("REMOTE_ADDR", "")
        hit.user_agent = request.META.get("HTTP_USER_AGENT", "")
        hit.save()
    except IntegrityError:
        pass
 
    return HttpResponseRedirect(target.target)

Функция index отображает пустую форму, если пользователь ещё не обращался к ней, и обрабадывает её в случае POST запроса. В первом случае шаблону shortener.html, который отвечает за интерфейс передаётся сама форма и пустая ссылка, во втором — только укороченная ссылка. Далее следует функция redirect, к которой обращается конфигурация URL, если она сочла обращение пользователя за просьбу развернуть «короткую» ссылку. Перед простым редиректом мы создаём объект класса Hit, описанного в models.py, с соответствующими атрибутами, полученными из объекта request. Ещё советую почитать комментарии, в них много интересного про конструкцию «except: pass».

Осталось совсем чуть-чуть, еще немного и у нас будет своя сокращалка ссылок!

Настало время описать наш шаблон shortener.html, отвечающий за HTML представление формы и укороченной ссылки. Вспомните, какие параметры он принимает.

<!--shortener.html-->
{% if not url %}
    <form action="." method="post" >
        {{ form.as_p }}
         <input type="submit" name="submit" value="submit"/>
    </form>
{% else %}
        <a href="{{url}}"></a>{{ url }}</a>
{% endif %}

В принципе, вы уже можете использовать этот код — сам URL Shortener уже создан. Осталось описать представление наших данных в интерфейсе администратора (не забудьте подключить соответствующую конфигурацию в файле urls.py).

#admin.py
from django.contrib import admin
 
from models import ShortUrl, Hit
 
 
class ShortUrlAdmin(admin.ModelAdmin):
    fields = ('target', 'key')
    list_display = ('key', 'target', 'added')
    ordering = ('-added',)
    list_filter = ('added',)
    date_hierarchy = 'added'
 
admin.site.register(ShortUrl, ShortUrlAdmin)
 
 
class HitAdmin(admin.ModelAdmin):
    list_display = ('target', 'ip', 'user_agent', 'referer', 'time')
    ordering = ('-time',)
    list_filter = ('target', 'referer', 'time')
    date_hierarchy = 'time'
 
admin.site.register(Hit, HitAdmin)

Вот и всё. Осталось выполнить
$ python manage.py syncdb

И наслаждаться результатом :)

Интерфейс администратора:

image

image

Результат работы нашего URL Shortener'a:

image

Попробовать его вы можете здесь.
+24
17 июня 2010, 17:30
88
fata1ex 101,9

комментарии (81)

+8
bobermaniac #
Только на моей памяти — уже третий.
+2
fata1ex #
На хабре, на Django? Ссылку, если не сложно.
+4
neuotq #
Ну согласись, задача из простых, и не важно на каком языке, одной публикации с алгоритмом вполне достаточно. К тому же тему начали мусолить еще пару лет назад, а сейчас совсем неактуальна, разве что just4fun
+9
fata1ex #
Думаю, новичкам легче учиться на примерах, а их сейчас много — лето. Писал для себя, то есть j4f, за одно решил поделиться.
0
fledgling #
Я бы не рекомендовал учиться по этому примеру.
0
fata1ex #
Подскажите что не так? 'except: pass'?
0
alarin #
1) except: pass
2) return 'http://example.com/%s' % (short_url.key)
3) так же не понятно назначение sleep(1)
0
fata1ex #
Да, хотел написать, что можно использовать sites. Назначение sleep(1) описал. 'except: pass' вполне уместно в данном случае, так как мы ничего не ловим, а просто «или да или нет».
0
alarin #
Да, хотел написать, что можно использовать sites.

Или как минимум вынести в settings.py

'except: pass' вполне уместно в данном случае, так как мы ничего не ловим, а просто «или да или нет»

import this ))))
В общем, лучше было бы писать ошибки в лог, это был бы хороший пример для новичков — ошибки замалчивать нельзя.
+2
ingspree #
Да какие нафиг сеттингсы? Чем редирект относительно корня не устраивает здесь?
0
alarin #
Тоже верно! Ступил )))
+2
ingspree #
«except:» не уместно нигде! Именно поэтому на данном примере учиться нельзя.
+1
fata1ex #
Да, вы правы. Поленился.
+1
fata1ex #
Byteflow | urls.py
def error500(request, template_name='500.html'):
    try:
        output = render_to_string(template_name, {}, RequestContext(request))
    except:
        output = "Critical error. Administrator was notified."
    return HttpResponseServerError(output)

Не знаю правда, насколько он сейчас к вам имеет отношение :-) Не сочтите, что я специально искал нечто подобное, просто вспомнил, что давно хотел посмотреть на него, а тут такое )
+1
ingspree #
piranha@gto ~/dev/web/byteflow> hg ann urls.py|g except:
 736:     except:
piranha@gto ~/dev/web/byteflow> hg log -r 736           
changeset:   736:29718324be36
user:        Yuri Baburov <burchik@gmail.com>
date:        Sun May 04 22:14:03 2008 +0700
summary:     Added critical bugs handling.


Это был большой мерж и я очевидно пропустил кое-чего. :( Это не извиняет, конечно, но объясняет.
+2
fata1ex #
Вообще подобные мелочи, которые несложно исправить, можно сообщать автору лично или публично, чтобы он исправил, а не просто писать подобный первому комментарий.
+6
alarin #
Я думаю, данное обсуждение будет полезно начинающим программистам.
А письмо в личку в данном случае, будет равносильно except: pass
–1
fata1ex #
Ну так я не против, только за ) Но просто писать что, на этом примере учиться нельзя, некрасиво.
+3
ingspree #
Ну, мне кажется, что учиться скорее нужно по примерам кого-то, кто придерживается принятых правил написания кода. А когда человек пишет для того, чтобы к ним придти, то это не пример для обучения, это тренировка самого человека, который пишет. И всë верно, учиться по чужому не очень хорошему коду — не самая удачная идея.
+1
fata1ex #
Ну так вместе с комментариями пост и составляет материал для учёбы. Учусь я, учатся читающие. Разве нет? Я же не претендую на то, что мой код идеален и я в праве кого-то учить.
0
bobry #
речь по моему всего навсего о соблюдении best practices и style guide, которые что для django что для Python уже давно зафиксированы. так что вы зря тут про совершенство.
0
ingspree #
> {{form.as_p}}

Django style guide надо читать. Там сказано, что фигурные скобки нужно отделять пробелами.
+4
fledgling #
  • — sleep(1) заблокирует весь сервер;
  • — Здесь логичнее использовать ModelForms;
  • — Сам алгоритм сокрощения урла глуповат;


Это пробежавшись по диагонали.
+1
fata1ex #
Спасибо, почему алгоритм глуповат?
+3
ingspree #
Потому что в случае, если сгенерируется существующий ключик, вылетит исключение. Вероятность не очень низка, потому что у нас всего 3 позиции.
+4
AlexanderYastrebov #
Лучше всего сделать так: в базе поставить целочисленный первичный ключ с автоинкрементом и его представлять по основанию размера алфавита.
0
ingspree #
Еще: форму нужно обрабатывать в форме (.save()), а не во вьюхе.
+1
barbuza #
я так понимаю там был time.sleep — с каких пор он блокирует все трэды?
0
fata1ex #
Он самый, вернуть? )
+1
ingspree #
Не стоит. :-)
0
fata1ex #
Окей, большое спасибо за комментарии.
0
barbuza #
ничего глупее для обеспечения «безопасности» придумать нельзя
+5
zw0rk #
можно :)
0
blackstone #
Очень хорошо, когда на хабре по Django кто-нибудь публикует такие примерчики.
Делая их чему-то новому учишься в плане реализации на Django.
Для совсем начинающих конечно не все будет понятно, но если знать основы, то можно разобраться.
Ведь какие классные обучающие скринкасты имеются по Rails (например railscasts.com и его текстовая версия) и какое там сообщество активное, а по Django очень мало обучающих материалов особенно для начинающих.
Поэтому, если есть возможность, то пишите больше таких обучающих примеров на разные темы.

Большое спасибо!
+1
fata1ex #
Пожалуйста. Пишите, о чем бы вам хотелось узнать, что увидеть. Не я, так кто-нибдут другой напишет.
+1
blackstone #
На данном этапе я бы хотел увидеть побольше практических примеров по укрощению джанговской админки: удобная реализация управлением порядком записей (типа ссылок-кнопок вверх-вниз с обновлением значений поля для сортировки), создание своих форм в админке, хранение древовидных структур.

Т.е. хотелось бы побольше туториалов по хакам админки.

+2
igorekk #
Админка — не панацея для всего. Накручивать её до ****нной логики нет смысла.
Зачем хакать, если можно написать нормальный админский интерфейс для своего проекта самому?
0
GSirr #
Кириллические домены видимо не поддерживайтся? =)
+1
GSirr #
*не поддерживаются
+5
fata1ex #
Ага, и мыло у меня с '@' :(
0
neuotq #
Сокращалки прям как helloworld уже на всем чем можно написали. Я видел на хабре несколько публикации под заголовком «Пишем свою сокращалку ссылок».
+7
mambet #
+2
ingspree #
Автор, и на этот коммент обрати внимание. Глюк в софтине, причëм очевидно, почему. :)
+1
zw0rk #
Также мы делаем небольшую задержку перед генерацией, чтобы создать кто-нибудь случайно не сгененрировал слишком много ссылок


Как-то мало правдоподобно %) Т.е. от этого можно более железно застраховаться без таких искусственных приемов.
0
fata1ex #
Конечно, можно, но это же рассчитано на новичков. Ничего проще не придумал :)

PS. Ты, наверно, первый кто прочитал )
+1
farcaller #
Ну так а зачем новичков таким гадостям обучать? А вдруг они джангу будут в тредах гонять, а там еще и GIL!
0
fata1ex #
Ну вообще такой приём используется очень много где. Во всех аутентификационных алгоритмах «Яндекса», например. Разумеется, там ещё много чего, но это тоже вариант. Я бы не стал делать на этот момент упор.
+4
fata1ex #
Пожалуй, я не прав.
+3
seriyPS #
Блин… sleep не блокирует треды
docs.python.org/library/thread.html в самом низу:
Not all built-in functions that may block waiting for I/O allow other threads to run. (The most popular ones (time.sleep(), file.read(), select.select()) work as expected.)


Хотя сам метод конечно не одобряю… Нефиг воркеру простаивать без дела
0
tenshi #
лучше бы написала сокращалку на чистом яваскрипте с распределённым хранилищем на клиентах х)
+5
akira #
И хранить там ссылки видеть «С:\Мои документы\Рисунок.jpg»
–2
tenshi #
даёшь веб-сервер на веб-сокетах! х)
–4
piumosso #
Круто. Подробный туториал))
Буквально на прошлой неделе реализовывал подобный функционал (только у меня создаются странички с такими короткими урлами))
+1
bobry #
мне кажется использовать choice() не очень корректно, потому что есть ненулевая вероятность, что вы сгенерируете один и тот же ключ несколько раз. как вариант можно использовать что нибудь из модуля uuid или просто сделать возрастающий счетчик в какой нибудь хитрой системе счисления.

да, еще можно было сделать string.letters + string.digits вместо явного перечисления.
0
fata1ex #
primary_key разве с этим не справится?
0
bobry #
смотря с чем с «этим». при попытке сохранения модели с уже существующим key используемый модуль dbapi выдаст OperationalError — если я ничего не пропустил в ваших кусочках кода, то вы этот случай не рассматриваете.
0
Zubchick #
ну вот, а я там думал, там какая то чумовая функция хеширования. Оказалось все банально.
+1
ernt #
Жду статью «100 и 1 способ применить короткий домен не как сокращалку ссылок» или просто «Как удержаться от соблазна написать очередную сокращалку, которая закроется через полгода и немного поломает Интернет» :)
–4
SmartT #
что так сложно, делаю сайт hrumm.ru так на пхп это
base_convert($id, 10, 36)
а потом
$uri=preg_replace('/\//','',$_SERVER['REQUEST_URI']);
$res=mysql_query(«SELECT url FROM links WHERE id=».intval($uri,36));
if(is_resource($res)) {
$url = mysql_fetch_assoc($res);
header('Location:'.$url[url]);
} else {
echo «404 — Page Not Found»;
}

и это всё!!!
+12
ingspree #
лол!!!
+6
bobry #
вы не оправдываете свой ник :D
–2
fork #
j.mp сделайте короче :)
+1
SeTeM #
facepalm.jpg
Не в тему.
0
Steward #
да легко to.
0
gribunin #
Адрес «http://f» приводит к 500-й ошибке. Причём, иногда выдаётся кастомная страница сайта для 500-й ошибки, а иногда выдаётся страница Apache
+1
fata1ex #
Это не адрес приводит к ошибке, а сервер перегружен — img. Некастомная страница выдаётся, когда ему совсем плохо.
0
crash #
try:
hit = Hit()
hit.target = target
hit.referer = request.META.get(«HTTP_REFERER», "")
hit.ip = request.META.get(«REMOTE_ADDR», "")
hit.user_agent = request.META.get(«HTTP_USER_AGENT», "")
hit.save()
except:
pass

а в чем тут суть, почему может быть исключение?
+1
zw0rk #
save не прошел, например. база отсохла в процессе или еще что-нибудь исключительное случилось.
0
zw0rk #
никто, к тому же, не может гарантировать, что в django 1.8, к примеру, на mysql 8 будет еще какая-нибудь функциональность вызывающая исключения, а такой код (молчаливый игнор всех исключений) может доставить некий неиллюзорный геморрой при поиске — почему хиты не сейвятся. Ну, в более большом приложении, конечно, тут-то и отлаживать по большому счету нечего.
–2
Mecid #
хорошая стать, как раз относительно недавно начал изучать python(Django) после java
+1
MpaK999 #
Нормально написано, это лучше чем выводить на главную всякие бестолковые картиночки.

Я не шарю в Python и Django прочитал с удовольствием.

Только одно покоробило, генерация ключа и всё это перечисление. Неужели алфавит нельзя было задать через range A..Z например, есть такое в Python?
И я бы с делал проще, генерил бы md5 например по времени и отрезал кол-во нужных символов.

И вот тут буква «r» точно не глюк?
(r'^$', 'views.index')
+1
fata1ex #
Спасибо. Исправил перечисление.

String literals may optionally be prefixed with a letter 'r' or 'R'; such strings are called raw strings and use different rules for interpreting backslash escape sequences.
+2
el777 #
И вот тут буква «r» точно не глюк?
(r'^$', 'views.index')

Нет, не глюк.
Это так называемые raw-строки — то есть строки, в которых не обрабатываются обратные слеши как символы экранирования — они обозначают просто обратные слеши. Это очень удобно для написания регекспов, урлов и так далее, из-за того что не приходится дважды экранировать символы. Сравните:
r'[\s\d]+' или
'[\\s\\d]+'
Первый вариант читается намного лучше, меньше вероятность сделать ошибку, неправильно посчитав слеши.
+1
psman #
Хотелось бы еще добавить кнопку «почитать что нить» при клике на которую был бы переход по случайной ссылке из базы.
0
boa #
не совсем он, кстати, shotrener:

n0te.ru/s/ ----> n0te.ru/s/MQU

:)
+1
fata1ex #
баг :)
при длине url'a менее чем len(n0te.ru/s/xyz) надо возвращать «it's short enough»
0
lig #
1. рандом — говно
2. ограничение на длину в 3 символа — говно (хотя, понятно, что у вас рандом, поэтому так)
3. правильно писать свой sequence в postgres
4. ну, на крайний случай, можно сохранять в базу, получать id, а потом конвертировать его в key как-нибудь так:

import string

characters = string.digits + string.letters
base = len(characters)

def make_key(num):
rem, res = divmod(num, base);
return ('' if rem == 0 else make_key(rem)) + characters[res]
0
lig #
вот тут этот код цветной: paste.nophp.ru/hN

кстати, есть же github.com/jacobian/django-shorturls
0
zero13cool #
К конструкции if form.is_valid() надо добавить что-нибудь такое:
  1.  
  2. else:
  3.     return render_to_response('shortener.html', {'form': form, 'url': ''})
  4.  

Т.к. если ввести например 11111111, то вылетит ValueError — index didn't return an HttpResponse object.
+1
fata1ex #
Ввёл ваш «например». Всё нормально.
0
zero13cool #
точно)и повнимательней присмотревшись я понял почему все нормально))-это у меня глаза кривые)

Только зарегистрированные пользователи могут оставлять комментарии. Войдите, пожалуйста.