Écriture d'un monolithe de pile complète avec Angular Universal + NestJS + PostgreSQL

Bonjour, Habr!


Dans cet article, nous allons créer un modèle monolithique prêt à l'emploi qui peut être utilisé comme base pour une nouvelle application fullstack en tant que squelette pour la fonctionnalité de suspension.



Cet article vous sera utile si vous:



  • Développeur fullstack débutant;
  • Une startup qui écrit un MVP pour tester une hypothèse.


Pourquoi j'ai choisi une telle pile:



  • Angular: J'ai beaucoup d'expérience avec ça, j'aime l'architecture stricte et Typescript prêt à l'emploi, vient de .NET
  • NestJS: le même langage, la même architecture, l'API REST à écriture rapide, la possibilité de passer à Serverless dans le futur (moins cher qu'une machine virtuelle)
  • PostgreSQL: je vais héberger dans Yandex.Cloud, au minimum c'est 30% moins cher que MongoDB


Prix ​​Yandex



Avant d'écrire un article, j'ai cherché sur Habré des articles sur un cas similaire, et j'ai trouvé ce qui suit:





À partir de là, il ne décrit pas «copié et collé» ou fournit des liens vers ce qui doit être finalisé.



Table des matières:



1. Créez une application Angular et ajoutez la bibliothèque de composants ng-zorro

2. Installez NestJS et résolvez les problèmes avec SSR

3. Créez une API dans NestJS et connectez-vous à l'avant

4. Connectez la base de données PostgreSQL





1. Angular



Angular-CLI SPA- :



npm install -g @angular/cli


Angular :



ng new angular-habr-nestjs


, :



cd angular-habr-nestjs
ng serve --open


Application SPA statique angulaire



. 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 connecté



, 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;
}


:



Plaque signalétique 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 angulaire + 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:



OBTENIR des demandes pour l'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, :



Secousses apiha en 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, , .



Aucune demande, données disponibles!





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 sur apiha avec base



. , DBeaver:



Enregistrements dans la base de données



! , :



Application fullstack fonctionnant



! fullstack , .



P.S. :





:






All Articles