Upgrade to Pro — share decks privately, control downloads, hide ads and more …

Александр Шибаев. Dependency injection: как использовать и почему это упрощает разработку

Александр Шибаев. Dependency injection: как использовать и почему это упрощает разработку

В нашем коде существует большое количество зависимостей. Большинство разработчиков не запариваются, как ими управлять, и иногда это приводит к dependency hell — распространенному анти-паттерну разработки. Чтобы не оказаться в таком аду, полезно знать, какие существуют подходы в разработке кода.
Популярные библиотеки на python используют такие подходы, как threadlocal переменные или singleton объекты. Команда Тинькофф выбрала путь, который популярен в других языках программирования, и наработала на нашем большом проекте опыт, которым не стыдно поделиться. Речь о dependency injection. В своем докладе я разберу:
- как использовать dependency injection контейнер в python c библиотекой punq;
- как прикрутить ее к pydantic и fastapi;
- каким образом это упрощает разработку и помогает в написании кода с аннотациями типов.

More Decks by Python Community Chelyabinsk

Other Decks in Programming

Transcript

  1. Как перестать хардкодить
    Как перестать хардкодить
    зависимости и начать жить
    зависимости и начать жить
    Шибаев Александр

    View Slide

  2. Обо мне
    Обо мне
    3 года работаю с python
    1.5 года в tinkoff

    View Slide

  3. dependency injection
    dependency injection

    View Slide

  4. Зачем нам DI?

    View Slide

  5. Flask
    Flask

    View Slide

  6. - пример из репы фласка
    falskr

    View Slide

  7. bp = Blueprint("blog", __name__)
    @bp.route("/")
    def index():
    """Show all the posts, most recent first."""
    db = get_db()
    posts = db.execute(
    "SELECT p.id, title, body, created, author_id, usernam
    " FROM post p JOIN user u ON p.author_id = u.id"
    " ORDER BY created DESC"
    ).fetchall()
    return render_template("blog/index.html", posts=posts)

    View Slide

  8. def get_db():
    if "db" not in g:
    g.db = sqlite3.connect(
    current_app.config["DATABASE"],
    detect_types=sqlite3.PARSE_DECLTYPES
    )
    g.db.row_factory = sqlite3.Row
    return g.db

    View Slide

  9. def close_db(e=None):
    db = g.pop("db", None)
    if db is not None:
    db.close()
    def init_app(app):
    app.teardown_appcontext(close_db)

    View Slide

  10. А как это всё
    А как это всё
    тестировать?
    тестировать?

    View Slide

  11. Нужно писать код без багов!

    View Slide

  12. def create_app(test_config=None):
    ...

    View Slide

  13. def create_app(test_config=None):
    ...
    if test_config is None:
    # load the instance config, if it exists, when not tes
    app.config.from_pyfile("config.py", silent=True)
    else:
    # load the test config if passed in
    app.config.update(test_config)
    ...
    return app

    View Slide

  14. @pytest.fixture
    def app():
    db_fd, db_path = tempfile.mkstemp()
    app = create_app({"TESTING": True, "DATABASE": db_path})
    with app.app_context():
    init_db()
    yield app
    os.close(db_fd)
    @pytest.fixture
    def client(app):
    with app.test_client() as client:
    yield client

    View Slide

  15. def test_index(client, auth):
    response = client.get("/")
    assert b"Log In" in response.data
    assert b"Register" in response.data
    auth.login()
    response = client.get("/")
    assert b"test title" in response.data
    assert b"by test on 2018-01-01" in response.data
    assert b"test\nbody" in response.data
    assert b'href="/1/update"' in response.data

    View Slide

  16. flask-login

    View Slide

  17. import flask_login
    login_manager = flask_login.LoginManager()
    login_manager.init_app(app)

    View Slide

  18. def init_app(self, app, add_context_processor=True):
    app.login_manager = self
    app.after_request(self._update_remember_cookie)
    if add_context_processor:
    app.context_processor(_user_context_processor)

    View Slide

  19. @app.route('/login', methods=['GET', 'POST'])
    def login():
    if flask.request.method == 'GET':
    return ...
    email = flask.request.form['email']
    password = flask.request.form['password']
    if chech_password(email, password):
    user = User()
    user.id = email
    flask_login.login_user(user)
    return flask.redirect(flask.url_for('protected'))
    return 'Bad login'

    View Slide

  20. def login_user(user, ...):
    ...
    login_manager = current_app.login_manager
    user_id = getattr(user, login_manager.id_attribute)()
    session['_user_id'] = user_id
    session['_fresh'] = fresh
    session['_id'] = login_manager._session_identifier_generat
    ...
    current_app.login_manager._update_request_context_with_use
    ...
    return True

    View Slide

  21. django

    View Slide

  22. from django.conf import settings

    View Slide

  23. monkey patch

    View Slide

  24. Итого:
    1. Хрупкий код
    2. Сложно тестировать
    3. Сложно изменять поведение
    4. Сложно читать

    View Slide

  25. Но не всё так плохо

    View Slide

  26. FastAPI!
    FastAPI!

    View Slide

  27. app = FastAPI()
    @app.put("/items/{item_id}")
    async def update_item(item_id: int, item: Item = Body(...)):
    results = {"item_id": item_id, "item": item}
    return results

    View Slide

  28. @app.put("/items/{item_id}")
    async def update_item(
    item_id: int,
    item: Item = Body(...),
    item_manager: ItemManager = Depends(get_item_manager)
    ) -> ItemUpdateModel:
    result = item_manager.update_item(item_id, item)
    return result

    View Slide

  29. def get_item_manager() -> ItemManager:
    return ItemManager()

    View Slide

  30. Вроде и тестировать приятно

    View Slide

  31. @pytest.fixture()
    def app_client(item_manager):
    app = create_app()
    app.dependency_overrides = {
    get_item_manager: lambda: item_manager
    }
    return TestClient(app)

    View Slide

  32. Допустим нам повезло меньше и мы всё-таки
    пишем на фласке

    View Slide

  33. И очень-очень хотим чуть более понятного кода

    View Slide

  34. def update_item(
    item_id: int,
    item_manager: ItemManager,
    request: Any = request,
    ) -> ItemUpdateModel:
    item = ItemModel.parse_obj(request.json)
    result = item_manager.update_item(item_id, item)
    return result

    View Slide

  35. class Factory:
    @property
    def item_manager() -> ItemManager:
    return ItemManager()
    def create_update_item_handler(self):
    return wraps(update_item)(
    partial(
    update_item, item_manager=self.item_manager
    )
    )

    View Slide

  36. def create_app():
    app = Flask('kek')
    factory = Factory()
    app.route('/')(factory.create_handler())
    return app

    View Slide

  37. class Factory
    @property
    def session_factory() -> SessionFactory:
    return SessionFactory()
    @property
    def item_manager() -> ItemManager:
    return ItemManager(self.session_factory)
    ...

    View Slide

  38. Доколе это будет продолжаться?

    View Slide

  39. DI Container

    View Slide

  40. dependency-injector

    View Slide

  41. class Container(containers.DeclarativeContainer):
    config = providers.Configuration()
    api_client = providers.Singleton(
    ApiClient,
    api_key=config.api_key,
    timeout=config.timeout.as_int(),
    )
    service = providers.Factory(
    Service,
    api_client=api_client,
    )

    View Slide

  42. @inject
    def main(service: Service = Provide[Container.service]):
    ...
    if __name__ == '__main__':
    container = Container()
    container.config.api_key.from_env('API_KEY')
    container.config.timeout.from_env('TIMEOUT')
    container.wire(modules=[sys.modules[__name__]])
    main()

    View Slide

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

    View Slide

  44. Минусы
    Минусы
    глобальный контейнер
    игнорирует типы
    много ручной работы
    позволяет внедрять аргументы функциям

    View Slide

  45. inject

    View Slide

  46. @inject.autoparams()
    def refresh_cache(cache: RedisCache, db: DbInterface):
    pass

    View Slide

  47. def foo(bar):
    cache = inject.instance(Cache)
    cache.save('bar', bar)

    View Slide

  48. def my_config(binder):
    binder.install(my_config2)
    binder.bind(Cache, RedisCache('localhost:1234'))
    inject.configure(my_config)

    View Slide

  49. Умеет в типы при резолве, но не при биндинге

    View Slide

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

    View Slide

  51. Минусы
    Минусы
    singleton
    по умолчанию может модифицироваться в
    рантайме
    много ручной работы
    позволяет внедрять аргументы функциям

    View Slide

  52. punq

    View Slide

  53. >>> from punq import Container
    >>> container = Container()

    View Slide

  54. >>> class EmailSender:
    ... def send(self, msg):
    ... pass
    ...
    >>> class SmtpEmailSender(EmailSender):
    ... def send(self, msg):
    ... print("Sending message via smtp")
    ...
    >>> container.register(EmailSender, SmtpEmailSender)
    >>> instance = container.resolve(EmailSender)
    >>> instance.send("beep")
    Sending message via smtp

    View Slide

  55. class A:
    pass
    class B(A):
    pass
    class C:
    def __init__(self, a: A):
    pass
    container.register(A, B)
    container.register(C, C)
    container.resolve(C)

    View Slide

  56. Плюсы:
    объекты-контейнеры
    можно обмазаться тайпингами и жить
    красиво

    View Slide

  57. Минусы:
    При сборке теряем явность
    Можно менять в рантайме

    View Slide

  58. Хотелка номер 1
    Хотелка номер 1
    Хотим научиться работать с функциями-
    фабриками

    View Slide

  59. def factory(a: IClient) -> IService:
    return ConcreteService(a)
    container.singleton(IService, factory)

    View Slide

  60. Хотелка номер 2
    Хотелка номер 2
    Хотим запретить перерегистрировать одно и то
    же

    View Slide

  61. container.singleton(IService, FirstService)
    ...
    container.singleton(IService, SecondService)

    View Slide

  62. Но тогда не очень понятно, как тестировать
    интеграционно

    View Slide

  63. Запрещаем, но чуть-чуть

    View Slide

  64. container.purge(IService)

    View Slide

  65. Хотелка номер 3
    Хотелка номер 3
    Хотим, чтобы можно было получить финальный
    контейнер, который нельзя менять

    View Slide

  66. container = Container()
    container.singleton(A, lambda: A(2))
    finalized_container = container.finalize()

    View Slide

  67. Хотелка номер 4
    Хотелка номер 4
    Хотим, чтобы можно было работать с
    дженериками!

    View Slide

  68. class A(Generic[T]):
    def __init__(self, value: T):
    self.value = value
    class B(Generic[T]):
    def __init__(self, value: T):
    self.value = value
    class C(Generic[T]):
    def __init__(self, value: A[B[T]]):
    self.value = value
    container = Container()
    container.singleton(B[int], B[int], value=1)
    container.singleton(A[B[int]], A[B[int]])
    container.singleton(C[int], C[int])

    View Slide

  69. from typing import get_args, get_origin
    T = TypeVar('T')
    class A(Generic[T]):
    pass
    assert get_args(A[int]) == (int,)
    assert get_origin(A[int]) == A
    assert A.__parameters__ == (T,)

    View Slide

  70. Окей, хотелки сделали
    Окей, хотелки сделали

    View Slide

  71. Что мы получаем за все наши
    Что мы получаем за все наши
    страдания?
    страдания?
    Кроме того, что нам удобно тестировать?

    View Slide

  72. class Factory:
    def __init__(self, container: FinalizedContainer):
    self._container = container
    def create_some_handler(self) -> Callable[[Data], None]:
    return partial(
    some_handler,
    first_client=self._container.resolve(IClient),
    manager=self._container.resolve(IManager),
    ...
    )

    View Slide

  73. Валидация переменных окружения
    Валидация переменных окружения

    View Slide

  74. @click.command()
    def consumer():
    container = get_container()
    factory = Factory(container)
    create_consumer(factory).run()

    View Slide

  75. Traceback (most recent call last):
    ...
    pydantic.error_wrappers.ValidationError:
    1 validation error for ClientSettings
    url
    field required (type=value_error.missing)

    View Slide

  76. class ClientSettings(BaseSettings):
    url: str
    class Config:
    env_prefix = "PREFIX_"

    View Slide

  77. ...
    pydantic.error_wrappers.ValidationError:
    1 validation error for OtherClientSettings
    url
    field required (type=value_error.missing)

    View Slide

  78. def collect_errors(container: Container):
    registered = container.registrations.registrations.keys()
    errors = []
    validation_errors = {}
    for key in registered:
    try:
    container.resolve(key)
    except ValidationError as exc:
    validation_errors[str(exc.model)] = exc
    except Exception as exc:
    errors.append((key, exc))
    return validation_errors

    View Slide

  79. Забыли зависимости?
    Забыли зависимости?

    View Slide

  80. Как тестровать контейнер?

    View Slide

  81. Точно так же!

    View Slide

  82. Нам достаточно просто проверить, что мы можем
    разрезолвить всё

    View Slide

  83. Explicit is better than implicit
    Explicit is better than implicit

    View Slide

  84. Пишите хороший код
    Пишите хороший код
    Даже если инструмент делает всё, чтобы вы не
    могли это делать

    View Slide

  85. Вопросы

    View Slide