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 full-size slide

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

    View full-size slide

  3. dependency injection
    dependency injection

    View full-size slide

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

    View full-size slide

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

    View full-size slide

  6. 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 full-size slide

  7. 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 full-size slide

  8. 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 full-size slide

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

    View full-size slide

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

    View full-size slide

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

    View full-size slide

  12. 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 full-size slide

  13. @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 full-size slide

  14. 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 full-size slide

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

    View full-size slide

  16. 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 full-size slide

  17. @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 full-size slide

  18. 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 full-size slide

  19. from django.conf import settings

    View full-size slide

  20. monkey patch

    View full-size slide

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

    View full-size slide

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

    View full-size slide

  23. FastAPI!
    FastAPI!

    View full-size slide

  24. 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 full-size slide

  25. @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 full-size slide

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

    View full-size slide

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

    View full-size slide

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

    View full-size slide

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

    View full-size slide

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

    View full-size slide

  31. 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 full-size slide

  32. 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 full-size slide

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

    View full-size slide

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

    View full-size slide

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

    View full-size slide

  36. DI Container

    View full-size slide

  37. dependency-injector

    View full-size slide

  38. 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 full-size slide

  39. @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 full-size slide

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

    View full-size slide

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

    View full-size slide

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

    View full-size slide

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

    View full-size slide

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

    View full-size slide

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

    View full-size slide

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

    View full-size slide

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

    View full-size slide

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

    View full-size slide

  49. >>> 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 full-size slide

  50. 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 full-size slide

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

    View full-size slide

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

    View full-size slide

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

    View full-size slide

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

    View full-size slide

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

    View full-size slide

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

    View full-size slide

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

    View full-size slide

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

    View full-size slide

  59. container.purge(IService)

    View full-size slide

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

    View full-size slide

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

    View full-size slide

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

    View full-size slide

  63. 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 full-size slide

  64. 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 full-size slide

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

    View full-size slide

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

    View full-size slide

  67. 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 full-size slide

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

    View full-size slide

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

    View full-size slide

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

    View full-size slide

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

    View full-size slide

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

    View full-size slide

  73. 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 full-size slide

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

    View full-size slide

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

    View full-size slide

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

    View full-size slide

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

    View full-size slide

  78. Explicit is better than implicit
    Explicit is better than implicit

    View full-size slide

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

    View full-size slide

  80. Вопросы

    View full-size slide