Init
This commit is contained in:
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,970 @@
|
||||
---
|
||||
tags:
|
||||
- 前端
|
||||
- React
|
||||
- Hooks
|
||||
- 路由
|
||||
- 测试
|
||||
- 自我考察
|
||||
create time: 2026-04-30 15:00
|
||||
update time: 2026-04-30 15:45
|
||||
status: reviewed
|
||||
---
|
||||
|
||||
# React Hook 与路由状态测试
|
||||
|
||||
## 使用说明
|
||||
|
||||
本试卷覆盖 **Hooks 核心机制 → 自定义 Hooks → 路由状态管理 → 工程实践** 全链路知识点。共四部分:选择题、编程题、场景分析、综合应用。
|
||||
|
||||
**建议用时:** 45 分钟 | **及格线:** 70 / 100
|
||||
|
||||
做完后对照下方「参考答案」自评,错的地方回到对应笔记重新理解。
|
||||
|
||||
---
|
||||
|
||||
## 一、选择题(每题 3 分,共 45 分)
|
||||
|
||||
> 每题只有一个正确答案。
|
||||
|
||||
### Q1.【useState 的状态更新机制】
|
||||
|
||||
以下关于 `useState` 的描述,**错误**的是:
|
||||
|
||||
A. 同一批次内连续调用多次 `setState` 会被合并优化
|
||||
B. 状态更新是异步的,因此更新后立即访问 state 可能是旧值
|
||||
C. 当新状态与旧状态使用 `Object.is` 比较为相同值时,组件不会重新渲染
|
||||
D. 可以直接修改 state 对象来触发重新渲染
|
||||
|
||||
> [!tip]- Q1 答案
|
||||
> **D — React 要求状态不可变,直接修改 state 对象不会触发重新渲染**
|
||||
>
|
||||
> **解析:**
|
||||
> React 的状态更新遵循不可变性原则。直接修改 state 对象(如 `state.count = count + 1`)不会触发组件重新渲染,因为 React 无法检测到这种变化。必须通过 `setState` 返回新对象。
|
||||
>
|
||||
> **为什么其他选项正确:**
|
||||
> - ✅ A:React 会将同一批次内的多次 setState 合并为一次更新,这是批处理优化
|
||||
> - ✅ B:setState 是异步的(在 React 18 中使用自动批处理),更新后立即访问 state 可能仍是旧值
|
||||
> - ✅ C:React 使用 `Object.is` 比较新旧状态,如果相同则跳过渲染
|
||||
>
|
||||
> ```tsx
|
||||
> // ❌ 错误:直接修改
|
||||
> state.count = state.count + 1;
|
||||
>
|
||||
> // ✅ 正确:返回新对象
|
||||
> setCount(prev => prev + 1);
|
||||
> ```
|
||||
>
|
||||
> 💡 **核心原则:** 状态不可变性是 React 的基石,它让 React 能够通过浅比较快速判断是否需要重新渲染。
|
||||
|
||||
### Q2.【useEffect 的依赖数组】
|
||||
|
||||
```tsx
|
||||
useEffect(() => {
|
||||
fetchData();
|
||||
}, [userId]);
|
||||
```
|
||||
|
||||
当 `userId` 变化时,以下哪种说法正确?
|
||||
|
||||
A. 先执行清理函数(如果有的话),再执行 effect
|
||||
B. 直接执行 effect,忽略之前的 effect
|
||||
C. 必须在 effect 内部手动调用 cleanup
|
||||
D. 以上都不对
|
||||
|
||||
> [!tip]- Q2 答案
|
||||
> **A — React 在每次执行新的 effect 之前,会先执行上一次 effect 的清理函数**
|
||||
>
|
||||
> **解析:**
|
||||
> 这是 React 的清理机制核心设计。当依赖项变化时:
|
||||
> 1. 先执行上一次 effect 的清理函数(cleanup)
|
||||
> 2. 再执行新的 effect
|
||||
>
|
||||
> 这种设计确保了资源的正确释放,避免内存泄漏。
|
||||
>
|
||||
> ```tsx
|
||||
> useEffect(() => {
|
||||
> const controller = new AbortController();
|
||||
> // effect 逻辑
|
||||
> return () => controller.abort(); // 清理函数
|
||||
> }, [userId]);
|
||||
> ```
|
||||
>
|
||||
> 💡 **应用场景:** 取消未完成的请求、清除定时器、取消事件监听器等。
|
||||
|
||||
### Q3.【路由参数获取】
|
||||
|
||||
在 React Router v6+ 中,以下哪种方式**不能**获取动态路由参数 `/user/:id` 中的 `id`?
|
||||
|
||||
A. `useParams()` hook
|
||||
B. `useMatch('/user/:id')` 返回的 match 对象
|
||||
C. `useLocation()` 解析 pathname
|
||||
D. 直接从 props 读取 `props.params.id`
|
||||
|
||||
> [!tip]- Q3 答案
|
||||
> **D — 函数组件不再通过 props 接收路由参数**
|
||||
>
|
||||
> **解析:**
|
||||
> 在 React Router v6+ 中,函数组件使用 hooks 获取路由参数,而不是通过 props。`props.params` 是 Class 组件时代的 React Router v5 写法。
|
||||
>
|
||||
> | 方式 | 是否可行 | 说明 |
|
||||
> |------|---------|------|
|
||||
> | `useParams()` | ✅ | 最常用,返回参数对象 |
|
||||
> | `useMatch()` | ✅ | 返回匹配信息,包含 params |
|
||||
> | `useLocation()` | ⚠️ | 可以解析 pathname,但需要手动提取 |
|
||||
> | `props.params` | ❌ | v5 写法,v6 不支持 |
|
||||
>
|
||||
> ```tsx
|
||||
> // React Router v6+ 正确做法
|
||||
> const { id } = useParams();
|
||||
> ```
|
||||
>
|
||||
> 💡 **迁移注意:** 从 v5 迁移到 v6 时,需要将 `props.params.xxx` 改为 `useParams().xxx`。
|
||||
|
||||
### Q4.【自定义 Hook 的调用规则】
|
||||
|
||||
以下关于自定义 Hook 的代码,哪个会导致运行时错误?
|
||||
|
||||
A. 在组件顶层调用 `useCustomHook()`
|
||||
B. 在 useEffect 回调中调用 `useCustomHook()`
|
||||
C. 在条件语句中调用 `useCustomHook()`
|
||||
D. 在事件处理函数中调用 `useCustomHook()`
|
||||
|
||||
> [!tip]- Q4 答案
|
||||
> **C — Hooks 必须在组件顶层或自定义 Hook 中调用,不能在条件语句、循环或嵌套函数中调用**
|
||||
>
|
||||
> **解析:**
|
||||
> 这违反了 Hooks 的调用顺序规则。React 依赖 Hook 的调用顺序来正确关联状态和 Effect。如果在条件语句中调用 Hook,当条件变化时,Hook 的调用顺序也会变化,导致状态错乱。
|
||||
>
|
||||
> ```tsx
|
||||
> // ❌ 错误:条件调用
|
||||
> if (isLoggedIn) {
|
||||
> const data = useUserData(); // 违反规则
|
||||
> }
|
||||
>
|
||||
> // ✅ 正确:顶层调用
|
||||
> const data = useUserData();
|
||||
> const result = isLoggedIn ? data : null;
|
||||
> ```
|
||||
>
|
||||
> 💡 **记忆口诀:** "Hook 只能在顶层调用,不在条件、循环、嵌套函数中使用"。
|
||||
|
||||
### Q5.【嵌套路由的 Outlet 使用】
|
||||
|
||||
关于 React Router 的 `<Outlet>` 组件,以下哪项描述正确?
|
||||
|
||||
A. Outlet 必须在父路由组件中渲染
|
||||
B. Outlet 只能渲染一层嵌套
|
||||
C. Outlet 的位置可以是任意的,只要在父组件中
|
||||
D. Outlet 渲染的内容由父路由的 `element` 属性决定
|
||||
|
||||
> [!tip]- Q5 答案
|
||||
> **A — Outlet 必须在父路由组件中渲染**
|
||||
>
|
||||
> **解析:**
|
||||
> `<Outlet>` 是嵌套路由的核心,它作为占位符,渲染匹配的子路由组件。每个嵌套路由的父组件都必须包含一个 `<Outlet>`,否则子路由无法渲染。
|
||||
>
|
||||
> ```tsx
|
||||
> // 父路由组件
|
||||
> function Layout() {
|
||||
> return (
|
||||
> <div>
|
||||
> <Sidebar />
|
||||
> <Outlet /> {/* 子路由在此渲染 */}
|
||||
> </div>
|
||||
> );
|
||||
> }
|
||||
>
|
||||
> // 路由配置
|
||||
> <Route path="/dashboard" element={<Layout />}>
|
||||
> <Route path="profile" element={<Profile />} />
|
||||
> <Route path="settings" element={<Settings />} />
|
||||
> </Route>
|
||||
> ```
|
||||
>
|
||||
> **为什么其他选项错误:**
|
||||
> - ❌ B:Outlet 可以渲染任意深度的嵌套
|
||||
> - ❌ C:位置可以任意,但通常放在需要显示子路由内容的地方
|
||||
> - ❌ D:Outlet 渲染的是匹配的子路由组件,不是父路由的 element
|
||||
>
|
||||
> 💡 **最佳实践:** Outlet 通常放在布局组件中,用于渲染子页面内容。
|
||||
|
||||
---
|
||||
|
||||
### Q6.【Context 的重新渲染行为】
|
||||
|
||||
```tsx
|
||||
const ThemeContext = createContext({ theme: 'light', setTheme: () => {} });
|
||||
|
||||
function App() {
|
||||
const [theme, setTheme] = useState('light');
|
||||
return (
|
||||
<ThemeContext.Provider value={{ theme, setTheme }}>
|
||||
<Header />
|
||||
<Sidebar />
|
||||
</ThemeContext.Provider>
|
||||
);
|
||||
}
|
||||
```
|
||||
|
||||
关于以上代码,以下说法**正确**的是:
|
||||
|
||||
A. `Header` 和 `Sidebar` 只有在 `theme` 变化时才会重新渲染
|
||||
B. `Header` 和 `Sidebar` 在任何时候都不会重新渲染,因为它们没有直接订阅 context
|
||||
C. 只要 `value` 引用变化,所有消费该 context 的组件都会重新渲染,即使用到的值没变
|
||||
D. 应该在每次渲染时创建新的 context 值,以确保数据最新
|
||||
|
||||
> [!tip]- Q6 答案
|
||||
> **C — Context Provider 使用引用比较来判断是否需要通知消费者**
|
||||
>
|
||||
> **解析:**
|
||||
> `value={{ theme, setTheme }}` 在每次渲染时都是新对象,导致所有消费该 context 的组件都重新渲染。即使用了的值没变也会渲染。
|
||||
>
|
||||
> **解决方案:**
|
||||
> ```tsx
|
||||
> // ✅ 使用 useMemo 稳定 value 引用(仅当相关值变化时更新)
|
||||
> const value = useMemo(() => ({ theme, setTheme }), [theme]);
|
||||
>
|
||||
> // ✅ 或拆分 context,只让需要的部分变化
|
||||
> const ThemeValueContext = createContext('light');
|
||||
> const SetThemeContext = createContext(() => {});
|
||||
> ```
|
||||
>
|
||||
> 💡 **核心原则:** context 是「全有或全无」的通知机制,无法按字段粒度优化。需要细粒度控制时应拆分 context 或使用状态管理库。
|
||||
>
|
||||
> **为什么其他选项错误:**
|
||||
> - ❌ A:忽略了 value 引用变化的影响
|
||||
> - ❌ B:消费 context 的组件会在 provider value 变化时重新渲染
|
||||
> - ❌ D:恰恰相反,应稳定 value 引用以避免不必要的渲染
|
||||
|
||||
### Q7.【React 虚拟 DOM Diff 算法】
|
||||
|
||||
关于 React 的 Diff 算法,以下描述**正确**的是:
|
||||
|
||||
A. React 会对树进行深度优先对比,逐个节点比较
|
||||
B. React 只对同层组件进行比较,不同层的组件不会跨层对比
|
||||
C. React 通过 key 属性来精确匹配组件身份,避免不必要的重建
|
||||
D. 当列表元素的顺序发生变化时,React 会重新排列 DOM 节点而不是重建
|
||||
|
||||
> [!tip]- Q7 答案
|
||||
> **B、C、D 均为正确描述(本题为多选题)**
|
||||
>
|
||||
> **解析:**
|
||||
> React 的 Diff 算法采用三种启发式策略简化复杂度:
|
||||
> 1. **只对同层比较**:不同树的节点直接销毁重建,不会跨层移动
|
||||
> 2. **key 匹配身份**:列表中使用稳定的 key 帮助 React 识别哪些元素变了
|
||||
> 3. **类型相同则复用**:同层同类型的组件会复用实例,仅更新变化的 props
|
||||
>
|
||||
> ```tsx
|
||||
> // ❌ 不推荐:用 index 作为 key,会导致不必要的状态重置和渲染错误
|
||||
> {items.map((item, index) => <Item key={index} item={item} />)}
|
||||
>
|
||||
> // ✅ 推荐:使用稳定且唯一的 id 作为 key
|
||||
> {items.map(item => <Item key={item.id} item={item} />)}
|
||||
> ```
|
||||
>
|
||||
> 💡 **性能关键:** key 的作用是帮助 React 识别「哪个元素变了」而非「是否应该渲染」。没有 key 时 React 依赖索引,列表增删时会出错。
|
||||
>
|
||||
> **为什么 A 错误:** React 的 diff 是同级比较,不是纯粹的深度优先,且采用了类型优先的比较策略。
|
||||
|
||||
### Q8.【useCallback 与 useMemo 的区别】
|
||||
|
||||
以下关于 `useCallback` 和 `useMemo` 的说法,**错误**的是:
|
||||
|
||||
A. `useCallback(fn, deps)` 等价于 `useMemo(() => fn, deps)`
|
||||
B. 两者返回的值在依赖不变时都是稳定的引用
|
||||
C. `useMemo` 用于缓存计算结果,`useCallback` 用于缓存函数本身
|
||||
D. 如果没有传入依赖数组,两者的行为与直接使用原始值没有区别
|
||||
|
||||
> [!tip]- Q8 答案
|
||||
> **D — 没有依赖数组时,每次渲染都会重新计算,失去缓存意义**
|
||||
>
|
||||
> **解析:**
|
||||
> 省略依赖数组意味着 Hooks 不会做任何缓存——每次渲染都会执行回调并返回新值,这与普通变量的行为无异。
|
||||
>
|
||||
> **对比:**
|
||||
>
|
||||
> | Hook | 返回值 | 典型用途 |
|
||||
> |------|--------|---------|
|
||||
> | `useCallback(fn, deps)` | 缓存的函数引用 | 传给子组件的回调 |
|
||||
> | `useMemo(() => val, deps)` | 缓存的计算结果 | 昂贵计算、衍生状态 |
|
||||
>
|
||||
> ```tsx
|
||||
> // ✅ 正确:有依赖数组
|
||||
> const memoizedFn = useCallback(() => doSomething(a, b), [a, b]);
|
||||
> const memoizedValue = useMemo(() => expensiveCalc(data), [data]);
|
||||
>
|
||||
> // ⚠️ 常见误区:不加 deps 等于白写
|
||||
> const badFn = useCallback(() => doSomething(a), []); // a 变化时不会更新
|
||||
> ```
|
||||
>
|
||||
> 💡 **最佳实践:** 只在传参给 memo 子组件或有第三方库依赖时使用 useCallback,日常函数不需要过度优化。
|
||||
|
||||
### Q9.【Error Boundary 的能力边界】
|
||||
|
||||
以下哪种错误**不能**被 Error Boundary 捕获?
|
||||
|
||||
A. 事件处理函数中的错误
|
||||
B. 生命周期钩子中的错误
|
||||
C. 组件树渲染过程中的错误
|
||||
D. `useEffect` 中的异步错误
|
||||
|
||||
> [!tip]- Q9 答案
|
||||
> **A、D — Error Boundary 只能捕获渲染期、生命周期期和构造期的同步错误**
|
||||
>
|
||||
> **解析:**
|
||||
> Error Boundary 是一个特殊的 React 组件,通过声明周期方法捕获子组件树的错误。但它有明确的限制:
|
||||
>
|
||||
> | 场景 | 能否捕获 | 原因 |
|
||||
> |------|---------|------|
|
||||
> | 渲染阶段 | ✅ | 标准捕获范围 |
|
||||
> | 生命周期 | ✅ | 标准捕获范围 |
|
||||
> | 构造函数 | ✅ | 标准捕获范围 |
|
||||
> | 事件处理 | ❌ | 属于异步回调,不在捕获范围 |
|
||||
> | useEffect 异步 | ❌ | 异步操作不在捕获范围 |
|
||||
> | 自身错误 | ❌ | 不会捕获自己抛出的错误 |
|
||||
> | 路由 | ❌ | React Router 的错误不属于 React 抛出 |
|
||||
> | 异常处理 | ❌ | `try/catch` 无法捕获 Promise 未处理错误 |
|
||||
>
|
||||
> **正确的错误处理方式:**
|
||||
> ```tsx
|
||||
> // 事件处理中自行处理
|
||||
> function handleClick() {
|
||||
> try {
|
||||
> riskyOperation();
|
||||
> } catch (err) {
|
||||
> handleError(err);
|
||||
> }
|
||||
> }
|
||||
>
|
||||
> // useEffect 中的异步错误
|
||||
> useEffect(() => {
|
||||
> fetchData().catch(err => console.error(err));
|
||||
> }, []);
|
||||
>
|
||||
> // Error Boundary 组件
|
||||
> class ErrorBoundary extends React.Component {
|
||||
> static getDerivedStateFromError(error) {
|
||||
> return { hasError: true };
|
||||
> }
|
||||
> componentDidCatch(error, errorInfo) {
|
||||
> reportToService(error, errorInfo);
|
||||
> }
|
||||
> render() {
|
||||
> if (this.state.hasError) return <FallbackUI />;
|
||||
> return this.props.children;
|
||||
> }
|
||||
> }
|
||||
> ```
|
||||
>
|
||||
> 💡 **记忆要点:** Error Boundary 只管「渲染时」的错误,异步路径一律不管。
|
||||
|
||||
### Q10.【Portal 的使用场景】
|
||||
|
||||
`ReactDOM.createPortal(child, container)` 的主要作用是什么?
|
||||
|
||||
A. 将子节点渲染到父组件 DOM 层级之外的指定容器中
|
||||
B. 提升子组件的状态到全局 store
|
||||
C. 绕过 React 的生命周期直接进入原生 DOM 操作
|
||||
D. 将子组件的内容克隆到多个位置同时渲染
|
||||
|
||||
> [!tip]- Q10 答案
|
||||
> **A — Portal 允许将子树渲染到 DOM 树的任何位置**
|
||||
>
|
||||
> **解析:**
|
||||
> Portal 改变了渲染的 DOM 位置,但**保持 React 树结构不变**。这意味着事件冒泡仍然按照 React 组件树的层次关系向上冒泡,不受 DOM 结构影响。
|
||||
>
|
||||
> ```tsx
|
||||
> // Modal 渲染到 document.body,但在 React 树中仍属于 Dialog 组件
|
||||
> function Modal({ isOpen, children }) {
|
||||
> if (!isOpen) return null;
|
||||
> return createPortal(
|
||||
> <div className="modal-overlay">{children}</div>,
|
||||
> document.body
|
||||
> );
|
||||
> }
|
||||
>
|
||||
> // 事件冒泡仍然是:Button → Dialog → Modal,不会因为 DOM 分离而中断
|
||||
> ```
|
||||
>
|
||||
> **常见使用场景:**
|
||||
> - 模态框 / 弹窗(需要突破 z-index 和 overflow:hidden 的限制)
|
||||
> - Tooltip / 下拉菜单(需要覆盖多个层级的定位)
|
||||
> - 固定定位的全局组件(Toast、Loading 遮罩等)
|
||||
>
|
||||
> 💡 **注意:** Portal 改变的是 DOM 挂载位置,不改变 React 组件层级。父子关系、Context、事件冒泡照常工作。
|
||||
|
||||
---
|
||||
|
||||
## 二、编程题(每题 10 分,共 40 分)
|
||||
|
||||
> 实现题目要求的功能,写出核心代码。
|
||||
|
||||
### Q11.【自定义 Hook:useFetch】
|
||||
|
||||
**要求**:实现一个通用的 `useFetch` hook,支持加载状态、错误处理和数据缓存。
|
||||
|
||||
```tsx
|
||||
// 请补全以下 Hook 的实现
|
||||
function useFetch<T>(url: string): {
|
||||
data: T | null;
|
||||
loading: boolean;
|
||||
error: Error | null;
|
||||
} {
|
||||
// 你的实现
|
||||
}
|
||||
|
||||
// 使用示例
|
||||
function UserProfile({ userId }: { userId: string }) {
|
||||
const { data, loading, error } = useFetch<User>(`/api/users/${userId}`);
|
||||
|
||||
if (loading) return <Spinner />;
|
||||
if (error) return <Error message={error.message} />;
|
||||
return <div>{data?.name}</div>;
|
||||
}
|
||||
```
|
||||
|
||||
> [!tip]- Q11 参考实现
|
||||
>
|
||||
> ```tsx
|
||||
> function useFetch<T>(url: string) {
|
||||
> const [data, setData] = useState<T | null>(null);
|
||||
> const [loading, setLoading] = useState(true);
|
||||
> const [error, setError] = useState<Error | null>(null);
|
||||
>
|
||||
> useEffect(() => {
|
||||
> const controller = new AbortController();
|
||||
> setLoading(true);
|
||||
>
|
||||
> fetch(url, { signal: controller.signal })
|
||||
> .then(res => {
|
||||
> if (!res.ok) throw new Error(`HTTP ${res.status}`);
|
||||
> return res.json();
|
||||
> })
|
||||
> .then(setData)
|
||||
> .catch(err => {
|
||||
> if (err.name !== 'AbortError') setError(err);
|
||||
> })
|
||||
> .finally(() => setLoading(false));
|
||||
>
|
||||
> return () => controller.abort();
|
||||
> }, [url]);
|
||||
>
|
||||
> return { data, loading, error };
|
||||
> }
|
||||
> ```
|
||||
>
|
||||
> **考察要点:**
|
||||
> - useState 多状态管理
|
||||
> - useEffect 副作用处理
|
||||
> - AbortController 取消请求
|
||||
> - 错误边界处理
|
||||
> - loading 状态管理
|
||||
>
|
||||
> 💡 **进阶优化:** 可以添加缓存机制、防抖、重试功能等。
|
||||
|
||||
### Q12.【路由守卫实现】
|
||||
|
||||
**要求**:实现一个路由守卫组件,根据用户认证状态决定是否允许访问。
|
||||
|
||||
```tsx
|
||||
// 要求:
|
||||
// 1. 已登录用户:直接渲染 children
|
||||
// 2. 未登录用户:重定向到 /login,并保存当前路径以便登录后返回
|
||||
// 3. 登录后自动跳转回原页面
|
||||
|
||||
// 请实现以下组件
|
||||
function ProtectedRoute({ children }: { children: React.ReactNode }) {
|
||||
// 你的实现
|
||||
}
|
||||
|
||||
// 路由配置示例
|
||||
<Route path="/dashboard" element={
|
||||
<ProtectedRoute>
|
||||
<Dashboard />
|
||||
</ProtectedRoute>
|
||||
} />
|
||||
```
|
||||
|
||||
> [!tip]- Q12 参考实现
|
||||
>
|
||||
> ```tsx
|
||||
> function ProtectedRoute({ children }: { children: React.ReactNode }) {
|
||||
> const { isAuthenticated } = useAuth();
|
||||
> const location = useLocation();
|
||||
>
|
||||
> if (!isAuthenticated) {
|
||||
> // 保存当前路径,登录后可返回
|
||||
> return <Navigate to="/login" state={{ from: location }} replace />;
|
||||
> }
|
||||
>
|
||||
> return <>{children}</>;
|
||||
> }
|
||||
>
|
||||
> // 登录页处理
|
||||
> function LoginPage() {
|
||||
> const navigate = useNavigate();
|
||||
> const location = useLocation();
|
||||
> const { login } = useAuth();
|
||||
>
|
||||
> const handleLogin = async (credentials: LoginCredentials) => {
|
||||
> await login(credentials);
|
||||
> // 从 state 获取原始路径
|
||||
> const from = (location.state as any)?.from?.pathname || '/';
|
||||
> navigate(from, { replace: true });
|
||||
> };
|
||||
>
|
||||
> // ... 登录表单
|
||||
> }
|
||||
> ```
|
||||
>
|
||||
> **考察要点:**
|
||||
> - `useNavigate` 编程式导航
|
||||
> - `useLocation` 获取当前路径
|
||||
> - `Navigate` 组件的 `state` 和 `replace` 属性
|
||||
> - 登录后返回原页面的实现思路
|
||||
>
|
||||
> 💡 **安全考虑:** 生产环境应验证 state.from 的合法性,防止开放重定向漏洞。
|
||||
|
||||
### Q13.【购物车状态同步】
|
||||
|
||||
假设你正在开发一个电商应用,购物车状态需要在以下地方同步:
|
||||
1. 商品列表页的"加入购物车"按钮
|
||||
2. 购物车页面的商品列表和总价
|
||||
3. 导航栏的购物车数量徽章
|
||||
|
||||
**要求**:使用 Context API 实现这个功能,写出核心代码结构。
|
||||
|
||||
> [!tip]- Q13 参考实现
|
||||
>
|
||||
> ```tsx
|
||||
> // CartContext.tsx
|
||||
> interface CartItem {
|
||||
> id: string;
|
||||
> name: string;
|
||||
> price: number;
|
||||
> quantity: number;
|
||||
> }
|
||||
>
|
||||
> interface CartState {
|
||||
> items: CartItem[];
|
||||
> total: number;
|
||||
> addItem: (item: Omit<CartItem, 'quantity'>) => void;
|
||||
> removeItem: (id: string) => void;
|
||||
> updateQuantity: (id: string, quantity: number) => void;
|
||||
> }
|
||||
>
|
||||
> const CartContext = createContext<CartState | null>(null);
|
||||
>
|
||||
> export function CartProvider({ children }: { children: React.ReactNode }) {
|
||||
> const [items, setItems] = useState<CartItem[]>([]);
|
||||
>
|
||||
> const addItem = useCallback((product: Omit<CartItem, 'quantity'>) => {
|
||||
> setItems(prev => {
|
||||
> const existing = prev.find(item => item.id === product.id);
|
||||
> if (existing) {
|
||||
> return prev.map(item =>
|
||||
> item.id === product.id
|
||||
> ? { ...item, quantity: item.quantity + 1 }
|
||||
> : item
|
||||
> );
|
||||
> }
|
||||
> return [...prev, { ...product, quantity: 1 }];
|
||||
> });
|
||||
> }, []);
|
||||
>
|
||||
> const total = useMemo(
|
||||
> () => items.reduce((sum, item) => sum + item.price * item.quantity, 0),
|
||||
> [items]
|
||||
> );
|
||||
>
|
||||
> return (
|
||||
> <CartContext.Provider value={{ items, total, addItem, ... }}>
|
||||
> {children}
|
||||
> </CartContext.Provider>
|
||||
> );
|
||||
> }
|
||||
>
|
||||
> // 自定义 Hook
|
||||
> export function useCart() {
|
||||
> const context = useContext(CartContext);
|
||||
> if (!context) throw new Error('useCart must be used within CartProvider');
|
||||
> return context;
|
||||
> }
|
||||
> ```
|
||||
>
|
||||
> **考察要点:**
|
||||
> - Context 创建和 Provider
|
||||
> - 自定义 Hook 封装
|
||||
> - useCallback 和 useMemo 优化
|
||||
> - 状态更新逻辑
|
||||
>
|
||||
> 💡 **性能优化:** 可以拆分 Context,将频繁变化的数据和不常变化的函数分离。
|
||||
|
||||
### Q14.【useLocalStorage Hook】
|
||||
|
||||
**要求**:实现一个与 `useState` API 完全兼容的 `useLocalStorage` hook,支持自动同步到 localStorage。
|
||||
|
||||
```tsx
|
||||
// 使用示例
|
||||
const [theme, setTheme] = useLocalStorage('theme', 'light');
|
||||
// 等同于 useState,但值会持久化到 localStorage
|
||||
```
|
||||
|
||||
**要求**:
|
||||
1. 支持懒初始化(与 useState 相同)
|
||||
2. 支持函数式更新
|
||||
3. 当 key 变化时,从对应 key 读取值
|
||||
4. 处理 localStorage 不可用的情况
|
||||
|
||||
> [!tip]- Q14 参考实现
|
||||
>
|
||||
> ```tsx
|
||||
> function useLocalStorage<T>(
|
||||
> key: string,
|
||||
> initialValue: T | (() => T)
|
||||
> ): [T, React.Dispatch<React.SetStateAction<T>>] {
|
||||
> // 懒初始化:从 localStorage 读取
|
||||
> const [storedValue, setStoredValue] = useState<T>(() => {
|
||||
> try {
|
||||
> const item = window.localStorage.getItem(key);
|
||||
> return item ? JSON.parse(item) :
|
||||
> initialValue instanceof Function ? initialValue() : initialValue;
|
||||
> } catch {
|
||||
> return initialValue instanceof Function ? initialValue() : initialValue;
|
||||
> }
|
||||
> });
|
||||
>
|
||||
> // 当 key 变化时,重新从 localStorage 读取
|
||||
> useEffect(() => {
|
||||
> try {
|
||||
> const item = window.localStorage.getItem(key);
|
||||
> if (item) setStoredValue(JSON.parse(item));
|
||||
> } catch (error) {
|
||||
> console.error(`Error reading localStorage key "${key}":`, error);
|
||||
> }
|
||||
> }, [key]);
|
||||
>
|
||||
> // 包装 setter,同时更新 localStorage
|
||||
> const setValue: React.Dispatch<React.SetStateAction<T>> = useCallback((value) => {
|
||||
> setStoredValue(prev => {
|
||||
> const nextValue = value instanceof Function ? value(prev) : value;
|
||||
> try {
|
||||
> window.localStorage.setItem(key, JSON.stringify(nextValue));
|
||||
> } catch (error) {
|
||||
> console.error(`Error setting localStorage key "${key}":`, error);
|
||||
> }
|
||||
> return nextValue;
|
||||
> });
|
||||
> }, [key]);
|
||||
>
|
||||
> return [storedValue, setValue];
|
||||
> }
|
||||
> ```
|
||||
>
|
||||
> **考察要点:**
|
||||
> - useState 懒初始化模式
|
||||
> - useCallback 优化函数引用稳定性
|
||||
> - useEffect 依赖 key 变化重新同步
|
||||
> - localStorage 错误处理(容量超限、隐私模式等)
|
||||
>
|
||||
> 💡 **扩展:** 可以添加跨标签页同步功能,监听 `storage` 事件。
|
||||
|
||||
---
|
||||
|
||||
## 三、场景分析(每题 10 分,共 20 分)
|
||||
|
||||
> 分析问题原因并给出解决方案。
|
||||
|
||||
### Q15.【避免无关组件重新渲染】
|
||||
|
||||
当购物车组件频繁更新时,如何避免无关组件重新渲染?列出至少两种优化方案。
|
||||
|
||||
> [!tip]- Q15 参考答案
|
||||
>
|
||||
> **方案 1:拆分 Context**
|
||||
>
|
||||
> ```tsx
|
||||
> // 分离频繁变化的数据和不常变化的函数
|
||||
> const CartItemsContext = createContext<CartItem[]>([]);
|
||||
> const CartActionsContext = createContext<{ addItem: Function }>({ addItem: () => {} });
|
||||
>
|
||||
> // 使用时只订阅需要的部分
|
||||
> function CartBadge() {
|
||||
> const items = useContext(CartItemsContext); // 只在 items 变化时渲染
|
||||
> return <Badge count={items.length} />;
|
||||
> }
|
||||
> ```
|
||||
>
|
||||
> **方案 2:使用 useReducer + useMemo**
|
||||
>
|
||||
> ```tsx
|
||||
> function cartReducer(state: CartItem[], action: CartAction): CartItem[] {
|
||||
> switch (action.type) {
|
||||
> case 'ADD': /* ... */
|
||||
> }
|
||||
> }
|
||||
>
|
||||
> function CartProvider({ children }) {
|
||||
> const [items, dispatch] = useReducer(cartReducer, []);
|
||||
>
|
||||
> const actions = useMemo(() => ({
|
||||
> addItem: (product) => dispatch({ type: 'ADD', payload: product }),
|
||||
> // ...
|
||||
> }), []); // dispatch 稳定,actions 不会变
|
||||
>
|
||||
> return <CartContext.Provider value={{ items, actions }}>{children}</CartContext.Provider>;
|
||||
> }
|
||||
> ```
|
||||
>
|
||||
> **方案 3:使用 memo 和 useMemo**
|
||||
>
|
||||
> ```tsx
|
||||
> const CartItem = React.memo(({ item }: { item: CartItem }) => {
|
||||
> // 只在 item 变化时重新渲染
|
||||
> return <div>{item.name}</div>;
|
||||
> });
|
||||
> ```
|
||||
>
|
||||
> 💡 **核心原则:** 最小化渲染范围,只让真正需要更新的组件重新渲染。
|
||||
|
||||
### Q16.【路由状态持久化】
|
||||
|
||||
**问题**:用户在 A 页面填写了表单但未提交,跳转到 B 页面后再返回,表单数据丢失。请分析原因并给出解决方案。
|
||||
|
||||
```tsx
|
||||
// A 页面
|
||||
function PageA() {
|
||||
const [formData, setFormData] = useState({ name: '', email: '' });
|
||||
return <form>...</form>;
|
||||
}
|
||||
|
||||
// 路由配置
|
||||
<Route path="/a" element={<PageA />} />
|
||||
<Route path="/b" element={<PageB />} />
|
||||
```
|
||||
|
||||
> [!tip]- Q16 参考答案
|
||||
>
|
||||
> **原因分析:**
|
||||
> - 当路由从 `/a` 切换到 `/b` 时,`PageA` 组件卸载
|
||||
> - `useState` 的状态是组件实例的一部分,卸载后状态丢失
|
||||
> - 返回 `/a` 时重新挂载 `PageA`,状态重置为初始值
|
||||
>
|
||||
> **解决方案 1:状态提升到父组件**
|
||||
>
|
||||
> ```tsx
|
||||
> function FormContainer() {
|
||||
> const [formData, setFormData] = useState({ name: '', email: '' });
|
||||
> return (
|
||||
> <Routes>
|
||||
> <Route path="/a" element={<PageA data={formData} onChange={setFormData} />} />
|
||||
> <Route path="/b" element={<PageB />} />
|
||||
> </Routes>
|
||||
> );
|
||||
> }
|
||||
> ```
|
||||
>
|
||||
> **解决方案 2:URL 搜索参数持久化**
|
||||
>
|
||||
> ```tsx
|
||||
> function PageA() {
|
||||
> const [searchParams, setSearchParams] = useSearchParams();
|
||||
> const name = searchParams.get('name') || '';
|
||||
>
|
||||
> const updateName = (value: string) => {
|
||||
> setSearchParams(prev => {
|
||||
> prev.set('name', value);
|
||||
> return prev;
|
||||
> });
|
||||
> };
|
||||
> }
|
||||
> ```
|
||||
>
|
||||
> **解决方案 3:使用 sessionStorage**
|
||||
>
|
||||
> ```tsx
|
||||
> function usePersistedState<T>(key: string, initialValue: T) {
|
||||
> const [state, setState] = useState<T>(() => {
|
||||
> const saved = sessionStorage.getItem(key);
|
||||
> return saved ? JSON.parse(saved) : initialValue;
|
||||
> });
|
||||
>
|
||||
> useEffect(() => {
|
||||
> sessionStorage.setItem(key, JSON.stringify(state));
|
||||
> }, [key, state]);
|
||||
>
|
||||
> return [state, setState];
|
||||
> }
|
||||
> ```
|
||||
>
|
||||
> **方案对比:**
|
||||
>
|
||||
> | 方案 | 优点 | 缺点 | 适用场景 |
|
||||
> |------|------|------|---------|
|
||||
> | 状态提升 | 简单直接 | 父组件会重新渲染 | 少量表单 |
|
||||
> | URL 参数 | 可分享、可书签 | 有长度限制 | 简单表单 |
|
||||
> | sessionStorage | 自动同步 | 需要手动清理 | 复杂表单 |
|
||||
>
|
||||
> 💡 **推荐:** 简单表单用 URL 参数,复杂表单用 sessionStorage 或全局状态管理。
|
||||
|
||||
---
|
||||
|
||||
## 四、综合应用(每题 10 分,共 10 分)
|
||||
|
||||
> 综合运用所学知识解决复杂问题。
|
||||
|
||||
### Q17.【完整的路由认证系统】
|
||||
|
||||
设计一个完整的路由认证系统,包括:
|
||||
1. 登录页面
|
||||
2. 受保护的路由
|
||||
3. 角色权限控制(admin/user)
|
||||
4. 自动登过期处理
|
||||
|
||||
**要求**:画出架构图并写出核心代码。
|
||||
|
||||
> [!tip]- Q17 参考答案
|
||||
>
|
||||
> **架构图:**
|
||||
>
|
||||
> ```mermaid
|
||||
> graph TD
|
||||
> A["用户访问"] --> B{"是否已登录?"}
|
||||
> B -->|否| C["重定向到 /login"]
|
||||
> B -->|是| D{"检查 Token 过期"}
|
||||
> D -->|过期| E["自动刷新 Token"]
|
||||
> D -->|有效| F{"检查角色权限"}
|
||||
> F -->|权限不足| G["重定向到 /403"]
|
||||
> F -->|权限足够| H["渲染页面"]
|
||||
> E -->|刷新成功| D
|
||||
> E -->|刷新失败| C
|
||||
> C --> I["登录页面"]
|
||||
> I -->|登录成功| J["保存 Token"]
|
||||
> J --> H
|
||||
> ```
|
||||
>
|
||||
> **核心代码:**
|
||||
>
|
||||
> ```tsx
|
||||
> // AuthContext.tsx
|
||||
> interface AuthState {
|
||||
> user: User | null;
|
||||
> token: string | null;
|
||||
> isAuthenticated: boolean;
|
||||
> login: (credentials: LoginCredentials) => Promise<void>;
|
||||
> logout: () => void;
|
||||
> }
|
||||
>
|
||||
> const AuthContext = createContext<AuthState | null>(null);
|
||||
>
|
||||
> export function AuthProvider({ children }: { children: React.ReactNode }) {
|
||||
> const [user, setUser] = useState<User | null>(null);
|
||||
> const [token, setToken] = useState<string | null>(null);
|
||||
>
|
||||
> // Token 过期检查
|
||||
> useEffect(() => {
|
||||
> if (!token) return;
|
||||
> const expiration = decodeToken(token).exp * 1000;
|
||||
> const timeout = expiration - Date.now() - 60000; // 提前 1 分钟刷新
|
||||
>
|
||||
> const timer = setTimeout(async () => {
|
||||
> try {
|
||||
> const newToken = await refreshToken(token);
|
||||
> setToken(newToken);
|
||||
> } catch {
|
||||
> logout();
|
||||
> }
|
||||
> }, timeout);
|
||||
>
|
||||
> return () => clearTimeout(timer);
|
||||
> }, [token]);
|
||||
>
|
||||
> const value = {
|
||||
> user,
|
||||
> token,
|
||||
> isAuthenticated: !!token,
|
||||
> login: async (credentials) => {
|
||||
> const { user, token } = await authService.login(credentials);
|
||||
> setUser(user);
|
||||
> setToken(token);
|
||||
> },
|
||||
> logout: () => {
|
||||
> setUser(null);
|
||||
> setToken(null);
|
||||
> },
|
||||
> };
|
||||
>
|
||||
> return <AuthContext.Provider value={value}>{children}</AuthContext.Provider>;
|
||||
> }
|
||||
>
|
||||
> // ProtectedRoute.tsx
|
||||
> function ProtectedRoute({
|
||||
> children,
|
||||
> requiredRole,
|
||||
> }: {
|
||||
> children: React.ReactNode;
|
||||
> requiredRole?: 'admin' | 'user';
|
||||
> }) {
|
||||
> const { isAuthenticated, user } = useAuth();
|
||||
> const location = useLocation();
|
||||
>
|
||||
> if (!isAuthenticated) {
|
||||
> return <Navigate to="/login" state={{ from: location }} replace />;
|
||||
> }
|
||||
>
|
||||
> if (requiredRole && user?.role !== requiredRole) {
|
||||
> return <Navigate to="/403" replace />;
|
||||
> }
|
||||
>
|
||||
> return <>{children}</>;
|
||||
> }
|
||||
>
|
||||
> // 路由配置
|
||||
> <Routes>
|
||||
> <Route path="/login" element={<LoginPage />} />
|
||||
> <Route
|
||||
> path="/admin"
|
||||
> element={
|
||||
> <ProtectedRoute requiredRole="admin">
|
||||
> <AdminDashboard />
|
||||
> </ProtectedRoute>
|
||||
> }
|
||||
> />
|
||||
> <Route
|
||||
> path="/user"
|
||||
> element={
|
||||
> <ProtectedRoute>
|
||||
> <UserDashboard />
|
||||
> </ProtectedRoute>
|
||||
> }
|
||||
> />
|
||||
> </Routes>
|
||||
> ```
|
||||
>
|
||||
> **考察要点:**
|
||||
> - Context 状态管理
|
||||
> - Token 过期处理
|
||||
> - 角色权限控制
|
||||
> - 路由守卫实现
|
||||
> - 登录后跳转
|
||||
>
|
||||
> 💡 **安全建议:**
|
||||
> - Token 存储在 httpOnly cookie 中比 localStorage 更安全
|
||||
> - 定期刷新 Token,避免长时间使用同一个 Token
|
||||
> - 服务端也要验证权限,不能只依赖前端
|
||||
|
||||
---
|
||||
|
||||
## 评分参考
|
||||
|
||||
| 题目类型 | 题目 | 满分 | 权重 |
|
||||
|---------|------|------|------|
|
||||
| 选择题 | Q1-Q10 | 45 分 | 45% |
|
||||
| 编程题 | Q11-Q14 | 40 分 | 40% |
|
||||
| 场景分析 | Q15-Q16 | 20 分 | 20% |
|
||||
| 综合应用 | Q17 | 10 分 | 10% |
|
||||
| **总计** | | **100 分** | **100%** |
|
||||
|
||||
**及格线:** ≥70 分
|
||||
**优秀线:** ≥85 分
|
||||
+1056
File diff suppressed because it is too large
Load Diff
+854
@@ -0,0 +1,854 @@
|
||||
---
|
||||
tags:
|
||||
- 后端
|
||||
- Go
|
||||
- Gin
|
||||
- 测试
|
||||
- 自我考察
|
||||
create time: 2026-04-28
|
||||
update time: 2026-04-28
|
||||
status: reviewed
|
||||
---
|
||||
|
||||
# Gin 框架自测题
|
||||
|
||||
## 使用说明
|
||||
|
||||
本试卷覆盖你笔记中的 **核心机制 → 进阶功能 → 工程实践** 全链路知识点。共四部分:选择题、填空题、代码补全、主观题。
|
||||
|
||||
**建议用时:** 45 分钟 | **及格线:** 70 / 100
|
||||
|
||||
做完后对照下方「参考答案」自评,错的地方回到对应笔记重新理解。
|
||||
|
||||
---
|
||||
|
||||
## 一、选择题(每题 3 分,共 30 分)
|
||||
|
||||
> 每题只有一个正确答案。
|
||||
|
||||
### Q1.【路由匹配优先级】
|
||||
|
||||
注册了以下三条路由:
|
||||
|
||||
```go
|
||||
r.GET("/users/list", listAll)
|
||||
r.GET("/users/:id", getOne)
|
||||
r.GET("/users/*path", catchAll)
|
||||
```
|
||||
|
||||
当收到 `GET /users/list` 请求时,哪个 handler 会被执行?
|
||||
|
||||
A. `catchAll` — 因为通配符匹配范围最广
|
||||
B. `getOne` — 因为动态参数 :id 能匹配到 "list"
|
||||
C. `listAll` — 静态路由优先级最高
|
||||
D. 取决于三者的注册顺序
|
||||
|
||||
> [!tip]- Q1 答案
|
||||
> **C — 静态路由优先级最高**
|
||||
>
|
||||
> **解析:**
|
||||
> Gin 的路由匹配基于 Radix Tree,匹配规则是按**路径片段的类型**决定优先级,而非注册顺序:
|
||||
>
|
||||
> 1. **静态片段(literal)** — 如 `list`,精确字符串匹配,优先级最高
|
||||
> 2. **动态片段(param)** — 如 `:id`,占位符匹配,优先级居中
|
||||
> 3. **通配片段(catch-all)** — 如 `*path`,贪婪匹配其余所有路径,优先级最低
|
||||
>
|
||||
> **为什么错选 A/B/D:**
|
||||
> - ❌ A:通配符确实能匹配 `/users/list`,但它在最后才尝试
|
||||
> - ❌ B:`:id` 也能捕获 "list" 作为参数值,但静态优先于动态
|
||||
> - ❌ D:这是标准库 `ServeMux` 的行为,Gin 的 Radix Tree 是结构化匹配,与顺序无关
|
||||
>
|
||||
> 💡 **拓展:** 如果两条完全相同的 path+method 重复注册,Gin 会 panic,因为此时不存在优先级区分的问题——它们是冲突的。
|
||||
|
||||
### Q2.【中间件执行顺序】
|
||||
|
||||
```go
|
||||
r := gin.Default()
|
||||
|
||||
r.Use(func(c *gin.Context) { fmt.Print("A"); c.Next(); fmt.Print("a") })
|
||||
r.Use(func(c *gin.Context) { fmt.Print("B"); c.Next(); fmt.Print("b") })
|
||||
|
||||
v1 := r.Group("/v1", func(c *gin.Context) { fmt.Print("C"); c.Next(); fmt.Print("c") })
|
||||
{
|
||||
v1.GET("/test", func(c *gin.Context) { fmt.Print("H") })
|
||||
}
|
||||
```
|
||||
|
||||
访问 `GET /v1/test` 的输出顺序是?
|
||||
|
||||
A. `ABChaCb`
|
||||
B. `ABCHabc`
|
||||
C. `ABCha b`
|
||||
D. `ChBa aB`
|
||||
|
||||
> [!tip]- Q2 答案
|
||||
> **B — `ABCHabc`**
|
||||
>
|
||||
> **解析:**
|
||||
> 中间件执行遵循**洋葱模型(Onion Model)**,需要区分两个维度:
|
||||
>
|
||||
> **前置部分(`c.Next()` 之前)—— 正序执行:**
|
||||
> ```
|
||||
> r.Use(A) → A开始
|
||||
> r.Use(B) → B开始
|
||||
> v1路由组(C) → C开始
|
||||
> handler(H) → H执行
|
||||
> ```
|
||||
> 所以前置输出顺序是:**A B C H**
|
||||
>
|
||||
> **后置部分(`c.Next()` 之后)—— 逆序执行:**
|
||||
> ```
|
||||
> handler 结束
|
||||
> v1路由组(C) 的 c.Next() 之后 → c输出
|
||||
> r.Use(B) 的 c.Next() 之后 → b输出
|
||||
> r.Use(A) 的 c.Next() 之后 → a输出
|
||||
> ```
|
||||
> 所以后置输出顺序是:**c b a**
|
||||
>
|
||||
> 合起来就是 **ABCH + abc = ABCHabc** ✅
|
||||
>
|
||||
> 💡 **记忆口诀:** "进来正序排排站,出去逆序往回走"。这是所有 Web 框架中间件的通用模式(Express/Koa/NestJS 同理)。
|
||||
|
||||
### Q3.【Context 生命周期】
|
||||
|
||||
关于 Gin 使用 `sync.Pool` 复用 Context 的说法,**错误**的是:
|
||||
|
||||
A. 每次请求从池中取出 Context,请求结束后归还池中
|
||||
B. 可以在 handler 的 goroutine 中安全地持有 Context 引用,在请求返回后继续使用
|
||||
C. `c.Reset()` 会把 Keys、Params、handlers、index 全部清空
|
||||
D. index 被重置为 -1 表示还未开始执行,第一次 `c.Next()` 走到索引 0
|
||||
|
||||
> [!tip]- Q3 答案
|
||||
> **B — Context 池化不安全跨请求持有**
|
||||
>
|
||||
> **解析:**
|
||||
> Gin 为了减少 GC 压力,使用 `sync.Pool` 复用 `Context` 实例。每次请求结束后,Context 会被 `c.Reset()` 清空并放回池中。**下一个请求可能取出同一个 Context 对象**。
|
||||
>
|
||||
> **逐项分析:**
|
||||
> - ✅ A:正确描述。取出来 → 用 → `c.Reset()` → 放回去,循环复用
|
||||
> - ❌ B:**错误!** goroutine 异步运行,handler 返回后 Context 已 reset 并被其他请求复用,此时读到的数据可能是下一个请求的(安全漏洞 + 数据错乱)
|
||||
> - ✅ C:`c.Reset()` 确实清空 Keys(map)、Params、handlers slice、index 等核心字段
|
||||
> - ✅ D:`index = -1` 是初始状态,`c.Next()` 先自增为 0,再执行 handlers[0]
|
||||
>
|
||||
> 💡 **关键陷阱:** 如果你在 handler 里 `go func() { time.Sleep(5s); c.Get("uid") }()`,5 秒后读到的很可能不是你的数据!
|
||||
|
||||
### Q4.【绑定与校验】
|
||||
|
||||
关于 `binding:"required"` 对不同类型字段的处理,以下说法**正确**的是:
|
||||
|
||||
A. 对 `string` 类型,值为空字符串 `""` 时校验失败
|
||||
B. 对 `*string` 指针类型,指向 nil 时校验失败
|
||||
C. 对 `int` 类型,值为 0 时校验失败
|
||||
D. A 和 B 都正确,C 不正确
|
||||
|
||||
> [!tip]- Q4 答案
|
||||
> **D — required 对 string="" 和 *string=nil 均失败,对 int=0 不失败**
|
||||
>
|
||||
> **解析:**
|
||||
> `validator` 包的 `required` 标签本质上是检查值的 **"零值" (zero value)**,但不同类型的零值判断规则不同:
|
||||
>
|
||||
> | 类型 | 零值 | `required` 是否失败 |
|
||||
> |------|------|---------------------|
|
||||
> | `string` | `""` | ✅ 失败 — 空串意味着前端没传 |
|
||||
> | `*string` (nil) | `nil` | ✅ 失败 — 未分配表示没传 |
|
||||
> | `int` / `int64` | `0` | ❌ **不失败** — 0 是一个合法的业务值 |
|
||||
> | `bool` | `false` | ❌ **不失败** — false 也是合法输入 |
|
||||
> | `[]string` | `nil` 或 `[]` | ✅ 失败 |
|
||||
>
|
||||
> **为什么 C 错?** 如果 0 算"必填",那页面上有个数量选择器默认选 0 就无法提交了。所以 `required` 只对引用类型(指针、slice、map)和 string 生效。
|
||||
>
|
||||
> 💡 **变通方案:** 如果你需要"int 不能为 0",应该自定义校验器:`binding:"min=1"` 或用自定义 tag。
|
||||
|
||||
### Q5.【c.Copy() 安全性】
|
||||
|
||||
在中间件中启动异步 goroutine,下列哪种做法是**安全**的?
|
||||
|
||||
A. 直接在中间件中 `go func() { c.JSON(200, ...) }()`
|
||||
B. 先 `copy := c.Copy()`,然后 `go func() { copy.GetString("userID") }()`
|
||||
C. 先 `copy := c.Copy()`,然后 `go func() { copy.JSON(200, ...) }()`
|
||||
D. 用 `c.Request.Context().Done()` 判断后再读 c.Keys
|
||||
|
||||
> [!tip]- Q5 答案
|
||||
> **B — c.Copy 后 goroutine 只读**
|
||||
>
|
||||
> **解析:**
|
||||
> `c.Copy()` 创建一个轻量级副本,共享 Request 和 Writer(所以不能对 copy 写响应),但拥有独立的 Keys map。
|
||||
>
|
||||
> **逐项分析:**
|
||||
> - ❌ A:**绝对不行!** goroutine 运行时 handler 已返回,Context 被 reset 放回 pool,此时读写都是数据竞争
|
||||
> - ✅ B:`c.Copy()` 的 Keys 是独立副本,GetString 只是读取安全
|
||||
> - ❌ C:copy 的 Writer 和原始 Context **共享**同一个 `http.ResponseWriter`,goroutine 中调用 JSON() 会和主流程竞争写入,导致响应损坏
|
||||
> - ❌ D:`ctx.Done()` 只能说明请求被取消,不能保证 Context 还没被复用——即使 context 没 done,pool 里的同一个对象也可能被其他请求取走并修改了 Keys
|
||||
>
|
||||
> 💡 **黄金法则:** goroutine 要用的数据,在主流程里先 `取值 → 存局部变量`,而不是依赖 Context。
|
||||
|
||||
### Q6.【SecureJSON 原理】
|
||||
|
||||
SecureJSON 防 JSON 劫持的原理是:
|
||||
|
||||
A. 在 JSON 前添加 `)]}',\n` 前缀,使浏览器无法将其解析为合法的 JavaScript
|
||||
B. 自动设置 `X-Content-Type-Options: nosniff` 响应头
|
||||
C. 只允许白名单域名通过 CORS 访问
|
||||
D. 对 JSON body 进行 HMAC 签名验证
|
||||
|
||||
> [!tip]- Q6 答案
|
||||
> **A — SecureJSON 前缀阻断 JS 解析**
|
||||
>
|
||||
> **解析:**
|
||||
> JSON 劫持(JSON Hijacking)的攻击场景:攻击者构造一个恶意页面,用 `<script src="https://your-api.com/user/data">` 加载你的 JSON 接口。因为浏览器同源策略不阻止 script 标签跨域加载,如果接口返回纯 JSON,恶意页面拿到后可以提取数据。
|
||||
>
|
||||
> `SecureJSON(")],{}", data)` 的输出变成:
|
||||
> ```
|
||||
> ]},"{"name":"Alice","token":"secret"}
|
||||
> ```
|
||||
> 由于以 `]},` 开头,这不再是合法的 JavaScript 表达式,浏览器无法直接执行。
|
||||
>
|
||||
> **为什么不是其他选项:**
|
||||
> - ❌ B:`nosniff` 是另一个安全头,防止 MIME 类型嗅探,但不是 SecureJSON 的作用
|
||||
> - ❌ C:这是 CORS 机制,解决的是跨域访问控制
|
||||
> - ❌ D:HMAC 签名用于完整性校验,与防劫持无关
|
||||
>
|
||||
> 💡 **注意:** SecureJSON 并非万能防御。现代最佳实践是同时使用 CSRF Token + CORS + Content-Type 验证。
|
||||
|
||||
### Q7.【ShouldBindBodyWith】
|
||||
|
||||
为什么需要 `ShouldBindBodyWith`?
|
||||
|
||||
A. Gin 的 Request Body 是 `io.ReadCloser`,只能读取一次,后续绑定会报错 EOF
|
||||
B. Gin 默认不会缓存 body,每次绑定时都会新建连接读取
|
||||
C. `ShouldBindJSON` 不支持 form data,必须用 `ShouldBindBodyWith` 替代
|
||||
D. `ShouldBindBodyWith` 可以将 body 同时绑定到多个结构体而无需额外调用
|
||||
|
||||
> [!tip]- Q7 答案
|
||||
> **A — io.ReadCloser 只能读一次**
|
||||
>
|
||||
> **解析:**
|
||||
> HTTP 请求的 Body 底层是 `io.ReadCloser`,本质是一个流式读取器。**读完了就没了**,没有 "重新定位到开头" 的操作。
|
||||
>
|
||||
> 典型场景:你在中间件中调用了 `c.ShouldBindJSON(&req)` 做了认证检查,进入 handler 后又想 `c.ShouldBindJSON(&req2)` 拿业务数据——第二次会报 `EOF`。
|
||||
>
|
||||
> **解决方案:**
|
||||
> ```go
|
||||
> // 第一次绑定后会缓存 body
|
||||
> c.ShouldBindBodyWith(&obj, binding.JSON)
|
||||
> // 第二次直接从缓存读取,不再碰原始 stream
|
||||
> c.ShouldBindJSON(&obj2)
|
||||
> ```
|
||||
>
|
||||
> **为什么错选 B:** Gin 确实不自动缓存 body,但这不是"每次都新建连接",而是同一条连接上 body 流读完后就消耗掉了。
|
||||
>
|
||||
> 💡 **扩展:** 中间件做通用 body 解析时建议用 `ShouldBindBodyWith`,避免下游 handler 再绑定时报错。
|
||||
|
||||
### Q8.【优雅停止】
|
||||
|
||||
关于 `server.Shutdown()` 的行为,以下说法**正确**的是:
|
||||
|
||||
A. 会立即关闭所有活跃连接,包括 WebSocket 长连接
|
||||
B. 停止接收新连接,但已有请求继续处理,直到全部完成或 context 超时
|
||||
C. 会取消所有正在处理的请求的 context
|
||||
D. 是异步非阻塞调用,不会卡住调用方
|
||||
|
||||
> [!tip]- Q8 答案
|
||||
> **B — Shutdown 停新不断旧**
|
||||
>
|
||||
> **解析:**
|
||||
> `server.Shutdown(ctx)` 的行为分两步:
|
||||
>
|
||||
> 1. **停止接受新连接** — `listener.Close()`,外部流量不再进来
|
||||
> 2. **等待活跃请求处理完毕** — 每个请求的 context 被取消(相当于触发 `ctx.Done()`),但 handler 可以继续处理
|
||||
> 3. **超时强制退出** — 如果某个请求超时未完成,直接断开
|
||||
>
|
||||
> **逐项分析:**
|
||||
> - ❌ A:WebSocket 连接不是 HTTP handler 级别的,Shutdown 不会主动清理它。如果需要关闭 WebSocket,要在 shutdown 逻辑中单独遍历关闭
|
||||
> - ✅ B:完全正确。停止新的、处理旧的、等超时就砍
|
||||
> - ❌ C:context 是被 cancel 了,但请求**不一定终止**——handler 可以自己选择不立即 return
|
||||
> - ❌ D:`Shutdown()` 是**同步阻塞**调用,它会一直等到所有请求处理完才返回
|
||||
>
|
||||
> 💡 **线上经验:** Docker kill signal 默认是 SIGTERM(对应 graceful shutdown),如果用 SIGKILL 则直接杀进程,不走 Shutdown 流程。
|
||||
|
||||
### Q9.【Radix Tree】
|
||||
|
||||
Gin 的路由匹配使用 Radix Tree,关于它的优势,以下说法**最准确**的是:
|
||||
|
||||
A. 时间复杂度 O(1),因为底层用了 hash map
|
||||
B. 时间复杂度 O(d),d 为 URL 路径深度,相比标准库 ServeMux 的 O(n)(n 为路由总数)大幅减少比较次数
|
||||
C. 每个 HTTP 方法共享一棵树,减少内存占用
|
||||
D. 支持正则表达式匹配,灵活性更高
|
||||
|
||||
> [!tip]- Q9 答案
|
||||
> **B — Radix Tree O(d) vs ServeMux O(n)**
|
||||
>
|
||||
> **解析:**
|
||||
>
|
||||
> | 特性 | Gin (Radix Tree) | 标准库 ServeMux |
|
||||
> |------|------------------|-----------------|
|
||||
> | 匹配复杂度 | **O(d)**,d = URL 路径段数(如 `/api/v1/users` → d=4) | **O(n)**,逐个比较路由 |
|
||||
> | 正则支持 | ❌ 仅 `:param` 和 `*wildcard` | ✅ Go 1.22+ 支持 pattern regex |
|
||||
> | 内存效率 | ✅ 合并公共前缀,节省空间 | 每条路由一个节点 |
|
||||
> | HTTP 方法存储 | 每方法一棵树(GET/POST 各一棵) | 按 method+path 组合存储 |
|
||||
>
|
||||
> **为什么 C 不对?** Gin 实际上**每 HTTP 方法一棵树**,不是共享。这是因为不同方法可以注册相同 path(`GET /users` 和 `POST /users` 是两个不同的路由)。
|
||||
>
|
||||
> 💡 **对比理解:** 假设你有 1000 条路由都在 `/api/*` 下,ServeMux 最坏情况要比较 1000 次;Radix Tree 只需要沿着公共前缀走一遍,约等于 URL 段数。
|
||||
|
||||
### Q10.【错误处理链】
|
||||
|
||||
在 handler 中连续调用三次 `c.Error(err)` 后,正确的做法是:
|
||||
|
||||
A. 每次 `c.Error()` 后直接 `return`,因为 error 会自动返回客户端
|
||||
B. 检查 `len(c.Errors) > 0`,如果大于 0 则统一返回错误列表
|
||||
C. 调用 `c.Abort()` 终止请求,Gin 会自动返回 500
|
||||
D. `c.Error()` 会直接写响应体,不需要额外处理
|
||||
|
||||
> [!tip]- Q10 答案
|
||||
> **B — len(c.Errors) 检查后统一返回**
|
||||
>
|
||||
> **解析:**
|
||||
> `c.Error(err)` 的设计意图是**非阻塞地记录错误到上下文**,它不会自动发送响应给客户端,也不会中断执行链。它会往 `c.Errors`(`gin.TypedErrors` 类型)追加一个错误对象。
|
||||
>
|
||||
> ```go
|
||||
> func batchProcess(c *gin.Context) {
|
||||
> // 第一步:绑定
|
||||
> if err := c.ShouldBindJSON(&req); err != nil {
|
||||
> c.Error(&gin.Error{Err: err, Meta: "bind"})
|
||||
> }
|
||||
>
|
||||
> // 第二步:业务校验
|
||||
> if err := validate(req); err != nil {
|
||||
> c.Error(&gin.Error{Err: err, Meta: "validate"})
|
||||
> }
|
||||
>
|
||||
> // 第三步:数据库写入
|
||||
> if err := db.Save(&req); err != nil {
|
||||
> c.Error(&gin.Error{Err: err, Meta: "db"})
|
||||
> }
|
||||
>
|
||||
> // 集中处理所有错误
|
||||
> if len(c.Errors) > 0 {
|
||||
> c.JSON(400, gin.H{"errors": c.Errors})
|
||||
> return
|
||||
> }
|
||||
>
|
||||
> c.JSON(200, gin.H{"message": "success"})
|
||||
> }
|
||||
> ```
|
||||
>
|
||||
> **为什么错选 A/C/D:**
|
||||
> - ❌ A:`c.Error()` 不会触发 return,后续代码照样执行
|
||||
> - ❌ C:`c.Abort()` 是终止中间件链的,和错误无关;它不会自动返回 500
|
||||
> - ❌ D:`c.Error()` 不写响应体,你需要自己决定怎么返回
|
||||
>
|
||||
> 💡 **进阶:** Gin 提供了 `c.Errors.Last()` 获取最后一个错误,也可以用 `c.Errors.ByType(gin.ErrorTypePrivate)` 按类型过滤。批量返回时用 `c.Errors` 整体更合适。
|
||||
|
||||
---
|
||||
|
||||
## 二、填空题(每题 3 分,共 24 分)
|
||||
|
||||
> 根据知识填写空缺的代码或概念。
|
||||
|
||||
### Q11.【路由分组】
|
||||
|
||||
```go
|
||||
r := gin.Default()
|
||||
api := r.Group("/api")
|
||||
{
|
||||
api.GET("/users", listUsers)
|
||||
v1 := api.Group("/v1")
|
||||
{
|
||||
// 完整路径为 _________
|
||||
v1.DELETE("/:id", deleteUser)
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
> [!tip]- Q11 答案
|
||||
> **`/api/v1/:id`**
|
||||
>
|
||||
> **解析:**
|
||||
> `Group()` 返回的新 RouterGroup 会继承父级的路径前缀。这里是两级分组:
|
||||
> ```
|
||||
> r → 无前缀
|
||||
> └─ api = r.Group("/api") → 前缀: /api
|
||||
> └─ v1 = api.Group("/v1") → 前缀: /api + /v1 = /api/v1
|
||||
> └─ DELETE("/:id") → 完整路径: /api/v1/:id
|
||||
> ```
|
||||
>
|
||||
> 💡 **坑点:** Group 的路径不会自动加 `/` 分隔,所以 `r.Group("api")` 和 `api.Group("/v1")` 拼接出来是 `apiv1` 而不是 `/api/v1`。建议始终以 `/` 开头。
|
||||
|
||||
### Q12.【中间件链终止】
|
||||
|
||||
认证中间件中,token 无效时需要终止后续中间件和执行链,应调用的方法是 _________;如果只想写状态码 401 不写 body,可以用 _________ 。
|
||||
|
||||
> [!tip]- Q12 答案
|
||||
> **`c.Abort()`** ; **`c.AbortWithStatus(http.StatusUnauthorized)`**(或 `c.AbortWithStatusJSON(401, gin.H{...})`)
|
||||
>
|
||||
> **解析:**
|
||||
> Gin 提供三级终止 API:
|
||||
>
|
||||
> | 方法 | 行为 | 适用场景 |
|
||||
> |------|------|---------|
|
||||
> | `c.Abort()` | 仅标记中断,不写响应 | 很少单独用,通常配合后面两个 |
|
||||
> | `c.AbortWithStatus(statusCode)` | 中断 + 写入指定状态码 + 空 body | 简单鉴权失败、权限不足 |
|
||||
> | `c.AbortWithStatusJSON(statusCode, jsonObj)` | 中断 + 写入 JSON 响应 | 需要返回结构化错误信息 |
|
||||
>
|
||||
> **关键区别:**
|
||||
> - `c.Abort()` 后中间件链不再往下走,但**当前中间件的后续代码会继续执行**(后置部分)
|
||||
> - `c.AbortWithStatus*` 除了终止链还自动写 status code 和 header
|
||||
>
|
||||
> 💡 **陷阱题:** `c.Abort()` 和 `return` 的区别 — `c.Abort()` 只是设了一个标志位,如果你写了 `c.Abort(); c.JSON(...)`,后面的 JSON 仍然会执行!正确写法:`c.Abort(); return;`
|
||||
|
||||
### Q13.【Context 数据共享】
|
||||
|
||||
```go
|
||||
// 中间件存入数据
|
||||
c.Set("requestID", uuid.New().String())
|
||||
|
||||
// handler 获取数据——方式一(安全):
|
||||
rid, ok := c.Get("requestID")
|
||||
|
||||
// handler 获取数据——方式二(key 不存在会 panic):
|
||||
rid = c.MustGet("_________").(string)
|
||||
```
|
||||
|
||||
> [!tip]- Q13 答案
|
||||
> **`requestID`**
|
||||
>
|
||||
> **解析:**
|
||||
> Context 的 key-value 存储提供三种读取方式:
|
||||
>
|
||||
> | 方法 | 返回值 | key 不存在时 |
|
||||
> |------|--------|-------------|
|
||||
> | `c.Get(key)` | `(any, bool)` | 返回 `(nil, false)` — **安全** |
|
||||
> | `c.MustGet(key)` | `any` | **panic!** — "key does not exist" |
|
||||
> | `c.GetString(key)` | `string` (零值 "") | 返回空字符串,无法区分 |
|
||||
>
|
||||
> `MustGet` 适合 **"这个 key 必须存在"** 的场景(如中间件强制设置的字段),提前暴露 bug 比静默得到零值更好。
|
||||
>
|
||||
> 💡 **工程实践:** requestID 通常在请求入口(CORS/日志中间件)统一生成,这样整个请求链路都有 trace_id,方便排查问题。
|
||||
|
||||
### Q14.【绑定方法选择】
|
||||
|
||||
将以下 binding 方法与数据来源连线(按序号填字母):
|
||||
|
||||
> [!tip]- Q14 答案
|
||||
> **①-C ②-A ③-D ④-B**
|
||||
>
|
||||
> **解析:**
|
||||
>
|
||||
> | 编号 | 方法 | 数据来源 | 对应内容 |
|
||||
> |------|------|---------|---------|
|
||||
> | ① | `ShouldBindJSON` | **C** JSON body | 解析 `{"key": "value"}` 到 struct |
|
||||
> | ② | `ShouldBindQuery` | **A** URL Query | 解析 `?name=alice&age=25` |
|
||||
> | ③ | `ShouldBindUri` | **D** URL 路径参数 | 解析路由 `:param` 中的值(如 `GET /users/:id` 中的 `id`) |
|
||||
> | ④ | `ShouldBindHeader` | **B** 请求头 | 解析 `Authorization: Bearer xxx` 等 Header 字段 |
|
||||
>
|
||||
> 💡 **扩展记忆:** 还有 `ShouldBindBodyWith(&obj, binding.Form)` 用于 form data POST body。Gin 最灵活的地方在于一个 struct 可以同时绑多个来源:
|
||||
> ```go
|
||||
> type SearchRequest struct {
|
||||
> Query string `form:"q"` // 从 query 参数绑
|
||||
> Type string `uri:"type"` // 从 uri 参数绑
|
||||
> }
|
||||
> c.ShouldBindQuery(&req) // 同时绑 Query 和 Type
|
||||
> ```
|
||||
|
||||
### Q15.【错误类型分类】
|
||||
|
||||
Gin 的 `gin.ErrorType` 分为四种,请写出三种的名称:
|
||||
|
||||
> [!tip]- Q15 答案
|
||||
> | 常量名 | 含义 | 说明 |
|
||||
> |--------|------|------|
|
||||
> | `ErrorTypeBind` (=1) | 绑定错误 | `ShouldBind*` 失败时设置 |
|
||||
> | `ErrorTypeRelease` (=2) | **资源释放错误** | ResponseWriter 关闭等操作出错 |
|
||||
> | `ErrorTypePrivate` (=4) | **内部业务错误** | 服务器内部错误,不应暴露给客户端 |
|
||||
> | `ErrorTypePublic` (=8) | **暴露给客户端的业务错误** | 可安全返回给调用方的错误 |
|
||||
>
|
||||
> **按位掩码使用示例:**
|
||||
> ```go
|
||||
> if err.Type() == gin.ErrorTypePrivate { /* 服务器内部错误 */ }
|
||||
> if err.Type()&gin.ErrorTypePublic != 0 { /* 公开错误 */ }
|
||||
> ```
|
||||
>
|
||||
> 💡 **注意:** ErrorType 是 bitmask 设计,用 `1 << iota` 可以组合使用。但实践中基本每个错误只属于一种类型。
|
||||
|
||||
### Q16.【PureJSON vs JSON】
|
||||
|
||||
`c.JSON()` 默认会将中文等非 ASCII 字符转义为 `\uXXXX`(如 `"你好"` → `"\\u4f60\\u597d"`),要原样输出 UTF-8 字节流,应该使用 _________ 。
|
||||
|
||||
> [!tip]- Q16 答案
|
||||
> **`c.PureJSON()`**
|
||||
>
|
||||
> **解析:**
|
||||
> Go 标准库 `encoding/json` 的 `Marshal` 函数有一个默认行为:对非 ASCII 字符进行 Unicode 转义,因为旧版浏览器可能存在编码兼容性问题。
|
||||
>
|
||||
> ```go
|
||||
> c.JSON(200, gin.H{"msg": "你好世界"})
|
||||
> // 输出: {"msg":"你好世界"}
|
||||
>
|
||||
> c.PureJSON(200, gin.H{"msg": "你好世界"})
|
||||
> // 输出: {"msg":"你好世界"}
|
||||
> ```
|
||||
>
|
||||
> **底层原理:** PureJSON 使用的是自定义的 JSON encoder(Gin 自己实现的),绕过了标准库的 Unicode 转义逻辑。注意 Gin 的 PureJSON 和标准库的 `json.MarshalIndent` 等无冲突,它只是控制了 `EscapeHTML=false` + `UndefinitelyByteOrder=false` 的行为组合。
|
||||
>
|
||||
> 💡 **性能差异:** PureJSON 比 JSON 略快(少了 Unicode 检查步骤),在大量中文场景下推荐使用。
|
||||
|
||||
### Q17.【文件上传限制】
|
||||
|
||||
Gin 默认的 `MaxMultipartMemory` 阈值为 __ MB(兆字节)。超过此大小的 multipart 请求体,超出部分会写入操作系统的 _________ 目录下的临时文件。
|
||||
|
||||
> [!tip]- Q17 答案
|
||||
> **`8`** ; **`os.TempDir()`**(通常是 `/tmp` 或 `C:\Windows\Temp`)
|
||||
>
|
||||
> **解析:**
|
||||
> MaxMultipartMemory 控制 Gin 处理 multipart/form-data 时的内存策略:
|
||||
> - **≤ 8MB**:全部内容留在内存中处理,速度快
|
||||
> - **> 8MB**:先读入内存 8MB,超出部分溢写到磁盘临时文件
|
||||
>
|
||||
> **手动调整:**
|
||||
> ```go
|
||||
> r := gin.Default()
|
||||
> r.MaxMultipartMemory = 64 << 20 // 64 MB
|
||||
> ```
|
||||
>
|
||||
> 💡 **安全问题:** 如果不设置合理的 MaxMultipartMemory,攻击者可以上传超大文件耗尽服务器内存。生产环境务必限制文件大小,可用 `c.Request.ContentLength` 或自定义中间件校验。
|
||||
|
||||
### Q18.【优雅停止信号】
|
||||
|
||||
```go
|
||||
quit := make(chan os.Signal, 1)
|
||||
signal.Notify(quit, syscall.SIGINT, syscall.SI_____)
|
||||
<-quit
|
||||
```
|
||||
|
||||
Linux/Unix 下常见的两个终止信号是 SIGINT 和 _________ 。
|
||||
|
||||
> [!tip]- Q18 答案
|
||||
> **`GTERM`**(代码填空补全为 `SIGTERM`);**`SIGTERM`**(文字填空填写完整信号名)
|
||||
>
|
||||
> **解析:**
|
||||
>
|
||||
> | 信号 | 触发方式 | 用途 |
|
||||
> |------|---------|------|
|
||||
> | `SIGINT` | Ctrl+C / 终端中断 | 用户主动中断进程 |
|
||||
> | `SIGTERM` | `kill <pid>` / Docker stop / Kube delete | 操作系统或服务管理程序请求优雅退出 |
|
||||
>
|
||||
> **为什么 buffer size = 1?** 因为只需要捕获一次信号来触发 shutdown 流程。如果设得太大,可能有多个信号堆积来不及处理导致进程异常退出。
|
||||
>
|
||||
> 💡 **补充:** Windows 上没有 SIGTERM/SIGINT,Gin 在 Windows 上建议使用 `server.Close()` 直接关闭,或者用 `^C` 触发 SIGBREAK 替代。Kubernetes 的 `preStop` hook 发送的就是 SIGTERM。
|
||||
|
||||
---
|
||||
|
||||
## 三、代码补全(每题 6 分,共 30 分)
|
||||
|
||||
> 补充代码中空缺的部分。有些题目有多个空。
|
||||
|
||||
### Q19.【CORS 中间件】
|
||||
|
||||
补全一个基础 CORS 中间件,处理 OPTIONS 预检请求并设置相关响应头:
|
||||
|
||||
```go
|
||||
func cors() gin.HandlerFunc {
|
||||
return func(c *gin.Context) {
|
||||
origin := c.Request.Header.Get("Origin")
|
||||
if origin != "" {
|
||||
c.Header("Access-Control-Allow-Origin", _________)
|
||||
c.Header("Access-Control-Allow-Methods", "GET,POST,PUT,DELETE,PATCH,OPTIONS")
|
||||
c.Header("Access-Control-Allow-Headers", "_________,Authorization")
|
||||
c.Header("Access-Control-Max-Age", "86400")
|
||||
}
|
||||
if c.Request.Method == "_________" {
|
||||
c.AbortWithStatus(http.StatusNoContent)
|
||||
return
|
||||
}
|
||||
c.Next()
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
> [!tip]- Q19 答案
|
||||
> **三个空分别是:`origin` ; `Origin,Content-Type`(或 `Content-Type,Origin,X-Token` 等合理值); `OPTIONS`**
|
||||
>
|
||||
> **解析:**
|
||||
>
|
||||
> ```go
|
||||
> func cors() gin.HandlerFunc {
|
||||
> return func(c *gin.Context) {
|
||||
> origin := c.Request.Header.Get("Origin")
|
||||
> if origin != "" {
|
||||
> // 空1:回显请求方的 Origin,不能硬编码 "*"(否则无法配合 withCredentials)
|
||||
> c.Header("Access-Control-Allow-Origin", origin)
|
||||
> c.Header("Access-Control-Allow-Methods", "GET,POST,PUT,DELETE,PATCH,OPTIONS")
|
||||
> // 空2:声明客户端允许发送的自定义 Header
|
||||
> c.Header("Access-Control-Allow-Headers", "Origin,Content-Type,Authorization")
|
||||
> c.Header("Access-Control-Max-Age", "86400") // 预检结果缓存 24h
|
||||
> }
|
||||
> // 空3:浏览器发 OPTIONS 预检请求时,直接返回 204 不往下走业务逻辑
|
||||
> if c.Request.Method == "OPTIONS" {
|
||||
> c.AbortWithStatus(http.StatusNoContent)
|
||||
> return
|
||||
> }
|
||||
> c.Next()
|
||||
> }
|
||||
> }
|
||||
> ```
|
||||
>
|
||||
> 💡 **进阶:** 生产环境不建议原样回显 `origin`(有 SSRF 风险),应该维护一个白名单校验后再赋值。另外如果前端设置了 `withCredentials = true`,就不能用 `Access-Control-Allow-Origin: *`,必须指定具体域名。
|
||||
|
||||
### Q20.【自定义 Validator】
|
||||
|
||||
注册一个手机号格式校验器:
|
||||
|
||||
> [!tip]- Q20 答案
|
||||
> **`matched` ; `required`**
|
||||
>
|
||||
> **解析:**
|
||||
>
|
||||
> ```go
|
||||
> v.RegisterValidation("phone", func(fl validator.FieldLevel) bool {
|
||||
> phone := fl.Field().String()
|
||||
> matched, _ := regexp.MatchString(`^1[3-9]\d{9}$`, phone)
|
||||
> return matched // ✅ 空1:正则匹配结果就是校验返回值
|
||||
> })
|
||||
>
|
||||
> type RegisterRequest struct {
|
||||
> Phone string `json:"phone" binding:"required,phone"` // ✅ 空2:先 required 再 phone
|
||||
> }
|
||||
> ```
|
||||
>
|
||||
> **关键点:**
|
||||
> 1. 自定义 validation 函数签名固定为 `func(fl validator.FieldLevel) bool`,返回 `true` 表示通过
|
||||
> 2. `fl.Field()` 返回 `reflect.Value`,调用 `.String()` 获取实际字符串值
|
||||
> 3. binding tag 中多个规则用逗号分隔,执行顺序是从左到右——`required` 会先检查非空,再通过 `phone` 校验格式
|
||||
>
|
||||
> 💡 **坑点:** 如果写 `binding:"phone|required"`(reverse order),当手机号为空时会跳过了 `phone` 校验直接进入 `required`,导致错误提示不准确。建议总是 `required` 在前。
|
||||
|
||||
### Q21.【批量更新中的 c.Error 模式】
|
||||
|
||||
```go
|
||||
func batchUpdate(c *gin.Context) {
|
||||
var items []Item
|
||||
if err := c.ShouldBindJSON(&items); err != nil {
|
||||
c.JSON(400, gin.H{"error": "invalid input"})
|
||||
return
|
||||
}
|
||||
|
||||
for _, item := range items {
|
||||
if err := validate(item); err != nil {
|
||||
c.Error(&gin.Error{
|
||||
Err: _________,
|
||||
Meta: item.ID,
|
||||
})
|
||||
_________ // 跳过当前 item,继续下一个
|
||||
}
|
||||
if err := saveToDB(item); err != nil {
|
||||
c.Error(&gin.Error{Err: err})
|
||||
}
|
||||
}
|
||||
|
||||
if len(_________ ) > 0 {
|
||||
c.JSON(400, gin.H{"errors": c.Errors.Last()})
|
||||
return
|
||||
}
|
||||
|
||||
c.JSON(200, gin.H{"message": "done"})
|
||||
}
|
||||
```
|
||||
|
||||
> [!tip]- Q21 答案
|
||||
> **`err` ; `continue` ; `c.Errors`**
|
||||
>
|
||||
> **解析:**
|
||||
>
|
||||
> | 空 | 填空 | 作用 |
|
||||
>|----|------|------|
|
||||
> | 空1 | `err` | `c.Error()` 接收 `*gin.Error`,其中 `Err` 字段存储真实错误 |
|
||||
> | 空2 | `continue` | 跳过当前失败的 item,继续处理后续项(半成功语义) |
|
||||
> | 空3 | `c.Errors` | 最后统一检查是否有任何错误积累 |
|
||||
>
|
||||
> **这个模式的精妙之处:**
|
||||
> - 不是"全部成功 or 全部失败"的原子操作,而是**尽量多做**(fail-fast but continue)
|
||||
> - 第 3 个 `if err := saveToDB(item)` 虽然没加 continue,意味着只要验证通过就会尝试保存——即使前面已有其他 item 出错
|
||||
>
|
||||
> 💡 **改进方向:** 如果想返回完整的错误汇总(而不是只显示最后一个),可以遍历 `c.Errors` 逐个返回:
|
||||
> ```go
|
||||
> if len(c.Errors) > 0 {
|
||||
> errs := make([]map[string]any, len(c.Errors))
|
||||
> for i, e := range c.Errors {
|
||||
> errs[i] = gin.H{"type": e.Type(), "msg": e.Err.Error()}
|
||||
> if e.Meta != nil { errs[i]["meta"] = e.Meta }
|
||||
> }
|
||||
> c.JSON(400, gin.H{"errors": errs})
|
||||
> }
|
||||
> ```
|
||||
|
||||
### Q22.【内容协商多格式响应】
|
||||
|
||||
> [!tip]- Q22 答案
|
||||
> **`c.XML(http.StatusOK, data)`**
|
||||
>
|
||||
> **解析:**
|
||||
> `c.Negotiate()` 根据客户端 `Accept` 头自动选择最合适的响应格式。Gin 内置支持三种 MIME 类型:
|
||||
>
|
||||
> | Gin 常量 | MIME Type | 渲染方法 |
|
||||
>|----------|-----------|---------|
|
||||
> | `gin.MIMEJSON` | `application/json; charset=utf-8` | `c.JSON()` |
|
||||
> | `gin.MIMEXML` | `application/xml` / `text/xml` | `c.XML()` |
|
||||
> | `gin.MIMEYAML` | `application/x-yaml` | `c.YAML()` |
|
||||
>
|
||||
> **工作流程:**
|
||||
> 1. 客户端发 `Accept: application/xml, application/json;q=0.9`
|
||||
> 2. Gin 从 Offered 列表中按优先级匹配第一个支持的
|
||||
> 3. 设置 `Content-Type` 响应头
|
||||
> 4. 调用对应 case 中的 Handler 写入 body
|
||||
> 5. 如果没有匹配的且没有设 Default → panic (406 Not Acceptable)
|
||||
>
|
||||
> 💡 **实战用法:** Negotiate 还有一个 `Default` 字段,建议始终设置 fallback:
|
||||
> ```go
|
||||
> Default: gin.MIMEJSON, // 客户端没带 Accept 头或都不支持时用 JSON
|
||||
> ```
|
||||
|
||||
### Q23.【JWT 认证中间件】
|
||||
|
||||
> [!tip]- Q23 答案
|
||||
> **`Authorization` ; `c.Next()`**
|
||||
>
|
||||
> **解析:**
|
||||
>
|
||||
> | 空 | 填空 | 作用 |
|
||||
>|----|------|------|
|
||||
> | 空1 | `Authorization` | HTTP 标准鉴权头,通常格式为 `Bearer <token>` |
|
||||
> | 空2 | `c.Next()` | 验证通过后放行到下游 handler |
|
||||
>
|
||||
> **完整流程:**
|
||||
> ```
|
||||
> 请求 → jwtAuth()
|
||||
> → 取 Authorization header
|
||||
> → parseJWT(token) 验证签名和过期时间
|
||||
> → claims.UserID / claims.Role 存入 Context
|
||||
> → c.Next() 进入业务 handler
|
||||
> → handler 用 c.GetString("userID") 读取
|
||||
> ```
|
||||
>
|
||||
> 💡 **工程补充:**
|
||||
> - `c.GetHeader()` 等价于 `c.Request.Header.Get()`,只是少了一层链式调用
|
||||
> - 如果是 `Authorization: Bearer xxx` 格式,需要先 Strip "Bearer " 前缀:`strings.TrimPrefix(token, "Bearer ")`
|
||||
> - JWT 中间件中不要做数据库查询来验证用户是否存在——应该在首次登录后缓存 userId,减少 DB 压力
|
||||
|
||||
---
|
||||
|
||||
## 四、主观题(每题 4 分,共 16 分)
|
||||
|
||||
> 简要回答即可,不需要长篇大论。关键是说出你的理解。
|
||||
|
||||
### Q24.【路由优先级深入】
|
||||
|
||||
假设你先注册了 `GET /users/:id`,再注册 `GET /users/list`。请问这两个路由是否会冲突?Gin 会 panic 吗?最终 `/users/list` 会匹配到哪个 handler?结合 Radix Tree 的工作原理解释原因。
|
||||
|
||||
> [!tip]- Q24 参考答案
|
||||
>
|
||||
> 不会 panic,也不会冲突。因为 Radix Tree 是按**路径片段类型**来决定优先级的:静态片段(`list`)永远优先于动态片段(`:id`)。Gin 在匹配时,遇到分支点会先尝试走静态边,找不到才走动态边。所以无论注册顺序如何,`/users/list` 始终匹配到第二个 handler(`GET /users/list`),而 `/users/123` 匹配到第一个。
|
||||
>
|
||||
> 不过,**注册两条完全相同的 path+method 会 panic**(比如两次注册 `GET /users/:id`),因为只有 path 和方法都相同才会被视为重复。
|
||||
|
||||
### Q25.【中间件作用域选择】
|
||||
|
||||
你有 `/api/v1/` 和 `/api/v2/` 两个 API 分组,都需要 CORS 支持。你会把 CORS 中间件注册到全局(`r.Use(cors())`),还是分别注册到各自分组(`v1.Group(..., cors())` 和 `v2.Group(..., cors())`)?给出理由。
|
||||
|
||||
> [!tip]- Q25 参考答案
|
||||
>
|
||||
> 两种都可以,取决于项目的整体需求:
|
||||
>
|
||||
> - **注册到全局**:如果你希望整个服务的所有接口(包括 health check、内部接口)都允许跨域,这是最简单的做法。但缺点是 CORS 头会加到所有路由上,包括不需要跨域的接口。
|
||||
> - **注册到各自分组**:更精细的控制,只有需要跨域的 API 分组带上 CORS。但如果 v1 和 v2 之外还有其他分组也需要 CORS,维护成本会变高。
|
||||
>
|
||||
> **最佳实践**:一般把 CORS 注册到全局(`r.Use(cors())`),因为:① 大多数现代前后端分离项目所有 API 都需要跨域;② CORS 头的开销极小;③ 如果需要排除特定路径,可以在中间件内部加 `if path == "/health" { c.Next(); return }` 的判断。
|
||||
|
||||
### Q26.【c.Copy 与 goroutine 陷阱】
|
||||
|
||||
下面这段代码有什么问题?如何修复?
|
||||
|
||||
```go
|
||||
func notifyHandler() gin.HandlerFunc {
|
||||
return func(c *gin.Context) {
|
||||
start := time.Now()
|
||||
c.Next()
|
||||
|
||||
go func() {
|
||||
// 发送通知
|
||||
notifyService.Send(c.GetString("userID"),
|
||||
c.Writer.Status(),
|
||||
time.Since(start))
|
||||
}()
|
||||
// handler 返回,Context 被回收放回 pool
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
> [!tip]- Q26 参考答案
|
||||
>
|
||||
> **问题:** goroutine 是在 `c.Next()` 之后启动的,handler 返回后 Context 会被 `reset()` 并放回 `sync.Pool`。此时 goroutine 还在运行并引用 `c`,可能出现:
|
||||
> 1. `c.GetString("userID")` 读到下一个请求的数据(Context 被复用了)
|
||||
> 2. `c.Writer.Status()` panic(Writer 已被 reset)
|
||||
>
|
||||
> **修复:** 使用 `c.Copy()` 创建独立副本,且只能在 goroutine 中**读取**不能**写入**:
|
||||
>
|
||||
> ```go
|
||||
> func notifyHandler() gin.HandlerFunc {
|
||||
> return func(c *gin.Context) {
|
||||
> start := time.Now()
|
||||
> c.Next()
|
||||
>
|
||||
> userID := c.GetString("userID")
|
||||
> status := c.Writer.Status()
|
||||
> latency := time.Since(start)
|
||||
>
|
||||
> copy := c.Copy()
|
||||
> go func() {
|
||||
> notifyService.Send(userID, status, latency)
|
||||
> }()
|
||||
> }
|
||||
> }
|
||||
> ```
|
||||
>
|
||||
> 关键原则:**提前取值、用 copy、goroutine 只读**。
|
||||
|
||||
### Q27.【错误处理方案设计】
|
||||
|
||||
如果你的团队有 10 人以上,负责一个大型 Gin 微服务项目。你觉得采用「Helpers 方案」(每个 handler 直接调用 `OK/Err`)还是「中间件兜底方案」(handler 只用 `c.Set`,中间件统一组装响应)更好?说明优缺点和你的选型依据。
|
||||
|
||||
> [!tip]- Q27 参考答案
|
||||
>
|
||||
> 对于 10+ 人的大型团队,推荐**中间件兜底方案(方案 B)**,理由如下:
|
||||
>
|
||||
> **Helpers 方案的缺点:**
|
||||
> - 每个人写 JSON 响应的风格不同,容易格式不一致(有人用 `gin.H`,有人用 struct)
|
||||
> - 新增字段(如 `trace_id`)需要改每个 helper 调用处,或每次手动加上
|
||||
> - 新人上手快,但随着人数增长,review 成本急剧上升
|
||||
>
|
||||
> **中间件兜底的优点:**
|
||||
> - handler 完全不接触 HTTP 响应细节,关注点分离清晰
|
||||
> - 响应格式由中间件强制统一,不可能出现格式不一致
|
||||
> - 横切逻辑(统一打日志、加 trace_id、指标采集)集中在中间件后置逻辑
|
||||
> - 适合多人协作,降低 merge conflict
|
||||
>
|
||||
> **但代价是:** 心智负担更高——需要理解中间件的时序(`c.Next()` 之前 vs 之后),调试不如 Helpers 直接打断点方便。
|
||||
>
|
||||
> **渐进式建议:** 小团队先用 Helpers 跑起来,当出现格式不统一的问题时再升级到中间件兜底。
|
||||
>
|
||||
> **补充:** 还可以结合方案 C(`errors.As`)实现跨层结构化错误传递,进一步提升大型项目的错误处理质量。
|
||||
|
||||
---
|
||||
|
||||
## 评分参考
|
||||
|
||||
| 题目类型 | 满分 | 权重 |
|
||||
|---------|------|------|
|
||||
| 选择题(Q1-Q10) | 30 分 | 30% |
|
||||
| 填空题(Q11-Q18) | 24 分 | 24% |
|
||||
| 代码补全(Q19-Q23) | 30 分 | 30% |
|
||||
| 主观题(Q24-Q27) | 16 分 | 16% |
|
||||
| **总计** | **100 分** | **100%** |
|
||||
|
||||
**及格线:** ≥70 分
|
||||
**优秀线:** ≥85 分
|
||||
@@ -0,0 +1,879 @@
|
||||
---
|
||||
tags:
|
||||
- 后端
|
||||
- Go
|
||||
- GORM
|
||||
- ORM
|
||||
- 测试
|
||||
- 自我考察
|
||||
create time: 2026-04-29 15:30
|
||||
---
|
||||
|
||||
# GORM 框架自测题
|
||||
|
||||
## 概述
|
||||
|
||||
这是一份 **GORM 框架**的自测试卷,覆盖核心机制 → 查询技巧 → 工程实践全链路知识点。通过选择题、填空题、代码补全、主观题四种题型帮助你检验对 GORM 的理解程度,找到知识盲区后回到对应笔记重新学习。
|
||||
|
||||
---
|
||||
|
||||
## 正文
|
||||
|
||||
## 一、选择题(每题 3 分,共 30 分)
|
||||
|
||||
> 每题只有一个正确答案。
|
||||
|
||||
### Q1.【软删除的设计】
|
||||
|
||||
为什么 GORM 的软删除使用 `DeletedAt` 时间戳而不是 `IsDeleted BOOLEAN`?以下原因**最全面**的是:
|
||||
|
||||
A. BOOLEAN 无法区分"未删除"和"误删恢复"两种场景
|
||||
B. 时间戳记录精确的删除时刻便于审计;NULL 表示未删除语义更清晰
|
||||
C. BOOLEAN 在 MySQL 中占 1 byte,时间戳占 8 bytes,浪费存储空间
|
||||
D. BOOLEAN 会导致唯一约束检查变慢
|
||||
|
||||
> [!tip]- Q1 答案
|
||||
> **B — 时间戳提供审计信息 + NULL 语义清晰**
|
||||
>
|
||||
> **解析:**
|
||||
> `gorm.DeletedAt`(本质是 `time.Time`)相比 BOOLEAN 的优势在于:
|
||||
>
|
||||
> 1. **精确时间**:记录了确切的删除时刻,方便后续审计和问题排查
|
||||
> 2. **NULL = 未删除**:Go 中零值 `time.Time{}` 不等于 nil,GORM 用 `nil` 指针表示"未删除"——这与 BOOLEAN 的 `false = 正常` 相比,语义更不容易混淆
|
||||
> 3. **时间排序**:可以按删除时间排序,做定时清理策略时更方便
|
||||
>
|
||||
> **为什么不是其他选项:**
|
||||
> - ❌ A:BOOLEAN 也能通过业务逻辑区分恢复,这不是核心原因
|
||||
> - ❌ C:这是缺点而非设计理由——软删除本身就比物理删除多占空间
|
||||
> - ❌ D:BOOLEAN 和时间戳在唯一约束上的行为差异取决于数据库实现,不是 GORM 的考量
|
||||
>
|
||||
> 💡 **拓展:** `DeletedAt` 的类型是 `*time.Time`(指针),这样就能用 `nil` 表示未删除。如果用的是非指针 `time.Time`,零值也会被误判为已删除。
|
||||
|
||||
### Q2.【Preload vs Joins】
|
||||
|
||||
关于 `Preload` 和 `Joins` 的区别,以下说法**正确**的是:
|
||||
|
||||
A. `Preload` 只执行一条 SQL,`Joins` 执行多条 SQL
|
||||
B. `Preload` 默认对关联数据加软删除过滤,`Joins` 不会自动加
|
||||
C. `Joins` 返回的数据会自动填充嵌套 struct,`Preload` 需要手动映射
|
||||
D. `Joins` 会产生行膨胀(笛卡尔积),`Preload` 不会产生
|
||||
|
||||
> [!tip]- Q2 答案
|
||||
> **D — Joins 会产生行膨胀,Preload 不会**
|
||||
>
|
||||
> **解析:**
|
||||
>
|
||||
> | 特性 | Preload | Joins |
|
||||
> |------|---------|-------|
|
||||
> | SQL 数量 | N+1 条(先查主表,再批量 IN 查关联) | 通常 1 条复杂 JOIN SQL |
|
||||
> | 行重复 | ❌ 不会产生 | ✅ 一对多关系会产生重复行(一个用户有多个订单会返回多行 User) |
|
||||
> | 关联过滤 | 容易(WHERE 作为第二参数) | 需写在 JOIN 子句中,控制不够灵活 |
|
||||
> | 内存占用 | 适中 | JOIN 膨胀时较高 |
|
||||
>
|
||||
> **逐项分析:**
|
||||
> - ❌ A:反了——Preload 是多条 SQL,Joins 是一条
|
||||
> - ❌ B:两者都会自动加软删除过滤,GORM 内部都对关联查询附加 `deleted_at IS NULL`
|
||||
> - ❌ C:两者都能自动填充嵌套 struct,不需要手动映射
|
||||
> - ✅ D:正确!LEFT JOIN 时若 OneToMany 关系会返回多条相同主记录的行(行膨胀),而 Preload 分两次查询完全避免了这个问题
|
||||
>
|
||||
> 💡 **经验法则:** "优先 Preload,必要时 Joins"——除非确定关联数据量极小,否则 Preload 更安全可控。
|
||||
|
||||
### Q3.【First vs Take vs Last】
|
||||
|
||||
三个方法都要求带条件,关于它们的行为差异,以下说法**正确**的是:
|
||||
|
||||
A. `First` 和 `Take` 都会按主键排序后取第一条
|
||||
B. `Take` 是无序的,适合随机抽奖或推荐场景
|
||||
C. `Last` 不按主键排序,而是按创建时间倒序
|
||||
D. 三者不带 Where 都能正常工作
|
||||
|
||||
> [!tip]- Q3 答案
|
||||
> **B — Take 无序,适合随机场景**
|
||||
>
|
||||
> **解析:**
|
||||
>
|
||||
> | 方法 | 排序方式 | 结果确定性 | 是否需要 Where |
|
||||
> |------|---------|-----------|---------------|
|
||||
> | `First` | 主键升序(ORDER BY PK ASC LIMIT 1) | ✅ 确定性 | ✅ 必须 |
|
||||
> | `Take` | **不指定任何排序** | ❌ 任意一条 | ✅ 必须 |
|
||||
> | `Last` | 主键降序(ORDER BY PK DESC LIMIT 1) | ✅ 确定性 | ✅ 必须 |
|
||||
>
|
||||
> **逐项分析:**
|
||||
> - ❌ A:`Take` 不带排序,数据库返回哪条就是哪条——这恰恰是关键区别
|
||||
> - ✅ B:正确。`Take` 等同于 `SELECT * FROM table LIMIT 1`,没有 ORDER BY,所以每次拿到的可能不同,正好适合"随机"语义
|
||||
> - ❌ C:`Last` 是按主键倒序,不是创建时间
|
||||
> - ❌ D:文档明确写了三者都需要至少一个 Where 条件
|
||||
>
|
||||
> 💡 **实际选择:** 要"最新的记录"用 `First`;要"随便来一条"用 `Take`;要"最后一条"用 `Last`。
|
||||
|
||||
### Q4.【Updates 的 zero value 陷阱】
|
||||
|
||||
以下代码的输出结果是?
|
||||
|
||||
```go
|
||||
user := User{Name: "Alice", Age: 25}
|
||||
db.Model(&user).Updates(User{Name: "", Status: "active"})
|
||||
```
|
||||
|
||||
A. UPDATE SET name='', status='active' —— Name 被更新为空字符串
|
||||
B. UPDATE SET status='active' —— Name 跳过了,因为空串是零值
|
||||
C. UPDATE SET name='', status='active' —— struct 方式的 Updates 不会跳过零值
|
||||
D. 报错,因为不能传入零值
|
||||
|
||||
> [!tip]- Q4 答案
|
||||
> **B — struct 方式跳过了零值字段**
|
||||
>
|
||||
> **解析:**
|
||||
> GORM 的 `Updates` 有两种传参方式,处理方式截然不同:
|
||||
>
|
||||
> | 方式 | 零值处理 | 生成的 SQL |
|
||||
> |------|---------|-----------|
|
||||
> | `Updates(struct)` | **跳过零值字段**(Zero Value Skipped) | `UPDATE users SET status='active' WHERE ...` |
|
||||
> | `Updates(map[string]any)` | **写入所有 key**(包含零值) | `UPDATE users SET name='', status='active' WHERE ...` |
|
||||
>
|
||||
> 在上面的例子中,`Name: ""` 是 string 类型的零值,所以 `Updates(User{Name: "", Status: "active"})` 只会生成 `SET status='active'`。
|
||||
>
|
||||
> **真实踩坑案例:** 用户想把昵称清空,调用了 `Updates(User{Nickname: ""})`,结果什么 SQL 都没生成——数据完全没有变化!
|
||||
>
|
||||
> 💡 **解决方案:** 需要更新零值时用 map 方式:`Updates(map[string]any{"name": ""})`。
|
||||
|
||||
### Q5.【事务回调的错误返回值】
|
||||
|
||||
```go
|
||||
err := db.Transaction(func(tx *gorm.DB) error {
|
||||
var account Account
|
||||
if err := tx.First(&account, 1).Error; err != nil {
|
||||
return err
|
||||
}
|
||||
account.Balance -= 100
|
||||
tx.Save(&account)
|
||||
return nil
|
||||
})
|
||||
```
|
||||
|
||||
如果 `tx.First` 找不到账户(返回 `ErrRecordNotFound`),会发生什么?
|
||||
|
||||
A. 错误被静默吞掉,继续执行 Save
|
||||
B. 函数 return err → GORM 自动 Rollback,最终 `err == ErrRecordNotFound`
|
||||
C. 函数 panic,因为 error 没有包装
|
||||
D. GORM 自动把错误转换为 HTTP 404 响应
|
||||
|
||||
> [!tip]- Q5 答案
|
||||
> **B — Transaction() 回调返回 error 自动回滚**
|
||||
>
|
||||
> **解析:**
|
||||
> `db.Transaction()` 是 GORM 提供的便捷事务 API,它的核心机制是:
|
||||
>
|
||||
> 1. **内部自动 Begin**:进入回调前开启事务
|
||||
> 2. **return nil → Commit**:回调返回 nil 说明一切正常,GORM 自动提交
|
||||
> 3. **return non-nil error → Rollback**:回调返回任何非 nil error,GORM 自动回滚
|
||||
> 4. **panic → Rollback**:回调内 panic,GORM 捕获后也回滚
|
||||
>
|
||||
> **在这个例子中:**
|
||||
> - `tx.First` 找不到记录 → 返回 `ErrRecordNotFound`
|
||||
> - `return err` → GORM 检测到非 nil → 自动 `Rollback()`
|
||||
> - 外层 `err` 拿到的是包装后的 `ErrRecordNotFound`
|
||||
>
|
||||
> **为什么要这样设计?** 避免了手动写 `Begin/Rollback/Commit` 的大量样板代码,且天然防止"事务泄漏"——出了闭包就拿不到 tx 实例了。
|
||||
>
|
||||
> 💡 **限制:** 回调内不能再调用 `Transaction()`,否则 panic——因为此时已经处于事务中了。
|
||||
|
||||
### Q6.【WHERE vs HAVING】
|
||||
|
||||
以下 GORM 查询语句的错误原因是:
|
||||
|
||||
```go
|
||||
db.Model(&Order{}).Where("COUNT(*) > 5").Group("user_id").Find(&result)
|
||||
```
|
||||
|
||||
A. GORM 不支持 GROUP BY
|
||||
B. WHERE 不能在 GROUP BY 之前执行聚合函数
|
||||
C. Find 不能和 Group 连用
|
||||
D. COUNT 必须配合 Select 才能使用
|
||||
|
||||
> [!tip]- Q6 答案
|
||||
> **B — WHERE 在分组前执行,无法使用聚合函数**
|
||||
>
|
||||
> **解析:**
|
||||
> SQL 的标准执行顺序是:
|
||||
> ```
|
||||
> FROM → WHERE → GROUP BY → HAVING → SELECT → ORDER BY
|
||||
> ```
|
||||
>
|
||||
> **关键理解:** `WHERE` 发生在 `GROUP BY` 之前——此时还没有分组,`COUNT(*)` 毫无意义。SQL 引擎会在解析 WHERE 时就报语法错误。
|
||||
>
|
||||
> **正确写法:** 把聚合条件放到 `HAVING` 中:
|
||||
> ```go
|
||||
> db.Model(&Order{}).
|
||||
> Group("user_id").
|
||||
> Having("COUNT(*) > ?", 5). // ✅ HAVING 才能用聚合函数
|
||||
> Find(&result)
|
||||
> ```
|
||||
>
|
||||
> **如果想先用 WHERE 缩小范围再做聚合:**
|
||||
> ```go
|
||||
> db.Model(&Order{}).
|
||||
> Where("created_at > ?", cutoff). // 先用 WHERE 过滤单行
|
||||
> Group("user_id").
|
||||
> Having("COUNT(*) > ?", 5). // 再用 HAVING 过滤聚合结果
|
||||
> Find(&result)
|
||||
> ```
|
||||
>
|
||||
> 💡 **记忆口诀:** "WHERE 筛行,HAVING 筛组"。
|
||||
|
||||
### Q7.【钩子与操作的关系】
|
||||
|
||||
如果一个模型包含 `DeletedAt` 字段并嵌入了 `gorm.Model`,当调用 `db.Delete(&user)`(软删除)时,哪个钩子**会被调用**?
|
||||
|
||||
A. `BeforeDelete` 和 `AfterDelete`
|
||||
B. `BeforeUpdate` 和 `AfterUpdate`
|
||||
C. `BeforeDelete` 和 `AfterUpdate`
|
||||
D. 没有任何钩子被调用
|
||||
|
||||
> [!tip]- Q7 答案
|
||||
> **B — BeforeUpdate 和 AfterUpdate**
|
||||
>
|
||||
> **解析:**
|
||||
> 这是软删除最常见的误区之一。虽然名字叫 `Delete()`,但在软删除模式下它发送的是 **UPDATE 语句**:
|
||||
> ```sql
|
||||
> UPDATE users SET deleted_at = NOW() WHERE id = ?
|
||||
> ```
|
||||
>
|
||||
> **因此触发的是 UPDATE 钩子链:**
|
||||
> ```
|
||||
> BeforeDelete(❌ 不会被调用)
|
||||
> ↓
|
||||
> BeforeUpdate (✅ 被调用)→ SQL: UPDATE ... SET deleted_at=...
|
||||
> ↓
|
||||
> AfterUpdate (✅ 被调用)
|
||||
> ↓
|
||||
> AfterDelete (❌ 不会被调用)
|
||||
> ```
|
||||
>
|
||||
> **如果想触发 BeforeDelete / AfterDelete:** 需要用 `Unscoped().Delete()` 执行真正的 DELETE 语句。
|
||||
>
|
||||
> 💡 **工程影响:** 如果你在 `BeforeDelete` 里做了级联清理(如删除 Redis Session),软删除时这些逻辑**不会执行**——需要额外处理。
|
||||
|
||||
### Q8.【DryRun vs Debug】
|
||||
|
||||
关于 DryRun 模式,以下说法**错误**的是:
|
||||
|
||||
A. DryRun 会构建完整 SQL 但**不执行**,零网络开销
|
||||
B. DryRun 的参数占位符会保持为 `?`,不会展开为实际值
|
||||
C. `Debug()` 方法会修改全局 Logger 配置,影响后续所有查询
|
||||
D. 可以用 DryRun 在 CI Pipeline 中校验 struct tag 变更是否合法
|
||||
|
||||
> [!tip]- Q8 答案
|
||||
> **C — Debug() 不影响全局配置**
|
||||
>
|
||||
> **解析:**
|
||||
>
|
||||
> | 特性 | DryRun | Debug() |
|
||||
> |------|--------|---------|
|
||||
> | 是否执行 SQL | ❌ 不执行 | ✅ 执行 |
|
||||
> | 性能开销 | 零(无网络 IO) | 正常查询开销 |
|
||||
> | 参数占位符 | 保持 `?` | 展开为实际值 |
|
||||
> | 全局影响 | 无 | 无——返回新 `*gorm.DB` 实例 |
|
||||
>
|
||||
> **逐项分析:**
|
||||
> - ✅ A:正确。DryRun 只做 SQL 构建,不走网络
|
||||
> - ✅ B:正确。预编译语句的参数化就是安全的体现
|
||||
> - ❌ C:**错误!** `Debug()` 返回一个新的 `*gorm.DB` 实例,原 DB 不受影响。这是函数式编程的思想
|
||||
> - ✅ D:正确。可以在 CI 中跑 DryRun 验证迁移脚本不会引入破坏性变更
|
||||
>
|
||||
> 💡 **实战用法:** `db.Session(&gorm.Session{DryRun: true}).First(&user, 1)` 可以用于审计复杂的链式调用生成的最终 SQL。
|
||||
|
||||
### Q9.【Preload 与 N+1 问题】
|
||||
|
||||
以下代码中提到了 **N+1 问题**,请问这里的 N 指的是:
|
||||
|
||||
```go
|
||||
var users []User
|
||||
db.Preload("Orders").Find(&users)
|
||||
```
|
||||
|
||||
A. 用户的总数
|
||||
B. Orders 的总行数
|
||||
C. 预处理阶段执行的次数
|
||||
D. 数据库连接数
|
||||
|
||||
> [!tip]- Q9 答案
|
||||
> **A — 用户总数**
|
||||
>
|
||||
> **解析:**
|
||||
> Preload 的工作方式是"两步走":
|
||||
>
|
||||
> ```
|
||||
> SQL 1: SELECT * FROM users; ← 获取 N 个用户
|
||||
> SQL 2: SELECT * FROM orders WHERE user_id IN (1,2,...); ← 一次性批量查询所有关联订单
|
||||
> ```
|
||||
>
|
||||
> 这里的"N+1"名称来源于另一种**懒加载**(手动循环查关联)的模式:
|
||||
> ```go
|
||||
> var users []User
|
||||
> db.Find(&users) // SQL 1: 查用户
|
||||
> for _, u := range users {
|
||||
> db.Where("user_id = ?", u.ID).Find(&u.Orders) // SQL 2~N+1: 每个用户一条 SQL
|
||||
> }
|
||||
> ```
|
||||
>
|
||||
> Preload 把 N+1 优化成了**恰好 2 条**(无论用户多少),因为第二条 SQL 用的是 `IN (...)` 批量查询。
|
||||
>
|
||||
> 💡 **延伸:** Preload 的深度是层级维度——`Preload("Orders.Products")` 变成 3 条 SQL(Users + Orders + Products),每层各一次批量 IN 查询。
|
||||
|
||||
### Q10.【连接池配置】
|
||||
|
||||
生产环境中连接池的配置,以下做法**正确**的是:
|
||||
|
||||
A. `SetMaxOpenConns(0)` 不设上限,让 Go 自己决定最大连接数
|
||||
B. `SetConnMaxLifetime(8 * time.Hour)` 匹配 MySQL 默认的 wait_timeout
|
||||
C. `SetMaxIdleConns(50)` 设为 MaxOpenConns 的一半以应对突发流量
|
||||
D. `SetConnMaxLifetime(5 * time.Minute)` 小于数据库侧的 wait_timeout
|
||||
|
||||
> [!tip]- Q10 答案
|
||||
> **D — ConnMaxLifetime 必须小于数据库 wait_timeout**
|
||||
>
|
||||
> **解析:**
|
||||
>
|
||||
> | 参数 | 常见错误 | 正确做法 |
|
||||
> |------|---------|---------|
|
||||
> | `MaxOpenConns` | 设为 0(无上限) | 显式设置,不超过 MySQL max_connections |
|
||||
> | `MaxIdleConns` | 设得过大浪费资源 | CPU 核数或 10~20,约为 MaxOpenConns 的 10%~25% |
|
||||
> | `ConnMaxLifetime` | 设为 8h 匹配 MySQL 默认 | **设为 5~10 分钟**,远小于 MySQL 的 8h wait_timeout |
|
||||
> | `ConnMaxIdleTime` | 不设置 | 设为 5 分钟,减少闲置连接占用 |
|
||||
>
|
||||
> **关键原理:** MySQL 默认 `wait_timeout = 8h`——空闲连接超过 8 秒务端主动断开。如果 Go 的连接池不知道这一点(即 `ConnMaxLifetime >= 8h`),它会从池中取出已经断开的旧连接,导致 `server has gone away` 错误。
|
||||
>
|
||||
> **正确的参数关系:** `IdleTime < Lifetime << wait_timeout`
|
||||
>
|
||||
> 💡 **经验公式:** `MaxOpenConns = CPU核心数 × 2 + 磁盘数`。例如 4 核 2 盘 → 10(I/O 密集型可放宽到 20~50)。
|
||||
|
||||
---
|
||||
|
||||
## 二、填空题(每题 3 分,共 24 分)
|
||||
|
||||
> 根据知识填写空缺的代码或概念。
|
||||
|
||||
### Q11.【建表与表名推导】
|
||||
|
||||
```go
|
||||
type Article struct {
|
||||
ID uint
|
||||
Title string
|
||||
}
|
||||
|
||||
// GORM 推断的表名是 _________
|
||||
db.AutoMigrate(&Article{})
|
||||
|
||||
// 显式指定表名的方法是实现 _________ 方法
|
||||
```
|
||||
|
||||
> [!tip]- Q11 答案
|
||||
> **`articles`** ; **`TableName() string`**
|
||||
>
|
||||
> **解析:**
|
||||
> GORM 的默认命名策略是将 struct 名转为蛇形复数形式:
|
||||
> - `User` → `users`
|
||||
> - `Article` → `articles`
|
||||
> - `OrderItem` → `order_items`
|
||||
>
|
||||
> `TableName()` 是 model 级别的约定,GORM 会优先使用其返回值作为表名。用值接收者 `(Article)` 而非指针 `(Article)` 是因为 GORM 内部先尝试值接收者,找不到再试指针接收者——值接收者兼容性更好。
|
||||
>
|
||||
> 💡 **注意:** `db.Table("custom_name")` 只在当前链式调用中生效,不会修改模型的 `TableName()` 返回值。
|
||||
|
||||
### Q12.【批量插入参数含义】
|
||||
|
||||
```go
|
||||
users := make([]User, 350)
|
||||
db.CreateInBatches(&users, 100).Error
|
||||
```
|
||||
|
||||
上面代码总共执行 _____ 批 INSERT,每批最多 _____ 条记录。
|
||||
|
||||
> [!tip]- Q12 答案
|
||||
> **4** ; **100**
|
||||
>
|
||||
> **解析:**
|
||||
> `CreateInBatches(slice, batchSize)` 将 slice 拆分为多个批次:
|
||||
>
|
||||
> ```
|
||||
> 350 ÷ 100 = 3.5 → 向上取整为 4 批
|
||||
> 第 1 批:100 条 → INSERT INTO users VALUES (...), (...), ...
|
||||
> 第 2 批:100 条
|
||||
> 第 3 批:100 条
|
||||
> 第 4 批:50 条 ← 剩余的不足一批的数据单独执行
|
||||
> ```
|
||||
>
|
||||
> 如果 `batchSize = 200`:350 ÷ 200 = 1.75 → 2 批(200 + 150)
|
||||
>
|
||||
> 💡 **底层机制:** GORM 内部的 `Create` 也会自动拆批(约 256 条/批),但不可自定义。需要精确控制时请用 `CreateInBatches`。
|
||||
|
||||
### Q13.【关联查询的外键位置】
|
||||
|
||||
```go
|
||||
type User struct {
|
||||
ID uint
|
||||
Name string
|
||||
}
|
||||
|
||||
type Order struct {
|
||||
ID uint
|
||||
UserID uint
|
||||
User User `gorm:"_________"` // 空1:声明关联类型
|
||||
}
|
||||
```
|
||||
|
||||
`Order` **属于** `User`,应使用的关联类型是 ____________(填关联类型名);该关联的外键位于 ____________(填"当前方"或"被关联方")的表中。
|
||||
|
||||
> [!tip]- Q13 答案
|
||||
> **`belongsTo`** ; **当前方**
|
||||
>
|
||||
> **解析:**
|
||||
> GORM 四种关联的核心区别在于**外键在哪张表上**:
|
||||
>
|
||||
> | 关联类型 | 描述 | 外键位置 | 示例 |
|
||||
> |---------|------|---------|------|
|
||||
> | HasOne | 一对一 | 被关联方 | User → Profile |
|
||||
> | HasMany | 一对多 | 被关联方 | User → Orders |
|
||||
> | BelongsTo | 多对一 | **当前方** | Order → User |
|
||||
> | ManyToMany | 多对多 | 中间表 | Order ↔ Product |
|
||||
>
|
||||
> `BelongsTo` 表示"属于"关系——订单属于某个用户。外键 `UserID` 定义在 `Order` 表(当前方),而不是 `User` 表。
|
||||
>
|
||||
> 💡 **判断口诀:** "谁 belongs to 谁,外键就在谁这边"——Order belongs to User,外键在 Order 表。
|
||||
|
||||
### Q14.【错误判断方式】
|
||||
|
||||
GORM 的错误应该通过 _______________ 来判断是否等于 `gorm.ErrRecordNotFound`,而不能直接用 `==` 比较。
|
||||
|
||||
> [!tip]- Q14 答案
|
||||
> **`errors.Is(err, gorm.ErrRecordNotFound)`**
|
||||
>
|
||||
> **解析:**
|
||||
> GORM 的哨兵错误(如 `ErrRecordNotFound`)内部会用 `fmt.Errorf("%w", ...)` 进行包装,形成错误链。标准库的 `errors.Is()` 能逐层识别哨兵错误,而直接 `==` 比较在复杂场景中可能漏判。
|
||||
>
|
||||
> ```go
|
||||
> result := db.First(&user, 1)
|
||||
> if errors.Is(result.Error, gorm.ErrRecordNotFound) {
|
||||
> // 正确处理
|
||||
> }
|
||||
> ```
|
||||
>
|
||||
> 💡 **最佳实践:** 统一使用 `errors.Is()` 做哨兵错误判断,`errors.As()` 做具体错误类型的类型断言。
|
||||
|
||||
### Q15.【Upsert 冲突策略】
|
||||
|
||||
MySQL 中 ON DUPLICATE KEY UPDATE 的等效 GORM 写法:
|
||||
|
||||
```go
|
||||
db.Clauses(clause.OnConflict{
|
||||
Columns: []clause.Column{{Name: "email"}},
|
||||
DoUpdates: clause._________("last_login"), // 空1:填入方法名
|
||||
}).Create(&users)
|
||||
```
|
||||
|
||||
> [!tip]- Q15 答案
|
||||
> **`AssignmentColumns`**
|
||||
>
|
||||
> **解析:**
|
||||
> `clause.OnConflict` 支持多种冲突策略:
|
||||
>
|
||||
> | 方法 | 作用 | 生成 SQL |
|
||||
> |------|------|---------|
|
||||
> | `DoNothing` | 冲突时不做任何事 | `ON CONFLICT DO NOTHING` |
|
||||
> | `AssignmentColumns(["col"])` | 冲突时更新指定列 | `ON CONFLICT DO UPDATE SET col = excluded.col` |
|
||||
> | `Assignments(gorm.Expr(...))` | 自定义表达式 | `ON CONFLICT DO UPDATE SET col = expr` |
|
||||
>
|
||||
> 对于 PostgreSQL,同样的写法生成 `ON CONFLICT (email) DO UPDATE SET last_login = excluded.last_login;`。
|
||||
>
|
||||
> 💡 **进阶:** 冲突时递增计数器:
|
||||
> ```go
|
||||
> DoUpdates: clause.Assignments(gorm.Expr("count = count + 1"))
|
||||
> ```
|
||||
|
||||
### Q16.【游标分页参数计算】
|
||||
|
||||
传统 OFFSET 分页中,第 5 页、每页 20 条数据的 Offset 值为 ________;对应的计算公式是 ________________(用变量表达)。
|
||||
|
||||
> [!tip]- Q16 答案
|
||||
> **80** ; **`(page - 1) * pageSize`**
|
||||
>
|
||||
> **解析:**
|
||||
>
|
||||
> | 页码 | 每页数 | Offset | 结果 |
|
||||
> |------|--------|--------|------|
|
||||
> | 第 1 页 | 20 | (1-1)×20 = **0** | 第 1-20 条 |
|
||||
> | 第 3 页 | 20 | (3-1)×20 = **40** | 第 41-60 条 |
|
||||
> | 第 5 页 | 20 | (5-1)×20 = **80** | 第 81-100 条 |
|
||||
>
|
||||
> ```go
|
||||
> offset := (page - 1) * pageSize
|
||||
> db.Limit(pageSize).Offset(offset).Find(&items)
|
||||
> ```
|
||||
>
|
||||
> 💡 **深度翻页问题:** OFFSET 80000 时数据库仍需扫描并跳过前面 80000 行。百万级数据下应改用游标分页(Keyset Pagination)。
|
||||
|
||||
### Q17.【批量更新字段控制】
|
||||
|
||||
```go
|
||||
// 只更新白名单中的字段(忽略 email)
|
||||
db.Model(&User{}).
|
||||
Where("status = ?", "trial").
|
||||
________("status", "plan"). // 空1:白名单控制
|
||||
Updates(map[string]any{
|
||||
"status": "active",
|
||||
"plan": "pro",
|
||||
"email": "should_not_change@test.com",
|
||||
})
|
||||
```
|
||||
|
||||
> [!tip]- Q17 答案
|
||||
> **`Select`**
|
||||
>
|
||||
> **解析:**
|
||||
>
|
||||
> | 方法 | 类型 | 效果 |
|
||||
> |------|------|------|
|
||||
> | `Select("col1", "col2")` | **白名单** | 只更新指定的字段 |
|
||||
> | `Omit("col1")` | **黑名单** | 除了指定字段外的其他字段全部更新 |
|
||||
>
|
||||
> ```go
|
||||
> // 黑名单方式(排除 password)
|
||||
> db.Model(&User{}).
|
||||
> Omit("password", "secret_key").
|
||||
> Updates(map[string]any{
|
||||
> "name": "new name",
|
||||
> "password": "new pass", // 被忽略
|
||||
> })
|
||||
> ```
|
||||
>
|
||||
> 两者可以组合:`Select("a", "b").Omit("b")` = 只更新 a。
|
||||
>
|
||||
> 💡 **注意:** `Select` 和 `Omit` 只对 `Updates` 有效,对 `Save` 和 `UpdateColumn` 等全量写入方法无效。
|
||||
|
||||
### Q18.【PrepareStmt 缓存机制】
|
||||
|
||||
开启 PrepareStmt 缓存后,GORM 使用 ____________ 存储已编译的 SQL 语句。这对 ____________ 类查询有显著的性能提升。
|
||||
|
||||
> [!tip]- Q18 答案
|
||||
> **`sync.Map`** ; **固定模式 / 重复执行**(或"热点查询")
|
||||
>
|
||||
> **解析:**
|
||||
> `PrepareStmt: true` 的工作原理:
|
||||
>
|
||||
> 1. 第一次执行 `SELECT * FROM users WHERE id = ?` → GORM 预编译 SQL,存入 `sync.Map`
|
||||
> 2. 第二次执行相同的 SQL → 直接从缓存取预编译语句,跳过解析和计划编译
|
||||
>
|
||||
> **适用场景:** 固定的查询模式(如按 ID 查询用户、按状态查询列表)
|
||||
> **不适用场景:** 大量参数不同的动态 SQL → 缓存命中率低,徒增内存消耗
|
||||
>
|
||||
> ```go
|
||||
> db, _ := gorm.Open(mysql.Open(dsn), &gorm.Config{
|
||||
> PrepareStmt: true,
|
||||
> })
|
||||
> ```
|
||||
>
|
||||
> 💡 **重要提醒:** PrepareStmt 的缓存是**独立于连接池**的,但它仍然依赖底层连接。高并发下仍需合理配置连接池大小。
|
||||
|
||||
---
|
||||
|
||||
## 三、代码补全(每题 6 分,共 30 分)
|
||||
|
||||
> 补充代码中空缺的部分。有些题目有多个空。
|
||||
|
||||
### Q19.【级联创建与关联追加】
|
||||
|
||||
```go
|
||||
// 创建用户的同时创建 Profile 并关联商品
|
||||
user := User{
|
||||
Name: "Alice",
|
||||
Profile: Profile{AvatarURL: "https://example.com/avatar.png"},
|
||||
Orders: []Order{
|
||||
{Amount: 99.9, Products: []Product{{Name: "Book", Price: 29}}},
|
||||
},
|
||||
}
|
||||
if err := db._________(&user).Error; err != nil { // 空1:方法名
|
||||
log.Fatal(err)
|
||||
}
|
||||
|
||||
// 给已有订单添加商品(追加,不覆盖)
|
||||
product := Product{Name: "New Book", Price: 39}
|
||||
db.Model(&order).Association("Products")._________(&product) // 空2:方法名
|
||||
```
|
||||
|
||||
> [!tip]- Q19 答案
|
||||
> **`Create`** ; **`Append`**
|
||||
>
|
||||
> **解析:**
|
||||
>
|
||||
> **空 1:** `db.Create(&user)` 会自动级联写入关联数据——先生成 INSERT INTO users,再 INSERT INTO profiles,最后 INSERT INTO orders。这是 GORM 的"递归保存"特性。
|
||||
>
|
||||
> **空 2:** Association API 提供了五个核心操作:
|
||||
>
|
||||
> | 方法 | 行为 | 适用场景 |
|
||||
> |------|------|---------|
|
||||
> | `Append` | 追加(新增不删除旧的) | 累积添加 |
|
||||
> | `Replace` | 替换全部(先删旧再加新) | 重新赋值 |
|
||||
> | `Delete` | 删除指定关联 | 移除单项 |
|
||||
> | `Clear` | 清空所有关联 | 批量移除 |
|
||||
> | `Count` | 统计关联数量 | 计数 |
|
||||
>
|
||||
> 💡 **Append 的 SQL 效果:** 只是向中间表 INSERT 新记录,不会影响已有的关联关系。
|
||||
|
||||
### Q20.【事务中的锁行读取】
|
||||
|
||||
```go
|
||||
func TransferMoney(fromID, toID uint, amount float64) error {
|
||||
return db.Transaction(func(tx *gorm.DB) error {
|
||||
// 转出账户需要悲观锁,防止并发扣款超余额
|
||||
var fromAccount Account
|
||||
if err := tx._______("gorm:query_option", "FOR UPDATE").
|
||||
First(&fromAccount, fromID).Error; err != nil { // 空1:设置查询选项的方法
|
||||
return fmt.Errorf("查询转出账户失败: %w", err)
|
||||
}
|
||||
|
||||
if fromAccount.Balance < amount {
|
||||
return errors.New("余额不足")
|
||||
}
|
||||
|
||||
fromAccount.Balance -= amount
|
||||
if err := tx.________(&fromAccount).Error; err != nil { // 空2:更新账户的方法
|
||||
return fmt.Errorf("扣款失败: %w", err)
|
||||
}
|
||||
|
||||
var toAccount Account
|
||||
if err := tx.First(&toAccount, toID).Error; err != nil {
|
||||
return fmt.Errorf("查询入账账户失败: %w", err)
|
||||
}
|
||||
toAccount.Balance += amount
|
||||
if err := tx.Save(&toAccount).Error; err != nil {
|
||||
return fmt.Errorf("入账失败: %w", err)
|
||||
}
|
||||
|
||||
return nil // 返回 nil → 自动 Commit
|
||||
})
|
||||
}
|
||||
```
|
||||
|
||||
> [!tip]- Q20 答案
|
||||
> **`Set`** ; **`Save`**
|
||||
>
|
||||
> **解析:**
|
||||
>
|
||||
> **空 1:** `tx.Set("key", value)` 用于在查询中携带额外的 session 级选项。`"gorm:query_option"` 是一个内置键名,GORM 会将其原样拼接到 SQL 末尾——`SELECT ... FOR UPDATE` 就是在数据库层面加排他锁,防止其他事务同时读取并修改同一行。
|
||||
>
|
||||
> **空 2:** `Save` 是全量更新,无视零值全部写回。这里用 `Save` 是因为需要确保 `Balance` 字段的最新值被持久化。
|
||||
>
|
||||
> ```go
|
||||
> // FOR UPDATE 的效果对比:
|
||||
> // ❌ 无锁:两个并发请求同时读到 Balance=100,都扣 60,结果 Balance=-20(超卖)
|
||||
> // ✅ 有锁:第二个请求必须等待第一个事务结束后才能读,保证原子性
|
||||
> ```
|
||||
>
|
||||
> 💡 **延伸:** PostgreSQL 同样使用 `SELECT ... FOR UPDATE`;SQLite 默认串行执行事务,不需要显式加锁。
|
||||
|
||||
### Q21.【条件预加载 + 排序】
|
||||
|
||||
```go
|
||||
// 加载用户及其订单,但只加载金额大于 100 的订单,并按金额降序排列
|
||||
db.Preload("Orders", func(db *gorm.DB) *gorm.DB {
|
||||
return db._______("amount > ?", 100).
|
||||
_________("amount DESC")
|
||||
}).Find(&users)
|
||||
```
|
||||
|
||||
> [!tip]- Q21 答案
|
||||
> **`Where`** ; **`Order`**
|
||||
>
|
||||
> **解析:**
|
||||
> `Preload` 的第二个参数可以是函数形式的条件构造器:
|
||||
>
|
||||
> ```go
|
||||
> db.Preload("AssociationName", func(db *gorm.DB) *gorm.DB {
|
||||
> return db.Where("condition").Order("field")
|
||||
> }).Find(&parent)
|
||||
> ```
|
||||
>
|
||||
> 这个函数的签名固定为 `func(*gorm.DB) *gorm.DB`——接收 DB 实例,返回修饰后的 DB 实例。可以在里面自由链式调用 `Where`、`Order`、`Limit`、`Select` 等方法。
|
||||
>
|
||||
> 💡 **注意:** 函数形式的条件只适用于 Preload,不适用于 Joins。Joins 的条件需要写在 `Joins("Table", "condition")` 的第二个参数中。
|
||||
|
||||
### Q22.【JSON 自定义类型的 Scanner】
|
||||
|
||||
```go
|
||||
type JSONMap map[string]interface{}
|
||||
|
||||
// Scan 方法负责从数据库读出数据(DB → Go)
|
||||
func (j *JSONMap) Scan(value interface{}) error {
|
||||
if value == nil {
|
||||
*j = nil
|
||||
return nil
|
||||
}
|
||||
str, ok := value._________ // 空1:类型断言
|
||||
if !ok {
|
||||
str = []byte(fmt.Sprintf("%v", value))
|
||||
}
|
||||
return json.Unmarshal(str, j) // 空2:反序列化的目标
|
||||
}
|
||||
```
|
||||
|
||||
> [!tip]- Q22 答案
|
||||
> **`([]byte)`** ; **`j`**(指针接收者本身)
|
||||
>
|
||||
> **解析:**
|
||||
> `sql.Scanner` 接口定义了 `Scan(value interface{}) error` 方法,由 GORM 在查询后调用,将数据库读出的原始值转换为 Go 类型。
|
||||
>
|
||||
> ```go
|
||||
> type Scanner interface {
|
||||
> Scan(value interface{}) error
|
||||
> }
|
||||
> ```
|
||||
>
|
||||
> **流程分解:**
|
||||
> 1. `value` 是从数据库来的原始字节数组 `[]byte`
|
||||
> 2. 类型断言 `value.([]byte)` 提取字节切片
|
||||
> 3. `json.Unmarshal(str, j)` 将 JSON 反序列化到 `*JSONMap`(指针接收者,这样才能修改外部结构体)
|
||||
>
|
||||
> 💡 **为什么用指针接收者?** `Scan` 需要将反序列化后的内容**写回**目标对象。如果用值接收者 `(j JSONMap)`,修改的只是副本,原始结构体不会被改变。同理 `Value()` 用值接收者 `(j JSONMap)` 是因为只需读不需写。
|
||||
|
||||
### Q23.【事务内的条件检查与日志输出】
|
||||
|
||||
```go
|
||||
func ExecTx(db *gorm.DB, fn func(tx *gorm.DB) error) error {
|
||||
tx := db.Begin()
|
||||
defer func() {
|
||||
if r := recover(); r != nil {
|
||||
tx._________ // 空1:出错时回滚
|
||||
panic(r)
|
||||
}
|
||||
}()
|
||||
|
||||
if err := fn(tx); err != nil {
|
||||
tx.Rollback()
|
||||
return err
|
||||
}
|
||||
return tx.__________.Error // 空2:提交事务的方法
|
||||
}
|
||||
```
|
||||
|
||||
> [!tip]- Q23 答案
|
||||
> **`Rollback()`** ; **`Commit()`**
|
||||
>
|
||||
> **解析:**
|
||||
> 这是一个通用的事务封装函数 `ExecTx`,消除了每个业务函数里重复的 Begin/Rollback 样板代码。
|
||||
>
|
||||
> ```go
|
||||
> // 使用方式
|
||||
> err := ExecTx(db, func(tx *gorm.DB) error {
|
||||
> // 业务逻辑...
|
||||
> return nil // 成功 → ExecTx 自动 Commit
|
||||
> }) // 失败 → ExecTx 自动 Rollback
|
||||
> ```
|
||||
>
|
||||
> **关键点:**
|
||||
> 1. `defer` 只在 panic 分支中执行 `Rollback()`,正常路径不调用——避免 Commit 后再 Rollback 报错
|
||||
> 2. `tx.Commit()` 返回 `*gorm.DB`(和大多数 GORM 方法一样),`.Error` 获取最终结果
|
||||
>
|
||||
> 💡 **对比:** 简单场景直接用 `db.Transaction(callback)` 即可,代码更简洁。复杂业务(如跨包传递 tx)才需要 `ExecTx` 这样的封装。
|
||||
|
||||
---
|
||||
|
||||
## 四、主观题(每题 4 分,共 16 分)
|
||||
|
||||
> 简要回答即可,不需要长篇大论。关键是说出你的理解。
|
||||
|
||||
### Q24.【何时不该用事务?】
|
||||
|
||||
以下场景中哪些**不需要**用事务?请给出理由。
|
||||
|
||||
① 单条 `INSERT` 记录
|
||||
② 转账:扣 A 加 B
|
||||
③ COUNT / SUM 统计查询
|
||||
④ 下单:减库存 + 创建订单 + 生成流水
|
||||
|
||||
> [!tip]- Q24 参考答案
|
||||
>
|
||||
> ① **不需要**——单条 INSERT 本身就是原子操作,数据库层面保证了要么全成功要么全失败,无需额外的事务包裹。
|
||||
>
|
||||
> ② **必须用事务**——涉及两笔以上的资金变动,如果没有事务,可能出现"A 扣了钱但 B 没到账"的数据不一致。
|
||||
>
|
||||
> ③ **不需要**——只读查询不涉及数据修改,不存在一致性问题。可以使用普通查询 + 缓存来减轻数据库压力。
|
||||
>
|
||||
> ④ **必须用事务**——三步操作具有业务原子性要求:如果减库存成功但创建订单失败,会导致库存少了但没有对应订单。
|
||||
>
|
||||
> **总结:** 涉及多步数据写入且要求原子性的场景才需要事务。单条写入、只读查询都可以免去事务开销。
|
||||
|
||||
### Q25.【软删除 + 唯一索引冲突】
|
||||
|
||||
某电商系统中 `Product.SKU` 设置了 `uniqueIndex`。现有记录 SKU="ABC-123" 已被软删除。此时还能再次创建 SKU="ABC-123" 的新产品吗?MySQL 和 PostgreSQL 的行为是否一致?说明原因和你的解决方案。
|
||||
|
||||
> [!tip]- Q25 参考答案
|
||||
>
|
||||
> **不一致!**
|
||||
>
|
||||
> - **MySQL(InnoDB):** ✅ 可以插入。InnoDB 在处理软删除行的唯一约束时有历史缺陷——已软删除的行不参与唯一约束检查,所以允许两条 SKU 相同的记录存在(一条被删,一条未删)。但这意味着可能出现"同一 SKU 多条有效记录"的数据不一致。
|
||||
> - **PostgreSQL:** ❌ 不能插入。PG 的正确行为是唯一约束包含软删除行,会报 duplicate key 错误。
|
||||
>
|
||||
> **解决方案:**
|
||||
> 1. **方案一(PG):** 用部分唯一索引 `CREATE UNIQUE INDEX ... WHERE deleted_at IS NULL`,只约束未删除行
|
||||
> 2. **方案二(通用):** 加版本字段 `SlugVersion uint`,保证同一 SKU 不会同时出现在两条未删除记录中
|
||||
> 3. **方案三:** 应用层校验——插入前先检查是否有未删除的同 SKU 记录
|
||||
>
|
||||
> 💡 **核心教训:** 不要依赖单一数据库的行为一致性,应在应用层做好校验。
|
||||
|
||||
### Q26.【BeforeUpdate Changed 的最佳实践】
|
||||
|
||||
```go
|
||||
func (u *User) BeforeUpdate(tx *gorm.DB) error {
|
||||
if tx.Statement.Changed("Password") {
|
||||
hashed, _ := bcrypt.GenerateFromPassword([]byte(u.Password), bcrypt.DefaultCost)
|
||||
u.Password = string(hashed)
|
||||
}
|
||||
return nil
|
||||
}
|
||||
```
|
||||
|
||||
为什么要用 `Changed("Password")` 判断,而不直接在 `BeforeUpdate` 里无条件哈希?这样做有什么优势?
|
||||
|
||||
> [!tip]- Q26 参考答案
|
||||
>
|
||||
> 核心原因是**避免重复哈希**。
|
||||
>
|
||||
> 如果无条件哈希,每次 UPDATE 操作(即使改的是名字或邮箱)都会重新对密码做 bcrypt 哈希。bcrypt 故意很慢(计算密集型),频繁调用会带来不必要的性能开销。
|
||||
>
|
||||
> 此外,如果用户修改了其他字段但未修改密码,数据库执行的是 `Updates(struct)`——`Password` 字段不在 struct 中参与更新,但前面的哈希操作仍然白白浪费了 CPU。
|
||||
>
|
||||
> `Changed("Password")` 的作用:只有当 `Updates(map)` 的 map 中确实包含了 `password` 键时才执行哈希。这样既保证了安全性(密码改了就加密),又避免了不必要的计算。
|
||||
>
|
||||
> 💡 **注意:** `Changed` 只对 `Updates` 方法有效,`Save` 是全量写入不会追踪变化。
|
||||
|
||||
### Q27.【AutoMigrate 的局限性与替代方案】
|
||||
|
||||
为什么生产环境不推荐仅靠 `AutoMigrate` 管理数据库迁移?请至少列举三条原因,并简述推荐的替代方案。
|
||||
|
||||
> [!tip]- Q27 参考答案
|
||||
>
|
||||
> **三条原因:**
|
||||
>
|
||||
> 1. **不可回滚:** AutoMigrate 只能"加"不能"减"。当代码回退到旧版本时,它无法撤销已添加的列或索引——只会跳过已存在的部分。版本化迁移方案通过 Down 脚本可以轻松应对回退场景。
|
||||
>
|
||||
> 2. **不可预测:** GORM 内部决定迁移的执行顺序和具体 ALTER 语句,不同数据库方言的行为可能不一致。版本化迁移用手写 SQL,完全可控。
|
||||
>
|
||||
> 3. **团队协作困难:** 每个人改了 model 就 auto migrate,容易产生冲突和不一致。版本化迁移通过文件合并可以更好地管理多人协作。
|
||||
>
|
||||
> **推荐替代方案:** 本地开发用 `AutoMigrate + DryRun` 快速迭代,生产环境用专门的迁移工具(如 Goose 或 golang-migrate),手写版本化的 Up/Down SQL 脚本。这样既享受了开发便利,又保证了生产环境的可控性和可回滚能力。
|
||||
|
||||
---
|
||||
|
||||
## 评分参考
|
||||
|
||||
| 题目类型 | 满分 | 权重 |
|
||||
|---------|------|------|
|
||||
| 选择题(Q1-Q10) | 30 分 | 30% |
|
||||
| 填空题(Q11-Q18) | 24 分 | 24% |
|
||||
| 代码补全(Q19-Q23) | 30 分 | 30% |
|
||||
| 主观题(Q24-Q27) | 16 分 | 16% |
|
||||
| **总计** | **100 分** | **100%** |
|
||||
|
||||
**及格线:** ≥70 分
|
||||
**优秀线:** ≥85 分
|
||||
Reference in New Issue
Block a user