1. 概述

本文介绍如何通过 CSV 文件来外部化应用的初始化数据,避免将数据硬编码在代码中。

这类初始化操作通常用于系统首次部署时,快速准备基础数据(如默认用户、角色、权限等)。相比手动插入或写死在代码里,CSV 方式更灵活、易维护,也方便不同环境间迁移。

2. 选择 CSV 处理库

Java 生态中有不少处理 CSV 的库,比如 OpenCSV、Apache Commons CSV 等。本文选用 Jackson CSV 扩展库,原因很实际:

✅ 项目大概率 already 引入了 Jackson(用于 JSON 处理)
✅ API 简洁,学习成本低
✅ 支持 POJO 映射,开发效率高

Maven 依赖如下:

<dependency>
    <groupId>com.fasterxml.jackson.dataformat</groupId>
    <artifactId>jackson-dataformat-csv</artifactId>       
    <version>2.5.3</version>
</dependency>

⚠️ 注意版本兼容性,建议与项目中已有的 Jackson 版本保持一致。

3. 初始化数据设计

不同项目需要初始化的数据类型不同。本文以用户系统为例,初始化以下三类数据:

  • 默认权限(Privilege)
  • 角色(Role)
  • 用户(User)

并通过两个关联文件维护多对多关系:

  • roles_privileges.csv:角色与权限的映射
  • users_roles.csv:用户与角色的映射

3.1 用户数据示例

users.csv 文件内容如下:

id,username,password,accessToken
1,john,123,token
2,tom,456,test

✅ 第一行是 header,对应实体字段名
✅ 后续每行代表一个用户记录

4. CSV 数据加载器

我们封装一个通用的 CsvDataLoader,负责将 CSV 文件读取为内存中的对象列表。

4.1 加载对象列表

核心方法 loadObjectList() 使用 Jackson CSV 实现 POJO 映射:

public <T> List<T> loadObjectList(Class<T> type, String fileName) {
    try {
        CsvSchema bootstrapSchema = CsvSchema.emptySchema().withHeader();
        CsvMapper mapper = new CsvMapper();
        File file = new ClassPathResource(fileName).getFile();
        MappingIterator<T> readValues = 
          mapper.reader(type).with(bootstrapSchema).readValues(file);
        return readValues.readAll();
    } catch (Exception e) {
        logger.error("Error occurred while loading object list from file " + fileName, e);
        return Collections.emptyList();
    }
}

关键点说明:

  • CsvSchema.emptySchema().withHeader():自动根据首行 header 映射字段
  • 泛型设计,支持任意类型 POJO
  • 异常兜底返回空列表,避免初始化失败导致应用启动中断

4.2 处理多对多关系

CSV 不支持嵌套对象,因此多对多关系需通过“关联表”方式单独管理。

我们提供 loadManyToManyRelationship() 方法,直接读取为 String[] 列表:

public List<String[]> loadManyToManyRelationship(String fileName) {
    try {
        CsvMapper mapper = new CsvMapper();
        CsvSchema bootstrapSchema = CsvSchema.emptySchema().withSkipFirstDataRow(true);
        mapper.enable(CsvParser.Feature.WRAP_AS_ARRAY);
        File file = new ClassPathResource(fileName).getFile();
        MappingIterator<String[]> readValues = 
          mapper.reader(String[].class).with(bootstrapSchema).readValues(file);
        return readValues.readAll();
    } catch (Exception e) {
        logger.error(
          "Error occurred while loading many to many relationship from file = " + fileName, e);
        return Collections.emptyList();
    }
}

例如 roles_privileges.csv 内容:

role,privilege
ROLE_ADMIN,ADMIN_READ_PRIVILEGE
ROLE_ADMIN,ADMIN_WRITE_PRIVILEGE
ROLE_SUPER_USER,POST_UNLIMITED_PRIVILEGE
ROLE_USER,POST_LIMITED_PRIVILEGE

⚠️ 注意:这里跳过 header(withSkipFirstDataRow(true)),因为我们只关心数据行。

5. 初始化服务实现

通过一个 @Component Bean 在应用启动时自动执行初始化。

@Component
public class Setup {
    
    @PostConstruct
    private void setupData() {
        setupRolesAndPrivileges();
        setupUsers();
    }
    
    // ...
}

5.1 初始化角色与权限

步骤如下:

  1. 从 CSV 加载权限列表
  2. 加载角色列表
  3. 加载角色-权限映射关系,并绑定到角色对象
  4. 逐个持久化(避免重复插入)
public List<Privilege> getPrivileges() {
    return csvDataLoader.loadObjectList(Privilege.class, PRIVILEGES_FILE);
}

public List<Role> getRoles() {
    List<Privilege> allPrivileges = getPrivileges();
    List<Role> roles = csvDataLoader.loadObjectList(Role.class, ROLES_FILE);
    List<String[]> rolesPrivileges = csvDataLoader.
      loadManyToManyRelationship(SetupData.ROLES_PRIVILEGES_FILE);

    for (String[] rolePrivilege : rolesPrivileges) {
        Role role = findRoleByName(roles, rolePrivilege[0]);
        Set<Privilege> privileges = role.getPrivileges();
        if (privileges == null) {
            privileges = new HashSet<Privilege>();
        }
        privileges.add(findPrivilegeByName(allPrivileges, rolePrivilege[1]));
        role.setPrivileges(privileges);
    }
    return roles;
}

private Role findRoleByName(List<Role> roles, String roleName) {
    return roles.stream()
      .filter(item -> item.getName().equals(roleName)).findFirst().get();
}

private Privilege findPrivilegeByName(List<Privilege> allPrivileges, String privilegeName) {
    return allPrivileges.stream()
      .filter(item -> item.getName().equals(privilegeName)).findFirst().get();
}

持久化逻辑在 SetupService 中:

private void setupRolesAndPrivileges() {
    List<Privilege> privileges = setupData.getPrivileges();
    for (Privilege privilege : privileges) {
        setupService.setupPrivilege(privilege);
    }

    List<Role> roles = setupData.getRoles();
    for (Role role : roles) {
        setupService.setupRole(role);
    }
}

public void setupPrivilege(Privilege privilege) {
    if (privilegeRepository.findByName(privilege.getName()) == null) {
        privilegeRepository.save(privilege);
    }
}

public void setupRole(Role role) {
    if (roleRepository.findByName(role.getName()) == null) { 
        Set<Privilege> privileges = role.getPrivileges(); 
        Set<Privilege> persistedPrivileges = new HashSet<Privilege>();
        for (Privilege privilege : privileges) { 
            persistedPrivileges.add(privilegeRepository.findByName(privilege.getName())); 
        } 
        role.setPrivileges(persistedPrivileges); 
        roleRepository.save(role); 
    }
}

✅ 先查后插,防止重复
✅ 权限需先持久化,角色才能引用

5.2 初始化用户

流程类似:

  1. 加载用户列表
  2. 加载用户-角色关系
  3. 绑定角色
  4. 持久化用户(同时创建默认偏好设置)
public List<User> getUsers() {
    List<Role> allRoles = getRoles();
    List<User> users = csvDataLoader.loadObjectList(User.class, SetupData.USERS_FILE);
    List<String[]> usersRoles = csvDataLoader.
      loadManyToManyRelationship(SetupData.USERS_ROLES_FILE);

    for (String[] userRole : usersRoles) {
        User user = findByUserByUsername(users, userRole[0]);
        Set<Role> roles = user.getRoles();
        if (roles == null) {
            roles = new HashSet<Role>();
        }
        roles.add(findRoleByName(allRoles, userRole[1]));
        user.setRoles(roles);
    }
    return users;
}

private User findByUserByUsername(List<User> users, String username) {
    return users.stream()
      .filter(item -> item.getUsername().equals(username)).findFirst().get();
}

持久化逻辑:

private void setupUsers() {
    List<User> users = setupData.getUsers();
    for (User user : users) {
        setupService.setupUser(user);
    }
}

@Transactional
public void setupUser(User user) {
    try {
        setupUserInternal(user);
    } catch (Exception e) {
        logger.error("Error occurred while saving user " + user.toString(), e);
    }
}

private void setupUserInternal(User user) {
    if (userRepository.findByUsername(user.getUsername()) == null) {
        user.setPassword(passwordEncoder.encode(user.getPassword()));
        user.setPreference(createSimplePreference(user));
        Set<Role> roles = user.getRoles(); 
        Set<Role> persistedRoles = new HashSet<Role>(); 
        for (Role role : roles) { 
            persistedRoles.add(roleRepository.findByName(role.getName())); 
        } 
        user.setRoles(persistedRoles);
        userRepository.save(user);
    }
}

private Preference createSimplePreference(User user) {
    Preference pref = new Preference();
    pref.setId(user.getId());
    pref.setTimezone(TimeZone.getDefault().getID());
    pref.setEmail(user.getUsername() + "@example.com");
    return preferenceRepository.save(pref);
}

✅ 密码需加密存储
✅ 创建默认 Preference 并保存

6. 单元测试:CSV 加载器

验证 CsvDataLoader 基本功能:

@Test
public void whenLoadingUsersFromCsvFile_thenLoaded() {
    List<User> users = csvDataLoader
      .loadObjectList(User.class, CsvDataLoader.USERS_FILE);
    assertFalse(users.isEmpty());
}

@Test
public void whenLoadingRolesFromCsvFile_thenLoaded() {
    List<Role> roles = csvDataLoader
      .loadObjectList(Role.class, CsvDataLoader.ROLES_FILE);
    assertFalse(roles.isEmpty());
}

@Test
public void whenLoadingPrivilegesFromCsvFile_thenLoaded() {
    List<Privilege> privileges = csvDataLoader
      .loadObjectList(Privilege.class, CsvDataLoader.PRIVILEGES_FILE);
    assertFalse(privileges.isEmpty());
}

@Test
public void whenLoadingUsersRolesRelationFromCsvFile_thenLoaded() {
    List<String[]> usersRoles = csvDataLoader
      .loadManyToManyRelationship(CsvDataLoader.USERS_ROLES_FILE);
    assertFalse(usersRoles.isEmpty());
}

@Test
public void whenLoadingRolesPrivilegesRelationFromCsvFile_thenLoaded() {
    List<String[]> rolesPrivileges = csvDataLoader
      .loadManyToManyRelationship(CsvDataLoader.ROLES_PRIVILEGES_FILE);
    assertFalse(rolesPrivileges.isEmpty());
}

7. 单元测试:初始化数据逻辑

验证 SetupData 是否正确构建了关联关系:

@Test
public void whenGettingUsersFromCsvFile_thenCorrect() {
    List<User> users = setupData.getUsers();

    assertFalse(users.isEmpty());
    for (User user : users) {
        assertFalse(user.getRoles().isEmpty());
    }
}

@Test
public void whenGettingRolesFromCsvFile_thenCorrect() {
    List<Role> roles = setupData.getRoles();

    assertFalse(roles.isEmpty());
    for (Role role : roles) {
        assertFalse(role.getPrivileges().isEmpty());
    }
}

@Test
public void whenGettingPrivilegesFromCsvFile_thenCorrect() {
    List<Privilege> privileges = setupData.getPrivileges();
    assertFalse(privileges.isEmpty());
}

8. 总结

本文实现了一套基于 CSV 的 Spring 应用数据初始化方案,特点如下:

  • ✅ 简单粗暴,适合中小项目快速搭建基础数据
  • ✅ 利用 Jackson 已有技术栈,无额外学习成本
  • ✅ 数据与代码分离,便于维护和环境迁移

⚠️ 注意:本文仅为 PoC 示例,不可直接用于生产环境。生产场景建议考虑:

  • 更健壮的错误处理(如 CSV 格式校验)
  • 支持增量更新与版本控制
  • 结合 Flyway/Liquibase 管理数据变更
  • 使用 YAML/JSON 替代 CSV(可读性更好)

该方案已在 Spring Reddit 案例研究 项目中实践,后续将持续优化。


原始标题:Setup Data via CSV in a Spring Application

» 下一篇: Baeldung每周评论33