Kiến trúc và Triển khai Microservice Quản lý Khóa học với Spring Cloud

Mô tả tổng quan: Dự án ml-course là một vi dịch vụ chịu trách nhiệm quản lý các nghiệp vụ liên quan đến học tập trực tuyến. Phạm vi bao gồm quản lý danh mục, thông tin khóa học, phân chia theo mùa học (season), các bài giảng (episode), hệ thống bình luận, báo cáo vi phạm và tính năng lưu trữ khóa học.

1. Chuẩn bị môi trường và Cấu hình

Trước khi khởi động dự án, cần đảm bảo các dịch vụ cơ sở dữ liệu và trung gian đang hoạt động:

docker start mysql nacos sentinel redis zipkin minio elasticsearch

1.1. Cấu hình dự án (pom.xml)

Tích hợp các thư viện cần thiết cho kiến trúc Microservices: Web, AOP, Driver MySQL, Swagger (Knife4j), Validation, MyBatis-Flex, Redis, Nacos (Discovery & Config), Sentinel, OpenFeign, Minio, Elasticsearch.

<dependencies>
    <!-- Common module -->
    <dependency>
        <groupId>com.joezhou</groupId>
        <artifactId>ml-common</artifactId>
        <version>1.0-SNAPSHOT</version>
    </dependency>

    <!-- Spring Boot Web -->
    <dependency>
        <groupId>org.springframework.boot</groupId>
        <artifactId>spring-boot-starter-web</artifactId>
    </dependency>
    
    <!-- ORM: MyBatis-Flex -->
    <dependency>
        <groupId>com.mybatis-flex</groupId>
        <artifactId>mybatis-flex-spring-boot3-starter</artifactId>
    </dependency>

    <!-- Service Discovery: Nacos -->
    <dependency>
        <groupId>com.alibaba.cloud</groupId>
        <artifactId>spring-cloud-starter-alibaba-nacos-discovery</artifactId>
    </dependency>

    <!-- Configuration Center: Nacos Config -->
    <dependency>
        <groupId>com.alibaba.cloud</groupId>
        <artifactId>spring-cloud-starter-alibaba-nacos-config</artifactId>
    </dependency>

    <!-- Object Storage: MinIO -->
    <dependency>
        <groupId>io.minio</groupId>
        <artifactId>minio</artifactId>
    </dependency>

    <!-- Search Engine: Elasticsearch -->
    <dependency>
        <groupId>org.springframework.boot</groupId>
        <artifactId>spring-boot-starter-data-elasticsearch</artifactId>
    </dependency>

    <!-- Remote Call: OpenFeign -->
    <dependency>
        <groupId>org.springframework.cloud</groupId>
        <artifactId>spring-cloud-starter-openfeign</artifactId>
    </dependency>
</dependencies>

1.2. Cấu hình Nacos (bootstrap.yaml)

spring:
  application:
    name: ml-course
  cloud:
    nacos:
      config:
        server-addr: 192.168.40.77:8848
        file-extension: yaml
        group: ml-group
        shared-configs:
          - data-id: common-config.yaml
            group: ml-group
            refresh: true
  profiles:
    active: dev

1.3. Cấu hình ứng dụng (ml-course-dev.yaml)

server:
  port: 24103
spring:
  datasource:
    url: jdbc:mysql://192.168.40.77:3306/ml_cms?useUnicode=true&characterEncoding=utf-8
  elasticsearch:
    uris: http://192.168.40.77:9200
springdoc:
  api-docs:
    path: /v3/api-docs
  swagger-ui:
    path: /swagger-ui.html

1.4. Lớp khởi động chính

package com.joezhou;

import org.mybatis.spring.annotation.MapperScan;
import org.springframework.boot.SpringApplication;
import org.springframework.boot.autoconfigure.SpringBootApplication;
import org.springframework.cloud.client.discovery.EnableDiscoveryClient;

@MapperScan("com.joezhou.mapper")
@EnableDiscoveryClient
@SpringBootApplication
public class CourseServiceApplication {
    public static void main(String[] args) {
        SpringApplication.run(CourseServiceApplication.class, args);
    }
}

2. Tích hợp Feign Client cho dịch vụ Người dùng

Tạo giao diện để gọi từ xa tới microservice người dùng nhằm lấy thông tin tài khoản.

@FeignClient(value = "ml-user", fallback = UserClientFallback.class)
public interface UserClient {
    @GetMapping("/internal/user/{uid}")
    Result<User> getUserById(@PathVariable("uid") Long uid);
}

@Component
class UserClientFallback implements UserClient {
    @Override
    public Result<User> getUserById(Long uid) {
        log.error("Không thể kết nối đến dịch vụ Người dùng");
        return Result.fail();
    }
}

3. Phát triển Module Danh mục (Category)

3.1. Định nghĩa DTO và Service

Sử dụng CategoryAddCmd để nhận dữ liệu tạo mới và CategoryModifyCmd để cập nhật.

public interface CategoryManager {
    Long create(CategoryAddCmd cmd);
    CategoryVO getDetail(Long id);
    PageResult<Category> search(CategoryQuery query);
    boolean modify(CategoryModifyCmd cmd);
    boolean remove(Long id);
    boolean batchRemove(List<Long> ids);
}

Logic nghiệp vụ bao gồm kiểm tra trùng lặp tiêu đề và cập nhật thời gian sửa đổi.

@Service
public class CategoryManagerImpl implements CategoryManager {
    @Resource
    private CategoryMapper categoryMapper;

    @Override
    public Long create(CategoryAddCmd cmd) {
        if (categoryMapper.selectCountByCondition(CategoryTableDef.CATEGORY.TITLE.eq(cmd.getTitle())) > 0) {
            throw new AppException("Tiêu đề danh mục đã tồn tại");
        }
        Category entity = BeanUtil.copyProperties(cmd, Category.class);
        entity.setCreated(LocalDateTime.now());
        entity.setUpdated(LocalDateTime.now());
        categoryMapper.insert(entity);
        return entity.getId();
    }
    // ... các phương thức khác
}

3.2. Tầng điều khiển (API)

@RestController
@RequestMapping("/api/v1/categories")
@Tag(name = "Quản lý Danh mục")
public class CategoryController {
    @Resource
    private CategoryManager categoryManager;

    @PostMapping
    @Operation(summary = "Tạo danh mục mới")
    public Long create(@Validated @RequestBody CategoryAddCmd cmd) {
        return categoryManager.create(cmd);
    }

    @GetMapping("/{id}")
    @Operation(summary = "Chi tiết danh mục")
    public CategoryVO detail(@PathVariable Long id) {
        return categoryManager.getDetail(id);
    }
    // ... API khác
}

4. Phát triển Module Khóa học (Course)

4.1. Quan hệ thực thể dữ liệu

Course liên kết nhiều-đến-một với Category, một-đến-nhiều với Season.

@Table("course")
public class Course {
    @Id
    private Long id;
    private String title;
    private String author;
    private Long fkCategoryId;
    
    @RelationManyToOne(selfField = "fkCategoryId", targetField = "id")
    private Category category;
    
    @RelationOneToMany(selfField = "id", targetField = "fkCourseId")
    private List<Season> seasonList;
}

4.2. Tìm kiếm toàn văn với Elasticsearch

Định nghĩa document index:

@Document(indexName = "idx_course")
public class CourseDocument {
    @Id
    private Long id;
    @Field(type = FieldType.Text, analyzer = "ik_max_word")
    private String title;
    @Field(type = FieldType.Text, analyzer = "ik_max_word")
    private String author;
    // ...
}

Repository tìm kiếm:

public interface CourseSearchRepo extends ElasticsearchRepository<CourseDocument, Long> {
    Page<CourseDocument> findByTitleOrAuthor(String keyword, Pageable pageable);
}

4.3. Xử lý tải ảnh bìa và tóm tắt

Logic tải file lên MinIO và cập nhật đường dẫn vào database.

public String uploadCover(MultipartFile file, Long courseId) {
    Course course = courseMapper.selectOneById(courseId);
    if (course == null) throw new AppException("Khóa học không tồn tại");

    String oldFile = course.getCover();
    String newFileName = MinioHelper.generateUniqueName(file);
    
    // Cập nhật DB
    course.setCover(newFileName);
    courseMapper.update(course);
    
    // Xử lý file
    if (!"default.jpg".equals(oldFile)) {
        MinioHelper.deleteFile(oldFile, "covers/");
    }
    MinioHelper.uploadFile(file, newFileName, "covers/");
    return newFileName;
}

5. Phát triển Module Mùa học (Season)

5.1. Cấu trúc DTO

@Data
public class SeasonAddCmd {
    @NotBlank(message = "Tên mùa học không được rỗng")
    private String title;
    
    @NotNull(message = "Thuộc khóa học là bắt buộc")
    private Long courseId;
    
    private Integer sortIdx;
}

5.2. Xóa cascade (Xóa Season tự động xóa Episode)

@Transactional
public boolean remove(Long seasonId) {
    // Lấy danh sách ID các bài học thuộc mùa này
    List<Long> episodeIds = episodeMapper.selectIdsBySeasonId(seasonId);
    if (CollUtil.isNotEmpty(episodeIds)) {
        episodeMapper.deleteBatchByIds(episodeIds);
    }
    return seasonMapper.deleteById(seasonId) > 0;
}

6. Phát triển Module Bài giảng (Episode)

6.1. Tải video lên

Tương tự như tải ảnh bìa, nhưng thao tác với file video dung lượng lớn.

public String uploadVideo(MultipartFile videoFile, Long episodeId) {
    Episode entity = episodeMapper.selectOneById(episodeId);
    String oldPath = entity.getVideoPath();
    String newName = MinioHelper.generateUniqueName(videoFile);

    entity.setVideoPath(newName);
    episodeMapper.update(entity);

    if (!StrUtil.isEmpty(oldPath)) MinioHelper.deleteFile(oldPath, "videos/");
    MinioHelper.uploadFile(videoFile, newName, "videos/");
    return newName;
}

6.2. Xuất báo cáo Excel

@Data
@AllArgsConstructor
@NoArgsConstructor
public class EpisodeExportVO {
    @ExcelProperty("Tên Khóa học")
    private String courseTitle;
    
    @ExcelProperty("Tên Mùa học")
    private String seasonTitle;
    
    @ExcelProperty("Tên Bài học")
    private String episodeTitle;
    // ...
}

@GetMapping("/export/excel")
public void exportExcel(HttpServletResponse response) {
    List<EpisodeExportVO> data = episodeManager.getExportData();
    EasyExcel.write(response.getOutputStream(), EpisodeExportVO.class)
             .sheet("Danh sách bài giảng")
             .doWrite(data);
}

7. Hệ thống Bình luận (Comment)

7.1. Thêm bình luận mới

Sử dụng Feign để lấy thông tin người dùng (nickname, avatar) trước khi lưu.

public void addComment(CommentAddCmd cmd) {
    Result<User> userRes = userClient.getUserById(cmd.getUserId());
    if (userRes == null || userRes.getData() == null) {
        throw new AppException("Người dùng không hợp lệ");
    }
    
    Comment entity = BeanUtil.copyProperties(cmd, Comment.class);
    entity.setUserNickname(userRes.getData().getNickname());
    entity.setCreateTime(LocalDateTime.now());
    commentMapper.insert(entity);
}

8. Module Báo cáo (Report) và Theo dõi (Follow)

8.1. Xóa dữ liệu liên quan đến User

Khi người dùng bị hủy, cần xóa các báo cáo và lượt theo dõi liên quan.

// Trong ReportService
public void deleteByUser(Long userId) {
    UpdateChain.of(reportMapper)
        .where(ReportTableDef.REPORT.USER_ID.eq(userId))
        .remove();
}

// Trong FollowService
public void deleteByUser(Long userId) {
    UpdateChain.of(followMapper)
        .where(FollowTableDef.FOLLOW.USER_ID.eq(userId))
        .remove();
}

Thẻ: Spring Cloud Microservices MyBatis-Flex Elasticsearch MinIO

Đăng vào ngày 17 tháng 05 lúc 09:03