Go 语言 CRUD 实战:从接口设计到真实业务场景

1. 为什么用 Go 写 CRUD

CRUD(Create、Read、Update、Delete)几乎是所有后端系统的骨架。无论是电商的商品管理、SaaS 的租户配置,还是内部运维平台的资产台账,本质上都是对某类实体做增删改查。

Go 语言在这类场景里优势明显:

  • 编译型 + 静态类型:接口字段、数据库模型在编译期就能发现大部分错误,重构时改一处编译器会帮你找出所有调用点。
  • 标准库够用 :net/http、encoding/json、database/sql 组合起来就能跑一个完整的 REST 服务,不必一上来就引入重型框架。
  • 并发模型简单 :goroutine + context 让超时控制、批量处理写起来很自然。
  • 部署成本低:单二进制、无运行时依赖,容器镜像可以做到几十 MB。

本文以一个**「设备资产管理系统」**为例,把 CRUD 的每个环节落到真实业务语义上,而不是只写一个玩具级的 User 结构体。

2. 业务场景与数据模型

假设我们维护一个公司内部的设备台账:每台设备有编号、名称、类型、所属部门、状态(在用/维修/报废)和入库时间。运维同学需要:

  • 新设备入库时登记(Create)
  • 按部门、状态筛选查看(Read)
  • 设备调拨或状态变更时修改(Update)
  • 设备报废后归档删除(Delete)

先定义模型:

go 复制代码
package model

import "time"

// DeviceStatus 设备状态
type DeviceStatus string

const (
	StatusInUse   DeviceStatus = "in_use"   // 在用
	StatusRepair  DeviceStatus = "repair"   // 维修中
	StatusRetired DeviceStatus = "retired"  // 已报废
)

// Device 设备实体
type Device struct {
	ID        int64        `json:"id"`
	Code      string       `json:"code"`       // 设备编号,业务唯一
	Name      string       `json:"name"`
	Category  string       `json:"category"`   // 类型:笔记本/服务器/打印机
	Dept      string       `json:"dept"`       // 所属部门
	Status    DeviceStatus `json:"status"`
	CreatedAt time.Time    `json:"created_at"`
	UpdatedAt time.Time    `json:"updated_at"`
}

这里有两个容易被忽略的细节:

  1. Code 是业务唯一键,和自增 ID 不是一回事。真实系统里经常需要按业务编号查询,所以数据库要给它建唯一索引。
  2. Status 用自定义字符串类型而不是 int,好处是日志和接口返回可读,坏处是需要在入库前做校验。

3. 数据库层:用 database/sql 写可测试的仓储

3.1 建表语句

sql 复制代码
CREATE TABLE devices (
    id         BIGSERIAL PRIMARY KEY,
    code       VARCHAR(64) NOT NULL UNIQUE,
    name       VARCHAR(128) NOT NULL,
    category   VARCHAR(64) NOT NULL,
    dept       VARCHAR(64) NOT NULL,
    status     VARCHAR(16) NOT NULL DEFAULT 'in_use',
    created_at TIMESTAMPTZ NOT NULL DEFAULT now(),
    updated_at TIMESTAMPTZ NOT NULL DEFAULT now()
);

CREATE INDEX idx_devices_dept_status ON devices (dept, status);

(dept, status) 的联合索引对应最常见的筛选条件,避免全表扫描。

3.2 仓储接口

先定义接口,再写实现,这样单元测试可以换成内存实现,不必依赖真实数据库:

go 复制代码
package repo

import (
	"context"

	"example.com/device/model"
)

type DeviceRepo interface {
	Create(ctx context.Context, d *model.Device) error
	GetByID(ctx context.Context, id int64) (*model.Device, error)
	List(ctx context.Context, dept string, status model.DeviceStatus, limit, offset int) ([]*model.Device, error)
	Update(ctx context.Context, d *model.Device) error
	Delete(ctx context.Context, id int64) error
}

3.3 PostgreSQL 实现

go 复制代码
package repo

import (
	"context"
	"database/sql"
	"errors"
	"fmt"

	"example.com/device/model"
)

var ErrNotFound = errors.New("device not found")

type pgDeviceRepo struct {
	db *sql.DB
}

func NewPGDeviceRepo(db *sql.DB) DeviceRepo {
	return &pgDeviceRepo{db: db}
}

func (r *pgDeviceRepo) Create(ctx context.Context, d *model.Device) error {
	const q = `INSERT INTO devices (code, name, category, dept, status)
	           VALUES ($1, $2, $3, $4, $5)
	           RETURNING id, created_at, updated_at`
	err := r.db.QueryRowContext(ctx, q, d.Code, d.Name, d.Category, d.Dept, d.Status).
		Scan(&d.ID, &d.CreatedAt, &d.UpdatedAt)
	if err != nil {
		return fmt.Errorf("create device: %w", err)
	}
	return nil
}

func (r *pgDeviceRepo) GetByID(ctx context.Context, id int64) (*model.Device, error) {
	const q = `SELECT id, code, name, category, dept, status, created_at, updated_at
	           FROM devices WHERE id = $1`
	var d model.Device
	err := r.db.QueryRowContext(ctx, q, id).Scan(
		&d.ID, &d.Code, &d.Name, &d.Category, &d.Dept, &d.Status, &d.CreatedAt, &d.UpdatedAt,
	)
	if errors.Is(err, sql.ErrNoRows) {
		return nil, ErrNotFound
	}
	if err != nil {
		return nil, fmt.Errorf("get device: %w", err)
	}
	return &d, nil
}

func (r *pgDeviceRepo) List(ctx context.Context, dept string, status model.DeviceStatus, limit, offset int) ([]*model.Device, error) {
	const q = `SELECT id, code, name, category, dept, status, created_at, updated_at
	           FROM devices
	           WHERE ($1 = '' OR dept = $1)
	             AND ($2 = '' OR status = $2)
	           ORDER BY id DESC
	           LIMIT $3 OFFSET $4`
	rows, err := r.db.QueryContext(ctx, q, dept, status, limit, offset)
	if err != nil {
		return nil, fmt.Errorf("list devices: %w", err)
	}
	defer rows.Close()

	var out []*model.Device
	for rows.Next() {
		var d model.Device
		if err := rows.Scan(&d.ID, &d.Code, &d.Name, &d.Category, &d.Dept, &d.Status, &d.CreatedAt, &d.UpdatedAt); err != nil {
			return nil, fmt.Errorf("scan device: %w", err)
		}
		out = append(out, &d)
	}
	return out, rows.Err()
}

func (r *pgDeviceRepo) Update(ctx context.Context, d *model.Device) error {
	const q = `UPDATE devices
	           SET name = $1, category = $2, dept = $3, status = $4, updated_at = now()
	           WHERE id = $5`
	res, err := r.db.ExecContext(ctx, q, d.Name, d.Category, d.Dept, d.Status, d.ID)
	if err != nil {
		return fmt.Errorf("update device: %w", err)
	}
	n, _ := res.RowsAffected()
	if n == 0 {
		return ErrNotFound
	}
	return nil
}

func (r *pgDeviceRepo) Delete(ctx context.Context, id int64) error {
	const q = `DELETE FROM devices WHERE id = $1`
	res, err := r.db.ExecContext(ctx, q, id)
	if err != nil {
		return fmt.Errorf("delete device: %w", err)
	}
	n, _ := res.RowsAffected()
	if n == 0 {
		return ErrNotFound
	}
	return nil
}

几个实践要点:

  • 全部方法带 ctx:HTTP 请求超时或客户端断开时,数据库查询能被及时取消,避免连接被长时间占用。
  • Update 不更新 code:业务编号一旦生成就不允许改,这是很多系统的硬性约束。
  • RowsAffected 判断存在性:比先查再改少一次往返,也避免并发下的竞态。
  • 错误用 %w 包装 :上层可以用 errors.Is(err, repo.ErrNotFound) 判断,而不必解析字符串。

4. HTTP 层:把 CRUD 映射成 REST 接口

4.1 路由与处理器

go 复制代码
package handler

import (
	"encoding/json"
	"errors"
	"net/http"
	"strconv"

	"example.com/device/model"
	"example.com/device/repo"
)

type DeviceHandler struct {
	repo repo.DeviceRepo
}

func NewDeviceHandler(r repo.DeviceRepo) *DeviceHandler {
	return &DeviceHandler{repo: r}
}

func (h *DeviceHandler) Register(mux *http.ServeMux) {
	mux.HandleFunc("POST /api/devices", h.create)
	mux.HandleFunc("GET /api/devices", h.list)
	mux.HandleFunc("GET /api/devices/{id}", h.get)
	mux.HandleFunc("PUT /api/devices/{id}", h.update)
	mux.HandleFunc("DELETE /api/devices/{id}", h.delete)
}

Go 1.22 起 net/http 的 ServeMux 已支持方法匹配和路径参数,中小项目可以省掉第三方路由库。

4.2 Create:入库登记

go 复制代码
type createReq struct {
	Code     string `json:"code"`
	Name     string `json:"name"`
	Category string `json:"category"`
	Dept     string `json:"dept"`
}

func (h *DeviceHandler) create(w http.ResponseWriter, r *http.Request) {
	var req createReq
	if err := json.NewDecoder(r.Body).Decode(&req); err != nil {
		writeErr(w, http.StatusBadRequest, "invalid json")
		return
	}
	if req.Code == "" || req.Name == "" || req.Dept == "" {
		writeErr(w, http.StatusBadRequest, "code/name/dept required")
		return
	}

	d := &model.Device{
		Code:     req.Code,
		Name:     req.Name,
		Category: req.Category,
		Dept:     req.Dept,
		Status:   model.StatusInUse,
	}
	if err := h.repo.Create(r.Context(), d); err != nil {
		writeErr(w, http.StatusInternalServerError, err.Error())
		return
	}
	writeJSON(w, http.StatusCreated, d)
}

真实业务里,code 重复会触发数据库唯一约束错误。生产环境建议把这类错误映射成 409 Conflict,而不是笼统的 500。

4.3 Read:列表与详情

go 复制代码
func (h *DeviceHandler) list(w http.ResponseWriter, r *http.Request) {
	q := r.URL.Query()
	dept := q.Get("dept")
	status := model.DeviceStatus(q.Get("status"))

	limit, _ := strconv.Atoi(q.Get("limit"))
	if limit <= 0 || limit > 100 {
		limit = 20
	}
	offset, _ := strconv.Atoi(q.Get("offset"))
	if offset < 0 {
		offset = 0
	}

	items, err := h.repo.List(r.Context(), dept, status, limit, offset)
	if err != nil {
		writeErr(w, http.StatusInternalServerError, err.Error())
		return
	}
	writeJSON(w, http.StatusOK, map[string]any{"items": items, "limit": limit, "offset": offset})
}

func (h *DeviceHandler) get(w http.ResponseWriter, r *http.Request) {
	id, err := strconv.ParseInt(r.PathValue("id"), 10, 64)
	if err != nil {
		writeErr(w, http.StatusBadRequest, "invalid id")
		return
	}
	d, err := h.repo.GetByID(r.Context(), id)
	if errors.Is(err, repo.ErrNotFound) {
		writeErr(w, http.StatusNotFound, "device not found")
		return
	}
	if err != nil {
		writeErr(w, http.StatusInternalServerError, err.Error())
		return
	}
	writeJSON(w, http.StatusOK, d)
}

limit 强制上限是必须的:没有上限的分页接口,一个 limit=1000000 就能把服务打挂。

4.4 Update 与 Delete

go 复制代码
type updateReq struct {
	Name     string            `json:"name"`
	Category string            `json:"category"`
	Dept     string            `json:"dept"`
	Status   model.DeviceStatus `json:"status"`
}

func (h *DeviceHandler) update(w http.ResponseWriter, r *http.Request) {
	id, err := strconv.ParseInt(r.PathValue("id"), 10, 64)
	if err != nil {
		writeErr(w, http.StatusBadRequest, "invalid id")
		return
	}
	var req updateReq
	if err := json.NewDecoder(r.Body).Decode(&req); err != nil {
		writeErr(w, http.StatusBadRequest, "invalid json")
		return
	}
	d := &model.Device{ID: id, Name: req.Name, Category: req.Category, Dept: req.Dept, Status: req.Status}
	if err := h.repo.Update(r.Context(), d); err != nil {
		if errors.Is(err, repo.ErrNotFound) {
			writeErr(w, http.StatusNotFound, "device not found")
			return
		}
		writeErr(w, http.StatusInternalServerError, err.Error())
		return
	}
	writeJSON(w, http.StatusOK, d)
}

func (h *DeviceHandler) delete(w http.ResponseWriter, r *http.Request) {
	id, err := strconv.ParseInt(r.PathValue("id"), 10, 64)
	if err != nil {
		writeErr(w, http.StatusBadRequest, "invalid id")
		return
	}
	if err := h.repo.Delete(r.Context(), id); err != nil {
		if errors.Is(err, repo.ErrNotFound) {
			writeErr(w, http.StatusNotFound, "device not found")
			return
		}
		writeErr(w, http.StatusInternalServerError, err.Error())
		return
	}
	w.WriteHeader(http.StatusNoContent)
}

4.5 统一响应工具

go 复制代码
func writeJSON(w http.ResponseWriter, code int, v any) {
	w.Header().Set("Content-Type", "application/json; charset=utf-8")
	w.WriteHeader(code)
	_ = json.NewEncoder(w).Encode(v)
}

func writeErr(w http.ResponseWriter, code int, msg string) {
	writeJSON(w, code, map[string]string{"error": msg})
}

5. 组装与启动

go 复制代码
package main

import (
	"database/sql"
	"log"
	"net/http"
	"time"

	_ "github.com/lib/pq"

	"example.com/device/handler"
	"example.com/device/repo"
)

func main() {
	db, err := sql.Open("postgres", "postgres://user:pass@localhost:5432/assets?sslmode=disable")
	if err != nil {
		log.Fatal(err)
	}
	defer db.Close()

	db.SetMaxOpenConns(25)
	db.SetMaxIdleConns(10)
	db.SetConnMaxLifetime(5 * time.Minute)

	mux := http.NewServeMux()
	handler.NewDeviceHandler(repo.NewPGDeviceRepo(db)).Register(mux)

	srv := &http.Server{
		Addr:              ":8080",
		Handler:           mux,
		ReadHeaderTimeout: 5 * time.Second,
		ReadTimeout:       10 * time.Second,
		WriteTimeout:      10 * time.Second,
	}
	log.Println("listening on :8080")
	log.Fatal(srv.ListenAndServe())
}

连接池参数不是随便填的:MaxOpenConns 要小于数据库的 max_connections 除以实例数,否则扩容时容易把数据库连接打满。

6. 请求流转示意

#mermaid-svg-heKmD5mpbkozYeWE{font-family:"trebuchet ms",verdana,arial,sans-serif;font-size:16px;fill:#333;}@keyframes edge-animation-frame{from{stroke-dashoffset:0;}}@keyframes dash{to{stroke-dashoffset:0;}}#mermaid-svg-heKmD5mpbkozYeWE .edge-animation-slow{stroke-dasharray:9,5!important;stroke-dashoffset:900;animation:dash 50s linear infinite;stroke-linecap:round;}#mermaid-svg-heKmD5mpbkozYeWE .edge-animation-fast{stroke-dasharray:9,5!important;stroke-dashoffset:900;animation:dash 20s linear infinite;stroke-linecap:round;}#mermaid-svg-heKmD5mpbkozYeWE .error-icon{fill:#552222;}#mermaid-svg-heKmD5mpbkozYeWE .error-text{fill:#552222;stroke:#552222;}#mermaid-svg-heKmD5mpbkozYeWE .edge-thickness-normal{stroke-width:1px;}#mermaid-svg-heKmD5mpbkozYeWE .edge-thickness-thick{stroke-width:3.5px;}#mermaid-svg-heKmD5mpbkozYeWE .edge-pattern-solid{stroke-dasharray:0;}#mermaid-svg-heKmD5mpbkozYeWE .edge-thickness-invisible{stroke-width:0;fill:none;}#mermaid-svg-heKmD5mpbkozYeWE .edge-pattern-dashed{stroke-dasharray:3;}#mermaid-svg-heKmD5mpbkozYeWE .edge-pattern-dotted{stroke-dasharray:2;}#mermaid-svg-heKmD5mpbkozYeWE .marker{fill:#333333;stroke:#333333;}#mermaid-svg-heKmD5mpbkozYeWE .marker.cross{stroke:#333333;}#mermaid-svg-heKmD5mpbkozYeWE svg{font-family:"trebuchet ms",verdana,arial,sans-serif;font-size:16px;}#mermaid-svg-heKmD5mpbkozYeWE p{margin:0;}#mermaid-svg-heKmD5mpbkozYeWE .label{font-family:"trebuchet ms",verdana,arial,sans-serif;color:#333;}#mermaid-svg-heKmD5mpbkozYeWE .cluster-label text{fill:#333;}#mermaid-svg-heKmD5mpbkozYeWE .cluster-label span{color:#333;}#mermaid-svg-heKmD5mpbkozYeWE .cluster-label span p{background-color:transparent;}#mermaid-svg-heKmD5mpbkozYeWE .label text,#mermaid-svg-heKmD5mpbkozYeWE span{fill:#333;color:#333;}#mermaid-svg-heKmD5mpbkozYeWE .node rect,#mermaid-svg-heKmD5mpbkozYeWE .node circle,#mermaid-svg-heKmD5mpbkozYeWE .node ellipse,#mermaid-svg-heKmD5mpbkozYeWE .node polygon,#mermaid-svg-heKmD5mpbkozYeWE .node path{fill:#ECECFF;stroke:#9370DB;stroke-width:1px;}#mermaid-svg-heKmD5mpbkozYeWE .rough-node .label text,#mermaid-svg-heKmD5mpbkozYeWE .node .label text,#mermaid-svg-heKmD5mpbkozYeWE .image-shape .label,#mermaid-svg-heKmD5mpbkozYeWE .icon-shape .label{text-anchor:middle;}#mermaid-svg-heKmD5mpbkozYeWE .node .katex path{fill:#000;stroke:#000;stroke-width:1px;}#mermaid-svg-heKmD5mpbkozYeWE .rough-node .label,#mermaid-svg-heKmD5mpbkozYeWE .node .label,#mermaid-svg-heKmD5mpbkozYeWE .image-shape .label,#mermaid-svg-heKmD5mpbkozYeWE .icon-shape .label{text-align:center;}#mermaid-svg-heKmD5mpbkozYeWE .node.clickable{cursor:pointer;}#mermaid-svg-heKmD5mpbkozYeWE .root .anchor path{fill:#333333!important;stroke-width:0;stroke:#333333;}#mermaid-svg-heKmD5mpbkozYeWE .arrowheadPath{fill:#333333;}#mermaid-svg-heKmD5mpbkozYeWE .edgePath .path{stroke:#333333;stroke-width:2.0px;}#mermaid-svg-heKmD5mpbkozYeWE .flowchart-link{stroke:#333333;fill:none;}#mermaid-svg-heKmD5mpbkozYeWE .edgeLabel{background-color:rgba(232,232,232, 0.8);text-align:center;}#mermaid-svg-heKmD5mpbkozYeWE .edgeLabel p{background-color:rgba(232,232,232, 0.8);}#mermaid-svg-heKmD5mpbkozYeWE .edgeLabel rect{opacity:0.5;background-color:rgba(232,232,232, 0.8);fill:rgba(232,232,232, 0.8);}#mermaid-svg-heKmD5mpbkozYeWE .labelBkg{background-color:rgba(232, 232, 232, 0.5);}#mermaid-svg-heKmD5mpbkozYeWE .cluster rect{fill:#ffffde;stroke:#aaaa33;stroke-width:1px;}#mermaid-svg-heKmD5mpbkozYeWE .cluster text{fill:#333;}#mermaid-svg-heKmD5mpbkozYeWE .cluster span{color:#333;}#mermaid-svg-heKmD5mpbkozYeWE div.mermaidTooltip{position:absolute;text-align:center;max-width:200px;padding:2px;font-family:"trebuchet ms",verdana,arial,sans-serif;font-size:12px;background:hsl(80, 100%, 96.2745098039%);border:1px solid #aaaa33;border-radius:2px;pointer-events:none;z-index:100;}#mermaid-svg-heKmD5mpbkozYeWE .flowchartTitleText{text-anchor:middle;font-size:18px;fill:#333;}#mermaid-svg-heKmD5mpbkozYeWE rect.text{fill:none;stroke-width:0;}#mermaid-svg-heKmD5mpbkozYeWE .icon-shape,#mermaid-svg-heKmD5mpbkozYeWE .image-shape{background-color:rgba(232,232,232, 0.8);text-align:center;}#mermaid-svg-heKmD5mpbkozYeWE .icon-shape p,#mermaid-svg-heKmD5mpbkozYeWE .image-shape p{background-color:rgba(232,232,232, 0.8);padding:2px;}#mermaid-svg-heKmD5mpbkozYeWE .icon-shape .label rect,#mermaid-svg-heKmD5mpbkozYeWE .image-shape .label rect{opacity:0.5;background-color:rgba(232,232,232, 0.8);fill:rgba(232,232,232, 0.8);}#mermaid-svg-heKmD5mpbkozYeWE .label-icon{display:inline-block;height:1em;overflow:visible;vertical-align:-0.125em;}#mermaid-svg-heKmD5mpbkozYeWE .node .label-icon path{fill:currentColor;stroke:revert;stroke-width:revert;}#mermaid-svg-heKmD5mpbkozYeWE :root{--mermaid-font-family:"trebuchet ms",verdana,arial,sans-serif;} 否
是
ErrNoRows
其他错误
成功
客户端 HTTP 请求
ServeMux 路由匹配
Handler 解析参数与校验
参数合法?
返回 400
Repo 执行 SQL
数据库返回
返回 404
返回 500
序列化 JSON 返回

7. 真实业务中的几个坑

7.1 删除用软删除还是硬删除

设备报废后直接 DELETE 会丢失历史,审计时查不到。更常见的做法是加 deleted_at 字段做软删除,查询时统一带 WHERE deleted_at IS NULL。代价是所有查询都要记得加条件,建议在仓储层封装,不要散落在各处。

7.2 并发更新覆盖

两个人同时编辑同一台设备,后提交的会覆盖先提交的。解决办法是加乐观锁:

sql 复制代码
ALTER TABLE devices ADD COLUMN version INT NOT NULL DEFAULT 1;

更新时带上版本号:

sql 复制代码
UPDATE devices SET name = $1, version = version + 1
WHERE id = $2 AND version = $3;

RowsAffected == 0 说明版本已过期,返回 409 Conflict 让前端刷新后重试。

7.3 批量导入

新办公室搬迁时往往一次导入几百台设备。逐条 INSERT 会很慢,可以用单条多值插入或 COPY:

go 复制代码
func (r *pgDeviceRepo) BatchCreate(ctx context.Context, ds []*model.Device) error {
	tx, err := r.db.BeginTx(ctx, nil)
	if err != nil {
		return err
	}
	defer tx.Rollback()

	stmt, err := tx.PrepareContext(ctx, `INSERT INTO devices (code, name, category, dept, status) VALUES ($1,$2,$3,$4,$5)`)
	if err != nil {
		return err
	}
	defer stmt.Close()

	for _, d := range ds {
		if _, err := stmt.ExecContext(ctx, d.Code, d.Name, d.Category, d.Dept, d.Status); err != nil {
			return err
		}
	}
	return tx.Commit()
}

放在事务里,任何一条失败整体回滚,避免导入一半留下脏数据。

7.4 上下文超时

Handler 里最好给数据库操作单独设超时,而不是完全依赖 HTTP 的超时:

go 复制代码
ctx, cancel := context.WithTimeout(r.Context(), 3*time.Second)
defer cancel()
d, err := h.repo.GetByID(ctx, id)

这样即使 HTTP 层超时设置得比较宽松,慢查询也不会拖住连接池。

8. 小结

把 CRUD 写出来不难,难的是让它经得起真实业务的考验。本文这套结构可以总结为几条经验:

  • 接口先行:仓储层定义接口,实现可替换,测试不依赖数据库。
  • 错误要可判断 :用哨兵错误 + %w 包装,让上层能区分「不存在」和「真出错」。
  • 参数必须校验:分页上限、必填字段、ID 格式,缺一个就是线上事故的入口。
  • 考虑并发与历史:乐观锁、软删除这些不是过度设计,而是业务跑一段时间后必然要补的。
  • 连接池与超时要显式配置:默认值在低流量下看不出问题,压测时才会暴露。

下一步可以在此基础上加中间件(日志、鉴权、限流),或者把仓储层换成 sqlc、GORM 等工具减少样板代码------但无论用什么工具,上面这些业务层面的判断都不会变。

相关推荐
锋行天下1 小时前
Langgraph 实现类似Langchain记忆上下文过长裁剪
人工智能·后端
YDS8291 小时前
Small Spring IOC篇:作用域和FactoryBean
java·后端·spring
郭小李1 小时前
springmvc 拦截器
java·开发语言·前端
web打印社区1 小时前
Chrome-kiosk-printing能上生产吗
开发语言·前端·javascript·chrome·websocket
大风起兮云飞扬丶1 小时前
Java——日期类
java·开发语言
Wang's Blog2 小时前
Java 项目实战:交友项目 - 联系人管理需求分析
java·开发语言·交友
没有气的汽水2 小时前
一次内存缓慢泄漏的排查:每次都 new 一个 OpenAI 客户端,问题出在事件循环上
后端
Nnnnnnn2 小时前
Kafka 踩坑实录:在 WSL2 环境下,容器死活连不上集群怎么办?
后端
就像楞次定律2 小时前
【Linux 学习记之二】C++ 构建体系与底层链接机制的深度透视(1)
linux·后端