Hallo Habr!
In diesem Artikel erstellen wir eine vorgefertigte Monolith-Vorlage, die als Grundlage für eine neue Fullstack-Anwendung als Grundgerüst für die Funktion zum Aufhängen verwendet werden kann.
Dieser Artikel ist hilfreich, wenn Sie:
Warum ich mich für einen solchen Stapel entschieden habe:
- Angular: Ich habe viel Erfahrung damit, ich liebe strenge Architektur und Typescript aus der Box, kommt von .NET
- NestJS: dieselbe Sprache, dieselbe Architektur, schnell schreibende REST-API, die Möglichkeit, in Zukunft auf Serverless zu wechseln (billiger als eine virtuelle Maschine)
- PostgreSQL: Ich werde in Yandex.Cloud hosten, es ist mindestens 30% billiger als MongoDB

Bevor ich einen Artikel schrieb, suchte ich bei Habré nach Artikeln über einen ähnlichen Fall und fand Folgendes:
- Angular und SEO: Wie kann man sie zu Freunden machen?
- Firebase + Angular Universal = das Unmögliche ist möglich
- Angular-Freunde mit Google finden (Angular Universal)
- Erstaunlicher Winkel
Daraus wird nicht "kopiert und eingefügt" beschrieben oder es werden Links zu dem bereitgestellt, was noch finalisiert werden muss.
Inhaltsverzeichnis:
1. Erstellen Sie eine Angular-Anwendung und fügen Sie die ng-zorro-Komponentenbibliothek hinzu.
2. Installieren Sie NestJS und lösen Sie Probleme mit SSR.
3. Erstellen Sie eine API in NestJS und stellen Sie eine Verbindung zur Vorderseite her.
4. Verbinden Sie die PostgreSQL-Datenbank
1. Angular
Angular-CLI SPA- :
npm install -g @angular/cli
Angular :
ng new angular-habr-nestjs
, :
cd angular-habr-nestjs
ng serve --open

. 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 , :

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

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 , 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 { }
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:

, ! , 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, :

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, , .

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:

. , DBeaver:

! , :

! fullstack , .
P.S. :
- Ng-Zorro , Angular Material. - ;
- , , . , "" MVP , ;
- http://localhost:4200/api