Введение
Трудно представить современное приложение, которое бы не использовало доступ в интернет для обмена данными с сервером. В этой главе мы разберём, как организовать такой обмен, какой формат данных для обмена использовать и как сохранить эти данные на диск.
Обмен данными с помощью HTTP
Большинство знакомых нам мобильных приложений постоянно взаимодействует с глобальной сетью с помощью протокола HTTP.
HTTP (HyperText Transfer Protocol, протокол передачи гипертекста) — протокол обмена данными между клиентом и сервером, благодаря которому разработчики разных сервисов взаимодействуют между собой, отправляют информацию в едином виде.
Изначально задуманный для передачи HTML-документов, протокол HTTP получил широкое применение для обмена произвольными данными: текст, картинки, аудио, видео и прочее.
Протокол работает по принципу «запрос — ответ». Клиент отправляет HTTP-запрос (HTTP Request
) серверу, тот его обрабатывает и отправляет клиенту HTTP-ответ (HTTP Response
). Давайте рассмотрим структуру запросов и ответов подробнее.
Пример HTTP-запроса:
GET /handbook/ HTTP/1.1
Host: academy.yandex.ru
User-Agent: Mozilla/5.0 (Macintosh; Intel Mac OS X 10_15_7)
Accept: text/html,application/xhtml+xml,application/xml
Accept-Language: en-US,en;q=0.9,ru;q=0.8
Accept-Encoding: gzip, deflate, br
Authorization: Basic YWxhZGRpbjpvcGVuc2VzYW1l
Структура HTTP запроса:
- Адрес (URL) — получение доступа к ресурсам осуществляется с помощью указателя URL (Uniform Resource Locator). URL представляет собой строку, которая позволяет указать запрашиваемый ресурс и ещё ряд параметров. Пример URL-адреса: https://academy.yandex.ru/handbook/.
- Метод (Method) — позволяет указать конкретное действие, которое нужно выполнить серверу. Например, метод
GET
указывает, что нужно получить некоторые данные с сервера, а методPOST
используется для отправки данных на сервер. - Заголовки (Headers) — характеризуют тело запроса, параметры передачи и прочие сведения. Имеют стандартную структуру для HTTP-заголовка «Название:Значение», с двоеточием в качестве разделителя. Например, заголовок
Authorization
, который в своём значении хранит токен авторизации, используется в качестве метода идентификации клиента на сервере. - Тело запроса (Body) — используется для передачи объекта, связанного с запросом. Не каждому HTTP-методу необходимо тело. Например, для метода
GET
оно обычно не требуется.
Пример HTTP-ответа:
HTTP/1.1 200 OK
Date: Tue, 02 May 2023 22:42:26 GMT
Content-Type: text/html; charset=utf-8
Content-Encoding: gzip
WWW-Authenticate: Basic
<!DOCTYPE html><html>...</html>
Структура HTTP ответа:
-
Код состояния (Status code) — коды состояния HTTP используются, чтобы сообщить клиенту статус его запроса. HTTP-сервер может вернуть код, принадлежащий одной из пяти категорий кодов состояния (1xx, 2xx, 3xx, 4xx, 5xx).
Например, самые распространенные коды ответов:
—200 OK
— запрос обработан успешно;
—400 Bad Request
— в запросе ошибка;
—500 Internal Error
— сервер не может обработать запрос. -
Заголовки (Headers) — используются, чтобы уточнить ответ, и никак не влияют на содержимое тела ответа. Например, заголовок
WWW-Authenticate
уведомляет клиента о типе аутентификации, который необходим для доступа к запрашиваемому ресурсу. -
Тело ответа (Body) — используется для передачи объекта, связанного с ответом. Несмотря на то, что у большинства ответов тело присутствует, оно также не является обязательным. Например, у кодов
201 Created
или204 No Content
тело отсутствует, так как достаточную информацию для ответа на запрос они передают в заголовке.
Благодаря протоколу HTTP все клиенты и серверы в интернете могут расшифровать присланные данные и отправлять их в понятном другим клиентам и серверам виде. Однако каждый сервер имеет свой API, действующий над протоколом HTTP.
API (Application Programming Interface, программный интерфейс приложений) — это набор инструкций, который позволяет разным приложениям общаться между собой. Правила API описывают возможные запросы к серверу и ответы сервера на эти запросы.
Давайте представим, что у нас есть абстрактный сервер, который предоставляет нам API для работы с пользователями, и посмотрим на доступные механизмы обмена данными с помощью протокола HTTP.
Пример АPI-сервера:
// Метод GET для получения данных о пользователе
https://mysite.ru/user/data
// Метод POST для отправки данных о пользователе
https://mysite.ru/user/update
HttpClient dart:io
Библиотека dart:io предоставляет нам классы для работы с файлами, директориями, ссылками, сокетами и другим функционалом. Например, для работы с HTTP библиотека dart:io предоставляет класс HttpClient
.
⚠️ Внимание!
Класс HttpClient
не подходит для разработки веб-приложений. При разработке на веб используйте класс HttpRequest
из библиотеки dart:html.
-
Создание URL-адреса
Чтобы сделать HTTP-запрос, вам нужен URL-адрес, который идентифицирует запрашиваемый ресурс или конечную точку, к которой осуществляется доступ. В Dart URL-адреса представлены через объектыUri
.
Существует множество способов созданияUri
, но самым распространённым являетсяUri.parse
(используйтеUri.tryParse
, чтобы не получитьFormatException
, если указали невалидный адрес).Uri.parse('https://mysite.ru/user');
-
Отправка запроса и получение ответа (метод GET)
Давайте рассмотрим, как сделатьGET
-запрос, который мы используем для получения данных от сервера. В данном примере успешныйGET
возвращает запрошенную нами информацию о пользователе по егоuserID
.Future<String?> getUserData(String userID) async { // Устанавливаем URL-адрес final userDataUrl = Uri.parse('http://mysite.ru/user/data/$userID'); // Создаем инстанс HTTP-клиента final httpClient = HttpClient(); // Открываем HTTP-соединение, используя метод GET final userDataRequest = await httpClient.getUrl(userDataUrl); // Закрываем HTTP-соединение final userDataResponse = await userDataRequest.close(); // Декодируем тело HTTP-ответа из UTF8 final userDataResponseBody = await userDataResponse.transform(const Utf8Decoder()).join(); // Завершаем работу HTTP-клиента httpClient.close(); // Если сервер обработал запрос с ошибкой, то statusCode != 200 if (userDataResponse.statusCode != 200) { // Выводим ошибку в консоль print('Failed to retrieve user data!'); // Возвращаем null return null; } // Возвращаем данные с сервера return userDataResponseBody; }
-
Отправка запроса и получение ответа (метод POST)
Мы используем методPOST
для отправки данных на сервер. ДляPOST
-запроса требуется тело — в нём мы определяем данные сущности, которую хотим отправить.
В примере ниже успешныйPOST
возвращает ответ, содержащий созданную или обновлённую нами информацию о пользователе. Однако тело возвращаемого ответа может быть и пустым — это зависит от конфигурации API-сервера.Future<String?> setUserData(String userId, String name, String email) async { // Устанавливаем URL-адрес final userUpdateUrl = Uri.parse('https://mysite.ru/user/update'); // Устанавливаем обновлённые данные пользователя final userUpdate = {'id': userId, 'name': name, 'email': email}; // Создаем инстанс HTTP-клиента final httpClient = HttpClient(); // Открываем HTTP-соединение, используя метод POST final userUpdateRequest = await httpClient.postUrl(userUpdateUrl); // Устанавливаем тип передаваемых данных application/json userUpdateRequest.headers .set(HttpHeaders.contentTypeHeader, 'application/json; charset=UTF-8'); // Устанавливаем передаваемые данные userUpdateRequest.write(userUpdate); // Закрываем HTTP-соединение final userUpdateResponse = await userUpdateRequest.close(); // Декодируем тело HTTP-ответа из UTF8 final userUpdateResponseBody = await userUpdateResponse.transform(const Utf8Decoder()).join(); // Завершаем работу HTTP-клиента httpClient.close(); // Если сервер обработал запрос с ошибкой, то statusCode != 200 if (userUpdateResponse.statusCode != 200) { // Выводим ошибку в консоль print('Failed to update user data!'); // Возвращаем null return null; } // Возвращаем данные с сервера return userUpdateResponseBody; }
Вы можете использовать HttpClient
из dart:io или HttpRequest
из dart:html для выполнения HTTP-запросов, однако эти библиотеки предоставляют низкоуровневую функциональность HTTP и зависят от платформы. Как вы могли заметить, выполнять запросы и получать ответы, используя эти классы напрямую, достаточно сложно.
Давайте рассмотрим возможность использования более удобного для разработчика интерфейса. Например, пакет http предоставляет универсальную кроссплатформенную библиотеку для HTTP-запросов, упрощая взаимодействие с HttpClient
и HttpRequest
.
package:http
Этот мультиплатформенный пакет содержит набор высокоуровневых функций и классов, упрощающих использование ресурсов HTTP.
-
Добавление зависимости
Для начала установим пакет http. Добавьте его в раздел зависимостей файла pubspec.yaml.dependencies: http: <latest_version>
⚠️ Внимание!
Если у вас не включена автоматическая установка зависимостей при сохранении изменений в файле pubspec.yaml, то вам необходимо выполнитьflutter pub get
(для Flutter-проекта) илиdart pub get
(для Dart-проекта). -
Добавление импорта
Добавьте импорт пакета http в необходимый файл.import 'package:http/http.dart' as http;
⚠️ Внимание!
Пакет http предоставляет глобальные функции, для удобства обращения к которым мы импортируем пакет с помощью префиксаas http
. Добавление префикса необязательно, вы можете использовать пакет без него. -
Отправка запроса и получение ответа (метод GET)
Перепишем метод для получения данных пользователя.Future<String?> getUserData(String userID) async { final userDataUrl = Uri.parse('https://mysite.ru/user/data/$userID'); final userDataResponse = await http.get(userDataUrl); if (userDataResponse.statusCode != 200) { print('Failed to retrieve user data!'); return null; } return userDataResponse.body; }
-
Отправка запроса и получение ответа (метод POST)
Перепишем метод для обновления данных пользователя.Future<String?> setUserData(String userId, String name, String email) async { final userUpdateUrl = Uri.parse('https://mysite.ru/user/update'); final userUpdate = {'id': userId, 'name': name, 'email': email}; final userUpdateResponse = await http.post(userUpdateUrl, body: userUpdate); if (userUpdateResponse.statusCode != 200) { print('Failed to update user data!'); return null; } return userUpdateResponse.body; }
Как видите, нам удалось значительно сократить объём кода и упростить работу с HTTP.
Помимо этого пакет предоставляет и другие возможности. Например, класс RetryClient
отправляет упавшие запросы повторно. Такое может пригодиться, например, когда ваш сервер не готов обработать запрос и возвращает ошибку 503 Service Unavailable
.
```dart
import 'package:http/http.dart' as http;
import 'package:http/retry.dart';
Future<String?> setUserData(String userId, String name, String email) async {
final userUpdateUrl = Uri.parse('https://mysite.ru/user/update');
final userUpdate = {'id': userId, 'name': name, 'email': email};
final client = RetryClient(http.Client());
final userUpdateResponse = await client.post(userUpdateUrl, body: userUpdate);
if (userUpdateResponse.statusCode != 200) {
print('Failed to update user data!');
return null;
}
return userUpdateResponse.body;
}
```
Методы GET
и POST
возвращают Response
, который содержит данные, полученные от успешного HTTP-запроса. Например, в поле statusCode
возвращается статус запроса, а в поле body
— тело ответа.
Наш API отправляет данные пользователя, которые содержатся в поле body
в виде строки.
Использование данных, которые приходят в виде строки, не очень удобно, при работе с ними могут возникнуть проблемы. Для более удобной работы используют формат передачи данных JSON.
Формат передачи данных JSON
Данные, передаваемые через HTTP, технически могут быть любого формата, но чаще всего они в формате JSON — он простой, легко читается и не зависит от конкретного языка программирования.
JSON (JavaScript Object Notation) — это формат обмена данными, который стал повсеместным при разработке приложений и взаимодействии между клиентом и сервером. С помощью JSON различные типы данных, такие как данные пользователя, могут быть сериализованы и представлены строками.
Пример JSON:
{'name': 'Alex', 'email': 'alex@ya.ru'}
Процесс преобразования данных в JSON называется кодированием, обратный процесс — декодированием. Существует два подхода преобразования данных: ручной и автоматический.
JSON dart:convert
Для ручного кодирования и декодирования в библиотеке dart:convert есть функции-преобразователи JSON. Если в вашем проекте не так много моделей JSON или вы хотите быстро протестировать какую-то гипотезу, лучше выбрать именно ручную сериализацию.
Наш API предоставляет возможность получать и отправлять данные пользователей. Давайте создадим класс User
для удобной работы с этими данными.
-
Создание класса
Создаем классUser
, который хранит информацию о пользователе.class User { final String name; final String email; const User(this.name, this.email); }
-
Ручное кодирование JSON
Чтобы отправлять данные пользователя на сервер, нам необходимо кодировать классUser
в формат данных JSON. Для этого добавим в него методtoJson()
, который преобразует экземпляр классаUser
вMap<String, dynamic>
.Map<String, dynamic> toJson() => { 'name': name, 'email': email, };
Если вы хотите преобразовать
Map<String, dynamic>
в строку, то можете воспользоваться функциейjsonEncode
. Это может понадобиться, например, при сохранении данных на диск или при записи в базу данных.Map<String, dynamic> userMap = user.toJson(); String jsonString = jsonEncode(user); print(jsonString);
⚠️ Внимание!
Вы можете не вызывать методtoJson()
у экземпляра классаUser
, функцияjsonEncode()
может сделать это за вас. -
Ручное декодирование JSON
Чтобы получать данные пользователя в виде классаUser
, нам необходимо декодировать их из формата данных JSON. Для этого добавим в него конструкторfromJson()
, который преобразуетMap<String, dynamic>
в экземпляр классаUser
.User.fromJson(Map<String, dynamic> json) : name = json['name'], email = json['email'];
Теперь, чтобы декодировать строку JSON в класс
User
, сначала передайте строку в функциюjsonDecode()
, а затем вызовите конструктор классаfromJson()
, передав получившейсяMap<String, dynamic>
.Map<String, dynamic> userMap = jsonDecode(jsonString); User user = User.fromJson(userMap); print('Hello, ${user.name}!');
-
Получившийся класс
Благодаря этому подходу вы можете легко кодировать и декодировать свои классы.class User { final String name; final String email; const User(this.name, this.email); User.fromJson(Map<String, dynamic> json) : name = json['name'], email = json['email']; Map<String, dynamic> toJson() => { 'name': name, 'email': email, }; }
Ручное кодирование и декодирование отлично подходит для маленьких проектов, где используется небольшое количество сущностей. Когда таких сущностей становится больше, написание логики вручную становится трудным для управления и подвержено ошибкам. В таком случае вам стоит обратиться к генераторам кода — например, для генерации JSON можно использовать пакет json_serializable.
package:json_serializable
Сериализация JSON с генерацией кода означает, что внешняя библиотека генерирует шаблон кодирования за вас.
-
Добавление зависимостей
Чтобы включить json_serializable в свой проект, нам понадобится установить в pubspec.yaml следующие зависимости.dependencies: json_annotation: <latest_version> dev_dependencies: build_runner: <latest_version> json_serializable: <latest_version>
-
Преобразование класса
Преобразуем наш класс User в классJsonSerializable
. Для этого добавьте аннотацию@JsonSerializable()
, функциюtoJson()
, конструкторfromJson()
и файл user.g.dart.import 'package:json_annotation/json_annotation.dart'; part 'user.g.dart'; @JsonSerializable() class User { final String name; final String email; const User(this.name, this.email); factory User.fromJson(Map<String, dynamic> json) => _$UserFromJson(json); Map<String, dynamic> toJson() => _$UserToJson(this); }
-
Настройка полей класса
Вы также можете использовать дополнительные аннотации для настройки полей класса.// Обязательность поля @JsonKey(required: true) final String id; // Значение поля по умолчанию @JsonKey(defaultValue: '') final String name; // Название поля @JsonKey(name: 'email') final String email;
С json_serializable вы можете забыть о ручной сериализации и десериализации JSON. Генератор кода создает файл с именем user.g.dart, содержащий всю необходимую логику сериализации.
По умолчанию json_serializable поддерживает следующие типы данных: bool
, DateTime
, double
, Duration
, Enum
, int
, Iterable
, List
, Map
, num
, Object
, Set
, String
, Uri
. Если вы хотите использовать типы, которые не поддерживаются из коробки, то у вас есть несколько вариантов решения этой проблемы, о которых вы можете прочитать в документации к пакету.
package:build_runner
При первом создании классов с json_serializable вы получите следующие ошибки:
И эти ошибки совершенно нормальны просто потому, что сгенерированный код для класса модели ещё не существует.
Чтобы исправить эту проблему, запустите в корне проекта команду генератора кода.
flutter pub run build_runner build --delete-conflicting-outputs
Она запускает однократную сборку, которая просматривает исходные файлы проекта, выбирает нужные файлы моделей и генерирует для них необходимый код сериализации JSON.
Обмен данными с помощью WebSockets
Как мы помним, протокол HTTP работает по принципу «запрос — ответ». Чтобы получить новую информацию от сервера, клиент должен направить ему запрос, каждый раз устанавливая и закрывая соединение с сервером.
Такой подход имеет два главных недостатка:
- чтобы клиент узнал о новых данных, он должен опрашивать сервер с некоторой периодичностью, что может быть труднодостижимо как с архитектурной точки зрения, так и с учётом возрастающей нагрузки на сервер;
- процесс передачи данных происходит с некоторыми задержками за счёт того, что есть накладные расходы на установку нового соединения при каждом запросе;
Однако многим приложениям важно получать данные в реальном времени — например, мессенджерам или торговым платформам. Для этого используются веб-сокеты.
Веб-сокеты (WebSockets) — это технология, с помощью которой вы можете создать соединение между клиентом и сервером для обмена сообщениями в реальном времени.
Главное отличие веб-сокетов заключается в том, что они позволяют получать данные без необходимости отправлять отдельный запрос, как это происходит в HTTP. После установки соединения данные будут приходить сами, не требуя отправки запроса.
WebSocket dart:io
Для работы с веб-сокетами библиотека dart:io предоставляет нам класс WebSocket
.
⚠️ Внимание!
WebSocket
из dart:io работает только для мобильных и десктопных платформ. Чтобы использовать веб-сокеты для веб-приложений, используйте библиотеку dart:html.
-
Подключение к веб-сокету
Создайте нового клиента и подключитесь к каналу с помощьюWebSocket.connect()
.final socket = await WebSocket.connect('ws://mysite.ru/user/ws');
-
Получение сообщения или ошибки
Слушайте входящие сообщения и ошибки с помощьюonMessage
иonError
.socket.listen( (message) => print('Received message: $message'), onError: (error) => print('Received error: $error'), );
-
Отправка сообщения на сервер
Используйте методadd
для отправки сообщений на сервер.socket.add(message);
-
Закрытие соединения
Используйте методclose
для закрытия соединения.await socket.close();
Если вы разрабатываете приложение только для одной платформы (мобильной, десктопной или веб), то можете безопасно использовать WebSocket
из dart:io или dart:html. Но если возникнет необходимость в компиляции сразу под несколько платформ, вы столкнётесь с проблемой.
Чтобы её избежать, команда Dart создала библиотеку web_socket_channel, которая абстрагирует и упрощает логику dart:io и dart:html, позволяя использовать один класс для создания мультиплатформенного приложения.
package:web_socket_channel
Пакет web_socket_channel предоставляет кроссплатформенную реализацию, обёртывающую класс WebSocket
из dart:io и dart:html.
-
Добавление зависимости
Добавьте плагин web_socket_channel в файл pubspec.yaml.dependencies: web_socket_channel: <latest_version>
-
Подключение к веб-сокету
Создайте нового клиента с помощьюWebSocketChannel
и подключитесь к каналу с помощью методаconnect
.final url = Uri.parse('ws://mysite.ru/user/ws'); final channel = WebSocketChannel.connect(url);
-
Получение сообщения или ошибки
Слушайте входящие сообщения и ошибки с помощьюstream
.channel.stream.listen( (message) => print('Received message: $message'), onError: (error) => print('Received error: $error'), );
-
Отправка сообщения на сервер
Используйте методadd
для отправки сообщений на сервер.channel.sink.add(message);
-
Закрытие соединения
Используйте методclose
для закрытия соединения.channel.sink.close();
Persistence — хранение данных простыми инструментами
В некоторых случаях необходимо сохранять данные с сервера. Например, чтобы использовать их в автономном режиме, когда отсутствует доступ к интернету. Для этого можно воспользоваться такими простыми инструментами, как чтение и запись файлов на диск или хранение коллекций «ключ-значение».
Чтение и запись файлов
Чтобы сохранить данные на диск, нам потребуется плагин path_provider и библиотека dart:io.
⚠️ Внимание!
Данный способ не подходит для разработки веб-приложений. При разработке на веб используйте Window.localStorage
или Window.sessionStorage
из библиотеки dart:html.
-
Добавление зависимости
Добавьте плагин path_provider в файл pubspec.yaml.dependencies: path_provider: <latest_version>
-
Поиск правильного локального пути
Пакет path_provider предоставляет независимый от платформы доступ к часто используемым местам в файловой системе устройства. Плагин поддерживает доступ к нескольким местоположениям файловой системы, но мы рассмотрим наиболее подходящий вариант для хранения данных — документы приложения (Application Documents).
Документы приложения — это каталог, в котором приложение может хранить файлы, доступ к которым есть только у него. Система очищает этот каталог только при удалении самого приложения. В iOS это соответствуетNSDocumentDirector
, в Android это каталогAppData
.
Вы можете найти путь к документам приложения следующим образом:Future<String> get _localPath async { final directory = await getApplicationDocumentsDirectory(); return directory.path; }
-
Создание ссылки на расположение файла
Как только вы узнаете расположение документов приложения, создайте ссылку на местоположение файла. Для этого вы можете использовать классFile
из библиотеки dart:io.Future<File> get _localFile async { final path = await _localPath; return File('$path/user_data.txt'); }
-
Запись данных в файл
Теперь, когда у нас есть файл для работы, используйте его для чтения и записи данных. Сначала запишите данные в файл.Future<File> writeUserData(User user) async { final file = await _localFile; return file.writeAsString(jsonEncode(user)); }
-
Чтение данных из файла
Теперь, когда у вас есть данные на диске, вы можете их прочитать.Future<User> readUserData() async { final file = await _localFile; final userData = await file.readAsString(); return User.fromJson(jsonDecode(userData)); }
Хранить данные в файлах и работать с ними не всегда удобно. Можете рассмотреть альтернативный вариант — хранить данные в нативном хранилище «ключ-значение» с помощью пакета shared_preferences.
package:shared_preferences
Если у вас есть относительно небольшая коллекция ключей и значений для сохранения, вы можете использовать плагин shared_preferences. Этот плагин оборачивает нативное хранилище данных NSUserDefaults
(на iOS и macOS) и SharedPreferences
(на Android), обеспечивая хранение для простых данных.
-
Добавление зависимости
Добавьте плагин shared_preferences в файл pubspec.yaml.dependencies: shared_preferences: <latest_version>
-
Инициализация SharedPreferences
Чтобы начать работу сSharedPreferences
, необходимо получить его инстанс.final prefs = await SharedPreferences.getInstance();
-
Запись данных
Для сохранения данных используйте set-методы.
Эти методы делают две вещи: синхронно обновляют пару «ключ-значение» в памяти приложения, затем сохраняют данные на диск.await prefs.setInt('userId', userId); await prefs.setBool('userValid', userValid); await prefs.setString('userName', userName); await prefs.setDouble('userRating', userRating); await prefs.setStringList('userRoles', userRoles);
-
Чтение данных
Для чтения данных используйте get-методы.final int? userId = prefs.getInt('userId'); final bool? userValid = prefs.getBool('userValid'); final String? userName = prefs.getString('userName'); final double? userRating = prefs.getDouble('userRating'); final List<String>? userRoles = prefs.getStringList('userRoles');
-
Удаление данных
Для удаления данных используйте методremove
.await prefs.remove('counter');
Хотя хранилище «ключ-значение» простое и удобное в использовании, а также поддерживает все доступные платформы, у него есть ряд ограничений:
- можно использовать только следующие типы данных:
int
,double
,bool
,String
иList<String>
; - оно не предназначено для хранения большого количества данных;
- оно больше подходит для данных, которые часто читаются, но редко обновляются;
- хранить конфиденциальные данные не безопасно, для этого стоит использовать плагин flutter_secure_storage или шифровать данные перед сохранением с помощью encrypt/flutter_string_encrypt.
Для хранения больших и конфиденциальных данных вам стоит использовать другие подходы, о которых мы поговорим уже в следующих статьях.
Заключение
В этом параграфе мы провели разбор основных способов и технологий передачи данных между клиентом и сервером, а также рассмотрели простые методы хранения информации. Выбор подходящего метода будет зависеть от сложности и требований конкретного проекта. Однако понимание основ работы с данными, механизмов отправки и получения запросов, а также методов их обработки и хранения является неотъемлемым элементом разработки современных приложений.