Escrevendo um monólito full stack com Angular Universal + NestJS + PostgreSQL

Olá, Habr!


Neste artigo, criaremos um modelo de monólito pronto que pode ser usado como base para um novo aplicativo fullstack como esqueleto para funcionalidade suspensa.



Este artigo será útil se você:



  • Desenvolvedor iniciante fullstack;
  • Uma startup que escreve um MVP para testar uma hipótese.


Por que escolhi tal pilha:



  • Angular: Tenho muita experiência com isso, adoro arquitetura rígida e Typescript out of the box, vem do .NET
  • NestJS: a mesma linguagem, a mesma arquitetura, API REST de escrita rápida, a capacidade de mudar para Serverless no futuro (mais barato que uma máquina virtual)
  • PostgreSQL: hospedarei em Yandex.Cloud, no mínimo é 30% mais barato que MongoDB


Preço Yandex



Antes de escrever um artigo, procurei em Habré artigos sobre um caso semelhante e encontrei o seguinte:





Disto não descreve "copiado e colado" ou fornece links para o que mais precisa ser finalizado.



Índice:



1. Angular ng-zorro

2. NestJS SSR

3. API NestJS

4. PostgreSQL





1. Angular



Angular-CLI SPA- :



npm install -g @angular/cli


Angular :



ng new angular-habr-nestjs


, :



cd angular-habr-nestjs
ng serve --open


Aplicação Angular Static SPA



. NG-Zorro:



ng add ng-zorro-antd


:



? Enable icon dynamic loading [ Detail: https://ng.ant.design/components/icon/en ] Yes
? Set up custom theme file [ Detail: https://ng.ant.design/docs/customize-theme/en ] No
? Choose your locale code: ru_RU
? Choose template to create project: sidemenu


app.component , :



NG-Zorro conectado



, src/app/pages/welcome, NG-Zorro:





// welcome.component.html
<nz-table #basicTable [nzData]="items$ | async">
  <thead>
  <tr>
    <th>Name</th>
    <th>Age</th>
    <th>Address</th>
  </tr>
  </thead>
  <tbody>
  <tr *ngFor="let data of basicTable.data">
    <td>{{ data.name }}</td>
    <td>{{ data.age }}</td>
    <td>{{ data.address }}</td>
  </tr>
  </tbody>
</nz-table>


// welcome.module.ts
import { NgModule } from '@angular/core';

import { WelcomeRoutingModule } from './welcome-routing.module';

import { WelcomeComponent } from './welcome.component';
import { NzTableModule } from 'ng-zorro-antd';
import { CommonModule } from '@angular/common';

@NgModule({
  imports: [
    WelcomeRoutingModule,
    NzTableModule, //   
    CommonModule //    async
  ],
  declarations: [WelcomeComponent],
  exports: [WelcomeComponent]
})
export class WelcomeModule {
}


// welcome.component.ts
import { Component, OnInit } from '@angular/core';
import { Observable, of } from 'rxjs';
import { HttpClient } from '@angular/common/http';
import { share } from 'rxjs/operators';

@Component({
  selector: 'app-welcome',
  templateUrl: './welcome.component.html',
  styleUrls: ['./welcome.component.scss']
})
export class WelcomeComponent implements OnInit {
  items$: Observable<Item[]> = of([
    {name: '', age: 24, address: ''},
    {name: '', age: 23, address: ''},
    {name: '', age: 21, address: ''},
    {name: '', age: 23, address: ''}
  ]);

  constructor(private http: HttpClient) {
  }

  ngOnInit() {
  }

  //     ,  
  getItems(): Observable<Item[]> {
    return this.http.get<Item[]>('/api/items').pipe(share());
  }
}

interface Item {
  name: string;
  age: number;
  address: string;
}


:



Placa de identificação NG-Zorro





2. NestJS



NestJS , Angular Universal (Server Side Rendering) .



ng add @nestjs/ng-universal


, SSR :



npm run serve


:) :



TypeError: Cannot read property 'indexOf' of undefined
    at D:\Projects\angular-habr-nestjs\node_modules\@nestjs\ng-universal\dist\utils\setup-universal.utils.js:35:43
    at D:\Projects\angular-habr-nestjs\dist\server\main.js:107572:13
    at View.engine (D:\Projects\angular-habr-nestjs\node_modules\@nestjs\ng-universal\dist\utils\setup-universal.utils.js:30:11)
    at View.render (D:\Projects\angular-habr-nestjs\node_modules\express\lib\view.js:135:8)
    at tryRender (D:\Projects\angular-habr-nestjs\node_modules\express\lib\application.js:640:10)
    at Function.render (D:\Projects\angular-habr-nestjs\node_modules\express\lib\application.js:592:3)
    at ServerResponse.render (D:\Projects\angular-habr-nestjs\node_modules\express\lib\response.js:1012:7)
    at D:\Projects\angular-habr-nestjs\node_modules\@nestjs\ng-universal\dist\angular-universal.module.js:60:66
    at Layer.handle [as handle_request] (D:\Projects\angular-habr-nestjs\node_modules\express\lib\router\layer.js:95:5)
    at next (D:\Projects\angular-habr-nestjs\node_modules\express\lib\router\route.js:137:13)


, server/app.module.ts liveReload false:



import { Module } from '@nestjs/common';
import { AngularUniversalModule } from '@nestjs/ng-universal';
import { join } from 'path';

@Module({
  imports: [
    AngularUniversalModule.forRoot({
      viewsPath: join(process.cwd(), 'dist/browser'),
      bundle: require('../server/main'),
      liveReload: false
    })
  ]
})
export class ApplicationModule {}


, - Ivy :



// tsconfig.server.json
{
  "extends": "./tsconfig.app.json",
  "compilerOptions": {
    "outDir": "./out-tsc/server",
    "target": "es2016",
    "types": [
      "node"
    ]
  },
  "files": [
    "src/main.server.ts"
  ],
  "angularCompilerOptions": {
    "enableIvy": false, //  
    "entryModule": "./src/app/app.server.module#AppServerModule"
  }
}


ng run serve SSR .



SSR Angular + NestJS



! SSR , devtools .



extractCss: true, styles.js, styles.css:



// angular.json
...
"architect": {
        "build": {
          "builder": "@angular-devkit/build-angular:browser",
          "options": {
            "outputPath": "dist/browser",
            "index": "src/index.html",
            "main": "src/main.ts",
            "polyfills": "src/polyfills.ts",
            "tsConfig": "tsconfig.app.json",
            "aot": true,
            "assets": [
              "src/favicon.ico",
              "src/assets",
              {
                "glob": "**/*",
                "input": "./node_modules/@ant-design/icons-angular/src/inline-svg/",
                "output": "/assets/"
              }
            ],
            "extractCss": true, //  
            "styles": [
              "./node_modules/ng-zorro-antd/ng-zorro-antd.min.css",
              "src/styles.scss"
            ],
            "scripts": []
          },
...


app.component.scss:



// app.component.scss
@import "~ng-zorro-antd/ng-zorro-antd.min.css"; //  

:host {
  display: flex;
  text-rendering: optimizeLegibility;
  -webkit-font-smoothing: antialiased;
  -moz-osx-font-smoothing: grayscale;
}

.app-layout {
  height: 100vh;
}
...


, SSR , SSR, CSR (Client Side Rendering). :



import { NgModule } from '@angular/core';
import { Routes, RouterModule } from '@angular/router';

const routes: Routes = [
  { path: '', pathMatch: 'full', redirectTo: '/welcome' },
  { path: 'welcome', loadChildren: () => import('./pages/welcome/welcome.module').then(m => m.WelcomeModule) }
];

@NgModule({
  imports: [RouterModule.forRoot(routes, {initialNavigation: 'enabled', scrollPositionRestoration: 'enabled'})], //  initialNavigation, scrollPositionRestoration
  exports: [RouterModule]
})
export class AppRoutingModule { }


  • initialNavigation: 'enabled' , SSR
  • scrollPositionRestoration: 'enabled' .



    3. NestJS



server items:



cd server
nest g module items
nest g controller items --no-spec


// items.module.ts
import { Module } from '@nestjs/common';
import { ItemsController } from './items.controller';

@Module({
  controllers: [ItemsController]
})
export class ItemsModule {
}


// items.controller.ts
import { Controller } from '@nestjs/common';

@Controller('items')
export class ItemsController {}


. items :



// server/src/items/items.controller.ts
import { Body, Controller, Get, Post } from '@nestjs/common';

class Item {
  name: string;
  age: number;
  address: string;
}

@Controller('items')
export class ItemsController {

  //      Angular 
  private items: Item[] = [
    {name: '', age: 24, address: ''},
    {name: '', age: 23, address: ''},
    {name: '', age: 21, address: ''},
    {name: '', age: 23, address: ''}
  ];

  @Get()
  getAll(): Item[] {
    return this.items;
  }

  @Post()
  create(@Body() newItem: Item): void {
    this.items.push(newItem);
  }
}


GET Postman:



Solicitações GET para o apish NestJS



, ! , GET items api, server/main.ts NestJS:



// server/main.ts
import { NestFactory } from '@nestjs/core';
import { ApplicationModule } from './app.module';

async function bootstrap() {
  const app = await NestFactory.create(ApplicationModule);
  app.setGlobalPrefix('api'); //  
  await app.listen(4200);
}
bootstrap();


. welcome.component.ts :



// welcome.component.ts
import { Component, OnInit } from '@angular/core';
import { Observable, of } from 'rxjs';
import { HttpClient } from '@angular/common/http';
import { share } from 'rxjs/operators';

@Component({
  selector: 'app-welcome',
  templateUrl: './welcome.component.html',
  styleUrls: ['./welcome.component.scss']
})
export class WelcomeComponent implements OnInit {
  items$: Observable<Item[]> = this.getItems(); //   

  constructor(private http: HttpClient) {
  }

  ngOnInit() {
  }

  getItems(): Observable<Item[]> {
    return this.http.get<Item[]>('/api/items').pipe(share());
  }
}

interface Item {
  name: string;
  age: number;
  address: string;
}


, SSR, :



Empurrando apiha em SSR



SSR :



// welcome.component.ts
import { Component, OnInit } from '@angular/core';
import { Observable, of } from 'rxjs';
import { HttpClient } from '@angular/common/http';
import { share } from 'rxjs/operators';

@Component({
  selector: 'app-welcome',
  templateUrl: './welcome.component.html',
  styleUrls: ['./welcome.component.scss']
})
export class WelcomeComponent implements OnInit {
  items$: Observable<Item[]> = this.getItems(); //   

  constructor(private http: HttpClient) {
  }

  ngOnInit() {
  }

  getItems(): Observable<Item[]> {
    return this.http.get<Item[]>('http://localhost:4200/api/items').pipe(share()); //       SSR  
  }
}

interface Item {
  name: string;
  age: number;
  address: string;
}


( SSR, ), :



  • @nguniversal/common:


npm i @nguniversal/common


  • app/app.module.ts SSR:


// app.module.ts
import { BrowserModule } from '@angular/platform-browser';
import { NgModule } from '@angular/core';

import { AppRoutingModule } from './app-routing.module';
import { AppComponent } from './app.component';
import { IconsProviderModule } from './icons-provider.module';
import { NzLayoutModule } from 'ng-zorro-antd/layout';
import { NzMenuModule } from 'ng-zorro-antd/menu';
import { FormsModule } from '@angular/forms';
import { HttpClientModule } from '@angular/common/http';
import { BrowserAnimationsModule } from '@angular/platform-browser/animations';
import { NZ_I18N } from 'ng-zorro-antd/i18n';
import { ru_RU } from 'ng-zorro-antd/i18n';
import { registerLocaleData } from '@angular/common';
import ru from '@angular/common/locales/ru';
import {TransferHttpCacheModule} from '@nguniversal/common';

registerLocaleData(ru);

@NgModule({
  declarations: [
    AppComponent
  ],
  imports: [
    BrowserModule.withServerTransition({ appId: 'serverApp' }),
    TransferHttpCacheModule, // 
    AppRoutingModule,
    IconsProviderModule,
    NzLayoutModule,
    NzMenuModule,
    FormsModule,
    HttpClientModule,
    BrowserAnimationsModule
  ],
  providers: [{ provide: NZ_I18N, useValue: ru_RU }],
  bootstrap: [AppComponent]
})
export class AppModule { }


app.server.module.ts:



// app.server.module.ts
import { NgModule } from '@angular/core';
import { ServerModule, ServerTransferStateModule } from '@angular/platform-server';

import { AppModule } from './app.module';
import { AppComponent } from './app.component';

@NgModule({
  imports: [
    AppModule,
    ServerModule,
    ServerTransferStateModule, // 
  ],
  bootstrap: [AppComponent],
})
export class AppServerModule {}


. SSR, , .



Sem solicitação, dados disponíveis!





4. PostgreSQL



PostgreSQL, TypeORM :



npm i pg typeorm @nestjs/typeorm


: PostgreSQL .



server/app.module.ts:



// server/app.module.ts
import { Module } from '@nestjs/common';
import { AngularUniversalModule } from '@nestjs/ng-universal';
import { join } from 'path';
import { ItemsController } from './src/items/items.controller';
import { TypeOrmModule } from '@nestjs/typeorm';

@Module({
  imports: [
    AngularUniversalModule.forRoot({
      viewsPath: join(process.cwd(), 'dist/browser'),
      bundle: require('../server/main'),
      liveReload: false
    }),
    TypeOrmModule.forRoot({ //    
      type: 'postgres',
      host: 'localhost',
      port: 5432,
      username: 'postgres',
      password: 'admin',
      database: 'postgres',
      entities: ['dist/**/*.entity{.ts,.js}'],
      synchronize: true
    })
  ],
  controllers: [ItemsController]
})
export class ApplicationModule {}


:



  • type: ,
  • host port:
  • username password:
  • database:
  • entities: ,


, Item :



// server/src/items/item.entity.ts
import { Column, CreateDateColumn, Entity, PrimaryGeneratedColumn } from 'typeorm/index';

@Entity()
export class ItemEntity {
  @PrimaryGeneratedColumn()
  id: number;

  @CreateDateColumn()
  createDate: string;

  @Column()
  name: string;

  @Column()
  age: number;

  @Column()
  address: string;
}




// items.module.ts
import { Module } from '@nestjs/common';
import { TypeOrmModule } from '@nestjs/typeorm';
import { ItemEntity } from './item.entity';
import { ItemsController } from './items.controller';

@Module({
  imports: [
    TypeOrmModule.forFeature([ItemEntity]) //  -    
  ],
  controllers: [ItemsController]
})
export class ItemsModule {
}


, , :



// items.controller.ts
import { Body, Controller, Get, Post } from '@nestjs/common';
import { ItemEntity } from './item.entity';
import { InjectRepository } from '@nestjs/typeorm';
import { Repository } from 'typeorm/index';

interface Item {
  name: string;
  age: number;
  address: string;
}

@Controller('items')
export class ItemsController {

  constructor(@InjectRepository(ItemEntity)
              private readonly itemsRepository: Repository<ItemEntity>) { //  
  }

  @Get()
  getAll(): Promise<Item[]> {
    return this.itemsRepository.find();
  }

  @Post()
  create(@Body() newItem: Item): Promise<Item> {
    const item = this.itemsRepository.create(newItem);
    return this.itemsRepository.save(item);
  }
}


Postman:



POST para apiha com base



. , DBeaver:



Registros no banco de dados



! , :



Aplicativo fullstack funcionando



! fullstack , .



P.S. :



  • Ng-Zorro , Angular Material. - ;
  • , , . , "" MVP , ;
  • Em vez de digitar http: // localhost: 4200 / api na frente, pode ser melhor escrever um interceptor e verificar de onde estamos batendo


Links Úteis:






All Articles