¡Hola, Habr!
En este artículo, crearemos una plantilla de monolito lista para usar que se puede utilizar como base para una nueva aplicación fullstack como esqueleto para la funcionalidad de suspensión.
Este artículo será útil si:
- Desarrollador de pila completa para principiantes;
- Una startup que escribe un MVP para probar una hipótesis.
Por qué elegí una pila así:
- Angular: tengo mucha experiencia con él, me encanta la arquitectura estricta y Typecript listo para usar, proviene de .NET
- NestJS: el mismo idioma, la misma arquitectura, API REST de escritura rápida, la capacidad de cambiar a Serverless en el futuro (más barato que una máquina virtual)
- PostgreSQL: voy a alojar en Yandex.Cloud, como mínimo es un 30% más barato que MongoDB
Antes de escribir un artículo, busqué en Habré artículos sobre un caso similar y encontré lo siguiente:
- Angular y SEO: ¿cómo hacerlos amigos?
- Firebase + Angular Universal = lo imposible es posible
- Hacer amigos de Angular con Google (Angular Universal)
- Increíble angular
A partir de aquí, no describe "copiado y pegado" ni proporciona enlaces a qué más necesita ser finalizado.
Tabla de contenido:
1. Cree una aplicación Angular y agregue la biblioteca de componentes ng-zorro
2. Instale NestJS y resuelva problemas con SSR
3. Cree una API en NestJS y conéctese al frente
4. Conecte la base de datos PostgreSQL
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