Разработка расширяемых приложений

Разработка расширяемых приложений

Владимир Филонов

На примере небольшой части интернет-магазина рассмотрели различные варианты реализации API для подключения плагинов к django-приложениям. Подробно, много кода, но всё понятно и логично выстроено. Хороший доклад, думаем, при разработке сложных систем со слабым связыванием компонентов всем придётся столкнуться с чем-либо подобным.

53b0434aded1fb944ec3037c382158c1?s=128

Moscow Python Meetup

May 10, 2012
Tweet

Transcript

  1. РАЗРАБОТКА РАСШИРЯЕМЫХ DJANGO-ПРИЛОЖЕНИЙ Владимир Филонов

  2. ЧТО ЭТО И ЗАЧЕМ? Расширяемость – возможность добавления функционала при

    помощи API, предоставляемого приложением Простой пример Простой пример AUTHENTICATION_BACKENDS в contrib.auth Решает проблемы: Повторное использование в различных условиях Изменение логики приложения, без вмешательства в основной код
  3. DJANGO - РАСШИРЯЕМОЕ ПРИЛОЖЕНИЕ :) Любое приложение для django -

    по сути расширение функционала при помощи API. Благодаря этому, в django есть все необходимые инструменты и множество примеров django.utils.importlib.import_module django.utils.module_loading.module_has_submodule
  4. ПРАКТИКУМ Представим, что нам надо разработать платформу Интернет-магазина # catalog.models

    class Category(models.Model): title = models.CharField(max_length=32) slug = models.SlugField(max_length=32 , unique=True) class Product(models.Model): title = models.CharField(max_length=32) slug = models.SlugField(max_length=32, unique=True) category = models.ForeignKey("Category") price = models.DecimalField(max_digits=10, decimal_places=2)
  5. ПРАКТИКУМ #shop.models class Order(models.Model): customer = models.CharField(max_length=128) email = models.EmailField()

    phone = models.CharField(max_length=32, blank=True, null=True) class OrderItem(models.Model): order = models.ForeignKey("Order") item = models.ForeignKey("catalog.Product") amount = models.PositiveSmallIntegerField(default=1) price = models.DecimalField(max_digits=10, decimal_places=2)
  6. ПРАКТИКУМ А что если нам понадобятся дополнительные услуги по заказам?

    Доставка – обязательно понадобиться Упаковка Еще что-нибудь Еще что-нибудь Причем, эти услуги могут быть разными, для разных ИМ на базе нашей платформы И мы даже не можем предсказать, какие именно
  7. ОБОБЩИМ ТРЕБОВАНИЯ К УСЛУГЕ Название Описание Цена – может статичная,

    или зависеть от заказа Статус выполнения Дополнительная информация от клиента
  8. КАК НАМ ВСЕ ЭТО ОРГАНИЗОВАТЬ? Заказ Услуга Услуга Диспетчер Бэкенд

    Бэкенд Заказ Услуга Услуга Диспетчер Бэкенд Бэкенд
  9. ПРИВЯЗКА К ЗАКАЗУ - МОДЕЛЬ #shop.models class OrderService(models.Model): order =

    models.ForeignKey("Order") service = models.ForeignKey("Service") status = models.CharField(max_length=32, blank=True, default="") data = models.TextField() #Мы будем хранить данные в JSON # Можно хранить сервисы в базе # Можно хранить сервисы в базе class Service(models.Model): title = models.CharField(max_length=32) description = models.TextField() base_price = models.DecimalField(max_digits=10, decimal_places=2) backend = models.CharField(max_length=32) active = models.BooleanField(default=False)
  10. ПРИВЯЗКА К ЗАКАЗУ - МОДЕЛЬ # А можно и не

    хранить class OrderService(models.Model): order = models.ForeignKey("Order") backend = models.CharField(max_length=32) status = models.CharField(max_length=32, blank=True, default="") data = models.TextField() #Мы будем хранить данные в JSON
  11. САМОЕ ИНТЕРЕСНОЕ Итак, нам осталось сделать базовый класс для бэкенда

    и диспетчер Какой функционал нам понадобиться? Вычисление цены Получение, сохранение и обработка Получение, сохранение и обработка дополнительной информации Получение списка доступных статусов Реакция на смену статусов
  12. БАЗОВЫЙ КЛАСС class BaseService(object): has_form = False def __init__(self, order=None,

    data=None): self.data = data self.order = order def get_title(self): return self.__class__.__name__ def get_description(self): return "" def get_statuses(self): return [] def calculate_price(self, base_price): return base_price def status_changed(self, old_status, new_status): pass def get_form(self): return None def get_template(self): return None
  13. #Построение списка бэкендов #Вариант первый – мы заранее знаем список

    плагинов #settings settings.SHOP_SERVICES_BACKENDS = { "simple_delivery" : "shop.services.delivery.SimpleDelivery" } #shop.utils def get_backends(init=False, initial_data=None): backends = [] И ДИСПЕТЧЕР backends = [] for backend_key in settings.SHOP_SERVICES_BACKENDS: try: path = settings.SHOP_SERVICES_BACKENDS[backend_key] i = path.rfind('.') module, attr = path[:i], path[i+1:] mod = import_module() cls = getattr(mod, attr) if init: backends.append(cls(data=initial_data)) else: backends.append(cls) except ImportError: continue return backends
  14. #Вариант второй – загрузка только тех модулей, которые указаны в

    БД def get_backends(init=False, initial_data=None): for service in Service.objects.all(): #Принцип тот же что и в первом варианте … И ДИСПЕТЧЕР
  15. #Вариант третий – инспектирование модуля для поиска плагинов import inspect

    import pkgutil from django.utils.importlib import import_module from shop import services def get_backends(init=False, initial_data=None, as_list=True): if as_list: backends = [] else: И ДИСПЕТЧЕР pkgutil.iter_modules(path=None, prefix='') Возвращает кортеж (module_loader, name, ispkg) для всех подмодулей import_module(name, package=None) Импортирует модуль. Удобство в том, что если else: backends = {} for mod in pkgutil.iter_modules(services.__path__): module = import_module('.{0}'.format(mod[1]), 'shop.services') predicate = lambda x: inspect.isclass(x) and issubclass(x, services.BaseService) and not x == services.BaseService for name, backend in inspect.getmembers(module, predicate): if init: value = backend(data=initial_data) else: value = backend if as_list: backends.append(value) else: backends.update({backend.keyword: value}) return backends ispkg) для всех подмодулей Импортирует модуль. Удобство в том, что если передать имя начинающееся с точки ".name", то поиск для импорта будет производиться не по sys.path, а только в указанном во втором аргументе пакете. inspect.getmembers(object[, predicate]) Возвращает список всех членов объекта (аттрибуты, функции, классы и т.д.). Если качестве второго аргумента передать функцию-ограничитель, то inspect.getmembers вернет только те члены, для которых predicate вернет True
  16. И ДИСПЕТЧЕР #Получение класса бэкенда по имени #Если бэкенда нет,

    мы можем или возвращать None def get_backend(name, init=False, initial_data=None): return get_backends(init, initial_data).get(name) #Или же def get_backend(name, init=False, initial_data=None): backend = get_backends(init, initial_data) .get(name) if not backend: raise ImproperlyConfigured(u"There is no service backend named `{0}`".format(name))
  17. ПОПРОБУЕМ СОБРАТЬ ЭТО ВСЕ class ProcessOrderView(View): def get(self, *args, **kwargs):

    context = { "order_form": OrderForm(), "services": get_backends(init=True) } return self.render_to_response(context) def get_services(self): if not hasattr(self, "_submitted_services"): services = [] services = [] for service_name in self.request.POST.getlist("service"): service = get_backend(service_name, init=True, initial_data=self.request.POST) services.append(service) self._submitted_services = services return self._submitted_services def all_services_valid(self): valid = True for service in self.get_services(): if not service.get_form().is_valid(): valid = False return valid
  18. ПОПРОБУЕМ СОБРАТЬ ЭТО ВСЕ def post(self, *args, **kwargs): order_form =

    OrderForm(self.request.POST) valid = True if order_form.is_valid() and self.all_services_valid(): order = order_form.save() for service in self.get_services(): form_data = json.dumps(service.get_form().cleaned_data) OrderService.objects.create(order=order, backend=service.keyword, data=form_data) return HttpResponseRedirect("/shop/success/") else: valid = False if not valid: services = self.get_filled_services() context = { context = { "order_form": order_form, "services": services } return self.render_to_response(context) def get_filled_services(self): services = [] for service in get_backends(): if service.keyword in self.request.POST.getlist("service"): service.checked = True services.append(service(data=self.request.POST)) else: service.checked = False services.append(service) return services
  19. ПОПРОБУЕМ СОБРАТЬ ЭТО ВСЕ #Шаблон #templates/shop/order_process.html {% extends "shop.html" %}

    {% block content %} <form method="POST">{% csrf_token %} {{ order_form.as_p }} {% for service in services %} <div class="service {{ service.keyword }}"> <input type="checkbox" name="service" value="{{ service.keyword }}"{% if service.checked %} checked{% endif %}><label>{{ service.get_title }}</label> <div><small>{{ service.get_description }}</small></div> <div><small>{{ service.get_description }}</small></div> {% if service.has_form %} {{ service.get_form.as_p }} {% endif %} </div> {% endfor %} <input type="submit"> </form>{% endblock %}
  20. ЧТО ПОЛУЧИЛОСЬ?

  21. СДЕЛАЕМ ПРОСТУЮ УСЛУГУ… #shop.services.simple_delivery class SimpleDelivery (BaseService): has_form = True

    keyword = "simple_delivery" def get_statuses(self): return ["planned", "in process", "done"] def calculate_price(self, base_price, order): return base_price def get_form_class(self): return SimpleDeliveryForm def get_form(self): if not hasattr(self, "_form"): self._form = self.get_form_class()(self.data, prefix=self.__class__.__name__) return self._form class SimpleDeliveryForm(forms.Form): address = forms.CharField(widget=forms.Textarea, label=u"Адрес", required=True) time = forms.CharField(label=u"Удобное время")
  22. ЧТО ПОЛУЧИЛОСЬ?

  23. А ТЕПЕРЬ ЕЩЕ ОДНУ class SingingCourier(BaseService): has_form = False keyword

    = "singing_courier" def get_title(self): return u"Поющий курьер" def get_description(self): return u"Курьер споет вам любую песню на ваш выбор"
  24. ЧТО ПОЛУЧИЛОСЬ?

  25. ЖМЕМ ОТПРАВИТЬ

  26. С ЗАПОЛНЕННЫМИ ПОЛЯМИ

  27. ПРОВЕРИМ ЧТО СОХРАНИЛОСЬ >>> from shop.models import Order >>> order

    = Order.objects.latest("id") >>> vars(order) {'customer': u'test', 'phone': u'', '_state': <django.db.models.base.ModelState object at 0x89607ec>, 'id': 1, 'email': u'example@example.com'} >>> order.orderservice_set.count() 1 >>> service = order.orderservice_set.latest("id") >>> service.backend u'simple_delivery' >>> print json.loads(service.data) >>> print json.loads(service.data) {u'address': u'Москва, Малый Конюшковский переулок, дом 2', u'time': u'с 19 до 22'}
  28. ЧТО ОСТАЛОСЬ? Интеграция с contrib.admin Редактирование данных Работа со статусами

    И еще много всего, но уже не сегодня =)
  29. СПАСИБО! Email: i@vladimir.filonov.name Email: i@vladimir.filonov.name Код: https://bitbucket.org/VladimirFilonov/django-shop