Перейти до вмісту

Пошук GitHub

advanced

У наступному посібнику ми створимо додаток пошуку GitHub у Flutter та AngularDart, щоб продемонструвати, як ми можемо спільно використовувати шари даних та бізнес-логіки між двома проєктами.

demo

demo

  • BlocProvider, Flutter віджет, який надає bloc своїм нащадкам.
  • BlocBuilder, Flutter віджет, який обробляє побудову віджета у відповідь на нові стани.
  • Використання Cubit замість Bloc. У чому різниця?
  • Запобігання непотрібним перебудовам за допомогою Equatable.
  • Використання користувацького EventTransformer з bloc_concurrency.
  • Виконання мережевих запитів за допомогою пакету http.

Спільна бібліотека пошуку GitHub

Section titled “Спільна бібліотека пошуку GitHub”

Спільна бібліотека пошуку GitHub міститиме моделі, провайдер даних, сховище, а також bloc, який буде спільно використовуватися між AngularDart та Flutter.

Ми почнемо зі створення нової директорії для нашого додатку.

Terminal window
mkdir -p github_search/common_github_search

Нам потрібно створити pubspec.yaml з необхідними залежностями.

common_github_search/pubspec.yaml
name: common_github_search
description: Shared Code between AngularDart and Flutter
version: 1.0.0+1
publish_to: none
environment:
sdk: ">=3.10.0 <4.0.0"
dependencies:
bloc: ^9.0.0
equatable: ^2.0.0
http: ^1.0.0
stream_transform: ^2.0.0
dev_dependencies:
bloc_lint: ^0.3.0

Нарешті, нам потрібно встановити наші залежності.

Terminal window
dart pub get

Це все для налаштування проєкту! Тепер ми можемо приступити до роботи над створенням пакету common_github_search.

GithubClient надаватиме необроблені дані з GitHub API.

Давайте створимо github_client.dart.

common_github_search/lib/src/github_client.dart
import 'dart:async';
import 'dart:convert';
import 'package:common_github_search/common_github_search.dart';
import 'package:http/http.dart' as http;
class GithubClient {
GithubClient({
http.Client? httpClient,
this.baseUrl = 'https://api.github.com/search/repositories?q=',
}) : _httpClient = httpClient ?? http.Client();
final String baseUrl;
final http.Client _httpClient;
Future<SearchResult> search(String term) async {
final response = await _httpClient.get(Uri.parse('$baseUrl$term'));
final results = json.decode(response.body) as Map<String, dynamic>;
if (response.statusCode == 200) {
return SearchResult.fromJson(results);
} else {
throw SearchResultError.fromJson(results);
}
}
void close() {
_httpClient.close();
}
}

Далі нам потрібно визначити наші моделі SearchResult та SearchResultError.

Модель результату пошуку

Section titled “Модель результату пошуку”

Створіть search_result.dart, який представляє список SearchResultItems на основі запиту користувача:

lib/src/models/search_result.dart
import 'package:common_github_search/common_github_search.dart';
class SearchResult {
const SearchResult({required this.items});
factory SearchResult.fromJson(Map<String, dynamic> json) {
final items = (json['items'] as List<dynamic>)
.map(
(dynamic item) =>
SearchResultItem.fromJson(item as Map<String, dynamic>),
)
.toList();
return SearchResult(items: items);
}
final List<SearchResultItem> items;
}

Модель елемента результату пошуку

Section titled “Модель елемента результату пошуку”

Далі ми створимо search_result_item.dart.

lib/src/models/search_result_item.dart
import 'package:common_github_search/common_github_search.dart';
class SearchResultItem {
const SearchResultItem({
required this.fullName,
required this.htmlUrl,
required this.owner,
});
factory SearchResultItem.fromJson(Map<String, dynamic> json) {
return SearchResultItem(
fullName: json['full_name'] as String,
htmlUrl: json['html_url'] as String,
owner: GithubUser.fromJson(json['owner'] as Map<String, dynamic>),
);
}
final String fullName;
final String htmlUrl;
final GithubUser owner;
}

Модель користувача GitHub

Section titled “Модель користувача GitHub”

Далі ми створимо github_user.dart.

lib/src/models/github_user.dart
class GithubUser {
const GithubUser({
required this.login,
required this.avatarUrl,
});
factory GithubUser.fromJson(Map<String, dynamic> json) {
return GithubUser(
login: json['login'] as String,
avatarUrl: json['avatar_url'] as String,
);
}
final String login;
final String avatarUrl;
}

На цьому етапі ми завершили реалізацію SearchResult та його залежностей. Тепер перейдемо до SearchResultError.

Модель помилки результату пошуку

Section titled “Модель помилки результату пошуку”

Створіть search_result_error.dart.

lib/src/models/search_result_error.dart
class SearchResultError implements Exception {
SearchResultError({required this.message});
factory SearchResultError.fromJson(Map<String, dynamic> json) {
return SearchResultError(
message: json['message'] as String,
);
}
final String message;
}

Наш GithubClient завершений, тому далі ми перейдемо до GithubCache, який відповідатиме за мемоізацію як оптимізацію продуктивності.

Наш GithubCache відповідатиме за запам’ятовування всіх минулих запитів, щоб ми могли уникнути непотрібних мережевих запитів до GitHub API. Це також допоможе покращити продуктивність нашого додатку.

Створіть github_cache.dart.

lib/src/github_cache.dart
import 'package:common_github_search/common_github_search.dart';
class GithubCache {
final _cache = <String, SearchResult>{};
SearchResult? get(String term) => _cache[term];
void set(String term, SearchResult result) => _cache[term] = result;
bool contains(String term) => _cache.containsKey(term);
void remove(String term) => _cache.remove(term);
void close() {
_cache.clear();
}
}

Тепер ми готові створити наш GithubRepository!

Github Repository відповідає за створення абстракції між шаром даних (GithubClient) та шаром бізнес-логіки (Bloc). Тут також ми будемо використовувати наш GithubCache.

Створіть github_repository.dart.

lib/src/github_repository.dart
import 'dart:async';
import 'package:common_github_search/common_github_search.dart';
class GithubRepository {
GithubRepository({GithubCache? cache, GithubClient? client})
: _cache = cache ?? GithubCache(),
_client = client ?? GithubClient();
final GithubCache _cache;
final GithubClient _client;
Future<SearchResult> search(String term) async {
final cachedResult = _cache.get(term);
if (cachedResult != null) {
return cachedResult;
}
final result = await _client.search(term);
_cache.set(term, result);
return result;
}
void dispose() {
_cache.close();
_client.close();
}
}

На цьому етапі ми завершили шар провайдера даних та шар сховища, тому ми готові перейти до шару бізнес-логіки.

Наш Bloc буде сповіщений, коли користувач введе назву репозиторію, що ми представимо як TextChanged GithubSearchEvent.

Створіть github_search_event.dart.

lib/src/github_search_bloc/github_search_event.dart
import 'package:equatable/equatable.dart';
sealed class GithubSearchEvent extends Equatable {
const GithubSearchEvent();
}
final class TextChanged extends GithubSearchEvent {
const TextChanged({required this.text});
final String text;
@override
List<Object> get props => [text];
@override
String toString() => 'TextChanged { text: $text }';
}

Наш шар представлення повинен мати кілька частин інформації, щоб правильно себе відобразити:

  • SearchStateEmpty — повідомить шару представлення, що користувач не надав жодного введення.

  • SearchStateLoading — повідомить шару представлення, що він повинен відобразити якийсь індикатор завантаження.

  • SearchStateSuccess — повідомить шару представлення, що він має дані для відображення.

    • items — буде List<SearchResultItem>, який буде відображено.
  • SearchStateError — повідомить шару представлення, що виникла помилка при отриманні репозиторіїв.

    • error — буде точна помилка, яка виникла.

Тепер ми можемо створити github_search_state.dart і реалізувати його наступним чином.

lib/src/github_search_bloc/github_search_state.dart
import 'package:common_github_search/common_github_search.dart';
import 'package:equatable/equatable.dart';
sealed class GithubSearchState extends Equatable {
const GithubSearchState();
@override
List<Object> get props => [];
}
final class SearchStateEmpty extends GithubSearchState {}
final class SearchStateLoading extends GithubSearchState {}
final class SearchStateSuccess extends GithubSearchState {
const SearchStateSuccess(this.items);
final List<SearchResultItem> items;
@override
List<Object> get props => [items];
@override
String toString() => 'SearchStateSuccess { items: ${items.length} }';
}
final class SearchStateError extends GithubSearchState {
const SearchStateError(this.error);
final String error;
@override
List<Object> get props => [error];
}

Тепер, коли ми реалізували наші події та стани, ми можемо створити наш GithubSearchBloc.

Створіть github_search_bloc.dart:

lib/src/github_search_bloc/github_search_bloc.dart
import 'package:bloc/bloc.dart';
import 'package:common_github_search/common_github_search.dart';
import 'package:stream_transform/stream_transform.dart';
const _duration = Duration(milliseconds: 300);
EventTransformer<Event> debounce<Event>(Duration duration) {
return (events, mapper) => events.debounce(duration).switchMap(mapper);
}
class GithubSearchBloc extends Bloc<GithubSearchEvent, GithubSearchState> {
GithubSearchBloc({required GithubRepository githubRepository})
: _githubRepository = githubRepository,
super(SearchStateEmpty()) {
on<TextChanged>(_onTextChanged, transformer: debounce(_duration));
}
final GithubRepository _githubRepository;
Future<void> _onTextChanged(
TextChanged event,
Emitter<GithubSearchState> emit,
) async {
final searchTerm = event.text;
if (searchTerm.isEmpty) return emit(SearchStateEmpty());
emit(SearchStateLoading());
try {
final results = await _githubRepository.search(searchTerm);
emit(SearchStateSuccess(results.items));
} catch (error) {
emit(
error is SearchResultError
? SearchStateError(error.message)
: const SearchStateError('something went wrong'),
);
}
}
}

Чудово! Ми завершили наш пакет common_github_search. Готовий продукт повинен виглядати як цей.

Далі ми працюватимемо над реалізацією на Flutter.

Flutter Github Search буде додатком Flutter, який повторно використовує моделі, провайдери даних, сховища та блоки з common_github_search для реалізації пошуку Github.

Нам потрібно почати зі створення нового проєкту Flutter у нашій директорії github_search на тому ж рівні, що й common_github_search.

Terminal window
flutter create flutter_github_search

Далі нам потрібно оновити наш pubspec.yaml, щоб включити всі необхідні залежності.

flutter_github_search/pubspec.yaml
name: flutter_github_search
description: A new Flutter project.
version: 1.0.0+1
publish_to: none
environment:
sdk: ">=3.10.0 <4.0.0"
dependencies:
bloc: ^9.0.0
common_github_search:
path: ../common_github_search
flutter:
sdk: flutter
flutter_bloc: ^9.0.1
url_launcher: ^6.0.0
flutter:
uses-material-design: true
dev_dependencies:
bloc_lint: ^0.3.0

Тепер нам потрібно встановити залежності.

Terminal window
flutter pub get

Це все для налаштування проєкту. Оскільки пакет common_github_search містить наш шар даних, а також наш шар бізнес-логіки, все, що нам потрібно побудувати — це шар представлення.

Нам потрібно створити форму з віджетами _SearchBar та _SearchBody.

  • _SearchBar відповідатиме за отримання введення від користувача.
  • _SearchBody відповідатиме за відображення результатів пошуку, індикаторів завантаження та помилок.

Давайте створимо search_form.dart.

Наш SearchForm буде StatelessWidget, який відображає віджети _SearchBar та _SearchBody.

_SearchBar також буде StatefulWidget, тому що йому потрібно підтримувати свій власний TextEditingController, щоб ми могли відстежувати, що користувач ввів.

_SearchBody — це StatelessWidget, який відповідатиме за відображення результатів пошуку, помилок та індикаторів завантаження. Він буде споживачем GithubSearchBloc.

Якщо наш стан SearchStateSuccess, ми відображаємо _SearchResults, який ми реалізуємо далі.

_SearchResults — це StatelessWidget, який приймає List<SearchResultItem> і відображає їх як список _SearchResultItems.

_SearchResultItem — це StatelessWidget, який відповідає за відображення інформації про один результат пошуку. Він також відповідає за обробку взаємодії користувача та навігацію за URL-адресою репозиторію при натисканні користувача.

flutter_github_search/lib/search_form.dart
import 'package:common_github_search/common_github_search.dart';
import 'package:flutter/material.dart';
import 'package:flutter_bloc/flutter_bloc.dart';
import 'package:url_launcher/url_launcher.dart';
class SearchForm extends StatelessWidget {
const SearchForm({super.key});
@override
Widget build(BuildContext context) {
return Column(
children: <Widget>[
_SearchBar(),
_SearchBody(),
],
);
}
}
class _SearchBar extends StatefulWidget {
@override
State<_SearchBar> createState() => _SearchBarState();
}
class _SearchBarState extends State<_SearchBar> {
final _textController = TextEditingController();
late GithubSearchBloc _githubSearchBloc;
@override
void initState() {
super.initState();
_githubSearchBloc = context.read<GithubSearchBloc>();
}
@override
void dispose() {
_textController.dispose();
super.dispose();
}
@override
Widget build(BuildContext context) {
return TextField(
controller: _textController,
autocorrect: false,
onChanged: (text) {
_githubSearchBloc.add(
TextChanged(text: text),
);
},
decoration: InputDecoration(
prefixIcon: const Icon(Icons.search),
suffixIcon: GestureDetector(
onTap: _onClearTapped,
child: const Icon(Icons.clear),
),
border: InputBorder.none,
hintText: 'Enter a search term',
),
);
}
void _onClearTapped() {
_textController.text = '';
_githubSearchBloc.add(const TextChanged(text: ''));
}
}
class _SearchBody extends StatelessWidget {
@override
Widget build(BuildContext context) {
return BlocBuilder<GithubSearchBloc, GithubSearchState>(
builder: (context, state) {
return switch (state) {
SearchStateEmpty() => const Text('Please enter a term to begin'),
SearchStateLoading() => const CircularProgressIndicator.adaptive(),
SearchStateError() => Text(state.error),
SearchStateSuccess() =>
state.items.isEmpty
? const Text('No Results')
: Expanded(child: _SearchResults(items: state.items)),
};
},
);
}
}
class _SearchResults extends StatelessWidget {
const _SearchResults({required this.items});
final List<SearchResultItem> items;
@override
Widget build(BuildContext context) {
return ListView.builder(
itemCount: items.length,
itemBuilder: (BuildContext context, int index) {
return _SearchResultItem(item: items[index]);
},
);
}
}
class _SearchResultItem extends StatelessWidget {
const _SearchResultItem({required this.item});
final SearchResultItem item;
@override
Widget build(BuildContext context) {
return ListTile(
leading: CircleAvatar(
child: Image.network(item.owner.avatarUrl),
),
title: Text(item.fullName),
onTap: () => launchUrl(Uri.parse(item.htmlUrl)),
);
}
}

Тепер залишилося лише реалізувати наш головний додаток у main.dart.

flutter_github_search/lib/main.dart
import 'package:common_github_search/common_github_search.dart';
import 'package:flutter/material.dart';
import 'package:flutter_bloc/flutter_bloc.dart';
import 'package:flutter_github_search/search_form.dart';
void main() => runApp(const App());
class App extends StatelessWidget {
const App({super.key});
@override
Widget build(BuildContext context) {
return RepositoryProvider(
create: (_) => GithubRepository(),
dispose: (repository) => repository.dispose(),
child: MaterialApp(
title: 'GitHub Search',
home: Scaffold(
appBar: AppBar(title: const Text('GitHub Search')),
body: BlocProvider(
create: (context) => GithubSearchBloc(
githubRepository: context.read<GithubRepository>(),
),
child: const SearchForm(),
),
),
),
);
}
}

Ось і все! Ми успішно реалізували додаток пошуку GitHub у Flutter, використовуючи пакети bloc та flutter_bloc, і ми успішно відокремили наш шар представлення від нашої бізнес-логіки.

Повний вихідний код можна знайти тут.

Нарешті, ми створимо наш додаток AngularDart GitHub Search.

AngularDart GitHub Search буде додатком AngularDart, який повторно використовує моделі, провайдери даних, сховища та блоки з common_github_search для реалізації пошуку Github.

Нам потрібно почати зі створення нового проєкту AngularDart у нашій директорії github_search на тому ж рівні, що й common_github_search.

Terminal window
stagehand web-angular

Потім ми можемо замінити вміст pubspec.yaml на:

angular_github_search/pubspec.yaml
name: angular_github_search
description: A web app that uses AngularDart Components
environment:
sdk: ">=3.10.0 <4.0.0"
dependencies:
angular_bloc: ^10.0.0-dev.5
bloc: ^9.0.0
common_github_search:
path: ../common_github_search
ngdart: ^8.0.0-dev.4
dev_dependencies:
build_daemon: ^4.0.0
build_runner: ^2.0.0
build_web_compilers: ^4.0.0

Так само, як і в нашому додатку Flutter, нам потрібно створити SearchForm з компонентами SearchBar та SearchBody.

Наш компонент SearchForm реалізовуватиме OnInit та OnDestroy, тому що йому потрібно створити та закрити GithubSearchBloc.

  • SearchBar відповідатиме за отримання введення від користувача.
  • SearchBody відповідатиме за відображення результатів пошуку, індикаторів завантаження та помилок.

Давайте створимо search_form_component.dart.

angular_github_search/lib/src/search_form/search_form_component.dart
import 'package:angular_bloc/angular_bloc.dart';
import 'package:angular_github_search/src/github_search.dart';
import 'package:common_github_search/common_github_search.dart';
import 'package:ngdart/angular.dart';
@Component(
selector: 'search-form',
templateUrl: 'search_form_component.html',
directives: [
SearchBarComponent,
SearchBodyComponent,
],
pipes: [BlocPipe],
)
class SearchFormComponent implements OnInit, OnDestroy {
@Input()
late GithubRepository githubRepository;
late GithubSearchBloc githubSearchBloc;
@override
void ngOnInit() {
githubSearchBloc = GithubSearchBloc(
githubRepository: githubRepository,
);
}
@override
void ngOnDestroy() {
githubSearchBloc.close();
}
}

Наш шаблон (search_form_component.html) буде виглядати так:

angular_github_search/lib/src/search_form/search_form_component.html
<div>
<h1>GitHub Search</h1>
<search-bar [githubSearchBloc]="githubSearchBloc"></search-bar>
<search-body [state]="$pipe.bloc(githubSearchBloc)"></search-body>
</div>

Далі ми реалізуємо компонент SearchBar.

SearchBar — це компонент, який відповідатиме за отримання введення від користувача та сповіщення GithubSearchBloc про зміни тексту.

Створіть search_bar_component.dart.

angular_github_search/lib/src/search_form/search_bar/search_bar_component.dart
import 'package:common_github_search/common_github_search.dart';
import 'package:ngdart/angular.dart';
@Component(
selector: 'search-bar',
templateUrl: 'search_bar_component.html',
)
class SearchBarComponent {
@Input()
late GithubSearchBloc githubSearchBloc;
void onTextChanged(String text) {
githubSearchBloc.add(TextChanged(text: text));
}
}

Далі ми можемо створити search_bar_component.html.

angular_github_search/lib/src/search_form/search_bar/search_bar_component.html
<label for="term" class="clip">Enter a search term</label>
<input
id="term"
placeholder="Enter a search term"
class="input-reset outline-transparent glow o-50 bg-near-black near-white w-100 pv2 border-box b--white-50 br-0 bl-0 bt-0 bb-ridge mb3"
autofocus
(keyup)="onTextChanged($event.target.value)"
/>

Ми закінчили з SearchBar, тепер переходимо до SearchBody.

SearchBody — це компонент, який відповідатиме за відображення результатів пошуку, помилок та індикаторів завантаження. Він буде споживачем GithubSearchBloc.

Створіть search_body_component.dart.

angular_github_search/lib/src/search_form/search_body/search_body_component.dart
import 'package:angular_github_search/src/github_search.dart';
import 'package:common_github_search/common_github_search.dart';
import 'package:ngdart/angular.dart';
@Component(
selector: 'search-body',
templateUrl: 'search_body_component.html',
directives: [
coreDirectives,
SearchResultsComponent,
],
)
class SearchBodyComponent {
@Input()
late GithubSearchState state;
bool get isEmpty => state is SearchStateEmpty;
bool get isLoading => state is SearchStateLoading;
bool get isSuccess => state is SearchStateSuccess;
bool get isError => state is SearchStateError;
List<SearchResultItem> get items =>
isSuccess ? (state as SearchStateSuccess).items : [];
String get error => isError ? (state as SearchStateError).error : '';
}

Створіть search_body_component.html.

angular_github_search/lib/src/search_form/search_body/search_body_component.html
<div *ngIf="state != null" class="mw10">
<div *ngIf="isEmpty" class="tc">
<span>🔍</span>
<p>Please enter a term to begin</p>
</div>
<div *ngIf="isLoading">
<div class="sk-chase center">
<div class="sk-chase-dot"></div>
<div class="sk-chase-dot"></div>
<div class="sk-chase-dot"></div>
<div class="sk-chase-dot"></div>
<div class="sk-chase-dot"></div>
<div class="sk-chase-dot"></div>
</div>
</div>
<div *ngIf="isError" class="tc">
<span>‼️</span>
<p>{{ error }}</p>
</div>
<div *ngIf="isSuccess">
<div *ngIf="items.length == 0" class="tc">
<span>⚠️</span>
<p>No Results</p>
</div>
<search-results [items]="items"></search-results>
</div>
</div>

Якщо наш стан isSuccess, ми відображаємо SearchResults. Ми реалізуємо його далі.

SearchResults — це компонент, який приймає List<SearchResultItem> і відображає їх як список SearchResultItems.

Створіть search_results_component.dart.

angular_github_search/lib/src/search_form/search_body/search_results/search_results_component.dart
import 'package:angular_github_search/src/github_search.dart';
import 'package:common_github_search/common_github_search.dart';
import 'package:ngdart/angular.dart';
@Component(
selector: 'search-results',
templateUrl: 'search_results_component.html',
directives: [coreDirectives, SearchResultItemComponent],
)
class SearchResultsComponent {
@Input()
late List<SearchResultItem> items;
}

Далі ми створимо search_results_component.html.

angular_github_search/lib/src/search_form/search_body/search_results/search_results_component.html
<ul class="list pa0 ma0">
<li *ngFor="let item of items" class="pa2 cf">
<search-result-item [item]="item"></search-result-item>
</li>
</ul>

Час реалізувати SearchResultItem.

Елемент результату пошуку

Section titled “Елемент результату пошуку”

SearchResultItem — це компонент, який відповідає за відображення інформації про один результат пошуку. Він також відповідає за обробку взаємодії користувача та навігацію за URL-адресою репозиторію при натисканні користувача.

Створіть search_result_item_component.dart.

angular_github_search/lib/src/search_form/search_body/search_results/search_result_item/search_result_item_component.dart
import 'package:common_github_search/common_github_search.dart';
import 'package:ngdart/angular.dart';
@Component(
selector: 'search-result-item',
templateUrl: 'search_result_item_component.html',
)
class SearchResultItemComponent {
@Input()
late SearchResultItem item;
}

та відповідний шаблон у search_result_item_component.html.

angular_github_search/lib/src/search_form/search_body/search_results/search_result_item/search_result_item_component.html
<div class="fl w-10 h-auto">
<img class="br-100" src="{{ item.owner.avatarUrl }}" />
</div>
<div class="fl w-90 ph3">
<h1 class="f5 ma0">{{ item.fullName }}</h1>
<p>
<a href="{{ item.htmlUrl }}" class="light-blue" target="_blank">{{
item.htmlUrl
}}</a>
</p>
</div>

У нас є всі наші компоненти, і тепер настав час зібрати їх усі разом в нашому app_component.dart.

angular_github_search/lib/app_component.dart
import 'package:angular_github_search/src/github_search.dart';
import 'package:common_github_search/common_github_search.dart';
import 'package:ngdart/angular.dart';
@Component(
selector: 'my-app',
template: '<search-form [githubRepository]="githubRepository"></search-form>',
directives: [SearchFormComponent],
)
class AppComponent {
final githubRepository = GithubRepository();
}

Ось і все! Ми успішно реалізували додаток пошуку GitHub в AngularDart, використовуючи пакети bloc та angular_bloc, і ми успішно відокремили наш шар представлення від нашої бізнес-логіки.

Повний вихідний код можна знайти тут.

У цьому посібнику ми створили додаток Flutter та AngularDart, спільно використовуючи всі моделі, провайдери даних та блоки між ними.

Єдине, що нам дійсно довелося написати двічі — це шар представлення (UI), що чудово з точки зору ефективності та швидкості розробки. Крім того, досить поширено, коли веб-додатки та мобільні додатки мають різний користувацький досвід та стилі, і цей підхід дійсно демонструє, наскільки легко створити два додатки, які виглядають абсолютно по-різному, але спільно використовують ті самі шари даних та бізнес-логіки.

Повний вихідний код можна знайти тут.