+3

Xây dựng ứng dụng fullstack với Angular, .NET Core Web API và ứng dụng triển khai phần mềm bằng CI/CD, Docker và Nginx (phần 1)

Giới thiệu

Trong bối cảnh phát triển ứng dụng hiện đại, việc tách riêng giao diện người dùng (frontend) và xử lý nghiệp vụ (backend) mang lại nhiều lợi ích vượt trội như khả năng mở rộng, bảo trì dễ dàng và tăng cường hiệu suất. Bài viết này sẽ hướng dẫn bạn cách xây dựng một ứng dụng fullstack sử dụng Angular làm frontend.NET Core Web API làm backend. Chúng ta sẽ cùng đi qua các bước từ thiết kế kiến trúc, xây dựng API, tích hợp giao diện và cuối cùng là triển khai ứng dụng bằng Docker.

1. Thiết Kế Kiến Trúc Ứng Dụng

Một kiến trúc fullstack điển hình sẽ tách biệt phần client-side và server-side. Điều này cho phép:

  • Tách biệt mối quan tâm: Frontend và backend được phát triển, kiểm thử và triển khai độc lập.
  • Khả năng mở rộng linh hoạt: Mỗi phần có thể được mở rộng theo nhu cầu mà không ảnh hưởng lẫn nhau.
  • Tối ưu hiệu năng: Giao tiếp qua API giúp giảm thiểu tải trọng trên server và cải thiện trải nghiệm người dùng.

2. Khởi Tạo Dự Án

2.1 Tạo Dự Án .NET Core Web API

  • Tạo thư mục code chính của bạn, sau đó mở terminal và chạy lệnh sau để tạo dự án Web API:
dotnet new webapi -n MyFullstackAPI
  • Chuyển vào thư mục dự án:
cd MyFullstackAPI
  • Cấu trúc dự án cơ bản:
  • Controllers: Chứa các API endpoint.
  • Models: Định nghĩa các đối tượng dữ liệu.
  • Services: Xử lý logic nghiệp vụ (nếu cần tách riêng).

2.2 Tạo Dự Án Angular

  • Sử dụng Angular CLI để tạo dự án Angular:
ng new my-fullstack-app --routing --style=scss
  • Chuyển vào thư mục dự án:
cd my-fullstack-app

3. Xây Dựng Backend với .NET Core Web API

Trong phần này, chúng ta sẽ xây dựng một API quản lý sản phẩm (Product) theo mô hình phân tách rõ ràng giữa các tầng:

  • Model: Định nghĩa đối tượng dữ liệu (Product).
  • Data Context: Kết nối và tương tác với database thông qua Entity Framework Core.
  • Repository: Tầng truy cập dữ liệu, đảm bảo việc truy xuất, thêm mới và cập nhật dữ liệu được tách riêng ra.
  • Service: Tầng xử lý nghiệp vụ, sử dụng Repository để thực hiện các tác vụ nghiệp vụ (business logic).
  • Controller: Tầng trình bày, nhận request từ client và trả về kết quả từ Service.

3.1 Tạo Model và DbContext

Tạo file Product.cs trong thư mục Models:

namespace MyFullstackAPI.Models
{
    public class Product
    {
        public int Id { get; set; }
        public string Name { get; set; }
        public decimal Price { get; set; }
    }
}

Tạo file AppDbContext.cs trong thư mục DbContext:

using Microsoft.EntityFrameworkCore;
using MyFullstackAPI.Models;

namespace MyFullstackAPI.Data
{
    public class AppDbContext : DbContext
    {
        public AppDbContext(DbContextOptions<AppDbContext> options)
            : base(options)
        {
        }

        public DbSet<Product> Products { get; set; }
    }
}

Lưu ý: Để kết nối với database MSSQL cục bộ (local), chúng ta sẽ cấu hình chuỗi kết nối trong file appsettings.json (xem phần 3.5).

3.2. Triển Khai Repository

Định nghĩa Interface IProductRepository.cs trong thư mục Repository:

using MyFullstackAPI.Models;
using System.Collections.Generic;
using System.Threading.Tasks;

namespace MyFullstackAPI.Repositories
{
    public interface IProductRepository
    {
        Task<IEnumerable<Product>> GetAllAsync();
        Task<Product> GetByIdAsync(int id);
        Task AddAsync(Product product);
        Task UpdateAsync(Product product);
        Task DeleteAsync(Product product);
    }
}

Định nghĩa và cài đặt ProductRepository.cs trong thư mục Repository:

using Microsoft.EntityFrameworkCore;
using MyFullstackAPI.Data;
using MyFullstackAPI.Models;
using System.Collections.Generic;
using System.Threading.Tasks;

namespace MyFullstackAPI.Repositories
{
    public class ProductRepository : IProductRepository
    {
        private readonly AppDbContext _context;
        public ProductRepository(AppDbContext context)
        {
            _context = context;
        }
        public async Task<IEnumerable<Product>> GetAllAsync()
        {
            return await _context.Products.ToListAsync();
        }
        public async Task<Product> GetByIdAsync(int id)
        {
            return await _context.Products.FindAsync(id);
        }
        public async Task AddAsync(Product product)
        {
            _context.Products.Add(product);
            await _context.SaveChangesAsync();
        }
        public async Task UpdateAsync(Product product)
        {
           // Với EF Core, khi entity đã được truy xuất từ DbContext thì chỉ cần gán lại các giá trị mới và gọi SaveChangesAsync là đủ.
            _context.Entry(product).State = EntityState.Modified;
            await _context.SaveChangesAsync();
        }
        public async Task DeleteAsync(Product product)
        {
            _context.Products.Remove(product);
            await _context.SaveChangesAsync();
        }
    }
}

3.3. Triển Khai Service

Định nghĩa Interface IProductService.cs trong thư mục Service:

using MyFullstackAPI.Models;
using System.Collections.Generic;
using System.Threading.Tasks;

namespace MyFullstackAPI.Services
{
    public interface IProductService
    {
        Task<IEnumerable<Product>> GetAllProductsAsync();
        Task<Product> GetProductByIdAsync(int id);
        Task<Product> CreateProductAsync(Product product);
        Task<Product> UpdateProductAsync(int id, Product product);
        Task<bool> DeleteProductAsync(int id);
    }
}

Định nghĩa và cài đặt ProductService.cs trong thư mục Service:

using MyFullstackAPI.Models;
using MyFullstackAPI.Repositories;
using System.Collections.Generic;
using System.Threading.Tasks;

namespace MyFullstackAPI.Services
{
    public class ProductService : IProductService
    {
        private readonly IProductRepository _productRepository;
        public ProductService(IProductRepository productRepository)
        {
            _productRepository = productRepository;
        }
        public async Task<IEnumerable<Product>> GetAllProductsAsync()
        {
            return await _productRepository.GetAllAsync();
        }
        public async Task<Product> GetProductByIdAsync(int id)
        {
            return await _productRepository.GetByIdAsync(id);
        }
        public async Task<Product> CreateProductAsync(Product product)
        {
            await _productRepository.AddAsync(product);
            return product;
        }
        public async Task<Product> UpdateProductAsync(int id, Product updatedProduct)
        {
            var product = await _productRepository.GetByIdAsync(id);
            
            if (product == null)
                return null;

            // Cập nhật các trường dữ liệu cần thiết
            product.Name = updatedProduct.Name;
            product.Price = updatedProduct.Price;

            await _productRepository.UpdateAsync(product);
            return product;
        }
        public async Task<bool> DeleteProductAsync(int id)
        {
            var product = await _productRepository.GetByIdAsync(id);
            
            if (product == null)
                return false;

            await _productRepository.DeleteAsync(product);
            return true;
        }
    }
}

3.4. Cập nhật Controller

using Microsoft.AspNetCore.Mvc;
using MyFullstackAPI.Models;
using MyFullstackAPI.Services;
using System.Threading.Tasks;

namespace MyFullstackAPI.Controllers
{
    [Route("api/[controller]")]
    [ApiController]
    public class ProductsController : ControllerBase
    {
        private readonly IProductService _productService;
        public ProductsController(IProductService productService)
        {
            _productService = productService;
        }
        [HttpGet]
        public async Task<IActionResult> GetAll()
        {
            var products = await _productService.GetAllProductsAsync();
            return Ok(products);
        }
        [HttpGet("{id}")]
        public async Task<IActionResult> GetById(int id)
        {
            var product = await _productService.GetProductByIdAsync(id);
            if (product == null) return NotFound();
            return Ok(product);
        }
        [HttpPost]
        public async Task<IActionResult> Create([FromBody] Product product)
        {
            if (product == null) return BadRequest();
            var createdProduct = await _productService.CreateProductAsync(product);
            return CreatedAtAction(nameof(GetById), new { id = createdProduct.Id }, createdProduct);
        }
        [HttpPut("{id}")]
        public async Task<IActionResult> Update(int id, [FromBody] Product product)
        {
            if (product == null || id != product.Id)
                return BadRequest();

            var updatedProduct = await _productService.UpdateProductAsync(id, product);
            if (updatedProduct == null)
                return NotFound();

            return Ok(updatedProduct);
        }
        [HttpDelete("{id}")]
        public async Task<IActionResult> Delete(int id)
        {
            var result = await _productService.DeleteProductAsync(id);
            if (!result)
                return NotFound();

            return NoContent();
        }
    }
}

3.5. Cấu Hình Dependency Injection và Kết Nối MSSQL

Trong file Program.cs (hoặc Startup.cs nếu sử dụng phiên bản trước .NET 6):

using Microsoft.EntityFrameworkCore;
using MyFullstackAPI.Data;
using MyFullstackAPI.Repositories;
using MyFullstackAPI.Services;

var builder = WebApplication.CreateBuilder(args);

// Cấu hình kết nối MSSQL cục bộ
builder.Services.AddDbContext<AppDbContext>(options =>
    options.UseSqlServer(builder.Configuration.GetConnectionString("DefaultConnection")));

// Đăng ký Repository và Service
builder.Services.AddScoped<IProductRepository, ProductRepository>();
builder.Services.AddScoped<IProductService, ProductService>();

builder.Services.AddControllers();
builder.Services.AddEndpointsApiExplorer();
builder.Services.AddSwaggerGen();

var app = builder.Build();

if (app.Environment.IsDevelopment())
{
    app.UseDeveloperExceptionPage();
    app.UseSwagger();
    app.UseSwaggerUI();
}

app.UseHttpsRedirection();
app.UseAuthorization();
app.MapControllers();
app.Run();

Cấu hình chuỗi kết nối trong appsettings.json:

{
  "ConnectionStrings": {
    "DefaultConnection": "Server=localhost;Database=MyFullstackDb;User Id=sa;Password=Your_password123;"
  },
  "Logging": {
    "LogLevel": {
      "Default": "Information",
      "Microsoft.AspNetCore": "Warning"
    }
  },
  "AllowedHosts": "*"
}

Lưu ý:

  • Đảm bảo SQL Server cục bộ đã được cài đặt và cấu hình đúng.
  • Nếu cần, bạn có thể sử dụng các công cụ migration của Entity Framework Core để tạo database tự động:
dotnet ef migrations add InitialCreate
dotnet ef database update

Với mô hình Repository và Service, chúng ta đã tách rời rõ ràng các mối quan tâm:

  • Repository: Quản lý truy xuất dữ liệu từ database.
  • Service: Xử lý nghiệp vụ, sử dụng repository để thao tác với dữ liệu.
  • Controller: Nhận request từ client và trả về kết quả từ Service.

Phương pháp này giúp ứng dụng có khả năng mở rộng, bảo trì và kiểm thử tốt hơn. Việc kết nối với MSSQL local qua Entity Framework Core cung cấp một cách tiếp cận hiện đại, hiệu quả để tương tác với cơ sở dữ liệu.

3.6. Chạy Ứng Dụng API

Chạy lệnh sau để khởi chạy API:

dotnet run

Ứng dụng sẽ chạy trên địa chỉ mặc định như http://localhost:5000 (HTTP) hoặc https://localhost:5001 (HTTPS).

4. Xây Dựng Frontend với Angular

4.1 Tạo Service Kết Nối API

Trong Angular, tạo một service để giao tiếp với API. Chạy lệnh:

ng generate service services/product

Sửa file src/app/services/product.service.ts như sau:

import { Injectable } from '@angular/core';
import { HttpClient } from '@angular/common/http';
import { Observable } from 'rxjs';

export interface Product {
  id: number;
  name: string;
  price: number;
}

@Injectable({
  providedIn: 'root'
})
export class ProductService {
  // Địa chỉ API backend
  private apiUrl = 'http://localhost:5000/api/products';

  constructor(private http: HttpClient) { }
  
  getProducts(): Observable<Product[]> {
    return this.http.get<Product[]>(this.apiUrl);
  }

  getProduct(id: number): Observable<Product> {
    return this.http.get<Product>(`${this.apiUrl}/${id}`);
  }

  addProduct(product: Product): Observable<Product> {
    return this.http.post<Product>(this.apiUrl, product);
  }

  updateProduct(product: Product): Observable<Product> {
    return this.http.put<Product>(`${this.apiUrl}/${product.id}`, product);
  }

  deleteProduct(id: number): Observable<any> {
    return this.http.delete(`${this.apiUrl}/${id}`);
  }
}

Lưu ý: Nếu gặp lỗi CORS trong quá trình phát triển, bạn có thể thiết lập proxy (xem mục 4.6).

4.2 Hiển Thị Danh Sách Sản Phẩm

Tạo component ProductList:

ng generate component components/product-list

Cập nhật file src/app/components/product-list/product-list.component.ts:

import { Component, OnInit } from '@angular/core';
import { ProductService, Product } from 'src/app/services/product.service';
import { Router } from '@angular/router';

@Component({
  selector: 'app-product-list',
  templateUrl: './product-list.component.html'
})
export class ProductListComponent implements OnInit {
  products: Product[] = [];

  constructor(private productService: ProductService, private router: Router) { }

  ngOnInit(): void {
    this.loadProducts();
  }

  loadProducts() {
    this.productService.getProducts().subscribe(data => {
      this.products = data;
    });
  }

  onDelete(id: number) {
    if (confirm('Bạn có chắc chắn muốn xóa sản phẩm này?')) {
      this.productService.deleteProduct(id).subscribe(() => {
        this.loadProducts();
      });
    }
  }

  onEdit(product: Product) {
    this.router.navigate(['/edit', product.id]);
  }
}

Và file src/app/components/product-list/product-list.component.html:

<h2>Danh sách sản phẩm</h2>
<button routerLink="/add">Thêm mới sản phẩm</button>
<table>
  <thead>
    <tr>
      <th>Tên sản phẩm</th>
      <th>Giá</th>
      <th>Hành động</th>
    </tr>
  </thead>
  <tbody>
    <tr *ngFor="let product of products">
      <td>{{ product.name }}</td>
      <td>{{ product.price | currency }}</td>
      <td>
        <button (click)="onEdit(product)">Sửa</button>
        <button (click)="onDelete(product.id)">Xóa</button>
      </td>
    </tr>
  </tbody>
</table>

4.3 Tạo Component Form Sản Phẩm (Product Form) để Thêm và Sửa

Component này sử dụng Reactive Forms để tạo form cho thao tác thêm mới và chỉnh sửa sản phẩm. src/app/components/product-form/product-form.component.ts:

import { Component, OnInit } from '@angular/core';
import { FormBuilder, FormGroup, Validators } from '@angular/forms';
import { ProductService, Product } from 'src/app/services/product.service';
import { ActivatedRoute, Router } from '@angular/router';

@Component({
  selector: 'app-product-form',
  templateUrl: './product-form.component.html'
})
export class ProductFormComponent implements OnInit {
  productForm: FormGroup;
  productId: number | null = null;
  isEditMode: boolean = false;

  constructor(
    private fb: FormBuilder,
    private productService: ProductService,
    private router: Router,
    private route: ActivatedRoute
  ) {
    this.productForm = this.fb.group({
      name: ['', Validators.required],
      price: [0, [Validators.required, Validators.min(0)]]
    });
  }

  ngOnInit(): void {
    this.route.paramMap.subscribe(params => {
      const idParam = params.get('id');
      if (idParam) {
        this.productId = +idParam;
        this.isEditMode = true;
        this.loadProduct(this.productId);
      }
    });
  }

  loadProduct(id: number) {
    this.productService.getProduct(id).subscribe((product: Product) => {
      this.productForm.patchValue({
        name: product.name,
        price: product.price
      });
    });
  }

  onSubmit() {
    if (this.productForm.invalid) {
      return;
    }
    const product: Product = {
      ...this.productForm.value,
      id: this.productId || undefined
    };
    if (this.isEditMode) {
      this.productService.updateProduct(product).subscribe(() => {
        this.router.navigate(['/']);
      });
    } else {
      this.productService.addProduct(product).subscribe(() => {
        this.router.navigate(['/']);
      });
    }
  }
}

src/app/components/product-form/product-form.component.html:

<h2>{{ isEditMode ? 'Chỉnh sửa sản phẩm' : 'Thêm mới sản phẩm' }}</h2>
<form [formGroup]="productForm" (ngSubmit)="onSubmit()">
  <div>
    <label for="name">Tên sản phẩm:</label>
    <input id="name" formControlName="name" type="text">
    <div *ngIf="productForm.get('name')?.invalid && productForm.get('name')?.touched">
      Tên sản phẩm là bắt buộc.
    </div>
  </div>
  <div>
    <label for="price">Giá:</label>
    <input id="price" formControlName="price" type="number">
    <div *ngIf="productForm.get('price')?.invalid && productForm.get('price')?.touched">
      Giá phải là số hợp lệ và không âm.
    </div>
  </div>
  <button type="submit" [disabled]="productForm.invalid">
    {{ isEditMode ? 'Cập nhật' : 'Thêm mới' }}
  </button>
</form>

4.5 Cấu Hình Routing

Để chuyển hướng giữa các component, bạn cần định nghĩa các route trong ứng dụng Angular. src/app/app-routing.module.ts:

import { NgModule } from '@angular/core';
import { RouterModule, Routes } from '@angular/router';
import { ProductListComponent } from './components/product-list/product-list.component';
import { ProductFormComponent } from './components/product-form/product-form.component';

const routes: Routes = [
  { path: '', component: ProductListComponent },
  { path: 'add', component: ProductFormComponent },
  { path: 'edit/:id', component: ProductFormComponent },
  { path: '**', redirectTo: '' }
];

@NgModule({
  imports: [RouterModule.forRoot(routes)],
  exports: [RouterModule]
})
export class AppRoutingModule { }

4.6 Cấu Hình Proxy (Tùy Chọn)

Để tránh lỗi CORS khi phát triển, tạo file proxy.conf.json ở thư mục gốc của Angular project:

{
  "/api": {
    "target": "http://localhost:5000",
    "secure": false
  }
}

Chỉnh sửa file mục serve trong file angular.json để thêm cấu hình proxy:

"serve": {
  "options": {
    "proxyConfig": "proxy.conf.json"
  }
}

Khởi chạy ứng dụng Angular:

ng serve

Ứng dụng chạy trên http://localhost:4200 và các yêu cầu tới /api sẽ được chuyển tiếp tới backend.

Tổng kết

Qua bài viết này, bạn đã nắm được quy trình xây dựng một ứng dụng fullstack cơ bản với Angular và .NET Core Web API:

  • Thiết kế kiến trúc: Tách biệt frontend và backend giúp tăng tính mở rộng và bảo trì.
  • Xây dựng API: Tạo các endpoint mẫu để quản lý dữ liệu (ví dụ: sản phẩm).
  • Tích hợp Angular: Sử dụng Angular Service để gọi API, hiển thị dữ liệu lên giao diện.

Hy vọng qua bài viết, bạn có thể áp dụng vào dự án của mình và mở rộng thêm nhiều tính năng nâng cao. Phần sau chúng ta sẽ tới việc ứng dụng CI/CD và Docker cùng Nginx để triển khai phần mềm. Nếu có bất kỳ thắc mắc hoặc đóng góp nào, hãy để lại bình luận để cùng trao đổi, cảm ơn các bạn đã theo dõi!

Chúc bạn thành công và có những trải nghiệm thú vị với công nghệ fullstack hiện đại! 😁

Bài viết được xây dựng dựa trên kinh nghiệm thực tế và mục đích chia sẻ kiến thức nhằm hỗ trợ cộng đồng phát triển phần mềm.


All rights reserved

Viblo
Hãy đăng ký một tài khoản Viblo để nhận được nhiều bài viết thú vị hơn.
Đăng kí