Функциональный подход обработки ошибок в Dart

При переходе на новую технологию, мы лишаемся привычных инструментов для разработки. В каких-то случая мы вынуждены смириться с их отсутствием из-за каких-то технических ограничений, но при возможности переносим инструменты с собой. Разрабатывая android приложения, за основу я брал пример чистой архитектуры предложенной Fernando Cejas. Разбираясь с используемыми во Flutter шаблонами проектирования, я решил отказаться от этой архитектуры в пользу BLoC. К данному шаблону я быстро привык, он очень похож на MVVM, с которым работал ранее, но с одной деталью я никак не хотел мириться. При вызове методов репозитория я должен был ловить исключения, кастить их к какому-то типу и в соответсвии с типом, создавать необходимое состояние. На мой взгляд, это очень захламляет блок и я портировал тип Either используемый ранее в android проектах, основанных на Fernando.


Either пришел из функциональных языков программирования. Он предоставляет значение одного из возможных типов:


  • Left (в случае неудачи);
  • Right (в случае успеха).

Базовая реализация Either
/// Signature of callbacks that have no arguments and return right or left value.
typedef Callback<T> = void Function(T value);

/// Represents a value of one of two possible types (a disjoint union).
/// Instances of [Either] are either an instance of [Left] or [Right].
/// FP Convention dictates that:
///   [Left] is used for "failure".
///   [Right] is used for "success".
abstract class Either<L, R> {
  Either() {
    if (!isLeft && !isRight)
      throw Exception('The ether should be heir Left or Right.');
  }

  /// Represents the left side of [Either] class which by convention is a "Failure".
  bool get isLeft => this is Left<L, R>;

  /// Represents the right side of [Either] class which by convention is a "Success"
  bool get isRight => this is Right<L, R>;

  L get left {
    if (this is Left<L, R>)
      return (this as Left<L, R>).value;
    else
      throw Exception('Illegal use. You should check isLeft() before calling ');
  }

  R get right {
    if (this is Right<L, R>)
      return (this as Right<L, R>).value;
    else
      throw Exception('Illegal use. You should check isRight() before calling');
  }

  void either(Callback<L> fnL, Callback<R> fnR) {
    if (isLeft) {
      final left = this as Left<L, R>;
      fnL(left.value);
    }

    if (isRight) {
      final right = this as Right<L, R>;
      fnR(right.value);
    }
  }
}

class Left<L, R> extends Either<L, R> {
  final L value;

  Left(this.value);
}

class Right<L, R> extends Either<L, R> {
  final R value;

  Right(this.value);
}

Реализация совсем базовая, уступает решениям на других языках, но со своей задачей справляется. Я использую этот тип как результат всех методов репозитория, а обработку исключений перенес в слой данных. Это избавляет блока от конструкций try/catch, за счет чего код становится более читаем.


Пример с try/catch
class ContactBloc {
  final ContactRepository contactRepository;

  ContactBloc(this.contactRepository);

  @override
  Stream<ContactState> mapEventToState(ContactEvent event) async* {
    if (event is GetContactEvent) {
      yield LoadContactState();
      try {
        var contact = contactRepository.getById(event.id);
        yield ContactIsShowingState(contact);
      } on NetworkConnectionException catch (e) {
        yield NetworkExceptionState(e);
      } catch (e) {
        yield UnknownExceptionState(e);
      }
    }
  }
}

abstract class ContactRepository {
  Future<Contact>getById(int id);
}

Пример с either
class ContactBloc {
  final ContactRepository contactRepository;

  ContactBloc(this.contactRepository);

  @override
  Stream<ContactState> mapEventToState(ContactEvent event) async* {
    if (event is GetContactEvent) {
      yield LoadContactState();
      final either = contactRepository.getById(event.id);
      if (either.isRight) {
        final contact = either.right;
        yield ContactIsShowingState(contact);
      } else {
        final failure = either.left;
        if (failure is NetworkFailure) yield NetworkFailureState(failure);
        if (failure is UnknownFailure) yield UnknownFailureState(failure);
      }
    }
  }
}

abstract class ContactRepository {
  Future<Either<Failure, Contact>>getById(int id);
}

По поводу читаемости, кто-то может возразить. Возможно кому-то привычен try/catch и будет по своему прав, по большей части это вкусовщина. Дополнительным преимуществом является то, что мы сами можем определить иерархию Failure и возвращать в левой части. Допустим сделать абстрактный Failure, от него сделать общие для всех фич ServerFailure, NetworkFailure и какие-нибудь специфичные для текущей фичи ContactFailure, с наследниками. В блоке мы точно будем знать, какие из Failure ожидать.


Минусом в реализации Failure на Dart, является отсутсвие sealed classes как в kotlin, иначе бы не было этих if'ов с кастингом. Язык молод, активно развивается и надеюсь, что придет время и у нас появятся инструменты, позволяющие более лаконично писать обработчики.


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


Ресурсы:


Исходный код

  • +12
  • 2,3k
  • 4
Поделиться публикацией

Комментарии 4

    +2

    Я не пишу на Dart, но почему бы добавить метод, который будет принимать две лямбды (для left и right частей) и вызывать одну из них?
    Чтобы выглядело не так:


    if (either.isRight) {
        final contact = either.right;
        yield ContactIsShowingState(contact);
    } else {
        final failure = either.left;
        if (failure is NetworkFailure) yield NetworkFailureState(failure);
        if (failure is UnknownFailure) yield UnknownFailureState(failure);
    }

    А как-то так:


    either.foreach(
        contact => { yield ContactIsShowingState(contact) },
        failure => {
            if (failure is NetworkFailure) yield NetworkFailureState(failure);
            if (failure is UnknownFailure) yield UnknownFailureState(failure);
        }
    )
      0
      Из лямбды нельзя выполнить yield.
      Там где это возможно, можно использовать метод «either», принимающий две функции и написать что-то вроде этого:

      getUserEither.either((ServerError error) {
            print("Error: ${error.code}");
          }, (User user) {
            print("User: ${user.name}");
          });
        +2

        А почему бы тогда не сделать так:


        T either<T>(T Function(L) fnL, T Function(R) fnR) {
          if (isLeft) {
            final left = this as Left<L, R>;
            return fnL(left.value);
          }
        
          if (isRight) {
            final right = this as Right<L, R>;
            return fnR(right.value);
          }
        }

        и вызывать:


        yield either.either(
            contact => ContactIsShowingState(contact),
            failure => {
                if (failure is NetworkFailure) return NetworkFailureState(failure);
                if (failure is UnknownFailure) return UnknownFailureState(failure);
            }
        )
          0
          Отличная идея, спасибо! Возьму на вооружение.

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

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