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

Порог вхождения в Angular 2 — теория и практика

Время на прочтение 16 мин
Количество просмотров 124K
Добрый день, дорогие хабра: жители, читатели, писатели, негативно-комментаторы.

В качестве вводной части и чтобы снять некоторые вопросы немного расскажу о себе. Меня зовут Тамара. Оужас, я девушка! Кого это пугает — закрывайте статью и не читайте.

Для остальных: у меня за плечам незаконченный лет 10 назад МИРЭА, факультет кибернетики. Но все эти 10 лет практики сложились таким образом, что по большей части я занималась рекламой и в перерывах случалось работать в различных стартапах, связанных с интернетом и не только.

image

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

Я покривлю душой, если скажу, что я не могу разобраться в чужом коде. Могу, на java и php могу даже какие-то простые вещи поправить. Но дальше этого мой опыт программирования никогда не уходил.

image

Но это же все не то, душа просила поэзии с чистого листа. И вот прекратив на некоторое время свою трудовую деятельность и взяв длительный отпуск для души и тела я таки решила попробовать что-то сделать с 0 и самостоятельно. Под "что-то" я понимаю свой маленький проект.

Когда думала и выбирала на чем делать, то для бэкенда остановилась на PHP. А точнее на фреймворке — Laravel.
На нем я остановилась по той причине, что для меня он показался самым низким по порогу вхождения. Мне не нравится в нем документация, так как с моей точки зрения многие моменты не раскрыты и приходится лезть в исходники, чтобы почитать комментарии. Но основные общие моменты разобраны на многих ресурсах. Laracasts как источник обучения весьма грустен. Тейлор там рассматривает все достаточно поверхностно, перескакивая с одного на другое и совершенно не углубляясь. Все по верхам.

Для фронтенда я выбрала Angular 2. Да, я знаю, что он в beta-режиме :), но мне он опять же показался логичным.
Для въезжания в Angular2 я пользуюсь их документацией, исходниками на github, чтения issue там же, stackoverflow — но там как-то все сейчас грустно — задают вопросы в основном ответы на которые есть в документации.

Ну, наверное с вводной частью буду заканчивать.
Перейду теперь к сути. Ниже я поделюсь своим маленьким опытом, что же у меня получилось и за какое время я осилила это сделать.

image

Тут не будет примеров todo и helloworld.
Я покажу маленький пример того, что я сейчас ковыряю и как у меня это работает.
В кусочке будет получение данных через api, вывод их, и отправка формы.

Настройка Angular 2 и Laravel.


Я не буду заострять на этом внимание. Для Angular 2 — вся базовая настройка проекта написана в их 5-и минутном туториале HelloWorld.
С Laravel тоже базовое создание проекта описано в документации.

Остановлюсь поподробнее только на том моменте, который меня на старте поставили в тупик.

Когда я начинала проект меня волновал вопрос взаимодействия этих товарищей в плане роутинга. А именно, если грузить Angular в папку public, то у меня лично возникли проблемы с роутингом. Так как у Laravel свой роутинг, который с роутингом Angular у меня вообще никак не совпадал, а манипуляции c отдачей нужных роутов не привели к нужному результату. При возврате через браузер на предыдущую страницу мне постоянно выбрасывалась laravelевская страница с ошибкой. Убив пару часов, чтобы подружить этих товарищей я приняла решение разнести по разным доменам api(бэкенд) и фронтенд. Как по мне, так в случае замены одной или другой части целого я не буду зависеть от незаменяемой части.
Так, что, условно сейчас я имею два проекта. Один, условно, крутится на домене: api.proect.dev, а второй на: proect.dev

Вдогонку автор sanex3339 подсказал как можно пробросить роуты Angular 2 через роутер Laravel 5.
За что ему огромное человеческое спасибо!
Теперь имеем два рабочих решения, из которых можно выбирать: разносить по доменам и разделять и властвовать, или компактно упаковать все в одном.

Так как я все-таки заявила в заголовке, про порог вхождения именно в Angular, то я не буду подробно останавливаться на API.

Быстренько сделаем бэкенд


Если коротко, то наша работа во фронтенде будет по 2 запросам к бэкенду. По одному запросу мы получаем данные из таблицы, по второму мы туда их записываем :) Элементарно, Ватсон :)
Далее я просто приведу куски кода бэкенда с комментариями в самом же коде, чтобы нам дальше двигаться.

Кому это надо - заглядывайте
php artisan make:model MainCategory -m

Эта команда создаст нам модель MainСategory и миграцию для этой модели.
В миграцию вставляем нужные нам строчки.

Миграция - как она выглядит
2016_02_22_135455_create_main_categories_table.php

<?php

use Illuminate\Database\Schema\Blueprint;
use Illuminate\Database\Migrations\Migration;

class CreateMainCategoriesTable extends Migration
{
    /**
     * Run the migrations.
     *
     * @return void
     */
    public function up()
    {
        Schema::create('main_categories', function (Blueprint $table) {
            $table->increments('id');
            $table->string('name', 255)->unique(); //это у меня будет название категории. 
            $table->string('slug', 255)->unique(); //это ссылка на эту категорию
            $table->boolean('show')->default(0); // тут статус публикации категории на сайте. Если true(1) - тогда показываем, если false(0) - нет.
            $table->timestamps();
            $table->softDeletes();
        });
    }

    /**
     * Reverse the migrations.
     *
     * @return void
     */
    public function down()
    {
        Schema::drop('main_categories');
    }
}


Модель - как она выглядит
MainCategory.php

<?php
namespace App\Models\Catalog;

use Illuminate\Database\Eloquent\Model;
use Illuminate\Database\Eloquent\SoftDeletes;

/**
 * Class MainCategory
 *
 * @package App
 *
 * @property integer  $id $primaryKey $autoincrement
 * @property string   $name $unique
 * @property string   slug $unique
 * @property boolean  show
 * @property datetime created_at
 * @property datetime updated_at
 * @property datetime deleted_at
 */
class MainCategory extends Model
{
    use SoftDeletes;

    protected $fillable = ['name', 'slug', 'show'];

    protected $guarded = ['id'];

    protected $dates = ['created_at', 'updated_at', 'deleted_at'];

}


Ну и собственно контроллер, который со стороны php будет определять в каком виде данные получать, как их из базы вытаскивать, как запихивать их обратно. Он создается командой php artisan make:controller MainCategoryController
У меня он лежит в своей папочке с названием Catalog, обращаю на это внимание, так как дальше в роутах он обязательно проскользнет.
Так, как чтобы со стороны бэкенда не плодить ненужны папки-подпапки я решила, что в тематическом контроллере под разными названиями плодить нужные мне запросы :)

Контроллер - как он выглядит
MainCategoryController.php

<?php

namespace App\Http\Controllers\Catalog;

use App\Models\Catalog\MainCategory;
use Illuminate\Http\Request;

use App\Http\Requests;
use App\Http\Controllers\Controller;

/**
 * @api
 * @package     App\Http\Controllers\Catalog
 * @class    MainCategoryController
 */
class MainCategoryController extends Controller
{
    /**
     * Возвращает список всех категорий каталога со всеми полями
     * @function indexAdmin
     * @return mixed $main_categories
     */
    public function indexAdmin()
    {
        $main_categories = MainCategory::all();
        return $main_categories;
    }

    /**
     * @function createAdmin
     * Создание новой категории каталога. Доступно только в административном функционале
     *
     * @param Request $request
     */
    public function createAdmin(Request $request)
    {
        $main_category = new MainCategory;
        $main_category->name = $request->name;
        $main_category->slug = $request->slug;
        $main_category->show = $request->show;
        $main_category->save();
    }
}


Ну и последнее, что осталось сделать — это прописать пути. Вот кусочек route.php и 2 пути по которым мы и будем запрашивать нужную нам информацию.

Пути
Route::group(['middleware' => 'cors'], function() {
    Route::group(['middleware' => 'api'], function () {
            Route::group(['prefix' => 'backend'], function () {
                Route::group(['namespace' => 'Catalog', 'prefix' => 'catalog'], function () {
                    Route::get('/main-categories', 'MainCategoryController@indexAdmin');
                    Route::post('/main-category/create', 'MainCategoryController@createAdmin');
                });
            });
    });
});



На выходе мы на самом деле получаем 2 ссылки:

get: http://api.project.dev/backend/catalog/main-categories
post: http://api.project.dev/backend/catalog/main-category/create

На этом миссия по настройке бэкенд завершена.

Ура! Обещанный Angular 2.


Ну теперь начинается самое интересное.
Так как я пока еще не определилась окончательно со структурой в самом проекте и что и как на страницах буду отображать, то вот скрин того, как это сейчас у меня выглядит. Единственное, что для habra я кусочки шаблонов внесу в сами .ts скрипты, хотя у меня они сейчас вынесены в отдельные html.
image

Как я уже говорила — за исходник я брала базовую конфигурация из туториала. Поэтому тут ничего особенного нет. Ну, кроме, что main.ts я переименовала для себя в boot.ts :)

index.html
Единственное, на что здесь стоит обратить внимание, так это на то, что к базовым скриптам добавлены

<script src="node_modules/angular2/bundles/router.dev.js"></script>
<script src="node_modules/angular2/bundles/http.dev.js"></script>

Без этих товарищей не будут работать роуты и запросы-ответы к API.

Полный вариант index.html
<html>
<head>
    <base href="/">
    <title>Angular 2 QuickStart</title>
    <meta name="viewport" content="width=device-width, initial-scale=1">

    <!-- 1. Load libraries -->
    <!-- IE required polyfills, in this exact order -->
    <script src="node_modules/es6-shim/es6-shim.js"></script>
    <script src="node_modules/systemjs/dist/system-polyfills.js"></script>
    <script src="node_modules/angular2/es6/dev/src/testing/shims_for_IE.js"></script>
    <script src="node_modules/angular2/bundles/angular2-polyfills.js"></script>
    <script src="node_modules/systemjs/dist/system.src.js"></script>
    <script src="node_modules/rxjs/bundles/Rx.js"></script>
    <script src="node_modules/angular2/bundles/angular2.dev.js"></script>
    <script src="node_modules/angular2/bundles/router.dev.js"></script>
    <script src="node_modules/angular2/bundles/http.dev.js"></script>
    <!-- 2. Configure SystemJS -->
    <script>
        System.config({
            packages: {
                app: {
                    format: 'register',
                    defaultExtension: 'js'
                }
            }
        });
        System.import('app/boot')
                .then(null, console.error.bind(console));
    </script>
</head>
<!-- 3. Display the application -->
<body>
<shop-app>Loading...</shop-app>
</body>
</html>


В приложении сейчас есть 2 роута: это главная страница, на которую можно вернуться и это страница с отображением всех категорий и добавлением новой.

Роуты у меня расположены в app.component.ts. И, соответственно он же у меня является тем самым входным компонентом, который и видно в виде тэгов <shop-app></shop-app> на главной странице.

Полный вариант app.component.ts
import {Component} from 'angular2/core';
import {RouteConfig, ROUTER_DIRECTIVES, ROUTER_PROVIDERS} from "angular2/router";
import {HomePageComponent} from "./home-page/home-page.component"
import {DashboardMainCategoryComponent} from "./dashboard/catalog/main-category/main-category.root.component";

@Component({
    selector: 'shop-app',
    template: `
    <a [routerLink]="['HomePage']">Главная</a>
    <a [routerLink]="['/DashboardMainCategory']">Категории</a>
    <router-outlet></router-outlet>
    `,
    directives: [ROUTER_DIRECTIVES],
    providers: [ROUTER_PROVIDERS]
})

@RouteConfig([
    {
        path: '/',
        name: 'HomePage',
        component: HomePageComponent,
        useAsDefault: true
    },

    {
        path: '/main-category',
        name: 'DashboardMainCategory',
        component: DashboardMainCategoryComponent
    }

])
export class ShopAppComponent { }


Собственно, чтобы роуты заработали нам осталось всего-ничего — добавить соответствующие компоненты: HomePageComponent и DashboardMainCategoryComponent.

Полный вариант HomePageComponent - home-page.component.ts
import {Component} from "angular2/core";

@Component({
    selector: 'home-page',
    template: '<h1>Главная страница</h1>'
})

export class HomePageComponent {}


Полный вариант DashboardMainCategoryComponent - main-category.root.component.ts
import {Component} from "angular2/core";

@Component({
    selector: 'dashboard-main-category',
    template: '<h1>Категории</h1>'
})

export class DashboardMainCategoryComponent {}


Так, сделали. Теперь надо пойти в boot.ts и импортировать основной компонент ShopAppComponent.

boot.ts
Это самый пустой компонент в моем проекте :) У меня он ничего не делает, кроме как загружает все, что нужно из основного компонента с названием app.component.ts

Полный вариант boot.ts
import {bootstrap} from 'angular2/platform/browser'
import {ShopAppComponent} from "./app.component";

bootstrap(ShopAppComponent);


На этом с роутами мы закончили. И, если сейчас сделать npm run start, то у вас уже будет сайт на котором можно попрыгать между двумя страничками.

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

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

Базовая модель MainCategory


Перво-наперво нам надо сделать простой класс — аналог Модели на php, pojo — на java.
Давайте его обзовем аутентично: main-category.ts

Полный вариант main-category.ts
export class MainCategory{
    constructor(
        public id: number,
        public name: string,
        public slug: string,
        public show: boolean,
        public created_at: string,
        public updated_at: string,
        public deleted_at: string
    ) {}
}


Все, что он делает — так это представляет нам структуру тех данных, которые мы будем запрашивать или отправлять по API.

Может возникнуть вопрос — почему даты у меня как string. Скажу честно — у меня был косяк с тем, чтобы запрашивать даты как даты. Постоянно выдавало ошибку, поэтому я пока отоложила ломание головы и пошла по простому пути.

MainCategoryService


Ладно, первый шаг сделали. Потопали дальше. Если заглянуть в ARCHITECTURE OVERVIEW Angular2, там они предлагают придерживаться той идеи, что ту часть приложения, которая что-то делает (например, авторизация, логгирование, калькулятор пошлины или, как в нашем случае — общение по API) надо называть service и выносить в отдельный файл, который мы потом будем импортировать туда, куда надо. Это необязательно, но желательно. Я так и поступила. Отсюда у меня появился main-category.service.ts

Полный вариант main-category.service.ts
import {Injectable} from "angular2/core";
import {Http, Headers, RequestOptions, Response} from "angular2/http";
import {Observable} from "rxjs/Observable";
import 'rxjs/Rx'; //без этого импорта у нас любое общение с API будет заканчиваться ошибками. Временная фича, которую обещают найти и устранить
import {MainCategory} from "./main-category";

//@Injectable - декоратор, который передает данные о нашем сервисе.
@Injectable()
export class MainCategoryService {

    constructor (private http: Http) {}

    //так как у меня по разным ссылкам запрос и отправка данных, то я сделала 2 переменные с их указанием. Если вдруг что поменяется в ссылках, то мне не надо будет разыскивать по всему документу :) Удобно
    private _getAdminMainCategories = 'http://api.shops.dev:8080/backend/catalog/main-categories';
    private _createAdminMainCategory = 'http://api.shops.dev:8080/backend/catalog/main-category/create';

   //запрашиваем все категории каталога 
   getAdminMainCategories() {
        //обращаемся к API через get
        return this.http.get(this._getAdminMainCategories)
                        //тут мы принимаем событие и возвращаем некоторые данные. В нашем случае - массив категорий в json формате
                        .map(res => <MainCategory[]> res.json())
                        .catch(this.handleError);
    }

    //создаем категорию каталога. Так как мы заранее знаем какие данные и в каком виде нам приходят, то мы указываем, что будем получать и передавать
    createAdminMainCategory(name:String, slug:String, show:boolean) : Observable<MainCategory> {
        //преобразуем данные в JSON-строку. Обещают, что потом нам эта строчка не будет нужна
        let body = JSON.stringify({name, slug, show});
        //устанавливаем нужный нам заголовок
        let headers = new Headers({ 'Content-Type': 'application/json' });
        let options = new RequestOptions({ headers: headers });

        //отправляем данные
        return this.http.post(this._createAdminMainCategory, body, options)
            .map(res =>  <MainCategory> res.json())
            .catch(this.handleError)
    }

    private handleError (error: Response) {
        //in a real world app, we may send the error to some remote logging infrastructure
        //instead of just logging it to the console
        console.error(error);
        return Observable.throw(error.json().error || 'Server error');
    }
}


На этом основное взаимодействие с сервером мы описали. Осталась сущая ерунда — пара компонентов и дело в шляпе!

GetMainCategories


Начнем с компонента, который получает данные: main-category.get.component.ts

Полный вариант main-category.get.component.ts`
import {Component} from "angular2/core";
import {MainCategoryService} from "./main-category.service";
import {OnInit} from "angular2/core";
import {MainCategory} from "./main-category";

@Component({
    selector: 'backend-get-main-categories',
    templateUrl: 'app/dashboard/catalog/main-category/main-category.get.template.html',
    providers: [MainCategoryService] //в качестве провайдера как раз указываем созданный нами сервис
})

export class BackendGetMainCategories implements OnInit {

    constructor (private _mainCategoryService: MainCategoryService) {}

    errorMessage: string;
    mainCategories: MainCategory[];

    ngOnInit() {
        this.getAdminMainCategories();
    }
    //обращаемся к созданному нами сервису, конкретно к getAdminMainCategories
    getAdminMainCategories() {
        this._mainCategoryService.getAdminMainCategories()
                                .subscribe(
                                    mainCategories => this.mainCategories = mainCategories,
                                    error => this.errorMessage = <any>error
                                );
    }
}


Полный вариант шаблона main-category.get.template.html
<h1>Категории каталога</h1>
<table>
    <thead>
    <tr>
        <th>id</th>
        <th>name</th>
        <th>slug</th>
        <th>show</th>
        <th>created_at</th>
        <th>updated_at</th>
        <th>deleted_at</th>
    </tr>
    </thead>
    <tbody>
   <!--Angular повторяет строку до тех пор пока у нас данные не закончатся :)-->
    <tr *ngFor="#mainCategory of mainCategories">
        <td>{{ mainCategory.id }}</td>
        <td>{{ mainCategory.name }}</td>
        <td>{{ mainCategory.slug }}</td>
        <td>{{ mainCategory.show }}</td>
        <td>{{ mainCategory.created_at }}</td>
        <td>{{ mainCategory.updated_at }}</td>
        <td>{{ mainCategory.deleted_at }}</td>
    </tr>
    </tbody>
</table>


PostMainCategory


В Angular2 есть два способа создания форм — template и data-driven. Принципиальное отличие у них в том, что в template — все проверки пишутся в самом шаблоне. Т.е. это более близко к Angular1. Data-driven — это нововведение в Angular2 и все проверки уходят из шаблона. Ну это пока то как я для себя поняла эту разницу. Боюсь, что тему я до конца не раскрыла, так как в голове по поводу этих форм еще каша. Честно сказать — второй вариант с формами мне показался проще и чище. Но с ним есть сейчас много своих косяков.

Полный вариант шаблона main-category.create.component.html
import {Component} from "angular2/core";
import {MainCategoryService} from "./main-category.service";
import {OnInit} from "angular2/core";
import {FORM_DIRECTIVES} from "angular2/common";
import {FORM_PROVIDERS} from "angular2/common";
import {ControlGroup} from "angular2/common";
import {FormBuilder} from "angular2/common";
import {Validators} from "angular2/common";
import {MainCategory} from "./main-category";
import {HTTP_PROVIDERS} from "angular2/http";

@Component({
    selector: 'backend-create-main-category',
    templateUrl: 'app/dashboard/catalog/main-category/main-category.create.component.html',
    providers: [MainCategoryService, FORM_PROVIDERS, HTTP_PROVIDERS],
    directives: [FORM_DIRECTIVES]
})

export class BackendCreateMainCategory implements OnInit {
    //сообщаем что у нас есть группа контроллеров в нашей форме и она одна :) 
    createMainCategoryForm: ControlGroup;
    mainCategories:MainCategory[];
    errorMessage: string;

    constructor( private _formBuilder: FormBuilder, private _mainCategoryService: MainCategoryService) {}

      //то о чем я писала - наши проверки вынесены из шаблона
      ngOnInit() {
        this.createMainCategoryForm = this._formBuilder.group({
            'name': ['', Validators.required],
            'slug': ['', Validators.required],
            'show': [false]
        });
    }

     //при сабмите формы отправляем данные на сервер
     onSubmit() {
        var name = this.createMainCategoryForm.value.name;
        var slug = this.createMainCategoryForm.value.slug;
        var show = this.createMainCategoryForm.value.show;
        this._mainCategoryService.createAdminMainCategory(name, slug, show).subscribe(
          main_category => this.mainCategories.push(main_category),
            error => this.errorMessage = <any>error
        );

    }


Полный вариант шаблона main-category.create.template.html
<h1>Создать категорию каталога</h1>

<form [ngFormModel]="createMainCategoryForm" (ngSubmit)="onSubmit()">
    <div>
        <label for="name">Название</label>
        <input type="text" id="name" [ngFormControl]="createMainCategoryForm.controls['name']">
    </div>
    <div>
        <label for="slug">Ссылка</label>
        <input type="text" id="slug" [ngFormControl]="createMainCategoryForm.controls['slug']">
    </div>
    <div>
        <label for="show">Опубликовать?</label>
        <input type="checkbox" id="show" [ngFormControl]="createMainCategoryForm.controls['show']">
    </div>
    <button type="submit">Сохранить</button>
</form>


К сожалению radiobutton пока шалит в Angular2 и работать может, но только после длительных плясок с бубном, так, что для своих нужд я остановилась пока на checkbox.

Осталось все нужное импортировать в наш класс DashboardMainCategoryComponent. Теперь он будет выглядеть вот так:

Полный вариант main-category.root.component.ts
import {Component} from "angular2/core";
import {FORM_DIRECTIVES} from "angular2/common";
import {ControlGroup} from "angular2/common";
import {Control} from "angular2/common";
import {FormBuilder} from "angular2/common";
import {Validators} from "angular2/common";
import {MainCategoryService} from "./main-category.service";
import {HTTP_PROVIDERS} from "angular2/http";
import {BackendGetMainCategories} from "./main-category.get.component";
import {BackendCreateMainCategory} from "./main-category.create.component";
@Component({
    selector: 'dashboard-main-category',
    template:`
    <h1>Категории</h1>
    <backend-get-main-categories></backend-get-main-categories> 
    <backend-create-main-category></backend-create-main-category>
    `,
    directives: [
        FORM_DIRECTIVES,
        BackendGetMainCategories,
        BackendCreateMainCategory],
    providers: [MainCategoryService, HTTP_PROVIDERS]
})

export class DashboardMainCategoryComponent {}


На этом мы имеем простое приложение с получением и отправкой данных на сервер.

Итоги


Если взять чистое время, которое у меня заняло написать то, что я выложила выше и заставить это работать:
Backend — 1 час 17 минут. Это не совсем чистое время, а вместе с загрузкой PhpStorm, хождениями на перекуры и отвлечениями на телефонные разговоры. Для меня это достаточно просто, так как все таки php я не первый раз вижу.
С Angular2 все сложнее.
Я никогда не копалась в JS. Нет, скриптик подключить я могла по инструкции, а вот дальше — для меня это был темный лес, в который я нос не совала. В итоге на курение доков по Angular2, JavaScript, TypeScript, вникание, написание, перепроверки, переделки у меня ушло чистых 12 часов 48 минут. Перекуры, разговоры, загрузки-перезагрузки IDE в этом времени не учтены.

Итого: IMHO Angular2 весьма опасен тем, что туда могут вот так вот, достаточно просто влезть такие блондинки как я, и даже потратив не так много времени сделать что-то большее, чем HelloWorld или же ToDo-список.

P.S. Тема статьи родилась из прочтения одного твита, где задавали вопрос — насколько высок порог вхождения в Angular2. Ну что же, можно сказать, что невысок. Все гуру могут хвататься за голову и предрекать наступление краха из-за того, что скоро полезут недоучки, которые будут писать полную ерунду, а им потом разгребать это.

P.P.S. За орфографию, грамматику, стилистику, некоторую саркастичность заранее прошу прощения, а при указании на что-то из первых трех пунктов — исправлю это :)

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

И огромное вам спасибо, если дочитали этот пост!
Теги:
Хабы:
+38
Комментарии 144
Комментарии Комментарии 144

Публикации

Истории

Работа

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

PG Bootcamp 2024
Дата 16 апреля
Время 09:30 – 21:00
Место
Минск Онлайн
EvaConf 2024
Дата 16 апреля
Время 11:00 – 16:00
Место
Москва Онлайн
Weekend Offer в AliExpress
Дата 20 – 21 апреля
Время 10:00 – 20:00
Место
Онлайн