Первая часть находится здесь

Привет, я Андрей, работаю Flutter разработчиком в компании Финам.

Давайте развивать сервис Umka основы которого мы заложили в первой части.

Реализация отправки ответа на полученный вопрос

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

[
    {
        "id": 0,
        "text": "7 x 5 = ?",
        "answer": "35"
    },
    {
        "id": 1,
        "text": "12 x 13 = ?",
        "answer": "156"
    },
    {
        "id": 2,
        "text": "2 ** 5 = ?",
        "answer": "32"
    },
    {
        "id": 3,
        "text": "2 ** 10 = ?",
        "answer": "1024"
    },
    {
        "id": 4,
        "text": "2 ** 11 = ?",
        "answer": "2048"
    }
]

В файл lib/questions_db_driver.dart добавим метод getCorrectAnswerById, для получения корректного ответа по идентификатору вопроса и сделаем небольшой рефакторинг кода:

import 'dart:io';
import 'dart:convert';

import 'generated/umka.pb.dart';

final List<Question> questionsDb = _readDb();

List _getQuestionsList() {
  final jsonString = File('db/questions_db.json').readAsStringSync();
  return jsonDecode(jsonString);
}

List<Question> _readDb() => _getQuestionsList()
    .map((entry) => Question()
      ..id = entry['id']
      ..text = entry['text'])
    .toList();

String? getCorrectAnswerById(int questionId) {
  final jsonList = _getQuestionsList();

  final correctAnswer = jsonList.firstWhere(
    (element) => element['id'] == questionId,
    orElse: () => null,
  );

  return correctAnswer?['answer'];
}

В класс UmkaService добавим реализацию метода sendAnswer в котором:

  • получим из "базы" правильный ответ

  • если по какой-то причине "клиент" передал несуществующий идентификатор вопроса "выбросим" ошибку

    throw grpc.GrpcError.invalidArgument('Invalid question id!');

  • оценим ответ (за правильный в поле mark запишем 5, за неверный "влепим двойку") и вернём оценку "клиенту"

  @override
  Future<Evaluation> sendAnswer(ServiceCall call, Answer request) async {
    print('Received answer for the question: $request');

    final correctAnswer = getCorrectAnswerById(request.question.id);

    if (correctAnswer == null) {
      throw grpc.GrpcError.invalidArgument('Invalid question id!');
    }

    final evaluation = Evaluation()
      ..id = 1
      ..answerId = request.id;

    if (correctAnswer == request.text) {
      evaluation.mark = 5;
    } else {
      evaluation.mark = 2;
    }
    return evaluation;
  }

Остальной код в файле lib/service.dart без изменений.

Реализация метода sendAnswer на стороне клиентского приложения такая:

  Future<void> sendAnswer(Student student, Question question) async {
    final answer = Answer()
      ..question = question
      ..student = student;

    print('Enter your answer: ');

    answer.text = stdin.readLineSync()!;

    final evaluation = await stub.sendAnswer(answer);

    print('Evaluation for the answer: ${answer.text} '
        '\non the question ${question.text}:'
        '\n$evaluation');
  }
  • создаем "экземпляр" класса Answer

  • добавляем в него текст ответа введённый "студентом" в терминал

  • отправляем ответ на "оценку"

  • дождавшись оценки от сервиса выводим её в консоль

Также чуть изменим метод обращения к сервису Umka callService:

  Future<void> callService(Student student) async {
    final question = await getQuestion(student);
    await sendAnswer(student, question);
    await channel.shutdown();
  }

Здесь все просто:

  • запрашиваем у сервиса вопрос

  • отправляем на него ответ

  • закрываем соединение

Запускаем сервис

Для запуска сервиса на localhost из директории проекта выполним команду:

dart lib/service.dart

В окне терминала сервиса будут видны логи отправленных ответов. Чтобы завершить работу сервиса, можно нажать ctrl+c.

Подключаемся к сервису терминальным клиентом

Командой dart lib/client.dart в соседнем окне терминала из папки проекта запустим нашего "клиента" и представим себя студентом, которому нужно ответить на полученный вопрос. Для этого читаем вопрос, и в терминал в виде числа вводим ответ. После этого нам "прилетит" оценка mark: 5 или mark: 2.

Демонстрация вышеописанного:

Ошибки gRPC

Давайте "заставим" вызов sendAnswer прислать нам ошибку. Для этого подменим question.id на нелепый, например так:

  Future<void> callService(Student student) async {
    final question = await getQuestion(student);
    question.id = 777;
    await sendAnswer(student, question);
    await channel.shutdown();
  }
}

Сервис пришлет нам ошибку

Unhandled exception:
gRPC Error (code: 3, codeName: INVALID_ARGUMENT, message: Invalid question id!, details: [], rawResponse: null)

Демонстрация:

Ошибки, конечно же, требуют корректной обработки.

Отправка потокa данных клиентскому приложению

Давайте добавим нашему сервису возможность обучать "студентов". Для этого организуем периодическую отправку вопросов клиентскому приложению вместе с ответом на него.

Здесь нам и пригодится возможность gRPC отправлять stream c сервера "клиентам".

Добавим к описанию нашего сервиса в файл protos/umka.proto один тип:

message AnsweredQuestion {
  Question question = 1;
  string answer = 2;
}

И один удалённый вызов:

rpc getTutorial(Student) returns (stream AnsweredQuestion) {}

Обратите внимание на аннотацию stream перед возвращаемым типом. Именно она "решает", что при вызове данной процедуры клиент будет получать поток данных, а не одиночный ответ.

Теперь описание выглядит так:

Мы изменили описание сервиса, поэтому нужна "регенерация" gRPC Dart кода, и мы в папке проекта просто запускаем знакомую уже команду:

protoc -I protos/ protos/umka.proto --dart_out=grpc:lib/generated

Можно заглянуть во вновь сгенерированный код и убедиться, что там появился новый "вызов" в виде метода getTutorial класса UmkaServiceBase в файле umka.pbgrpc.dart и класс AnsweredQuestion в файле umka.pb.dart.

Код метода getTutorial для сервиса

Перейдя в файл lib/service.dart обнаруживаем "ворчание" компилятора на то, что в классе UmkaService отсутствует реализация метода getTutorial.

Напишем его код следующим образом:

  @override
  Stream<AnsweredQuestion> getTutorial(
      ServiceCall call, Student request) async* {
    for (var question in questionsDb) {
      final answeredQuestion = AnsweredQuestion()
        ..question = question
        ..answer = getCorrectAnswerById(question.id)!;

      yield answeredQuestion;

      await Future.delayed(Duration(seconds: 2));
    }
  }
}

В Dart для того, чтобы функция возвращала стрим её нужно пометить ключевым словом async*. После этого в стрим объекты указанного типа Stream<AnsweredQuestion> отправляются с помощью ключевого слова yield.

Метод getTutorial по одному будет брать вопросы из "базы", отправлять их "студенту" в клиентское приложение, делать паузу 2 секунды для "подумать". Процесс будет повторяться пока не закончатся данные. После этого соединение, установленное при вызове метода getTutorial, будет прервано.

Доработка клиентского приложения

Изменения здесь небольшие:

  • добавим в UmkaTerminalClient метод запроса урока takeTutorial,

  • изменим обращение к сервису сосредоточившись только на "уроке".

  Future<void> takeTutorial(Student student) async {
    await for (var answeredQuestion in stub.getTutorial(student)) {
      print(answeredQuestion);
    }
  }

  Future<void> callService(Student student) async {
    await takeTutorial(student);
    await channel.shutdown();
  }
}

Dart конструкция await for позволяет удобно брать данные из потока до тех пор, пока поток не "иссякнет", после чего метод takeTutorial завершится. Текст вопросов с ответами на них просто печатаем в консоль.

Запускаем dart lib/service.dart в одном терминальном окне и dart lib/client.dart в другом, и наблюдаем поток вопросов в клиентский терминал:

На этом вторая и завершим вторую часть.

Мы поработали над развитием нашей системы добавив полезные "фичи":

  • Отправка на сервер ответа на полученный вопрос.

  • Получение обучающего материала в виде потока задачек с ответами.

Посмотрели как "прилетает" ошибка.

Думаю, стало понятно, что в gRPC предусмотрена удобная возможность развития системы.

До встречи в части №3 где мы продолжим добавлять полезные возможности нашему сервису на основе потока данных от клиента к сервису и двунаправленного потока данных.