gRPC-автогенерація Front-end-у
Привіт, мене звати Ярослав. Я працюю розробником у компанії Evrius . У цій статті розглянємо автогенерацію клієнт-серверної взаємодії на основі добре відомого прикладу, що зацікавить веброзробників.
Маленький ліричний відступ
Це вже п'ятдесят п'ята моя стаття на DOU, і, звісно, кожну статтю після публікації я надсилав подивитися друзям і колегам, щоб отримати зворотний зв'язок.
Здебільшого статті друзям подобалися, але й частку критики вдалося здобути: так дізнався, що статті «сухі». І справді, статті схожі на мій код (так само мало коментарів) або на інструкцію, як доїхати від Києва до Львова й назад на велосипеді (знаю лише одного велотуриста, що так може).
Ця стаття теж буде інструкцією, та цього разу писатиму більше пояснень і думок.
Ще одна відмінність від уже написаних статей у тому, що раніше я розглядав завдання, які вже розв'язків язав, тому процес написання складався з підготовки прикладів коду й подальшого написання статті на основі вже готових прикладів. А в цій публікації мені ще самому треба буде розібратися з grpc-web і зробити інструкцію, яку зможу в майбутньому використовувати.
Чому важлива автогенерація
Автогенерація коду — це перекладання однотипної роботи на комп'ютер (як і має бути) або спосіб уникнення помилок-одруків під час копіювання коду (навіть досвідчені спеціалісти помиляються).
Процес розробки й однотипна робота
Коли тільки починаєш працювати на новій роботі, та ще й з новими технологіями, то є азарт, усе цікаво, працювати приємно й комфортно.
Звикаєш до інструментів: IDE, тестів, CI/CD, каркасу з командами для генерації шаблонів DB-міграцій, CRUD-генераторів і ApiDoc-генераторів.
Звикаєш до того, що процес розробки налаштовано і можна зосередитися на логіці. Звісно, є винятки. Для мене такими були валідації, які робив на стороні сервера, а потім копіював на клієнт, і API методи з моделями, які спершу робив на сервері й знову копіював на клієнт у браузері.
Так, процес розробки, як смуги на зебрі: білі — то цікава розробка нової логіки, а темні — однотипне копіювання вже наявної логічної структури й адаптування під нові критерії (зазвичай таке з радістю роблять початківці).
Правильні метафори
Щоб зрозуміло пояснити технологію, треба вибрати всім добре знайомий приклад.
Сайт з новинами LamerNews використовується як приклад для пояснення Redis-у.
У книжках про архітектуру я часто зустрічав приклади облікових систем . У цій статті для прикладу я виберу форум .
gRPC для міжсервісної взаємодії
gRPC — високопродуктивний каркас для взаємодії між сервісами, що дає можливість згенерувати код клієнта й сервера на основі файлів з розширенням .proto .
Код клієнта й сервера можна згенерувати для різних мов програмування. Proto-файли містять у собі опис повідомлень, що відправляються та отримуються у форматі protobuf . Якщо ви чуєте про protobuf уперше, то вважайте, що це альтернатива JSON-у.
Розгляньмо на основі прикладу про форум , який вигляд матиме proto-файл, де будуть методи для створення нової тими та редагування вже наявної:
syntax = "proto3"; service Forum { rpc CreateTopic (CreateTopicRequest) returns (UpdateTopicResponse); rpc UpdateTopic (UpdateTopicRequest) returns (UpdateTopicResponse); } message UpdateTopicResponse { bool success = 1; string error_message = 2; } message CreateTopicRequest { string title = 1; string description = 2; } message UpdateTopicRequest { uint32 code = 1; string title = 2; string description = 3; }
Ідентифікатори-назви сервісу та повідомлень можуть бути довільними, індекси мають бути унікальними й використовуватися для серіалізації, і цей приклад може мати також інакший вигляд, застосовуючи одну структуру для створення та оновлення:
syntax = "proto3"; service ForumService { rpc TopicCreate (UpdateTopicRequest) returns (UpdateTopicResponse); rpc TopicUpdate (UpdateTopicRequest) returns (UpdateTopicResponse); } message UpdateTopicResponse { bool success = 1; string error_message = 2; } message UpdateTopicRequest { uint32 code = 1; string title = 2; string description = 3; }
Або навіть так, виділивши дані в окреме повідомлення і використовуючи як спільний код:
syntax = "proto3"; service ForumService { rpc TopicCreate (CreateTopicRequest) returns (UpdateTopicResponse); rpc TopicUpdate (UpdateTopicRequest) returns (UpdateTopicResponse); } message UpdateTopicResponse { bool success = 1; string error_message = 2; } message Topic { string title = 1; string description = 2; } message CreateTopicRequest { Topic data = 1; } message UpdateTopicRequest { uint32 code = 1; Topic data = 2; }
Я вибрав би перший або другий варіант, коли повідомлення мають мало полів, і третій — коли повідомлення вже має (чи ми сподіваємося, що воно матиме) багато полів.
На основі proto-файлу можна згенерувати повноцінний клієнт під багато платформ; це може бути мікросервіс, мобільний застосунок або ж клієнт у браузері.
Ця стаття саме про клієнт, що застосовуватиметься в браузері.
Як це працює
Ми написали proto-файл forum.proto , на основі якого, за допомогою інструменту protoc , згенерували моделі й інтерфейс; інтерфейс реалізували на сервері, і тепер сервер готовий до використання.
Далі ми копіюємо цей proto-файл forum.proto в репозиторій з Front-end-ом; на його основі за допомогою інструменту protoc і плагіна до нього protoc-gen-grpc-web генеруємо моделі та клієнт, готові до використання.
Коли нам треба буде додати нові поля у вже наявні повідомлення чи додати нові rpc-методи, ми оновимо proto-файл, згенеруємо на сервері код, реалізуємо нову логіку, так само скопіюємо proto-файл forum.proto в репозиторій з Front-end-ом і згенеруємо клієнт.
Таким чином Front-end-розробник матиме готовий до використання gRPC-клієнт, а подивитися proto-файл буде простіше, ніж API документацію.
При описі я зробив спрощення, коли писав про один proto-файл. Зазвичай це тека, де є service.proto, у якому підключаються файли з повідомленнями.
Завдання і технологічний стек
У цій статті я реалізую прототип форуму DOU, заради цікавості додам нових фіч; сервер буде на Go, зберігатиму в MongoDB, запускатиму через docker-compose, а на клієнті буде Vue.js і Webpack.
Якщо ви хочете самі розібратися з gRPC Web, то можете клонувати репозиторій github.com/grpc/grpc-web з простою інструкцією, як запустити :
docker-compose pull docker-compose up browse http://localhost:8081/echotest.html
AJAX-лічильник, від простого до складного
Перед тим, як розглядати приклад з gRPC, розгляньмо простіший.
Візьмемо для прикладу вебсторінку, на якій показуємо число запитів; число запитів отримуватимемо через AJAX, а в наступному прикладі замінимо AJAX на gRPC.
Маємо три файли: index.html для зображення вмісту, counter.js, що робить AJAX-запит, та main.go сервер на Go:
??? main.go ??? public ??? index.html ??? js ??? counter.js
<!DOCTYPE html> <html lang="en"> <head> <meta charset="UTF-8"> <title>AJAX counter example</title> </head> <body> <p> The page was viewed <span id="js-counter">0</span> times </p> <script src="js/counter.js"></script> </body> </html>
{ fetch("/api/counter.json") .then(function (response) { return response.json(); }) .then(function (json) { document.getElementById("js-counter").innerHTML = json.count; }) .catch(console.error); }
package main import ( "encoding/json" "log" "net/http" "sync/atomic" ) type CounterResponse struct { Count uint32 `json:"count"` } func main() { var counter = uint32(0) http.Handle("/", http.FileServer(http.Dir("./public"))) http.HandleFunc("/api/counter.json", func(w http.ResponseWriter, _ *http.Request) { var newCounter = atomic.AddUint32(&counter, 1) json.NewEncoder(w).Encode(&CounterResponse{ Count: newCounter, }) }) err := http.ListenAndServe(":8080", nil) if err != nil { log.Fatal(err) } }
Якщо хочете перевірити приклад, треба мати вже встановлений Golang.
go run main
browse http://localhost:8080/
Код AJAX-прикладу доступний у репозиторії .
gRPC-лічильник
У цьому прикладі ми:
- Налаштуємо кодогенерацію серверної та клієнтської частини.
- Опишемо файл counter.proto , на основі якого згенеруємо код для клієнт-серверної взаємодії.
- На стороні сервера реалізуємо інтерфейс лічильника (інтерфейс згенерований через кодогенерацію).
- На стороні клієнта під'єднаємо згенерований клієнт і зберемо проєкт через Webpack .
Встановити protoc можна з офіційною інструкцією для Ubuntu (на момент написання статті найсвіжіша версія protoc 3.11.4):
PROTOC_ZIP=protoc-3.11.4-linux-x86_64.zip curl -OL https://github.com/protocolbuffers/protobuf/releases/download/v3.11.4/$PROTOC_ZIP sudo unzip -o $PROTOC_ZIP -d /usr/local bin/protoc sudo unzip -o $PROTOC_ZIP -d /usr/local 'include/*' rm -f $PROTOC_ZIP
Інструмента protoc досить для кодогенерації серверної частини на Go.
А вісь для кодогенерації клієнтської частини на JavaScript потрібен плагін protoc-gen-grpc-web , що можна встановити на Ubuntu так:
curl -sSL https://github.com/grpc/grpc-web/releases/download/1.0.7/protoc-gen-grpc-web-1.0.7-linux-x86_64 -o /usr/local/bin/protoc-gen-grpc-web chmod +x /usr/local/bin/protoc-gen-grpc-web
Опишемо counter.proto :
syntax = "proto3"; package counter; message Empty { } message Response { uint32 count = 1; } service CounterSomeServiceName { rpc CountSomeMethodName(Empty) returns (Response); }
Файл counter.proto я розмістив у теці з довільною назвою protos :
~/go/src/gitlab.com/go-yp/grpc-counter ??? protos ??? services ??? counter ??? counter.proto
Назвавши Counter SomeServiceName і Count SomeMethodName, щоб було простіше побачити, які суфікси та префікси додаються після кодогенерації.
Згенеруємо код для серверної частини:
mkdir -p ./models protoc -I . protos/services/counter/*.proto --go_out=plugins=grpc:models
Оскільки кодогенерацію ви будете запускати після оновлення proto-файлів, рекомендую зберігати в Makefile:
proto-server: mkdir -p ./models protoc -I . protos/services/counter/*.proto --go_out=plugins=grpc:models
make proto-server
Після кодогенерації proto-server отримаємо файл counter.pb.go :
~/go/src/gitlab.com/go-yp/grpc-counter ??? Makefile ??? protos ? ??? services ? ??? counter ? ??? counter.proto ??? models ??? protos ??? services ??? counter ??? counter.pb.go [+]
У файлі counter.pb.go буде згенерований код моделей Response і Empty та методи цих моделей:
// Code generated by protoc-gen-go. DO NOT EDIT. // source: protos/services/counter/counter.proto package counter // ... type Response struct { Count uint32 `protobuf:"varint,1,opt,name=count,proto3" json:"count,omitempty"` XXX_NoUnkeyedLiteral struct{} `json:"-"` XXX_unrecognized []byte `json:"-"` XXX_sizecache int32 `json:"-"` } // ... type Empty struct { XXX_NoUnkeyedLiteral struct{} `json:"-"` XXX_unrecognized []byte `json:"-"` XXX_sizecache int32 `json:"-"` } // ...
Методи моделей Response і Empty — звичайні обгортки для реалізації службових інтерфейсів серіалізації protobuf-у через рефлексію, тому я їх прибравши з прикладу.
Також у counter.pb.go нам буде цікавий інтерфейс сервісу та реєстрації:
package counter // ... type CounterSomeServiceNameServer interface { CountSomeMethodName(context.Context, *Empty) (*Response, error) } func RegisterCounterSomeServiceNameServer(s *grpc.Server, srv CounterSomeServiceNameServer) { // ... }
Тепер реалізуємо інтерфейс CounterSomeServiceNameServer :
package main import ( "context" "gitlab.com/go-yp/grpc-counter/models/protos/services/counter" "sync/atomic" ) type counterServer struct { count uint32 } func (s *counterServer) CountSomeMethodName(context.Context, *counter.Empty) (*counter.Response, error) { var newCount = atomic.AddUint32(&s.count, 1) return &counter.Response{ Count: newCount, }, nil } var _ counter.CounterSomeServiceNameServer = new(counterServer)
Реалізуємо й запустимо на 50551-порті gRPC-сервер (також залишимо static-server з прикладу про AJAX-лічильник):
package main import ( "context" "gitlab.com/go-yp/grpc-counter/models/protos/services/counter" "log" "net" "net/http" "sync/atomic" "google.golang.org/grpc" ) type counterServer struct { count uint32 } func (s *counterServer) CountSomeMethodName(context.Context, *counter.Empty) (*counter.Response, error) { var newCount = atomic.AddUint32(&s.count, 1) return &counter.Response{ Count: newCount, }, nil } var ( mainServer counter.CounterSomeServiceNameServer = new(counterServer) ) func main() { go func() { lis, err := net.Listen("tcp", ":50551") if err != nil { log.Fatal(err) } defer lis.Close() grpcServer := grpc.NewServer() counter.RegisterCounterSomeServiceNameServer(grpcServer, mainServer) if err := grpcServer.Serve(lis); err != nil { log.Fatal(err) } }() http.Handle("/", http.FileServer(http.Dir("./public"))) err := http.ListenAndServe(":8080", nil) if err != nil { log.Fatal(err) } }
Зробимо ініціалізацію Golang-проєкту й запустимо сервер:
go mod init go run main.go
~/go/src/gitlab.com/go-yp/grpc-counter ??? go.mod [+] ??? go.sum [+] ??? main.go [+] ??? Makefile ??? protos ? ??? services ? ??? counter ? ??? counter.proto ??? models ??? protos ??? services ??? counter ??? counter.pb.go
gRPC-сервер чекає даних, переданих протоколом HTTP/2, а JavaScript-клієнт у браузері (який ми згенеруємо далі) передає дані протоколом HTTP 1.1; відповідно потрібен проксі, що зможе перетворити один протокол на інший.
Рекомендованим вирішенням є Envoy Proxy , про Envoy можна почитати в DevOps дайджесті або послухати доповідь Envoy as TCP proxy Олега Миколайченка .
Я хотів зробити клієнт-серверну взаємодію напряму — тому знайшов вирішенню, як це здійснити, у статті Proxy gRPC-Web directly in your Go Server .
package main import ( "context" "gitlab.com/go-yp/grpc-counter/models/protos/services/counter" "golang.org/x/net/http2" "golang.org/x/net/http2/h2c" "log" "net/http" "sync/atomic" "github.com/improbable-eng/grpc-web/go/grpcweb" "google.golang.org/grpc" ) type counterServer struct { count uint32 } func (s *counterServer) CountSomeMethodName(context.Context, *counter.Empty) (*counter.Response, error) { var newCount = atomic.AddUint32(&s.count, 1) return &counter.Response{ Count: newCount, }, nil } var ( mainServer counter.CounterSomeServiceNameServer = new(counterServer) ) func main() { go func() { grpcServer := grpc.NewServer() grpcWebServer := grpcweb.WrapServer(grpcServer) counter.RegisterCounterSomeServiceNameServer(grpcServer, mainServer) var handler = h2c.NewHandler(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { w.Header().Set("Access Control-Allow-Origin", "*") w.Header().Set("Access Control-Allow-Methods", "POST, GET, OPTIONS, PUT, DELETE") w.Header().Set("Access Control-Allow-Headers", "Accept, Content-Type Content-Length, Accept-Encoding, X-CSRF-Token, Authorization, X-User-Agent, X-Grpc-Web") grpcWebServer.ServeHTTP(w, r) }), new(http2.Server)) err := http.ListenAndServe(":50551", handler) if err != nil { log.Fatal(err) } }() http.Handle("/", http.FileServer(http.Dir("./public"))) err := http.ListenAndServe(":8080", nil) if err != nil { log.Fatal(err) } }
gRPC-сервер успішно запускається, тепер залишилося згенерувати й під'єднати клієнт у браузері:
proto-client: mkdir -p ./client protoc -I . protos/services/counter/*.proto --js_out=import_style=commonjs,binary:client --grpc-web_out=import_style=commonjs,mode=grpcwebtext:client
make proto-client
~/go/src/gitlab.com/go-yp/grpc-counter ??? client ? ??? app.js ? ??? protos ? ??? services ? ??? counter ? ??? counter_grpc_web_pb.js [+] ? ??? counter_pb.js [+] ??? go.mod ??? go.sum ??? main.go ??? Makefile ??? protos ? ??? services ? ??? counter ? ??? counter.proto ??? models ??? protos ??? services ??? counter ??? counter.pb.go
У файлі counter_pb.js будуть моделі та службові обгортки:
// source: protos/services/counter/counter.proto // GENERATED CODE -- DO NOT EDIT! var jspb = require('google-protobuf'); // ... proto.counter.Empty = function(opt_data) { jspb.Message.initialize(this, opt_data, 0, -1, null, null); }; // ... proto.counter.Response = function(opt_data) { jspb.Message.initialize(this, opt_data, 0, -1, null, null); }; // ...
У файлі counter_grpc_web_pb.js буде gRPC-клієнт:
// GENERATED CODE -- DO NOT EDIT! const grpc = {}; grpc.web = require('grpc-web'); const proto = {}; proto.counter = require('./counter_pb.js'); // ... proto.counter.CounterSomeServiceNameClient = function(hostname, credentials, options) { if (!options) options = {}; options['format'] = 'text'; this.client_ = new grpc.web.GrpcWebClientBase(options); this.hostname_ = hostname; }; // ...
Цього досить, щоб зробити реалізацію в app.js , сходжу на попередній AJAX-приклад:
const {Empty, Response} = require("./protos/services/counter/counter_pb"); const {CounterSomeServiceNameClient} = require("./protos/services/counter/counter_grpc_web_pb"); const app = new CounterSomeServiceNameClient("http://localhost:50551"); const request = new Empty(); app.countSomeMethodName(request, {}, (err, response) => { if (err) { console.error(err); return; } /** @type Response response */ document.getElementById("js-counter").innerHTML = response.getCount(); });
І останні приготування package.json і webpack.config.js , щоб зібрати клієнтську частину:
{ "name": "grpc-counter-example", "version": "0.1.0", "description": "gRPC counter example", "license": "MIT", "dependencies": { "grpc-web": "^1.0.0", "google-protobuf": "^3.6.1" }, "devDependencies": { "@grpc/proto-loader": "^0.5.4", "webpack": "^4.16.5", "webpack-cli": "^3.1.0" }, "scripts": { "build": "webpack --mode production" } }
module.exports = { context: __dirname, entry: { app: './client/app' }, output: { path: __dirname + '/public/js', filename: '[name].js' }, };
<!DOCTYPE html> <html lang="en"> <head> <meta charset="UTF-8"> <title>gRPC counter example</title> </head> <body> <p> The page was viewed <span id="js-counter">0</span> times </p> <script src="js/app.js"></script> </body> </html>
~/go/src/gitlab.com/go-yp/grpc-counter ??? [4.0 K] client ? ??? [ 514] app.js ? ??? [4.0 K] protos ? ??? [4.0 K] services ? ??? [4.0 K] counter ? ? ??? [3.5 K] counter_grpc_web_pb.js ? ? ??? [8.4 K] counter_pb.js ??? [ 386] go.mod ??? [5.8 K] go.sum ??? [1.4 K] main.go ??? [ 887] Makefile ??? [4.0 K] models ? ??? [4.0 K] protos ? ??? [4.0 K] services ? ??? [4.0 K] counter ? ??? [7.0 K] counter.pb.go ??? [ 382] package.json ??? [4.0 K] protos ? ??? [4.0 K] services ? ??? [4.0 K] counter ? ? ??? [ 187] counter.proto ??? [4.0 K] public ? ??? [ 257] index.html ? ??? [4.0 K] js ? ??? [282K] app.js ??? [ 186] webpack.config.js
npm i npm run build
go run main
browse http://localhost:8080/
Готовий приклад можна побачити в репозиторії .
Серед мінусів — розмір app.js ~ 282 KB, у якому підключені лише gRPC - і protobuf-бібліотеки.
gRPC-прототип структури форуму
Зробімо трохи складніший приклад, щоб подивитися, який буде розмір app.js .
Завдання: треба зробити простий форум зі створенням тими, коментарями та модерацією.
Підготуємо proto-файл, що описує методи:
syntax = "proto3"; package forum; import "protos/services/forum/topic.proto"; service AnonymousForum { rpc CreateTopic (CreateTopicRequest) returns (UpdateTopicResponse); rpc UpdateTopic (UpdateTopicRequest) returns (UpdateTopicResponse); rpc TopicList (Empty) returns (TopicListResponse); rpc AddComment (AddCommentRequest) returns (Empty); rpc Topic (TopicRequest) returns (FullTopicResponse); } service ModerationForum { rpc TopicList(Empty) returns (TopicListResponse); rpc TopicApprove(TopicRequest) returns (Empty); rpc TopicReject(TopicRequest) returns (Empty); rpc CommentList(Empty) returns (CommentListResponse); rpc CommentApprove(CommentRequest) returns (Empty); rpc CommentReject(CommentRequest) returns (Empty); }
А такий вигляд матимуть моделі:
syntax = "proto3"; package forum; message Empty {} message UpdateTopicResponse { bool success = 1; string error_message = 2; } message CreateTopicRequest { string title = 1; string description = 2; } message UpdateTopicRequest { string id = 1; string title = 2; string description = 3; } message Topic { string id = 1; string title = 2; string description = 3; } message TopicListResponse { repeated Topic items = 1; } message TopicRequest { string id = 1; } message AddCommentRequest { string topic_id = 1; string username = 2; string text = 3; } message Comment { string id = 1; string username = 2; string text = 3; } message CommentListResponse { repeated Comment items = 1; } message CommentRequest { string id = 1; } message FullTopicResponse { string id = 1; string title = 2; string description = 3; repeated Comment items = 4; }
??? protos ??? services ??? forum ??? anonymous.proto ??? topic.proto
За аналогією з gRPC-лічильником згенерую клієнт:
protoc -I . protos/services/forum/*.proto --js_out=import_style=commonjs,binary:client --grpc-web_out=import_style=commonjs,mode=grpcwebtext:client
Під'єднаємо згенерований клієнт і використаємо його в прикладі:
const {CreateTopicRequest, UpdateTopicResponse} = require("./protos/services/forum/topic_pb"); const {AnonymousForum} = require("./protos/services/forum/anonymous_grpc_web_pb"); const app = new AnonymousForum("http://localhost:50551"); const request = new CreateTopicRequest(); request .setTitle("gRPC forum example") .setDescription("gRPC forum example"); app.addTopic(request, {}, (err, response) => { if (err) { console.error(err); return; } /** @type UpdateTopicResponse response */ console.log(response); });
??? [4.0 K] client ? ??? [ 514] app.js ? ??? [4.0 K] protos ? ??? [4.0 K] services ? ??? [4.0 K] forum ? ??? [ 27K] anonymous_grpc_web_pb.js ? ??? [ 530] anonymous_pb.js ? ??? [ 65K] topic_pb.js ??? [1.0 K] Makefile ??? [ 382] package.json ??? [4.0 K] protos ? ??? [4.0 K] services ? ??? [4.0 K] forum ? ??? [ 755] anonymous.proto ? ??? [ 898] topic.proto ??? [4.0 K] public ? ??? [4.0 K] js ? ??? [310K] forum-app.js ??? [ 229] webpack.config.js
Бачимо, що розмір forum-app.js ~ 310 KB.
Далі буде
У цій статті я планував створити прототип форуму DOU зі збереженням у MongoDB, але стаття і так вийшла об'єднання невід'ємною; якщо сподобалася, то зроблю продовження.
Епілог
Одна із цілей написання — це ті, що мені тема цікава, а шукаючи, бачив мало повноцінних прикладів; тому буду радий зустріти посилання на гарні приклади в коментарях.
Пишучи статтю, я зрозумів, що gRPC web добре підходить для розробки нових і складних проєктів.
Якщо прочитавши статтю, ви захотіли перевести вже наявну мікросервісну взаємодію з REST на gRPC, щоб було простіше розробляти нові методи й сервіси, то так, це доцільно.
Чи переводити вже наявну REST-взаємодію браузера й сервера на gRPC web ? Ліпше подумайте, а чи справді вам це треба.
У майбутньому хочу писати кращі статті, тому буду радий коментарям про ті, як і що міг би пояснити простіше.
Опубліковано: 01/05/20 @ 10:00
Розділ Різне
Рекомендуємо:
"Ми можемо бути не лише масажистами". Як люди з порушеннями зору вчаться робити сайти доступними
Не ставте питання «чому». Як менеджера спілкуватися з командою правильно
Міст до Піднебесної: робота в Воѕс?, visa challenge, IT-ринок
iOS дайджест #37: MVVM + SwiftUI, досвід використання Catalyst
Огляд Akka.NET: проектувати IoT-системи з допомогою цієї бібліотеки