从 0 到 1 开发项目?你是否也是这样开始?先有再优化一步一步带你了解架构设计

25 阅读16分钟

针对于很多刚开始上手设计系统的小伙伴,很多东西都是一个不断踩坑优化的过程,回头看看自己做过的很多东西,是否还有很多感想


我们也是在开发中不断学习成长,从一开始的只会写业务的curd,到慢慢有了封装的思想,到完整架构设计,独当一面,一路走来虽磕磕绊绊但依然再不断坚持进不


一、技术选型,选择当下合适的方案

技术选型:以下面为例

层级技术选型理由
前端框架Vue 3 + Composition API最熟悉,快速出活
构建工具Vite快,开发体验好
UI 组件库Ant Design Vue企业级后台标配
状态管理PiniaVue 3 官方推荐,比 Vuex 轻量
HTTP 客户端Axios拦截器机制适合统一处理
图表ECharts + vue-echarts监控看板离不开图表
后端框架Spring Boot 3Java 生态成熟稳定
数据库MySQL轻量,够用
ORMSpring Data JPA少写 SQL,专注业务
数据库迁移Flyway版本化管理数据库变更
认证JWT无状态,适合前后端分离
API 文档SpringDoc OpenAPI自动生成 Swagger 文档

后端运行在 8080 端口,前端通过 Vite 代理 /api 到后端。分开部署,互不干扰。


二、后端先行:打好地基

2.1 第一个版本的"草稿"

最开始写后端的时候,我没有想太多架构,先让接口跑起来。我写了几个 Controller,每个 Controller 里直接注入 Repository,在 Controller 里直接写业务逻辑:

@RestController
@RequestMapping("/api/users")
public class UserController {
    private final UserRepository userRepository;

    @GetMapping
    public List<UserEntity> list() {
        return userRepository.findAll();
    }
}

这样做在项目初期完全没有问题——代码少,逻辑简单,改起来快。但当业务逻辑开始变多(角色管理、权限分配、树形结构查询),Controller 里的代码迅速膨胀。

2.2 引入 Service 层

大概在第二个模块写完的时候,Controller 已经到了一个难以维护的体积。我决定引入 Service 层:

@Service
public class RoleServiceImpl implements RoleService {

    private final RoleRepository roleRepository;
    private final RoleMenuRepository roleMenuRepository;
    private final MenuRepository menuRepository;

    public RoleServiceImpl(...) { /* 构造器注入 */ }

    @Transactional
    public RoleView create(RoleSaveRequest request) {
        // 校验公司是否存在
        if (!companyRepository.existsById(request.getCompanyId())) {
            throw new EntityNotFoundException("公司不存在");
        }
        // 校验编码唯一性
        if (roleRepository.existsByCodeAndCompanyId(...)) {
            throw new IllegalArgumentException("该公司下角色编码已存在");
        }
        // 创建实体
        RoleEntity role = new RoleEntity();
        applyRequest(role, request);
        return toView(roleRepository.save(role));
    }
}

Controller 变成了这样:

@GetMapping("/{id}")
public ApiResponse<RoleView> getById(@PathVariable Long id) {
    return ApiResponse.success(roleService.getById(id));
}

这里学到的:Controller 负责接收请求和返回响应,业务逻辑必须下沉到 Service 层。这不是过度设计,而是随着业务复杂度增长必然会发生的事情。

2.3 数据库迁移:Flyway 的引入

一开始我用的是 JPA 的 ddl-auto: update,改实体类字段直接自动更新表结构。听起来很方便,但上线后问题来了:

  • 测试环境改了字段,生产环境没有对应的变更记录
  • 不知道哪些 SQL 执行过,哪些没执行过
  • 团队协作时,各自改的数据库不一样

Flyway 解决了这个问题。每个数据库变更都写成一个版本化的 SQL 文件:

db/migration/
  V1__init_schema.sql       # 初始建表
  V2__add_user_display_name.sql  # 后续字段变更

应用启动时自动按顺序执行,确保每个环境的数据库版本一致。

2.4 统一响应格式

最早各个接口返回的数据格式不统一,有的返回实体,有的返回 Map,有的返回 List。前端 axios 拦截器里每次都要处理各种格式。

后来统一了响应格式:

public class ApiResponse<T> {
    private final boolean success;
    private final int code;
    private final String message;
    private final T data;

    public static <T> ApiResponse<T> success(T data) {
        return new ApiResponse<>(true, 0, "ok", data);
    }

    public static <T> ApiResponse<T> fail(int code, String message) {
        return new ApiResponse<>(false, code, message, null);
    }
}

从此所有接口返回格式一致,前端 axios 拦截器只需要处理一种情况:

api.interceptors.response.use((response) => {
  const payload = response.data;
  if (payload.success === false) {
    return Promise.reject(new Error(payload.message));
  }
  return payload.data ?? null;
});

三、JWT 认证:走过的弯路

3.1 最开始的"朴素"方案

最开始我用的是 Spring Security 的表单登录,在后端维护 Session。这种方式在小项目里很常见,但我很快遇到了问题:

  • 前端和后端分开部署,跨域情况下 Session 需要额外处理 Redis 存储
  • 如果前端是 SPA,Session 的管理很麻烦
  • 移动端或第三方接入时,Session 方案不友好

3.2 切换到 JWT

换成 JWT 无状态认证后,整个认证流程清晰了很多:

public class SecurityConfig {
    @Bean
    public SecurityFilterChain filterChain(HttpSecurity http) throws Exception {
        http
            .csrf(csrf -> csrf.disable())
            .sessionManagement(session -> session
                .sessionCreationPolicy(SessionCreationPolicy.STATELESS))
            .authorizeHttpRequests(auth -> auth
                .requestMatchers("/api/auth/login").permitAll()
                .requestMatchers("/v3/api-docs/**", "/swagger-ui/**").permitAll()
                .requestMatchers("/api/**").authenticated()
            )
            .addFilterBefore(jwtAuthFilter, UsernamePasswordAuthenticationFilter.class);
        return http.build();
    }
}

JWT 过滤器在每个请求里提取 Token 并验证:

public class JwtAuthFilter extends OncePerRequestFilter {
    @Override
    protected void doFilterInternal(HttpServletRequest request,
                                    HttpServletResponse response,
                                    FilterChain filterChain) {
        String authHeader = request.getHeader("Authorization");
        if (authHeader != null && authHeader.startsWith("Bearer ")) {
            String token = authHeader.substring(7);
            try {
                Claims claims = jwtUtil.parseToken(token);
                String username = claims.get("username", String.class);
                if (username != null && SecurityContextHolder.getContext()
                        .getAuthentication() == null) {
                    UserDetails userDetails =
                        userDetailsService.loadUserByUsername(username);
                    UsernamePasswordAuthenticationToken authToken =
                        new UsernamePasswordAuthenticationToken(
                            userDetails, null, userDetails.getAuthorities());
                    authToken.setDetails(
                        new WebAuthenticationDetailsSource().buildDetails(request));
                    SecurityContextHolder.getContext()
                        .setAuthentication(authToken);
                }
            } catch (Exception ignored) { }
        }
        filterChain.doFilter(request, response);
    }
}

踩的坑:第一次实现时,我把 Token 验证失败当作正常流程处理(直接忽略异常继续过滤链),这导致即使 Token 无效,请求也能通过过滤器进入 Controller。后来在 SecurityConfig 里明确配置了 authenticationEntryPointaccessDeniedHandler,才把未认证和未授权两种情况正确区分开来。

3.3 前端 Token 处理

前端的 axios 拦截器和后端 JWT 配合:

api.interceptors.request.use((config) => ({
  ...config,
  headers: {
    ...(getToken() ? { Authorization: `Bearer ${getToken()}` } : {}),
    ...(config.headers || {}),
  },
}));

api.interceptors.response.use(
  (response) => { /* 正常返回 */ },
  (error) => {
    if (status === 401 || status === 403) {
      clearToken();
      window.location.href = `/login?redirect=${encodeURIComponent(currentPath)}`;
    }
    return Promise.reject(error);
  }
);

四、前端:Vue 3 的 Composition API 之旅

4.1 最开始的复制粘贴

后端接口ready后,我开始写前端。第一版写表格的方式是这样的——每个页面写一套:

const columns = [...];
const dataSource = ref([]);
const loading = ref(false);
const pagination = reactive({ current: 1, pageSize: 10, total: 0 });

async function load() {
  loading.value = true;
  try {
    const res = await fetchUserList({ ...pagination, ...searchForm });
    dataSource.value = res.data.records;
    pagination.total = res.data.total;
  } finally {
    loading.value = false;
  }
}

五个页面,每页这套逻辑写了约 50 行,加起来就是 250 行重复代码。当时觉得完全没问题——复制粘贴不累。

这是我踩的第一个前端坑:当时没有意识到"重复"在软件工程里的真正代价。当页面数量翻倍,这套逻辑开始变得难以维护。

4.2 useTable 组合式函数

写到第五个页面的时候,终于受不了了。每次写新页面都需要:复制粘贴状态和逻辑、修改 API 调用、修改响应解析、处理分页器事件、处理搜索重置。

我把这套逻辑抽成了一个通用的 composable:

export function useTable(fetchFn, options = {}) {
  const queryParams = ref({ page: 1, pageSize: 10 });
  const searchForm = ref({});
  const dataSource = ref([]);
  const total = ref(0);
  const loading = ref(false);

  async function load() {
    loading.value = true;
    const result = await fetchFn(buildParams());
    // 智能解析各种响应格式...
    dataSource.value = list;
    total.value = totalCount;
  }

  watch(searchForm, () => {
    resetPage();
    load();
  }, { deep: true });

  return {
    searchForm, dataSource, loading, pagination,
    refresh, resetSearch, setPage, load,
  };
}

有了这个之后,每个页面的代码从约 80 行缩减到 20 行:

const { searchForm, dataSource, loading, pagination, refresh } =
  useTable((params) => fetchUserList(params), { immediate: true });

教训:重复代码的真正成本不在于"写的时候费劲",而在于"改的时候要改 N 处并且很容易漏改"。消除重复永远是第一优先级。


五、前后端的接口设计博弈

5.1 后端先写还是前端先写?

这是一个没有标准答案的问题。在实际开发中,我是交替进行的:

  1. 先在白纸上定义好数据结构和接口契约
  2. 后端先实现一个模块的接口
  3. 前端对接这个模块,同时后端实现下一个模块
  4. 发现契约问题及时调整

这样交替进行的最大好处是:前后端始终有一端是可用的,不会因为一方没完成而完全卡住。

5.2 分页格式的统一

后端的分页格式是 PageResponse

public class PageResponse<T> {
    private final List<T> records;
    private final long total;
    private final int page;
    private final int pageSize;
}

前端 useTable 里的响应解析必须兼容这种格式:

if (result?.data != null) {
  const pageData = result.data;
  const list = Array.isArray(pageData)
    ? pageData
    : (pageData.records ?? pageData.list ?? pageData.items ?? []);
  dataSource.value = Array.isArray(list) ? list : [];
  total.value = pageData.total ?? pageData.totalCount ?? list.length ?? 0;
}

这里踩的坑:后端早期用的是 Page<> 对象返回给前端,前端以为返回的是 { records, total } 的格式,但 JPA 的 Page 对象里字段名是 content 而不是 records。排查了半天才发现是字段名不对应。

5.3 树形结构的处理

菜单是树形结构,后端返回嵌套的 JSON:

[
  {
    "id": 1,
    "path": "/system",
    "title": "系统管理",
    "children": [
      { "id": 2, "path": "/system/menu", "title": "菜单管理", "children": [] }
    ]
  }
]

前端在 menu-registry.js 里用递归扁平化树:

function flattenEntries(nodes) {
  const result = [];
  for (const node of nodes) {
    result.push(normalizeEntry(node));
    if (node.children?.length) {
      result.push(...flattenEntries(node.children));
    }
  }
  return result;
}

同时后端也用递归构建树。这里有一个权衡:菜单数据量不大,两端都做树构建没有问题。但如果数据量大,应该只在后端做一次构建,前端直接使用。


六、后端角色权限系统的实现

6.1 权限模型的设计

权限系统的核心是角色-菜单的关联关系。我设计了三层结构:

用户 <--N:N--> 角色 <--N:N--> 菜单

对应的数据库表是 sys_user_rolesys_role_menu。这种多对多设计比直接在用户上挂权限灵活得多——一个用户可以有多个角色,权限继承关系自然形成。

6.2 角色菜单的分配

角色分配菜单权限时,后端采用"覆盖式"策略:

@Transactional
public void assignMenus(Long roleId, AssignMenuRequest request) {
    if (!roleRepository.existsById(roleId)) {
        throw new EntityNotFoundException("角色不存在");
    }
    // 先删除旧关联
    roleMenuRepository.deleteByRoleId(roleId);
    // 再插入新关联
    List<RoleMenuEntity> relations = request.getMenuIds().stream()
        .filter(menuRepository::existsById)
        .map(menuId -> {
            RoleMenuEntity relation = new RoleMenuEntity();
            relation.setRoleId(roleId);
            relation.setMenuId(menuId);
            return relation;
        })
        .toList();
    roleMenuRepository.saveAll(relations);
}

踩的坑:一开始没有加事务注解,deleteByRoleIdsaveAll 不在同一个事务里,导致删除成功但插入失败时数据不一致。加上 @Transactional 后问题解决。


七、路由系统的反复折腾

7.1 最开始的手动配置

最开始,所有前端路由是手动写的:

const routes = [
  { path: '/system/users', component: () => import('../views/system/users/index.vue') },
  { path: '/system/roles', component: () => import('../views/system/roles/index.vue') },
  // 每新增一个页面都要加一条...
];
页面一多,配置和实际文件之间很容易不同步——改了文件名但忘了改路由,或者删了页面但忘了删路由。

### 7.2 文件驱动的自动路由

我写了 `auto-routes.js`,通过 `import.meta.glob` 扫描 `views/` 目录自动生成路由:

```javascript
const viewModules = import.meta.glob("../views/**/*.vue");

function buildGeneratedRoutes() {
  return Object.entries(viewModules)
    .filter(([filePath]) => isRoutableView(filePath))
    .map(([filePath, loader]) => buildRouteRecord(filePath, loader));
}

每个页面通过 routeMeta 导出元数据:

export const routeMeta = {
  title: "角色管理",
  order: 2,
  affix: true,  // 固定在标签页
};

这个设计的精妙之处在于:路由信息不需要写在配置文件里,它就长在页面文件自己身上

7.3 后端菜单数据与前端路由的桥接

菜单从哪里来?这个问题我想了很久。前端路由定义了"有哪些页面",后端菜单定义了"用户能看到哪些页面"。两者需要桥接。

menu-registry.js 承担了这个职责:

export async function refreshMenuEntries() {
  const remoteTree = await fetchMenuTree();    // 从后端拉菜单树
  state.entries = flattenEntries(remoteTree);  // 扁平化存储
  state.initialized = true;
}

// 将菜单元数据合并到路由配置
function resolveRouteMeta(path, fallbackMeta = {}) {
  const current = getEntry(normalizedPath);
  return {
    title: current?.title ?? fallbackMeta.title,
    icon: current?.icon ?? fallbackMeta.icon,
    order: current?.order ?? fallbackMeta.order ?? 0,
    affix: current?.affix ?? fallbackMeta.affix === true,
    // ...
  };
}

八、后端菜单初始化:鸡生蛋的问题

8.1 启动时的数据初始化

菜单管理本身也是系统的一部分,但菜单数据需要先存在才能管理。这形成了一个"鸡生蛋"的问题。

我的解决方案是:在应用启动时自动初始化默认菜单。

@Bean
CommandLineRunner seedMenus(MenuService menuService, DataSeeder dataSeeder) {
    return args -> {
        menuService.initializeDefaultsIfEmpty();
        dataSeeder.seedBaseData();
    };
}

initializeDefaultsIfEmpty 检查数据库是否为空,为空则写入默认菜单数据:

@Transactional
public void initializeDefaultsIfEmpty() {
    if (menuRepository.count() > 0) {
        return;  // 已有数据,跳过
    }
    saveDefaults();  // 写入 /home, /system, /system/menu
}

这样系统第一次启动时就有了一个可用的菜单骨架,后续可以通过管理页面继续扩展。

8.2 DataSeeder 的全量种子数据

除了菜单,我还用 DataSeeder 预置了完整的测试数据:

  • 公司:智行科技、华东智能装备、先达自动化三家示例公司
  • 设备:20 台不同类型的工业设备,覆盖压力容器、塑料机械、气体设备、机器人等
  • 字典:告警级别(紧急/重要/次要/提示)、设备状态、设备类型、厂商等完整字典数据
  • 用户:默认管理员账户 admin / 123456
  • 角色:ADMIN 超级管理员角色,关联所有菜单权限

这样做的好处是:clone 代码后,数据库迁移完就能直接跑起来,不需要手动造数据


九、前端弹窗系统的演进

9.1 从散弹枪到通用弹窗

最早的弹窗,每个页面自己写 <a-modal>,样式各不相同。后来抽成了两层:

  • CommonModal:封装 Ant Design Modal 的统一样式(圆角、阴影、头部、关闭按钮)
  • FormModal:在 CommonModal 基础上,用 fields 配置驱动表单渲染
const roleFormFields = [
  { key: "name", label: "角色名称", type: "input", required: true },
  { key: "code", label: "角色编码", type: "input", required: true },
  { key: "enabled", label: "启用状态", type: "switch" },
];

新增一个表单页面只需要定义字段配置,不需要写新的组件。

9.2 特殊场景的弹窗

但通用弹窗不能覆盖所有场景:

  • 角色分配弹窗:需要按公司分组展示角色列表
  • 权限分配弹窗:需要树形菜单展示和半选中状态

这两个弹窗各自独立实现,因为它们的交互模式完全不同。

这里暴露了一个设计权衡:过度抽象会让代码难以理解,适当的重复反而更健康


十、前端标签页导航

10.1 从没想到到离不开

标签页不是一开始就规划的功能。第一版做的是单页导航,后来在实际使用时发现一个问题:在"用户管理"页面编辑表单,想去看一眼首页数据,再点回来——表单没了。

标签页成了必需品。

10.2 标签页状态管理

const state = reactive({ visitedTabs: [] });

function addTab(route) {
  ensureHomeTab();
  if (!route?.path || route.meta?.hidden) return;
  const existing = state.visitedTabs.find((t) => t.path === route.path);
  if (existing) {
    existing.title = route.meta?.title ?? existing.title;
    return;  // 已存在则只更新标题
  }
  state.visitedTabs.push(buildTab(route));
}

几个细节:

  • 固定标签页:首页永远固定,不能关闭
  • 自动滚动:新标签添加后,自动滚动使当前标签可见
  • 删除回退:关闭当前标签时自动跳转到前一个标签

十一、样式系统:一直在纠结

样式这块我折腾了很久。最早用纯 CSS 变量管理主题色,后来引入了 Tailwind CSS 做工具类,最后又加上了 Less 做组件级样式。

:root {
  --color-primary: #2f78e6;
  --color-primary-strong: #1e5fc7;
  --color-primary-soft: rgba(47, 120, 230, 0.1);
  --radius-md: 8px;
  --shadow-highlight: 0 2px 8px rgba(47, 120, 230, 0.08);
}

设计风格上,最终选择了"玻璃拟态"方向:半透明卡片、毛玻璃背景、柔和的阴影和渐变。这个审美是在看了大量后台模板之后慢慢形成的,不是最初就确定的。


十二、后端缓存策略

菜单数据在 MenuServiceImpl 里使用了 Spring Cache:

@Cacheable(cacheNames = "menus:list", key = "#visibleOnly")
public List<MenuView> list(boolean visibleOnly) {
    return menuRepository.findAll().stream().map(this::toView).toList();
}

@CacheEvict(cacheNames = {"menus:list", "menus:tree"}, allEntries = true)
public MenuView create(MenuSaveRequest request) { ... }

菜单属于读多写少的数据,缓存命中率很高。但要注意 allEntries = true 的清理策略——写操作时清除整个缓存,虽然简单但不够精细。对于菜单这种数据量不大的场景,足够了。


十三、回过头来看,哪些做对了

后端

  1. Service 接口与实现分离。接口定义业务契约,实现负责具体逻辑。这让我在重构 RoleServiceImpl 的树构建算法时,不需要动 Controller 一行代码。

  2. 统一响应格式 ApiResponse。所有接口返回格式一致,前后端对接时减少了很多不必要的沟通。

  3. Flyway 数据库迁移。版本化管理数据库变更,团队协作和多环境部署不再是问题。

  4. DataSeeder 种子数据。新环境启动后就有完整数据,不需要手动造,开发者体验大大提升。

  5. JWT 无状态认证。配合前端 axios 拦截器,认证逻辑清晰可靠。

前端

  1. 文件驱动的路由系统。新增页面只需要在 views/ 下创建 .vue 文件,路由配置自动生成。这是整个前端最满意的设计之一。

  2. useTable 组合式函数。把表格相关的状态和逻辑封装成一个可复用单元,页面代码从 80 行缩减到 20 行。

  3. routeMeta 元数据约定。页面自身参与系统的元数据构建,而不是把所有信息写在配置里。

  4. menu-registry 作为中间层。它桥接了前端文件路由和后端菜单数据,解决了"谁来定义菜单"这个架构问题。


十四、哪些地方留下了遗憾

后端

  1. 没有全局异常处理器。目前每个 Service 方法里的异常都是自己 throw,没有统一的 @ControllerAdvice 来兜底。如果某个异常没被捕获,就会变成 500 堆栈信息暴露给前端。

  2. 没有单元测试。Service 层的核心逻辑(如树构建、权限分配)应该有单元测试覆盖。

  3. 权限校验是空的。目前的 SecurityConfig 只做了"认证"(是否登录),没有做"授权"(是否有权限访问某个接口)。理论上每个接口都应该校验当前用户是否拥有对应的菜单权限。

  4. 数据Seeder的幂等性检查不够精细seedBaseData() 用的是"如果存在就跳过"策略,当数据量大了之后,性能会下降。

前端

  1. 登录页的 mock 数据。品牌展示面板里的数据全是写死的 mock,没有接入真实后端。

  2. 字典管理模块。左右布局是正确的,但部分数据还没有完全与后端打通。

  3. 样式管理混乱。CSS 文件散落在多处,没有统一的规范。重来一次会强制使用 Scoped CSS + CSS Variables。

  4. ECharts 配置重复。每个图表的 ECharts 配置写法类似但没有充分抽成工厂函数。


十五、写在最后

开发这套系统让我深刻体会到一件事:架构是被问题驱动着长出来的,不是设计出来的

后端从最早的直接在 Controller 里写逻辑,到引入 Service 层、DVO 转换、统一响应格式;前端从复制粘贴表格代码,到 useTable 组合式函数、文件驱动路由、菜单注册中心。每一步重构的背后,都是"这里不舒服"的真实痛点在驱动。

前后端分离的协作模式也教会了我一件事:接口契约的重要性远超实现细节。后端定义好 ApiResponse<T> 的格式后,前端可以完全不关心后端用什么框架实现。接口是前后端之间的"宪法",稳定比灵活更重要。

MVP 优先跑起来,在真实使用中发现问题,用重构代替空想设计。这是我做这个项目的核心方法论,也是我认为最务实的个人项目开发路径。

希望这篇记录对你有所启发。代码从来不完美,完美的是那些让代码越来越好的思考和行动。