Как создать простой SSE клиент на Dart

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

Мы рассматривали два подхода: WebSocket и Server-Sent Events (SSE). Оба варианта соответствовали нашим требованиям, но в итоге мы остановились на SSE — из-за его простоты реализации и использования стандартного HTTP-протокола.

Стоит отметить, что SSE имеет определённые ограничения по сравнению с WebSocket:
1. Поддерживаются только текстовые сообщения;
2. Передача данных возможна только в одну сторону — от сервера к клиенту.
Однако в нашем случае этого было достаточно: клиенту нужно было лишь получать данные в формате JSON, без необходимости отправлять что-либо в ответ.

Читать на Habr
Читать на Дзен

Что потребуется для установления соединения?

1. Нам нужна модель данных которую мы будем отдавать в выходящий стрим. В нашем случае она будет содержать айди сообщения, и данные - JSON.
2. Создадим класс нашего клиента. Подключение к SSE стриму и отключение от него мы обернем в статические методы. Так же для работы класса нам понадобятся следующие приватные поля:
class SSEClient {
  static http.Client _client = http.Client();
  static StreamController<SSEModel>? _streamController;
  static StreamSubscription? _subscription;
  static StreamSubscription? _dataSubscription;

  static Stream<SSEModel> subscribeToSSE()

  static Future<void> unsubscribeFromSSE() async {}
}
3. Теперь будем реализовывать метод подключения. Внутри метода первым делом инициализируем HTTP-клент и стримконтроллер, который будет отдавать вовне данные полученные из канала.
dart
_streamController = StreamController();

_client = http.Client();
 final request = http.Request(
      method == 'GET' 
      Uri.parse(url),
 );
Заголовок авторизации и URL можно передать в аргументы метода.
4. Создаём переменную модельки данных (пустую):
var currentSSEModel = SSEModel(data: '', id: '', event: '');
5. Делаем GET запрос, при этом в запрос кладём следующие заголовки:
dart
final headers = <String, String>{
        'Authorization': 'Bearer ${token.accessToken}',
        'Accept': 'text/event-stream',
        'Cache-Control': 'no-cache',
      };
header.forEach((key, value) {
    request.headers[key] = value;
  });
6. В ответе получаем StreamedResponse, на который можем подписаться и получать события:
dart
Future<http.StreamedResponse> response = _client.send(request);
 _subscription = response.asStream().listen((data) {}
7. Внутри этой подписки, нам необходимо отделить одно сообщение от другого, для этого необходимо трансформировать содержимое этого стрима в другой с отсечением одного сообщения от другого переводом строки:
dart
_dataSubscription = data.stream.transform(const Utf8Decoder()).transform(const LineSplitter()).listen((dataLine) {})
Если строка пуста, это означает, что сообщение завершено. В этом случае мы добавляем текущую модель в выходящий стрим, а затем создаём новую пустую модель для следующего сообщения.
if (dataLine.isEmpty) {
     _streamController!.add(currentSSEModel);
     currentSSEModel = SSEModel(data: '', id: '', event: '');
      return;
}
Нам понадобится регулярное выражение по которому мы будем идентифицировать строки с данными:
dart
final lineRegex = RegExp(r'^([^:]*)(?::)?(?: )?(.*)?$');
final match = lineRegex.firstMatch(dataLine)!;
final field = match.group(1);
 if (field!.isEmpty) {
    return;
}
var value = '';
if (field == 'data') {
  value = dataLine.substring(
      5,
   );
 } else {
     value = match.group(2)  ?? '';
 }
 switch (field) {
     case 'event':
        currentSSEModel.event = value;
     case 'data':
        currentSSEModel.data = '${currentSSEModel.data ?? ''}$value\n';
      case 'id':
         currentSSEModel.id = value;
      case 'retry':
        break;
}
8. Соединение установлено, парсинг реализован, так что нам осталось вернуть результат работы метода подключения, стрим с моделями данных.
return _streamController!.stream;

Заключение

Использование Server-Sent Events (SSE) стало для нас оптимальным решением задачи по передаче данных в реальном времени от сервера к клиенту. Несмотря на определённые ограничения SSE по сравнению с WebSocket — одностороннюю передачу и поддержку только текстовых сообщений — в нашем кейсе этого оказалось более чем достаточно. SSE позволил нам реализовать простой и надёжный канал обновлений без лишней сложности.