Как стать автором
Обновить

Сервисы: строим масштабируемые и гибкие приложения с помощью чистой архитектуры

Уровень сложностиСредний
Время на прочтение10 мин
Количество просмотров5.3K

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

Изменение API вы можете встретить в разных кейсах. От изменения протоколов взаимодействия с сетью до изменения нативных API биометрией.

Проблема: Пример 1. изменение API native

На Android существует возможность устанавливать пакеты. Например, это может быть APK. Для того, чтобы воспользоваться данным механизмом нужно запросить REQUEST_INSTALL_PACKAGES у пользователя.

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

Допустим, вы использовали какой-то пакет с pub.dev для того, чтобы пользоваться данным нативным кодом. Вызов в вашем коде выглядел примерно так:

import 'package:apk_installer/apk_installer.dart';

class SomeBussinessLogic {
	Future<void> installPackage(String apkURL) {
		ApkInstaller.install(apkURL); // Вызываем пакет который вы нашли на pub.dev
	}
}

Но есть проблема: начиная с android API level 29 данное разрешение становиться Deprecated, и вас как разработчика начинают обязывать использовать новый нативный API PackageInstaller.

Теперь нативный код для Android API level выглядит следующим образом:

private suspend fun installCoroutine(apkUri: Uri) =
    withContext(Dispatchers.IO) {
      resolver.openInputStream(apkUri)?.use { apkStream ->
        val length = DocumentFile.fromSingleUri(getApplication(), apkUri)?.length() ?: -1
        val params = PackageInstaller.SessionParams(PackageInstaller.SessionParams.MODE_FULL_INSTALL)
        val sessionId = installer.createSession(params)
        val session = installer.openSession(sessionId)

        session.openWrite(NAME, 0, length).use { sessionStream ->
          apkStream.copyTo(sessionStream)
          session.fsync(sessionStream)
        }

        val intent = Intent(getApplication(), InstallReceiver::class.java)
        val pi = PendingIntent.getBroadcast(
          getApplication(),
          PI_INSTALL,
          intent,
          PendingIntent.FLAG_UPDATE_CURRENT
        )

        session.commit(pi.intentSender)
        session.close()
      }
    }

Для того, чтобы поддержать установку пакетов программным образом на новых версиях Android вы идёте смотреть новую версию пакета apk_installer который вы использовали раньше, но как часто это бывает разработчик ещё не поддержал новые функции или вообще забросил пакет. Знакомо? Думаю, большинство из вас сталкивались с подобной ситуацией.

Вы начинаете расстроенный искать другие пакеты, поддерживающие новый API, и видете что находите пакет package_installer, который поддерживает только новый API.

Изображение 1
Изображение 1

Для того, чтобы проверить Android API level вам нужен теперь ещё один плагин. Итоговый код будет выглядеть так:

import 'package:apk_installer/apk_installer.dart';
import 'package:package_installer/package_installer.dart';
import 'package:device_info_plus/device_info_plus.dart';

class SomeBussinessLogic {
	Future<void> installPackage(String apkURL) {
		DeviceInfoPlugin deviceInfo = DeviceInfoPlugin();
		int androidInfo = await deviceInfo.androidInfo.version.baseOS.toInt();
		
		if (androidInfo >= 29) {
			PackageInstaller.install(apkURL);
		} else {
			ApkInstaller.install(ApkURL);
	  }
	}
}

В данном примере ветвление и кол-во вызовов не большое. Давайте представим что данный API менялся не один раз, а каждые 1-2 новые версии операционной системы. Плюс ещё ваше приложение поддерживает несколько операционных систем. Ваш метод installPackage() превратиться в не поддерживаемую лапшу.

Видя данный код я задаюсь сразу закономерным вопросом “Почему в бизнес логике появился данный код? Кажется это вообще не часть бизнес знания!”.

Проблема: Пример 2. замена

Расскажу реальный кейс из личной практики. Существует приложение которое на старте начало использовать firebase_analytics для сбора продуктовой аналитики. Через месяц пришёл менеджер с и говорит “выпиливай свой firebase, мне нужна AppMetrica”. Скорее всего интеграция firebase у вас была как-то так:

import 'package:firebase_analytics/firebase_analytics.dart';

class SomeWidgetState extends State<SomeWidget> {
	...
	@override
	void initState() {
		FirebaseAnalytics.instance.logEvent(name: 'name', parameters: {});
	}
}

Допустим, приложение отслеживало 50 событий. Теперь вам нужно в 50 местах изменить вызов Firebase на AppMetrica. Давайте рассмотрим проблемы, которые могут появиться:

  • Что будет если менеджер захочет добавить ещё один сервис аналитики параллельно AppMetrica?

  • Что будет если нужно будет добавить во всех методах logEvent() обработку ошибки при помощи catchError()?

  • Что будет если пакет firebase_analytics вам больше не будет подходить?

Данные примеры, показывающие, проблемы с которыми вы сталкивались при разработке приложений возможно загоняли вас в ступор. Основные проблемы расширяемость и гибкость.

Решение

Использование чистой архитектуры и сервисов помогает разработчикам создавать масштабируемые и гибкие приложения. Чистая архитектура определяет четкие границы ответственности компонентов приложения и упрощает его структуру.

Изображение 1
Изображение 1

Давайте рассмотрим данное изображение. Наверняка все видели данный круг в разных видах. Давайте упростим данную схему для простоты понимания.

Изображение 2
Изображение 2

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

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

Сервисы в чистой архитектуре имеют свои особенности:

  • Сервисы ничего не знают о внешней среде, такой как базы данных, веб-серверы и т.д.

  • Сервисы не зависят от фреймворков, библиотек и других внешних ресурсов, что делает их легко переносимыми и тестируемыми.

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

  • Сервисы предоставляют интерфейс исходя из потребностей потребителя, а не исходя из знания как устроен сервис внутри.

Давайте рассмотрим несколько основных примеров.

Решение: Пример 1. Web

При взаимодействии с вашим или другими серверами все запросы и сам HTTP клиент имеет смысл обернуть в сервисы.

Почему стоит оборачивать HTTP клиент в сервис?

Как и в примере с “Проблема: Пример 1. изменение API native” со временем ваш HTTP клиент может изменить API или выбранная вами библиотека может вас не устраивать со временем, что заставит вас при переходе менять все вызовы HTTP клиента.

Давайте рассмотрим пример когда вы использовали стандартный пакет http в своём приложении. Приложение начало расти и развиваться и вам стало не достаточно данного пакета и вы решили перейти на пакет dio.

Как выглядит работа с http:

import 'package:http/http.dart' as http;

void createWaitList() {
	var client = http.Client();
	try {
	  var response = await client.post(
	      Uri.https('example.com', 'whatsit/create'),
	      body: {'name': 'doodle', 'color': 'blue'});
	  var decodedResponse = jsonDecode(utf8.decode(response.bodyBytes)) as Map;
	  var uri = Uri.parse(decodedResponse['uri'] as String);
	  print(await client.get(uri));
	} finally {
	  client.close();
	}
}

Метод createWaitList() знает слишком много о том как устроена библиотека http, вам придётся в каждом методе работы с сетью заменять http.Client() и await client.post() на вызовы новой библиотеки. Не гибко, не масштабируемо и не расширяемо.

Как выглядит ваш код если обернуть HTTP клиент в сервис:

import 'http_service/http.dart';

class WaitListAPI {
	IHTTPService _httpService;

	WhaitlistAPI(this._httpService);

	void createWaitList() {
		  var response = await _httpService.post(
		      Uri.https('example.com', 'whatsit/create'),
		      body: {'name': 'doodle', 'color': 'blue'});
		  var decodedResponse = jsonDecode(utf8.decode(response.bodyBytes)) as Map;
		  var uri = Uri.parse(decodedResponse['uri'] as String);
		  print(await client.get(uri));
	}
}

Изменилось всего 4 строчки которые выделены жирным, но теперь мы сделали наш код независимым от библиотеки http и в любое время можем безболезненно мигрировать на любую другую библиотеку.

Как будет выглядеть код HTTPService:

import 'package:dio/dio.dart';

abstract class IHTTPService {
	Future<Response> get();
	Future<Response> post();
}

class HTTPService implements IHTTPService {
		final Dio _dio;

		HTTPService(this._dio);

		Future<Response> get(Uri uri) {
			return _dio.get(uri);
		}

		Future<Response> post(Uri uri, Map body) {
			return _dio.post(uri, body: body);
		}
}

Почему стоит оборачивать все запросы в сервис?

Запросы в себе хранят данные об имплементации вашего бэкенда. При изменении протоколов или принципов взаимодействия с бэкендом вы столкнётесь с проблемой, что заставит вас изменять логику вашей бизнес логике.

Давайте рассмотрим пример с изменением протоколов взаимодействия с бэкендом. У вас есть запрос на получение списка курса валют. Данный список вам требуется на нескольких экранах.

Пример ответа в формате JSON:

[
	"Рубли/Доллары": 35.82,
	"Рубли/Евро": 45.0,
	"Доллары/Франки": 30.3,
]

Ваш сервис выглядит так:

import 'package:some_parser/parse.dart';

abstract class IExcahngeRateAPI {
	Future<Map<String, double>> getExchangeRate();
}

class ExcahngeRateAPI implements IExcahngeRateAPI {
	final IHttpService _httpService;

	ExcahngeRateAPI(this._httpService);

	@ovveride
	Future<Map<String, double>> getExchangeRate() async {
 		final response = await _httpService.get('/exchange-rate');
		return json.decode(response);
	}	
}

Вызов вашего сервиса выглядит так:

class SomeBussinessLogic {
	final IExcahngeRateAPI _excahngeRateAPI;
	final IDataBaseService _dataBaseService;

	SomeBussinessLogic(this._excahngeRateAPI);

	Future<void> getExchangeDataAndSave() async {
 		final excahgeRate = await _excahngeRateAPI.getExchangeRate();
 		await _dataBaseService.saveExchangeRate(excahgeRate);
	}
}

Ок, супер. Теперь ваша бизнес логика знает что есть 2 сервиса ответственные за получение и сохранение данных. Сама бизнес логика ничего не знает о том как работает ваш сервер и о работе базы данных.

Приходит менеджер через 2 недели и просить расширить данную функцию.

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

{
	"rates": [
		"RUB-USD": 35.82,
		"RUB-EUR": 45.0,
	],
	"data": {
		"RUB-USD": {
			"translate": "Рубли/Доллары",
			"icon": "https:exch.rate/RUB-USD.png"
		},
		"RUB-EUR": {
			"translate": "Рубли/Евро",
			"icon": "https:exch.rate/RUB-EUR.png"
		}
	}
}

Исходя из нового протокола бэкенда в текущий момент вам потребуется изменить только сервис.

Как будет выглядеть сервис:

import 'package:some_parser/parse.dart';

class Currency {
	String? translate;
	String? icon;
	double rate;
}

abstract class IExcahngeRateAPI {
	Future<List<Currency>> getExchangeRate();
}

class ExcahngeRateAPI implements IExcahngeRateAPI {
	final IHttpService _httpService;

	ExcahngeRateAPI(this._httpService);

	@ovveride
	Future<List<Currency>> getExchangeRate() async {
 		final response = await _httpService.get('/exchange-rate');
		
		final Map<String, dynamic> parsedJson = json.decode(response);
	  final List<Currency> currencies = [];
	  final Map<String, dynamic> data = parsedJson['data'];

		parsedJson['rates'].forEach((key, value) {
	    final currencyData = data[key];
	
	    if (currencyData != null) {
	      currencies.add(Currency(
	        translate: currencyData['translate'],
	        icon: currencyData['icon'],
	        rate: value,
	      ));
	    }
	  });

		return currencies;
	}	
}

Как будет выглядеть бизнес логика:

class SomeBussinessLogic {
  final IExcahngeRateAPI _excahngeRateAPI;
  final IDataBaseService _dataBaseService;

  SomeBussinessLogic(this._excahngeRateAPI);

	Future<void> getExchangeDataAndSave() async {
 		final excahgeRate = await _excahngeRateAPI.getExchangeRate();
 		await _dataBaseService.saveExchangeRate(excahgeRate);
	}
}

Ничего вообще не изменилось. Тем самым вы сделали свой код гибким к изменениям, легко масштабируемым и расширяемым.

Решение: Пример 2. devices

Все взаимодействия вашего приложения с hardware или нативными SDK должны быть инкапсулированы в сервисы. Перед нами стоит следующая задача: нужно получить имя устройства. Находим на pub.dev несколько библиотек, которые позволяют получать данные от платформы. Допустим, device_info.

Рассмотрим как будет выглядеть наш сервис:

import 'package:device_info/device_info.dart';

// Интерфейс взаимодействия данного сервиса
abstract class IDeviceInformationService {
  Future<String> getDeviceName();
}

// Имплементация сервиса
class DeviceInformationService implements IDeviceInformationService {
  final _deviceInfoPlugin = DeviceInfoPlugin();

  @override
  Future<String> getDeviceName() async {
    if (Platform.isAndroid) {
      final deviceInformation = await _deviceInfoPlugin.androidInfo;

      return deviceInformation.device;
    } else if (Platform.isIOS) {
      final deviceInformation = await _deviceInfoPlugin.iosInfo;

      return deviceInformation.utsname.machine;
    }

    throw Exception();
  }
}

Пример потребителя данного сервиса:

class SomeBussinessLogic {
	final IDeviceInformationService _deviceInfoService;
	final IHttpService _httpService;

	SomeBussinessLogic(this._deviceInfoService);

	Future<void> sendDeviceNameToServer() async {
		final String deviceName = await _deviceInfoService.getDeviceName();
		await _httpService.post('/device', queryParams: {'name': deviceName});
	}
}

Пример 3. framework

Допустим, вы используете Flutter для написания мультиплатформенного приложения. Фреймфорк в рамках вашего приложения - это тоже сервис, но его ответственность не в том, чтобы ходить в сеть или в нативные API, а в том, чтобы рисовать интерфейс. Большой разницы в этом нет. Давайте рассмотрим самый базовый пример работы с фреймфорком. Flutter из коробки предоставляет некоторые API для работы с нативным API, но вы как программист вызываете непосредственно Flutter.

Один из таких примеров - это работа с Clipboard. Flutter есть встроенная библиотека package:flutter/services.dart которая позволяет работать с ClipboardManager на Android и UIPasteboard на iOS.

Вызов выглядит следующим образом:

Clipboard.getData('');
Clipboard.setData(ClipboardData(text: 'Text'));

Можно подумать, но тут же всё супер просто и прозрачно.

Зачем нам вообще оборачивать данные вызовы в сервис?

Ваше приложение уже успешно работает на Android и iOS. Вы решаете адаптировать уже существующую кодовую базу ещё и под Windows. Но проблема в том, на Windows Flutter не умеет работать с Clipboard.

Screen Shot 2023-04-14 at 10.21.02 PM.png
Screen Shot 2023-04-14 at 10.21.02 PM.png

Как всегда, можно найти библиотеку clipboard на pub.dev которая умеет это делать. В данном кейсе вам нужно будет везде при вызове Clipboard делать такое:

import 'package:clipboard/clipboard.dart';
import 'package:flutter/services.dart';

if (Platform.Windows) {
	Clipboard.setData(ClipboardData(text: 'Text'));
} else {
	FlutterClipboard.copy('Text');
}

Куда лучше сделать это следующим образом:

import 'package:flutter/services.dart';
import 'package:clipboard/clipboard.dart';

abstract class IClipboardService {
  Future<String?> getText();
  Future<void> setText(String text);
}

class ClipboardService implements IClipboardService {
  @override
  Future<String?> getText() async {
		if (Platform.Windows) {
	    return await Clipboard.getData('');
		} else {
			return FlutterClipboard.paste();
		}
  }

  @override
  Future<void> setText(String text) {
		if (Platform.Windows) {
			Clipboard.setData(ClipboardData(text: text));
		} else {
			FlutterClipboard.copy(text);
		}
  }
}

И каждый вызов вашего сервиса будет выглядеть так:

class SomeBusinessLogic {
	final IClipboardService _clipboardService;

	void someMethod() {
		var text;
		text = 'some text'; // например тут мы получаем данные из базы данных
		_clipboardService.setText(text);
	}
}

Теперь при расширении или изменении вашего сервиса IClipboardService ваша бизнес логика вообще ничего не будет знать об этом что позволит вам добиться трёх основных преимуществ использования сервисов: расширяемость и гибкость.

Какие плюсы для бизнеса и программистов

Использование сервисов в мобильных приложениях имеет несколько преимуществ:

  1. Расширяемость: использование сервисов позволяет бизнесу быстро вносить изменения и адаптироваться к потребностям клиентов.

  2. Гибкость: сервисы могут быть переиспользованы в других приложениях и выполнять определенные задачи, что делает их более гибкими.

Если сказать просто и понятно, данный подход позволяет тратить меньше времени при разработке программного обеспечения с течением времени.

Теги:
Хабы:
Всего голосов 4: ↑2 и ↓20
Комментарии4

Публикации

Истории

Работа

iOS разработчик
24 вакансии
Swift разработчик
42 вакансии

Ближайшие события

27 августа – 7 октября
Премия digital-кейсов «Проксима»
МоскваОнлайн
28 сентября – 5 октября
О! Хакатон
Онлайн
3 – 18 октября
Kokoc Hackathon 2024
Онлайн
10 – 11 октября
HR IT & Team Lead конференция «Битва за IT-таланты»
МоскваОнлайн
25 октября
Конференция по росту продуктов EGC’24
МоскваОнлайн
7 – 8 ноября
Конференция byteoilgas_conf 2024
МоскваОнлайн