company_banner

Мобильная версия для Django-проекта



    С каждым днем пользователи смартфонов занимают все большую долю интернета. По данным LiveInternet доля российских пользователей OS Android уже превысила долю Windows7. В выходные дни пользователи мобильных платформ пользуются интернетом значительно чаще. Та же тенденция наблюдается и в мире. Все это еще раз доказывает необходимость адаптации сайта для смартфонов и планшетов.

    О том, как можно адаптировать ваш Django-проект для мобильных устройств, я расскажу в этой статье. Но сначала давайте разберем, какие есть варианты создания мобильной версии сайта.

    1. Варианты мобильной версии сайта

    1.1 Адаптивная верстка


    В этом случае мы отдаем одинаковое количество данных для большой и мобильной версии сайта. Этот подход самый простой для backend разработки, все решается версткой.

    Из плюсов:
    • не требует редиректов;
    • не нужно отдельно сообщать поисковым роботам о наличии мобильной версии (мета теги alternate, сайтмапы и прочее).

    Из минусов:
    • поскольку на мобильной версии приходится отдавать все и скрывать лишнее, создается лишний трафик и нагрузка на сервер;
    • версия для мобильных устройств всегда должна создаваться с большой версией сайта;
    • так как данные на обоих версиях одинаковы, может быть сложно удобно организовать обе версии, скорее всего мобильной версией придется пожертвовать; к примеру, я не представляю, как можно сделать удобный форум при таком подходе.

    Такой подход хорошо подходит для небольших сайтов. Когда выводимого контента на страницу становится много, простота в реализации создает большую проблему в юзабилити.

    1.2 Мобильная версия на поддомене


    По сути, это два отдельных сайта. Такой подход решает проблемы лишнего трафика, дает больше гибкости и возможностей в разработке версии для мобильных устройств. Однако при этом вопрос, какую версию показывать пользователю, решается сервером, а не браузером. Также нужно дать возможность пользователю выбрать, какая версия сайта ему нужна, и «подружить» обе версии сайта редиректами и альтернейтами.

    Разные URL одной страницы приводят к недостатку этого способа: относительные ссылки в материалах сайта могут вести на страницы, отсутствующие в мобильной версии. Поэтому приходится указывать абсолютные ссылки на основной домен и потом редиректить пользователя на нужную версию. Решением этого недостатка будет полностью соответствующие большая и мобильная версии, но в этом случае правильнее идти третьим способом.

    1.3 Мобильная версия на том же домене


    Это доработка первого подхода и решение его минуса с трафиком и лишней нагрузкой. Реализуется он так же как с поддоменом: вы определяете, какая версия нужна клиенту, и отдаете нужное количество данных в нужный шаблон. Одинаковый URL для обоих версий сайта — безусловно плюс. Хотя проблема организации контента для обоих версий еще остается, но решать ее уже проще, так как ограничения на одинаковые данные уже нет.

    1.4 Наш опыт


    В отделе контентных проектов Mail.Ru Group мы используем второй подход, хотя и плавно движемся в сторону третьего. Проекты Дети Mail.Ru и Здоровье Mail.Ru написаны на Django, оба имеют мобильные и/или тач версии. Несмотря на то, что проекты под капотом немного отличаются, механизм создания мобильных версий у них одинаков. Об этом я хочу с вами поделиться.



    2. Исходные соглашения

    2.1 Все роуты мы именуем


    url(r'^$', views.MainIndexView.as_view(), name='health-main-index')
    url(r'^(?P<slug>[-\w]+)/$', views.NewsDetailView.as_view(), name='health-news-detail')
    

    И обращаемся к ним всегда по этому имени.

    reverse('health-main-index')
    

    Мы никогда не собираем URL'ы сами в контроллерах или шаблонах, они указаны только в urls.py. DRY.

    2.2 Используем django-hosts


    Об этой библиотеке уже упоминали на Хабре. Изначально мы ее использовали на «детях» для форума на поддомене, сейчас форум переехал у нас на основной хост, и эта библиотека используется только для мобильный версий.

    Вкратце, как она работает: вы подключаете middleware, которая в зависимости от Host заголовка подменяет схему URL'ов. Помимо джанговской функции reverse, вы можете использовать из этой библиотеки reverse_full, которая строит абсолютный URL. Подобный тег host_url можно использовать в шаблонах. Используемые функции reverse_host, get_host также взяты из этого приложения.

    2.3 Отдельные контроллеры для большой и мобильной версии


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

    3. Разработка мобильной версии

    Мобильная версия должна решать следующие задачи:

    1. Определение версии сайта и редирект для соответствующих устройств.
    2. Возможность пользователя отказаться от мобильной версии и использовать основную.
    3. Редиректы должны перенаправлять на соответствующую мобильную версию и наоборот. Нехорошо кидать пользователя на главную и заставлять искать с начала.
    4. Если пользователь попал на отсутствующую страницу в мобильной версии, для которой есть аналог в большой версии, ему нужно явно сообщать об этом. Этой задачи не было, если бы обе версии соответствовали друг другу.
    5. Необходимо указывать мета теги alternate и canonical, если для страницы доступен мобильный аналог.

    3.1 Определение версии сайта


    С какого устройства пользователь зашел на наш проект мы определяем с помощью нашего модуля nginx. Выглядит это примерно вот так:

    set $mobile $rb_mobile;
    if ($cookie_mobile ~ 0) { set $mobile ""; }  # discard by cookie
    proxy_set_header X-Mobile-Version $mobile;
    

    Модуль опредеяет тип версии, которую нужно показать (m или touch), но если у пользователя стоит кука mobile, мы игнорируем это. Результат передается в виде http заголовка на бэкенд.

    Дальнейшая обработка запроса происходит в middleware.

    class MobileMiddleware(object):
    
        def process_request(self, request):
            if request.method != 'GET':  # redirect only GET requests
                return
    
            mobile_version = request.META.get(MOBILE_HEADER, '')
            if not mobile_version:  # redirect only for mobile devices
                return
    
            hostname = request.host.name
            if hostname in settings.MOBILE_HOSTS:  # redirect only for main version
                return
    
            if mobile_version == 'm':
                host = get_host('mobile-' + hostname)
            elif mobile_version == 'touch':
                host = get_host('touch-' + hostname)
            else:
                # wrong header value
                return
    
            if not is_valid_path(request.path, host.urlconf):
                # url doesn't exist in mobile version
                return
    
            redirect_to = u'http://{}{}'.format(reverse_host(host), request.get_full_path())
            return http.HttpResponseRedirect(redirect_to)
    

    Редирект пользователя возможен, если:
    • пришел GET запрос;
    • запрос пришел на основную версию сайта (если пользователь явно набрал адрес мобильной версии — оставим его на ней);
    • проверим, есть ли такая страница в мобильной версии (не редиректить же его на 404).

    В общем случае какую версию отдать пользователю, определяется по UserAgent в middleware. Там же нужно проверить значение куки mobile. Сам я не пользовался приложением django-mobile, возможно, есть другие более точные библиотеки для определения типа устройства. Предложите их в комментариях.

    3.2 Переход на большую версию сайта


    На мобильную версию мы отправили пользователя, дадим ему также возможность перейти обратно на большую версию. В подвалах наших проектов содержится ссылка вида /go-health/, по которой и осуществляется переход.

    url(r'^go-health(?P<path>/.*)$', 'health.mobile.views.go')
    

    К сожалению, иногда страницы мобильной версии отличаются от основной. Та информация, которая легко помещается на большой версии, в мобильной разделяется на 3 страницы. Поэтому отбрасывать поддомен и редиректить на тот же URL, было бы неправильно. Мы выбрали следующий алгоритм:

    1. Определяем имя роута той страницы, на которой мы находимся.
    2. Функция контроллера может содержать специальный атрибут go_view_name. В этом случае мы редиректим на страницу с этим (другим) именем роута. Это нужно как раз для того случая, когда несколько страниц одной версии соответствуют одной странице большой версии.
    3. В остальных случаях редиректим на роут большой версии с тем же именем.

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

    @never_use_mobile
    def go(request, path):
    	meta_query_string = request.META.get('QUERY_STRING', '')
    	query_string = '?' + iri_to_uri(meta_query_string) if meta_query_string else ''
    
    	main_host = get_main_host(request.host)
    
    	try:
    		resolver_match = resolve(path)
    	except Resolver404:
    		pass
    	else:
    		if hasattr(resolver_match.func, 'go_view_name'):
    			redirect_to = 'http:%s%s' % (reverse_full(
    				main_host.name, resolver_match.func.go_view_name,
    				view_args=resolver_match.args, view_kwargs=resolver_match.kwargs), 
    				query_string)
    			return HttpResponseRedirect(redirect_to)
    
    	# path matches url patterns, otherwise 404
    	resolver_match = resolve(path, main_host.urlconf)
    	redirect_to = 'http:%s%s' % (reverse_full(
    		main_host.name, resolver_match.view_name,
    		view_args=resolver_match.args, view_kwargs=resolver_match.kwargs), 
    		query_string)
    	return HttpResponseRedirect(redirect_to)
    

    Атрибут go_url_name назначается через декоратор

    def go(url_name):
        def decorator(view_func):
            @wraps(view_func, assigned=available_attrs(view_func))
            def _wrapped_view_func(*func_args, **func_kwargs):
                return view_func(*func_args, **func_kwargs)
            _wrapped_view_func.go_url_name = url_name
            return _wrapped_view_func
        return decorator
    
    @go('health-news-index')
    def rubric_list(request):
        ...
    

    А декоратор never_use_mobile ставит куку mobile для отмены автоматического редиректа

    def never_use_mobile(view_func):
        @wraps(view_func, assigned=available_attrs(view_func))
        def _wrapped_view_func(request, *args, **kwargs):
            response = view_func(request, *args, **kwargs)
            set_mobile_cookie(response, 0)
            return response
        return _wrapped_view_func
    

    К сожалению, тач версии развиваются после основных разделов и не всегда соответствуют страницам на большой версии, поэтому такой код приходится держать.
    Атрибут go_view_name просто подменяет имя роута для страницы-аналога. Это довольно ограниченное решение, но его пока хватает.

    3.3 404 для мобильной версии


    Вы можете не просто сообщать пользователю о том, что страница не найдена, но и указать, что такая страница есть в полной версии сайта. При этом проверить URL по схеме URL'ов недостаточно: запрос /news/foo/ соответствует схеме URL'ов, а новости такой нет. Поэтому надо попытаться выполнить функцию контроллера в основной схеме урлов. Есть еще одна тонкость: надо подменять текущую схему URL'ов для большой версии, так как она нужна функциям reverse и тегу url. Иначе вы будете рендерить страницу большой версии в схеме URL'ов мобильной.

    def page_not_found(request):
    	current_host = request.host
    	hostname = current_host.name
    	main_host = get_host(hostname.replace('mobile-', ''))
    	try:
    		# path matches url patterns
    		resolver_match = resolve(request.path, urlconf=main_host.urlconf)
    	except Resolver404:
    		return mobile_404(request)
    
    	set_urlconf(main_host.urlconf)
    	try:
    		# function returns not 404 with passed arguments
    		resolver_match.func(request, *resolver_match.args, **resolver_match.kwargs)
    	except Http404:
    		set_urlconf(current_host.urlconf)
    		return mobile_404(request)
    
    	set_urlconf(current_host.urlconf)
    	meta_query_string = request.META.get('QUERY_STRING', '')
    	query_string = '?' + iri_to_uri(meta_query_string) if meta_query_string else ''
    	redirect_to = 'http:%s%s' % (reverse_full(
    		main_host.name, resolver_match.view_name,
    		view_args=resolver_match.args, view_kwargs=resolver_match.kwargs), 
    		query_string)
    	return mobile_fallback404(request, redirect_to)
    

    3.4 Мета теги alternate и canonical


    Эти URL'ы строятся с помощью функций или шаблонных тегов приложения django-host.

    context['canonical'] = build_canonical(reverse_full('www', 'health-news-index'))
    context['alternate'] = {
        'touch': build_canonical(reverse_full('touch-www', 'health-news-index'))
    }
    

    4. Вместо заключения

    Хочется повторить, что основные трудности реализации вызваны расхождением основной и мобильной версий. Пока не получается развивать мобильную версию одновременно с большой, приходится идти этим путем и держать в коде эти проверки. Возможно, в скором времени мы перейдем на мобильную версию на том же домене и отдельно напишем об этом способе.
    Mail.Ru Group 779,65
    Строим Интернет
    Поделиться публикацией
    Похожие публикации
    Комментарии 10
    • +1
      Спасибо, заполнил пробел в этом вопросе :)
      • 0
        а модуль nginx для определения мобильной версии у вас выложен в опенсорс?
        • +1
          Определение типа устройства — лишь малая часть функционала этого модуля, а сам модуль сильно заточен под наши проекты.
        • +1
          Заключение надо ставить первым абзацем, как по мне — правильный дисклеймер. Резолвить преходы — такой геморрой, бррр.
          Насколько все стало краше, когда перешел на адаптивную верстку. Кстати, это потребовало значительно облегчить дизайн и, следовательно, шаблоны/код.
          p.s все еще пишете def foo(request)? Тогда мы идем к вам!
          • +1
            class based views удобны, но это не замена, а альтернатива, я использую оба способа.
            class based view я обычно использую, когда код вьюхи становится большой, уднобно куски вынести в отдельные методы. Или когда несколько вьюх содежат общую логику и удобно сделать общий класс и понаследоваться. Когда код вьюхи короткий и нужно обрабатывать только один HTTP метод — функции гораздо удобнее.
            • 0
              Ну автор таки написал декоратор, который мог идти в родительском методе класса и обновление context словаря (canonical, alternate) тоже скорее всего можно добавить в get_context_data.
              Мне кажется класс-вью это новый django-guideline. По крайней мере, многие батарейки, которые я использую переходят на views.generic просто так, заранее.
              Это я так. Никто, конечно, никому ничего не запрещает и не указывает.
              • 0
                Это да, мне декоратор в этом месте тоже показался странным решением. Я просто подумал, что вы вообще против вьюх-функций.
            • 0
              CBV вовсю используются на проекте, в тексте есть примеры роутов с ними.
              Что касается контроллера с декоратором, согласен, можно было реализовать с классовой вьюхой на основе RedirectView.
            • +4
              Сегодня я узнал, что mail.ru использует django.
              • +1
                А постоянные конференции в мейле на мысль не навевали?

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

              Самое читаемое