[{"data":1,"prerenderedAt":3527},["ShallowReactive",2],{"post:\u002Fposts\u002Fjavaspring\u002Fspring-notes-04":3,"posts:ordered":3250},{"id":4,"title":5,"body":6,"category":3233,"cover":3234,"date":3235,"description":3236,"extension":3237,"featured":3238,"meta":3239,"navigation":252,"path":3240,"seo":3241,"series":3242,"seriesOrder":249,"stem":3243,"tags":3244,"__hash__":3249},"posts\u002Fposts\u002Fjavaspring\u002Fspring-notes-04.md","Spring 学习笔记 04：JPA 关联映射与仓储测试",{"type":7,"value":8,"toc":3214},"minimark",[9,16,21,24,32,36,43,99,102,106,116,133,146,152,156,166,183,381,406,409,842,845,882,886,889,1223,1229,1330,1335,1372,1375,1424,1430,1434,1445,1511,1514,1537,1543,1547,1598,1619,1624,1694,1697,1815,1834,1838,1841,1914,1924,1927,1959,1963,1970,1994,1998,2024,2729,2734,2772,2776,2842,2849,2853,2856,2952,2961,2965,3143,3147,3207,3210],[10,11,12],"blockquote",{},[13,14,15],"p",{},"这是一份个人学习笔记：概念部分是我按自己的理解写的，示例代码都是自己敲的，参考的是\nSpring 官方文档与 Jakarta Persistence 规范。理解有偏差的地方欢迎指出。",[17,18,20],"h2",{"id":19},"_0-单表模型很快就到头了","0 单表模型很快就到头了",[13,22,23],{},"「图书」和「分类」天然有关联：一个分类下有好多本书，一本书属于一个分类。用两张互相不知道\n对方存在的表存这些数据也能跑，但每次查「某分类下的书」都得手写两段查询再在 Java 里拼起来。",[13,25,26,27,31],{},"JPA 的做法是在实体之间直接把引用建起来。代价是",[28,29,30],"strong",{},"关联映射是 JPA 里最容易出错的部分","——\n映射没写错也能启动成功，跑起来却在延迟加载、级联删除或 JSON 序列化上爆掉。这一篇把它一层层\n拆开。",[17,33,35],{"id":34},"_1-先回到数据库外键放哪一侧","1 先回到数据库：外键放哪一侧",[13,37,38,39,42],{},"对象模型和关系模型的根本差别是：",[28,40,41],{},"对象可以有双向引用，数据库的外键只能在一侧","。",[44,45,46,59],"table",{},[47,48,49],"thead",{},[50,51,52,56],"tr",{},[53,54,55],"th",{},"关系",[53,57,58],{},"数据库里的表现",[60,61,62,80,88],"tbody",{},[50,63,64,68],{},[65,66,67],"td",{},"一对多",[65,69,70,71,75,76,79],{},"外键放在「多」的那一侧（",[72,73,74],"code",{},"book"," 表放 ",[72,77,78],{},"category_id","）",[50,81,82,85],{},[65,83,84],{},"一对一",[65,86,87],{},"外键放在任意一侧，通常放在「主要」的那一侧",[50,89,90,93],{},[65,91,92],{},"多对多",[65,94,95,96],{},"双方都放不下，需要一张",[28,97,98],{},"中间表",[13,100,101],{},"把这个事实记住，后面所有注解都能推出为什么这么写。",[17,103,105],{"id":104},"_2-维护方owning-side谁写得进数据库","2 维护方（owning side）：谁写得进数据库",[13,107,108,109,112,113,42],{},"JPA 规定：",[28,110,111],{},"一次关联里只有一个维护方，只有维护方负责把外键写进数据库","。另一侧是「反方」\n（inverse side），它只读、也",[28,114,115],{},"不可写",[117,118,119,127],"ul",{},[120,121,122,123,126],"li",{},"维护方：用 ",[72,124,125],{},"@JoinColumn"," 指定外键列名；",[120,128,129,130,42],{},"反方：用 ",[72,131,132],{},"mappedBy = \"对方实体里的字段名\"",[13,134,135,138,139,142,143,145],{},[72,136,137],{},"mappedBy"," 的值写的是",[28,140,141],{},"对方的字段名，不是表名也不是列名","，这是最常写错的地方。它写错了\n通常启动就能报错，还算幸运；真正难查的是「两边都写了 ",[72,144,125],{},"」，启动一切正常，只是\n数据偶尔少写一条——因为两边都想当维护方，实际行为由 Hibernate 决定。",[13,147,148,149,42],{},"判断维护方的经验法则：",[28,150,151],{},"外键在哪张表上，哪个实体就是维护方",[17,153,155],{"id":154},"_3-一对多-多对一","3 一对多 \u002F 多对一",[13,157,158,159,161,162,165],{},"「一个分类下有多本书，一本书属于一个分类」，数据库里外键在 ",[72,160,74],{}," 表，所以 ",[72,163,164],{},"Book"," 是维护方：",[13,167,168,169,171,172,175,176,175,179,182],{},"在 02 篇那个 ",[72,170,164],{}," 实体上，只加一个关联字段和几处配套改动，其余字段原样不动（新增的 import\n是 ",[72,173,174],{},"jakarta.persistence.FetchType","、",[72,177,178],{},"JoinColumn",[72,180,181],{},"ManyToOne","）：",[184,185,190],"pre",{"className":186,"code":187,"language":188,"meta":189,"style":189},"language-java shiki shiki-themes github-light github-dark","    @ManyToOne(fetch = FetchType.LAZY)\n    @JoinColumn(name = \"category_id\")\n    private Category category;\n\n    \u002F\u002F 构造器多一个「带分类」的版本，原来那个 (title, author) 保留\n    public Book(String title, Category category) {\n        this.title = title;\n        this.category = category;\n    }\n\n    \u002F** 关联只能从维护方这一侧改 *\u002F\n    public void changeCategory(Category category) {\n        this.category = category;\n    }\n\n    public Category getCategory() {\n        return category;\n    }\n","java","",[72,191,192,217,238,247,254,261,274,289,302,308,313,319,333,344,349,354,368,376],{"__ignoreMap":189},[193,194,197,201,204,207,211,214],"span",{"class":195,"line":196},"line",1,[193,198,200],{"class":199},"sVt8B","    @",[193,202,181],{"class":203},"szBVR",[193,205,206],{"class":199},"(",[193,208,210],{"class":209},"sj4cs","fetch",[193,212,213],{"class":203}," =",[193,215,216],{"class":199}," FetchType.LAZY)\n",[193,218,220,222,224,226,229,231,235],{"class":195,"line":219},2,[193,221,200],{"class":199},[193,223,178],{"class":203},[193,225,206],{"class":199},[193,227,228],{"class":209},"name",[193,230,213],{"class":203},[193,232,234],{"class":233},"sZZnC"," \"category_id\"",[193,236,237],{"class":199},")\n",[193,239,241,244],{"class":195,"line":240},3,[193,242,243],{"class":203},"    private",[193,245,246],{"class":199}," Category category;\n",[193,248,250],{"class":195,"line":249},4,[193,251,253],{"emptyLinePlaceholder":252},true,"\n",[193,255,257],{"class":195,"line":256},5,[193,258,260],{"class":259},"sJ8bj","    \u002F\u002F 构造器多一个「带分类」的版本，原来那个 (title, author) 保留\n",[193,262,264,267,271],{"class":195,"line":263},6,[193,265,266],{"class":203},"    public",[193,268,270],{"class":269},"sScJk"," Book",[193,272,273],{"class":199},"(String title, Category category) {\n",[193,275,277,280,283,286],{"class":195,"line":276},7,[193,278,279],{"class":209},"        this",[193,281,282],{"class":199},".title ",[193,284,285],{"class":203},"=",[193,287,288],{"class":199}," title;\n",[193,290,292,294,297,299],{"class":195,"line":291},8,[193,293,279],{"class":209},[193,295,296],{"class":199},".category ",[193,298,285],{"class":203},[193,300,301],{"class":199}," category;\n",[193,303,305],{"class":195,"line":304},9,[193,306,307],{"class":199},"    }\n",[193,309,311],{"class":195,"line":310},10,[193,312,253],{"emptyLinePlaceholder":252},[193,314,316],{"class":195,"line":315},11,[193,317,318],{"class":259},"    \u002F** 关联只能从维护方这一侧改 *\u002F\n",[193,320,322,324,327,330],{"class":195,"line":321},12,[193,323,266],{"class":203},[193,325,326],{"class":203}," void",[193,328,329],{"class":269}," changeCategory",[193,331,332],{"class":199},"(Category category) {\n",[193,334,336,338,340,342],{"class":195,"line":335},13,[193,337,279],{"class":209},[193,339,296],{"class":199},[193,341,285],{"class":203},[193,343,301],{"class":199},[193,345,347],{"class":195,"line":346},14,[193,348,307],{"class":199},[193,350,352],{"class":195,"line":351},15,[193,353,253],{"emptyLinePlaceholder":252},[193,355,357,359,362,365],{"class":195,"line":356},16,[193,358,266],{"class":203},[193,360,361],{"class":199}," Category ",[193,363,364],{"class":269},"getCategory",[193,366,367],{"class":199},"() {\n",[193,369,371,374],{"class":195,"line":370},17,[193,372,373],{"class":203},"        return",[193,375,301],{"class":199},[193,377,379],{"class":195,"line":378},18,[193,380,307],{"class":199},[13,382,383,393,394,397,398,401,402,405],{},[28,384,385,386,389,390],{},"要不要写成 ",[72,387,388],{},"optional = false"," \u002F ",[72,391,392],{},"nullable = false","，取决于「书能不能没有分类」。写成非空\n更严谨，但代价是",[28,395,396],{},"所有创建书的代码路径都必须给分类","：示例工程里最初写成非空，而另一条路径\n",[72,399,400],{},"new Book(title, author)"," 没给分类，插入时直接报 ",[72,403,404],{},"NULL not allowed for column \"CATEGORY_ID\"","。\n所以要么让构造器强制传入分类（把不带分类的构造器删掉），要么就明确允许为空，两头不要不一致。",[13,407,408],{},"反方：",[184,410,412],{"className":186,"code":411,"language":188,"meta":189,"style":189},"package com.example.library.domain;\n\nimport jakarta.persistence.Entity;\nimport jakarta.persistence.FetchType;\nimport jakarta.persistence.GeneratedValue;\nimport jakarta.persistence.GenerationType;\nimport jakarta.persistence.Id;\nimport jakarta.persistence.OneToMany;\n\nimport java.util.ArrayList;\nimport java.util.List;\n\n@Entity\npublic class Category {\n\n    @Id\n    @GeneratedValue(strategy = GenerationType.IDENTITY)\n    private Long id;\n\n    private String name;\n\n    @OneToMany(mappedBy = \"category\", fetch = FetchType.LAZY)\n    private List\u003CBook> books = new ArrayList\u003C>();\n\n    protected Category() {\n    }\n\n    public Category(String name) {\n        this.name = name;\n    }\n\n    \u002F** 双向关联要两边都改，交给实体自己的方法保证一致性 *\u002F\n    public void addBook(Book book) {\n        books.add(book);\n        book.changeCategory(this);\n    }\n\n    public Long getId() {\n        return id;\n    }\n\n    public String getName() {\n        return name;\n    }\n\n    public List\u003CBook> getBooks() {\n        return List.copyOf(books);\n    }\n}\n",[72,413,414,422,426,434,441,448,455,462,469,473,480,487,491,499,513,517,524,541,548,553,561,566,592,613,618,628,633,638,654,667,672,677,683,700,712,729,734,739,752,760,765,770,783,790,795,800,817,831,836],{"__ignoreMap":189},[193,415,416,419],{"class":195,"line":196},[193,417,418],{"class":203},"package",[193,420,421],{"class":199}," com.example.library.domain;\n",[193,423,424],{"class":195,"line":219},[193,425,253],{"emptyLinePlaceholder":252},[193,427,428,431],{"class":195,"line":240},[193,429,430],{"class":203},"import",[193,432,433],{"class":199}," jakarta.persistence.Entity;\n",[193,435,436,438],{"class":195,"line":249},[193,437,430],{"class":203},[193,439,440],{"class":199}," jakarta.persistence.FetchType;\n",[193,442,443,445],{"class":195,"line":256},[193,444,430],{"class":203},[193,446,447],{"class":199}," jakarta.persistence.GeneratedValue;\n",[193,449,450,452],{"class":195,"line":263},[193,451,430],{"class":203},[193,453,454],{"class":199}," jakarta.persistence.GenerationType;\n",[193,456,457,459],{"class":195,"line":276},[193,458,430],{"class":203},[193,460,461],{"class":199}," jakarta.persistence.Id;\n",[193,463,464,466],{"class":195,"line":291},[193,465,430],{"class":203},[193,467,468],{"class":199}," jakarta.persistence.OneToMany;\n",[193,470,471],{"class":195,"line":304},[193,472,253],{"emptyLinePlaceholder":252},[193,474,475,477],{"class":195,"line":310},[193,476,430],{"class":203},[193,478,479],{"class":199}," java.util.ArrayList;\n",[193,481,482,484],{"class":195,"line":315},[193,483,430],{"class":203},[193,485,486],{"class":199}," java.util.List;\n",[193,488,489],{"class":195,"line":321},[193,490,253],{"emptyLinePlaceholder":252},[193,492,493,496],{"class":195,"line":335},[193,494,495],{"class":199},"@",[193,497,498],{"class":203},"Entity\n",[193,500,501,504,507,510],{"class":195,"line":346},[193,502,503],{"class":203},"public",[193,505,506],{"class":203}," class",[193,508,509],{"class":269}," Category",[193,511,512],{"class":199}," {\n",[193,514,515],{"class":195,"line":351},[193,516,253],{"emptyLinePlaceholder":252},[193,518,519,521],{"class":195,"line":356},[193,520,200],{"class":199},[193,522,523],{"class":203},"Id\n",[193,525,526,528,531,533,536,538],{"class":195,"line":370},[193,527,200],{"class":199},[193,529,530],{"class":203},"GeneratedValue",[193,532,206],{"class":199},[193,534,535],{"class":209},"strategy",[193,537,213],{"class":203},[193,539,540],{"class":199}," GenerationType.IDENTITY)\n",[193,542,543,545],{"class":195,"line":378},[193,544,243],{"class":203},[193,546,547],{"class":199}," Long id;\n",[193,549,551],{"class":195,"line":550},19,[193,552,253],{"emptyLinePlaceholder":252},[193,554,556,558],{"class":195,"line":555},20,[193,557,243],{"class":203},[193,559,560],{"class":199}," String name;\n",[193,562,564],{"class":195,"line":563},21,[193,565,253],{"emptyLinePlaceholder":252},[193,567,569,571,574,576,578,580,583,586,588,590],{"class":195,"line":568},22,[193,570,200],{"class":199},[193,572,573],{"class":203},"OneToMany",[193,575,206],{"class":199},[193,577,137],{"class":209},[193,579,213],{"class":203},[193,581,582],{"class":233}," \"category\"",[193,584,585],{"class":199},", ",[193,587,210],{"class":209},[193,589,213],{"class":203},[193,591,216],{"class":199},[193,593,595,597,600,602,605,607,610],{"class":195,"line":594},23,[193,596,243],{"class":203},[193,598,599],{"class":199}," List\u003C",[193,601,164],{"class":203},[193,603,604],{"class":199},"> books ",[193,606,285],{"class":203},[193,608,609],{"class":203}," new",[193,611,612],{"class":199}," ArrayList\u003C>();\n",[193,614,616],{"class":195,"line":615},24,[193,617,253],{"emptyLinePlaceholder":252},[193,619,621,624,626],{"class":195,"line":620},25,[193,622,623],{"class":203},"    protected",[193,625,509],{"class":269},[193,627,367],{"class":199},[193,629,631],{"class":195,"line":630},26,[193,632,307],{"class":199},[193,634,636],{"class":195,"line":635},27,[193,637,253],{"emptyLinePlaceholder":252},[193,639,641,643,645,648,651],{"class":195,"line":640},28,[193,642,266],{"class":203},[193,644,509],{"class":269},[193,646,647],{"class":199},"(String ",[193,649,228],{"class":650},"s4XuR",[193,652,653],{"class":199},") {\n",[193,655,657,659,662,664],{"class":195,"line":656},29,[193,658,279],{"class":209},[193,660,661],{"class":199},".name ",[193,663,285],{"class":203},[193,665,666],{"class":199}," name;\n",[193,668,670],{"class":195,"line":669},30,[193,671,307],{"class":199},[193,673,675],{"class":195,"line":674},31,[193,676,253],{"emptyLinePlaceholder":252},[193,678,680],{"class":195,"line":679},32,[193,681,682],{"class":259},"    \u002F** 双向关联要两边都改，交给实体自己的方法保证一致性 *\u002F\n",[193,684,686,688,690,693,696,698],{"class":195,"line":685},33,[193,687,266],{"class":203},[193,689,326],{"class":203},[193,691,692],{"class":269}," addBook",[193,694,695],{"class":199},"(Book ",[193,697,74],{"class":650},[193,699,653],{"class":199},[193,701,703,706,709],{"class":195,"line":702},34,[193,704,705],{"class":199},"        books.",[193,707,708],{"class":269},"add",[193,710,711],{"class":199},"(book);\n",[193,713,715,718,721,723,726],{"class":195,"line":714},35,[193,716,717],{"class":199},"        book.",[193,719,720],{"class":269},"changeCategory",[193,722,206],{"class":199},[193,724,725],{"class":209},"this",[193,727,728],{"class":199},");\n",[193,730,732],{"class":195,"line":731},36,[193,733,307],{"class":199},[193,735,737],{"class":195,"line":736},37,[193,738,253],{"emptyLinePlaceholder":252},[193,740,742,744,747,750],{"class":195,"line":741},38,[193,743,266],{"class":203},[193,745,746],{"class":199}," Long ",[193,748,749],{"class":269},"getId",[193,751,367],{"class":199},[193,753,755,757],{"class":195,"line":754},39,[193,756,373],{"class":203},[193,758,759],{"class":199}," id;\n",[193,761,763],{"class":195,"line":762},40,[193,764,307],{"class":199},[193,766,768],{"class":195,"line":767},41,[193,769,253],{"emptyLinePlaceholder":252},[193,771,773,775,778,781],{"class":195,"line":772},42,[193,774,266],{"class":203},[193,776,777],{"class":199}," String ",[193,779,780],{"class":269},"getName",[193,782,367],{"class":199},[193,784,786,788],{"class":195,"line":785},43,[193,787,373],{"class":203},[193,789,666],{"class":199},[193,791,793],{"class":195,"line":792},44,[193,794,307],{"class":199},[193,796,798],{"class":195,"line":797},45,[193,799,253],{"emptyLinePlaceholder":252},[193,801,803,805,807,809,812,815],{"class":195,"line":802},46,[193,804,266],{"class":203},[193,806,599],{"class":199},[193,808,164],{"class":203},[193,810,811],{"class":199},"> ",[193,813,814],{"class":269},"getBooks",[193,816,367],{"class":199},[193,818,820,822,825,828],{"class":195,"line":819},47,[193,821,373],{"class":203},[193,823,824],{"class":199}," List.",[193,826,827],{"class":269},"copyOf",[193,829,830],{"class":199},"(books);\n",[193,832,834],{"class":195,"line":833},48,[193,835,307],{"class":199},[193,837,839],{"class":195,"line":838},49,[193,840,841],{"class":199},"}\n",[13,843,844],{},"几个要点：",[117,846,847,861,867,876],{},[120,848,849,852,853,856,857,860],{},[28,850,851],{},"反方的集合字段要初始化","（",[72,854,855],{},"new ArrayList\u003C>()","），否则延迟加载时容易碰到\n",[72,858,859],{},"NullPointerException","；",[120,862,863,866],{},[28,864,865],{},"不要给反方的集合提供 public setter","。外部替换整个集合，会绕过维护方的逻辑；",[120,868,869,852,872,875],{},[28,870,871],{},"双向关联用一个方法同时改两边",[72,873,874],{},"addBook","），这是保持内存中对象图一致的关键；",[120,877,878,881],{},[72,879,880],{},"getBooks()"," 返回只读副本，避免调用方直接改内部集合。",[17,883,885],{"id":884},"_4-多对多","4 多对多",[13,887,888],{},"「一本书可以有多个标签，一个标签可以贴在多本书上」。数据库里没有地方放这个关系，得建中间表：",[184,890,892],{"className":186,"code":891,"language":188,"meta":189,"style":189},"package com.example.library.domain;\n\nimport jakarta.persistence.Entity;\nimport jakarta.persistence.GeneratedValue;\nimport jakarta.persistence.GenerationType;\nimport jakarta.persistence.Id;\nimport jakarta.persistence.ManyToMany;\n\nimport java.util.LinkedHashSet;\nimport java.util.Set;\n\n@Entity\npublic class Tag {\n\n    @Id\n    @GeneratedValue(strategy = GenerationType.IDENTITY)\n    private Long id;\n\n    private String name;\n\n    \u002F** 反方：维护方在上面那侧，这里只读 *\u002F\n    @ManyToMany(mappedBy = \"tags\")\n    private Set\u003CBook> books = new LinkedHashSet\u003C>();\n\n    protected Tag() {\n    }\n\n    public Tag(String name) {\n        this.name = name;\n    }\n\n    public Long getId() {\n        return id;\n    }\n\n    public String getName() {\n        return name;\n    }\n\n    public void setName(String name) {\n        this.name = name;\n    }\n\n    public Set\u003CBook> getBooks() {\n        return Set.copyOf(books);\n    }\n}\n",[72,893,894,900,904,910,916,922,928,935,939,946,953,957,963,974,978,984,998,1004,1008,1014,1018,1023,1041,1059,1063,1071,1075,1079,1091,1101,1105,1109,1119,1125,1129,1133,1143,1149,1153,1157,1172,1182,1186,1190,1204,1215,1219],{"__ignoreMap":189},[193,895,896,898],{"class":195,"line":196},[193,897,418],{"class":203},[193,899,421],{"class":199},[193,901,902],{"class":195,"line":219},[193,903,253],{"emptyLinePlaceholder":252},[193,905,906,908],{"class":195,"line":240},[193,907,430],{"class":203},[193,909,433],{"class":199},[193,911,912,914],{"class":195,"line":249},[193,913,430],{"class":203},[193,915,447],{"class":199},[193,917,918,920],{"class":195,"line":256},[193,919,430],{"class":203},[193,921,454],{"class":199},[193,923,924,926],{"class":195,"line":263},[193,925,430],{"class":203},[193,927,461],{"class":199},[193,929,930,932],{"class":195,"line":276},[193,931,430],{"class":203},[193,933,934],{"class":199}," jakarta.persistence.ManyToMany;\n",[193,936,937],{"class":195,"line":291},[193,938,253],{"emptyLinePlaceholder":252},[193,940,941,943],{"class":195,"line":304},[193,942,430],{"class":203},[193,944,945],{"class":199}," java.util.LinkedHashSet;\n",[193,947,948,950],{"class":195,"line":310},[193,949,430],{"class":203},[193,951,952],{"class":199}," java.util.Set;\n",[193,954,955],{"class":195,"line":315},[193,956,253],{"emptyLinePlaceholder":252},[193,958,959,961],{"class":195,"line":321},[193,960,495],{"class":199},[193,962,498],{"class":203},[193,964,965,967,969,972],{"class":195,"line":335},[193,966,503],{"class":203},[193,968,506],{"class":203},[193,970,971],{"class":269}," Tag",[193,973,512],{"class":199},[193,975,976],{"class":195,"line":346},[193,977,253],{"emptyLinePlaceholder":252},[193,979,980,982],{"class":195,"line":351},[193,981,200],{"class":199},[193,983,523],{"class":203},[193,985,986,988,990,992,994,996],{"class":195,"line":356},[193,987,200],{"class":199},[193,989,530],{"class":203},[193,991,206],{"class":199},[193,993,535],{"class":209},[193,995,213],{"class":203},[193,997,540],{"class":199},[193,999,1000,1002],{"class":195,"line":370},[193,1001,243],{"class":203},[193,1003,547],{"class":199},[193,1005,1006],{"class":195,"line":378},[193,1007,253],{"emptyLinePlaceholder":252},[193,1009,1010,1012],{"class":195,"line":550},[193,1011,243],{"class":203},[193,1013,560],{"class":199},[193,1015,1016],{"class":195,"line":555},[193,1017,253],{"emptyLinePlaceholder":252},[193,1019,1020],{"class":195,"line":563},[193,1021,1022],{"class":259},"    \u002F** 反方：维护方在上面那侧，这里只读 *\u002F\n",[193,1024,1025,1027,1030,1032,1034,1036,1039],{"class":195,"line":568},[193,1026,200],{"class":199},[193,1028,1029],{"class":203},"ManyToMany",[193,1031,206],{"class":199},[193,1033,137],{"class":209},[193,1035,213],{"class":203},[193,1037,1038],{"class":233}," \"tags\"",[193,1040,237],{"class":199},[193,1042,1043,1045,1048,1050,1052,1054,1056],{"class":195,"line":594},[193,1044,243],{"class":203},[193,1046,1047],{"class":199}," Set\u003C",[193,1049,164],{"class":203},[193,1051,604],{"class":199},[193,1053,285],{"class":203},[193,1055,609],{"class":203},[193,1057,1058],{"class":199}," LinkedHashSet\u003C>();\n",[193,1060,1061],{"class":195,"line":615},[193,1062,253],{"emptyLinePlaceholder":252},[193,1064,1065,1067,1069],{"class":195,"line":620},[193,1066,623],{"class":203},[193,1068,971],{"class":269},[193,1070,367],{"class":199},[193,1072,1073],{"class":195,"line":630},[193,1074,307],{"class":199},[193,1076,1077],{"class":195,"line":635},[193,1078,253],{"emptyLinePlaceholder":252},[193,1080,1081,1083,1085,1087,1089],{"class":195,"line":640},[193,1082,266],{"class":203},[193,1084,971],{"class":269},[193,1086,647],{"class":199},[193,1088,228],{"class":650},[193,1090,653],{"class":199},[193,1092,1093,1095,1097,1099],{"class":195,"line":656},[193,1094,279],{"class":209},[193,1096,661],{"class":199},[193,1098,285],{"class":203},[193,1100,666],{"class":199},[193,1102,1103],{"class":195,"line":669},[193,1104,307],{"class":199},[193,1106,1107],{"class":195,"line":674},[193,1108,253],{"emptyLinePlaceholder":252},[193,1110,1111,1113,1115,1117],{"class":195,"line":679},[193,1112,266],{"class":203},[193,1114,746],{"class":199},[193,1116,749],{"class":269},[193,1118,367],{"class":199},[193,1120,1121,1123],{"class":195,"line":685},[193,1122,373],{"class":203},[193,1124,759],{"class":199},[193,1126,1127],{"class":195,"line":702},[193,1128,307],{"class":199},[193,1130,1131],{"class":195,"line":714},[193,1132,253],{"emptyLinePlaceholder":252},[193,1134,1135,1137,1139,1141],{"class":195,"line":731},[193,1136,266],{"class":203},[193,1138,777],{"class":199},[193,1140,780],{"class":269},[193,1142,367],{"class":199},[193,1144,1145,1147],{"class":195,"line":736},[193,1146,373],{"class":203},[193,1148,666],{"class":199},[193,1150,1151],{"class":195,"line":741},[193,1152,307],{"class":199},[193,1154,1155],{"class":195,"line":754},[193,1156,253],{"emptyLinePlaceholder":252},[193,1158,1159,1161,1163,1166,1168,1170],{"class":195,"line":762},[193,1160,266],{"class":203},[193,1162,326],{"class":203},[193,1164,1165],{"class":269}," setName",[193,1167,647],{"class":199},[193,1169,228],{"class":650},[193,1171,653],{"class":199},[193,1173,1174,1176,1178,1180],{"class":195,"line":767},[193,1175,279],{"class":209},[193,1177,661],{"class":199},[193,1179,285],{"class":203},[193,1181,666],{"class":199},[193,1183,1184],{"class":195,"line":772},[193,1185,307],{"class":199},[193,1187,1188],{"class":195,"line":785},[193,1189,253],{"emptyLinePlaceholder":252},[193,1191,1192,1194,1196,1198,1200,1202],{"class":195,"line":792},[193,1193,266],{"class":203},[193,1195,1047],{"class":199},[193,1197,164],{"class":203},[193,1199,811],{"class":199},[193,1201,814],{"class":269},[193,1203,367],{"class":199},[193,1205,1206,1208,1211,1213],{"class":195,"line":797},[193,1207,373],{"class":203},[193,1209,1210],{"class":199}," Set.",[193,1212,827],{"class":269},[193,1214,830],{"class":199},[193,1216,1217],{"class":195,"line":802},[193,1218,307],{"class":199},[193,1220,1221],{"class":195,"line":819},[193,1222,841],{"class":199},[13,1224,1225,1226,1228],{},"选择 ",[72,1227,164],{}," 作为维护方：",[184,1230,1232],{"className":186,"code":1231,"language":188,"meta":189,"style":189},"@ManyToMany\n@JoinTable(\n        name = \"book_tags\",\n        joinColumns = @JoinColumn(name = \"book_id\"),\n        inverseJoinColumns = @JoinColumn(name = \"tag_id\"))\nprivate Set\u003CTag> tags = new LinkedHashSet\u003C>();\n",[72,1233,1234,1241,1251,1264,1288,1311],{"__ignoreMap":189},[193,1235,1236,1238],{"class":195,"line":196},[193,1237,495],{"class":199},[193,1239,1240],{"class":203},"ManyToMany\n",[193,1242,1243,1245,1248],{"class":195,"line":219},[193,1244,495],{"class":199},[193,1246,1247],{"class":203},"JoinTable",[193,1249,1250],{"class":199},"(\n",[193,1252,1253,1256,1258,1261],{"class":195,"line":240},[193,1254,1255],{"class":209},"        name",[193,1257,213],{"class":203},[193,1259,1260],{"class":233}," \"book_tags\"",[193,1262,1263],{"class":199},",\n",[193,1265,1266,1269,1271,1274,1276,1278,1280,1282,1285],{"class":195,"line":249},[193,1267,1268],{"class":209},"        joinColumns",[193,1270,213],{"class":203},[193,1272,1273],{"class":199}," @",[193,1275,178],{"class":203},[193,1277,206],{"class":199},[193,1279,228],{"class":209},[193,1281,213],{"class":203},[193,1283,1284],{"class":233}," \"book_id\"",[193,1286,1287],{"class":199},"),\n",[193,1289,1290,1293,1295,1297,1299,1301,1303,1305,1308],{"class":195,"line":256},[193,1291,1292],{"class":209},"        inverseJoinColumns",[193,1294,213],{"class":203},[193,1296,1273],{"class":199},[193,1298,178],{"class":203},[193,1300,206],{"class":199},[193,1302,228],{"class":209},[193,1304,213],{"class":203},[193,1306,1307],{"class":233}," \"tag_id\"",[193,1309,1310],{"class":199},"))\n",[193,1312,1313,1316,1318,1321,1324,1326,1328],{"class":195,"line":263},[193,1314,1315],{"class":203},"private",[193,1317,1047],{"class":199},[193,1319,1320],{"class":203},"Tag",[193,1322,1323],{"class":199},"> tags ",[193,1325,285],{"class":203},[193,1327,609],{"class":203},[193,1329,1058],{"class":199},[13,1331,1332,1334],{},[72,1333,1320],{}," 侧则是反方：",[184,1336,1338],{"className":186,"code":1337,"language":188,"meta":189,"style":189},"@ManyToMany(mappedBy = \"tags\")\nprivate Set\u003CBook> books = new LinkedHashSet\u003C>();\n",[72,1339,1340,1356],{"__ignoreMap":189},[193,1341,1342,1344,1346,1348,1350,1352,1354],{"class":195,"line":196},[193,1343,495],{"class":199},[193,1345,1029],{"class":203},[193,1347,206],{"class":199},[193,1349,137],{"class":209},[193,1351,213],{"class":203},[193,1353,1038],{"class":233},[193,1355,237],{"class":199},[193,1357,1358,1360,1362,1364,1366,1368,1370],{"class":195,"line":219},[193,1359,1315],{"class":203},[193,1361,1047],{"class":199},[193,1363,164],{"class":203},[193,1365,604],{"class":199},[193,1367,285],{"class":203},[193,1369,609],{"class":203},[193,1371,1058],{"class":199},[13,1373,1374],{},"要点：",[117,1376,1377,1393,1414],{},[120,1378,1379,1389,1390,1392],{},[28,1380,1381,1382,1385,1386],{},"用 ",[72,1383,1384],{},"Set"," 而不是 ",[72,1387,1388],{},"List","。中间表没有主键去重，用 ",[72,1391,1388],{}," 时重复删改容易产生重复行；",[120,1394,1395,1398,1399,1402,1403,1406,1407,1402,1410,1413],{},[72,1396,1397],{},"@JoinTable"," 里 ",[72,1400,1401],{},"joinColumns"," 指",[28,1404,1405],{},"本实体","在中间表里的列，",[72,1408,1409],{},"inverseJoinColumns",[28,1411,1412],{},"对方","\n的列，写反了不会报错，只会关联错数据；",[120,1415,92,1416,1419,1420,1423],{},[28,1417,1418],{},"不适合带额外字段的关系","（比如「借阅」还带借出日期、归还日期）。这种关系要用\n「两个一对多 + 一个显式中间实体」来表达，中间实体就是那个业务对象（如 ",[72,1421,1422],{},"Loan","）。",[13,1425,1426,1427,42],{},"最后这条经验很重要：只要关系本身有业务属性，它就该是一个实体，而不是一个 ",[72,1428,1429],{},"@ManyToMany",[17,1431,1433],{"id":1432},"_5-一对多反向来写多对一优先","5 一对多反向来写：多对一优先",[13,1435,1436,1437,1444],{},"实际项目里更常用的写法是",[28,1438,1439,1440,1443],{},"只在多的一侧建 ",[72,1441,1442],{},"@ManyToOne","，不要反方的集合","：",[184,1446,1448],{"className":186,"code":1447,"language":188,"meta":189,"style":189},"@Entity\npublic class Book {\n    \u002F\u002F ...\n    @ManyToOne(fetch = FetchType.LAZY)\n    @JoinColumn(name = \"category_id\")\n    private Category category;\n}\n",[72,1449,1450,1456,1466,1471,1485,1501,1507],{"__ignoreMap":189},[193,1451,1452,1454],{"class":195,"line":196},[193,1453,495],{"class":199},[193,1455,498],{"class":203},[193,1457,1458,1460,1462,1464],{"class":195,"line":219},[193,1459,503],{"class":203},[193,1461,506],{"class":203},[193,1463,270],{"class":269},[193,1465,512],{"class":199},[193,1467,1468],{"class":195,"line":240},[193,1469,1470],{"class":259},"    \u002F\u002F ...\n",[193,1472,1473,1475,1477,1479,1481,1483],{"class":195,"line":249},[193,1474,200],{"class":199},[193,1476,181],{"class":203},[193,1478,206],{"class":199},[193,1480,210],{"class":209},[193,1482,213],{"class":203},[193,1484,216],{"class":199},[193,1486,1487,1489,1491,1493,1495,1497,1499],{"class":195,"line":256},[193,1488,200],{"class":199},[193,1490,178],{"class":203},[193,1492,206],{"class":199},[193,1494,228],{"class":209},[193,1496,213],{"class":203},[193,1498,234],{"class":233},[193,1500,237],{"class":199},[193,1502,1503,1505],{"class":195,"line":263},[193,1504,243],{"class":203},[193,1506,246],{"class":199},[193,1508,1509],{"class":195,"line":276},[193,1510,841],{"class":199},[13,1512,1513],{},"要查「这个分类下的书」，用仓储方法就行：",[184,1515,1517],{"className":186,"code":1516,"language":188,"meta":189,"style":189},"List\u003CBook> findByCategoryId(Long categoryId);\n",[72,1518,1519],{"__ignoreMap":189},[193,1520,1521,1523,1526,1528,1531,1534],{"class":195,"line":196},[193,1522,1388],{"class":199},[193,1524,1525],{"class":203},"\u003C",[193,1527,164],{"class":199},[193,1529,1530],{"class":203},">",[193,1532,1533],{"class":269}," findByCategoryId",[193,1535,1536],{"class":199},"(Long categoryId);\n",[13,1538,1539,1542],{},[28,1540,1541],{},"单向关联的问题更少","：不用担心双向一致性、不用担心序列化成环、不用担心集合被误改。只有当\n「从一的一侧遍历到多的一侧」真的是高频操作，才值得加上集合这一侧。",[17,1544,1546],{"id":1545},"_6-fetchtype延迟加载与-n1","6 FetchType：延迟加载与 N+1",[44,1548,1549,1562],{},[47,1550,1551],{},[50,1552,1553,1556,1559],{},[53,1554,1555],{},"取值",[53,1557,1558],{},"行为",[53,1560,1561],{},"JPA 默认用在",[60,1563,1564,1581],{},[50,1565,1566,1571,1574],{},[65,1567,1568],{},[72,1569,1570],{},"FetchType.LAZY",[65,1572,1573],{},"用到时才去查（拿到的往往是代理）",[65,1575,1576,175,1579],{},[72,1577,1578],{},"@OneToMany",[72,1580,1429],{},[50,1582,1583,1588,1591],{},[65,1584,1585],{},[72,1586,1587],{},"FetchType.EAGER",[65,1589,1590],{},"查主实体时顺便全查出来",[65,1592,1593,175,1595],{},[72,1594,1442],{},[72,1596,1597],{},"@OneToOne",[13,1599,1600,1444,1603,1605,1606,1609,1610,1612,1613,1615,1616,42],{},[28,1601,1602],{},"默认值是个陷阱",[72,1604,1442],{}," 默认是 ",[72,1607,1608],{},"EAGER","。一个列表页查 100 本书，如果每本书都 ",[72,1611,1608],{},"\n带出分类，就会产生额外查询。所以示例里我给每个 ",[72,1614,1442],{}," 都显式写了 ",[72,1617,1618],{},"LAZY",[1620,1621,1623],"h3",{"id":1622},"_61-n1-是怎么来的","6.1 N+1 是怎么来的",[184,1625,1627],{"className":186,"code":1626,"language":188,"meta":189,"style":189},"List\u003CBook> books = bookRepository.findAll();        \u002F\u002F 1 条 SQL\nfor (Book book : books) {\n    System.out.println(book.getCategory().getName()); \u002F\u002F 每条再补 1 条 SQL\n}\n",[72,1628,1629,1652,1666,1690],{"__ignoreMap":189},[193,1630,1631,1634,1636,1638,1640,1643,1646,1649],{"class":195,"line":196},[193,1632,1633],{"class":199},"List\u003C",[193,1635,164],{"class":203},[193,1637,604],{"class":199},[193,1639,285],{"class":203},[193,1641,1642],{"class":199}," bookRepository.",[193,1644,1645],{"class":269},"findAll",[193,1647,1648],{"class":199},"();        ",[193,1650,1651],{"class":259},"\u002F\u002F 1 条 SQL\n",[193,1653,1654,1657,1660,1663],{"class":195,"line":219},[193,1655,1656],{"class":203},"for",[193,1658,1659],{"class":199}," (Book book ",[193,1661,1662],{"class":203},":",[193,1664,1665],{"class":199}," books) {\n",[193,1667,1668,1671,1674,1677,1679,1682,1684,1687],{"class":195,"line":240},[193,1669,1670],{"class":199},"    System.out.",[193,1672,1673],{"class":269},"println",[193,1675,1676],{"class":199},"(book.",[193,1678,364],{"class":269},[193,1680,1681],{"class":199},"().",[193,1683,780],{"class":269},[193,1685,1686],{"class":199},"()); ",[193,1688,1689],{"class":259},"\u002F\u002F 每条再补 1 条 SQL\n",[193,1691,1692],{"class":195,"line":249},[193,1693,841],{"class":199},[13,1695,1696],{},"一共 1 + N 条 SQL，N 稍大就明显变慢。三种应对：",[1698,1699,1700,1762,1809],"ol",{},[120,1701,1702,1708,1709,1755,1758,1761],{},[28,1703,1704,1705],{},"JPQL 用 ",[72,1706,1707],{},"join fetch","（明确要一起取回来）：",[184,1710,1712],{"className":186,"code":1711,"language":188,"meta":189,"style":189},"@Query(\"select distinct b from Book b join fetch b.category where b.category.id = :categoryId\")\nList\u003CBook> findByCategoryWithCategory(@Param(\"categoryId\") Long categoryId);\n",[72,1713,1714,1728],{"__ignoreMap":189},[193,1715,1716,1718,1721,1723,1726],{"class":195,"line":196},[193,1717,495],{"class":199},[193,1719,1720],{"class":203},"Query",[193,1722,206],{"class":199},[193,1724,1725],{"class":233},"\"select distinct b from Book b join fetch b.category where b.category.id = :categoryId\"",[193,1727,237],{"class":199},[193,1729,1730,1732,1734,1736,1738,1741,1744,1747,1749,1752],{"class":195,"line":219},[193,1731,1388],{"class":199},[193,1733,1525],{"class":203},[193,1735,164],{"class":199},[193,1737,1530],{"class":203},[193,1739,1740],{"class":269}," findByCategoryWithCategory",[193,1742,1743],{"class":199},"(@",[193,1745,1746],{"class":203},"Param",[193,1748,206],{"class":199},[193,1750,1751],{"class":233},"\"categoryId\"",[193,1753,1754],{"class":199},") Long categoryId);\n",[1756,1757],"br",{},[72,1759,1760],{},"distinct"," 在这里是必要的：join 之后主实体行会重复。",[120,1763,1764,1444,1770],{},[28,1765,1381,1766,1769],{},[72,1767,1768],{},"@EntityGraph"," 声明式地指定要一并加载的关联",[184,1771,1773],{"className":186,"code":1772,"language":188,"meta":189,"style":189},"@EntityGraph(attributePaths = \"category\")\nList\u003CBook> findByTitleContaining(String keyword);\n",[72,1774,1775,1793],{"__ignoreMap":189},[193,1776,1777,1779,1782,1784,1787,1789,1791],{"class":195,"line":196},[193,1778,495],{"class":199},[193,1780,1781],{"class":203},"EntityGraph",[193,1783,206],{"class":199},[193,1785,1786],{"class":209},"attributePaths",[193,1788,213],{"class":203},[193,1790,582],{"class":233},[193,1792,237],{"class":199},[193,1794,1795,1797,1799,1801,1803,1806],{"class":195,"line":219},[193,1796,1388],{"class":199},[193,1798,1525],{"class":203},[193,1800,164],{"class":199},[193,1802,1530],{"class":203},[193,1804,1805],{"class":269}," findByTitleContaining",[193,1807,1808],{"class":199},"(String keyword);\n",[120,1810,1811,1814],{},[28,1812,1813],{},"投影\u002F DTO 查询","，只取需要的那几个字段（上一篇讲的投影）。",[13,1816,1817,1818,1821,1822,1825,1826,1829,1830,1833],{},"还有一个连带问题：",[28,1819,1820],{},"延迟加载必须在事务内","。Service 方法的事务结束后再去访问没加载的关联，\n会抛 ",[72,1823,1824],{},"LazyInitializationException","。解决办法是",[28,1827,1828],{},"在事务内取够数据","（join fetch \u002F EntityGraph \u002F\n投影），而不是把 ",[72,1831,1832],{},"@Transactional"," 加到 Controller 上。",[17,1835,1837],{"id":1836},"_7-cascadetype-与-orphanremoval","7 CascadeType 与 orphanRemoval",[13,1839,1840],{},"级联把对父实体的操作传播到子实体：",[44,1842,1843,1852],{},[47,1844,1845],{},[50,1846,1847,1849],{},[53,1848,1555],{},[53,1850,1851],{},"传播的操作",[60,1853,1854,1864,1874,1884,1894,1904],{},[50,1855,1856,1861],{},[65,1857,1858],{},[72,1859,1860],{},"CascadeType.PERSIST",[65,1862,1863],{},"保存父实体时一起保存子",[50,1865,1866,1871],{},[65,1867,1868],{},[72,1869,1870],{},"CascadeType.MERGE",[65,1872,1873],{},"更新父实体时一起更新子",[50,1875,1876,1881],{},[65,1877,1878],{},[72,1879,1880],{},"CascadeType.REMOVE",[65,1882,1883],{},"删除父实体时一起删除子",[50,1885,1886,1891],{},[65,1887,1888],{},[72,1889,1890],{},"CascadeType.REFRESH",[65,1892,1893],{},"从数据库刷新父时一起刷新子",[50,1895,1896,1901],{},[65,1897,1898],{},[72,1899,1900],{},"CascadeType.DETACH",[65,1902,1903],{},"从持久化上下文摘除",[50,1905,1906,1911],{},[65,1907,1908],{},[72,1909,1910],{},"CascadeType.ALL",[65,1912,1913],{},"以上全部",[13,1915,1916,1919,1920,1923],{},[72,1917,1918],{},"orphanRemoval = true"," 是另一个维度：",[28,1921,1922],{},"子实体从父的集合里被移除时，直接删掉它","（而不是把外键\n置空）。",[13,1925,1926],{},"用法上的建议：",[117,1928,1929,1946],{},[120,1930,1931,1934,1935,1938,1939,1941,1942,1945],{},[28,1932,1933],{},"级联只用在真正的父子聚合关系","上（订单与订单明细），别为了省一次 ",[72,1936,1937],{},"save"," 到处写\n",[72,1940,1910],{},"——尤其是 ",[72,1943,1944],{},"REMOVE","：删一个分类顺手删掉这个分类下所有书，往往不是你要的；",[120,1947,1948,1949,1954,1955,1958],{},"关联是「引用」而非「拥有」（书属于分类，但书不因分类被删而消失）时，",[28,1950,1951,1952],{},"不要加 ",[72,1953,1944],{},"；\n删除父实体时要么先把子实体转移，要么在数据库层用 ",[72,1956,1957],{},"ON DELETE SET NULL"," \u002F 限制删除。",[17,1960,1962],{"id":1961},"_8-序列化成-json-时的循环引用","8 序列化成 JSON 时的循环引用",[13,1964,1965,1966,1969],{},"双向关联直接交给 Jackson 序列化会无限递归：",[72,1967,1968],{},"category.books[i].category.books...","。\n三种处理方式，推荐最后一种：",[1698,1971,1972,1979,1988],{},[120,1973,1974,1975,1978],{},"反方字段加 ",[72,1976,1977],{},"@JsonIgnore","——简单，但等于把「哪边不输出」写死在实体里；",[120,1980,1381,1981,389,1984,1987],{},[72,1982,1983],{},"@JsonManagedReference",[72,1985,1986],{},"@JsonBackReference"," 配对；",[120,1989,1990,1993],{},[28,1991,1992],{},"别直接输出实体","，在 Controller 层转成 DTO。实体是持久化模型，本来就不该承担 API 契约的\n职责，这也能顺手回避延迟加载字段在序列化时的意外触发。",[17,1995,1997],{"id":1996},"_9-把仓储层测起来datajpatest","9 把仓储层测起来：@DataJpaTest",[13,1999,2000,2001,2004,2005,2008,2009,2012,2013,2016,2017,2020,2021,1444],{},"仓储的查询方法（尤其是派生查询和 JPQL）值得自动测试，因为写错时通常到运行时才发现。\n",[72,2002,2003],{},"@DataJpaTest"," 是专门给这层用的切片测试。",[28,2006,2007],{},"Boot 4 里它的包路径变了","：Boot 3 是\n",[72,2010,2011],{},"org.springframework.boot.test.autoconfigure.orm.jpa","，现在在\n",[72,2014,2015],{},"org.springframework.boot.data.jpa.test.autoconfigure","；依赖也从 ",[72,2018,2019],{},"spring-boot-starter-test","\n换成了专门的 ",[72,2022,2023],{},"spring-boot-starter-data-jpa-test",[184,2025,2027],{"className":186,"code":2026,"language":188,"meta":189,"style":189},"package com.example.library.repository;\n\nimport com.example.library.domain.Book;\nimport com.example.library.domain.BookStatus;\nimport com.example.library.domain.Category;\nimport org.junit.jupiter.api.DisplayName;\nimport org.junit.jupiter.api.Test;\nimport org.springframework.beans.factory.annotation.Autowired;\nimport org.springframework.boot.data.jpa.test.autoconfigure.DataJpaTest;\n\nimport java.util.List;\n\nimport static org.assertj.core.api.Assertions.assertThat;\n\n@DataJpaTest\nclass BookRepositoryTest {\n\n    @Autowired\n    private BookRepository bookRepository;\n\n    @Autowired\n    private CategoryRepository categoryRepository;\n\n    @Test\n    @DisplayName(\"按分类查书：只返回该分类下的书，并按书名排序\")\n    void findsBooksByCategoryOrderedByTitle() {\n        \u002F\u002F given\n        Category novel = categoryRepository.save(new Category(\"小说\"));\n        Category tech = categoryRepository.save(new Category(\"技术\"));\n\n        bookRepository.save(new Book(\"活着\", novel));\n        bookRepository.save(new Book(\"许三观卖血记\", novel));\n        bookRepository.save(new Book(\"重构\", tech));\n\n        \u002F\u002F when\n        List\u003CBook> books = bookRepository.findByCategoryIdOrderByTitleAsc(novel.getId());\n\n        \u002F\u002F then\n        assertThat(books)\n                .hasSize(2)\n                .extracting(Book::getTitle)\n                .containsExactlyInAnyOrder(\"活着\", \"许三观卖血记\");\n    }\n\n    @Test\n    @DisplayName(\"Containing 会自动补通配符，Like 不会\")\n    void containingVersusLike() {\n        bookRepository.save(new Book(\"重构：改善既有代码的设计\", \"Martin Fowler\"));\n\n        assertThat(bookRepository.findByTitleContaining(\"重构\")).hasSize(1);\n        assertThat(bookRepository.findByTitleLike(\"重构%\")).hasSize(1);\n        assertThat(bookRepository.findByTitleLike(\"重构\")).isEmpty();   \u002F\u002F 没带通配符就等同于完全相等\n    }\n\n    @Test\n    @DisplayName(\"@Modifying 更新之后，同一个事务里读到的是新值\")\n    void updatesStatusInPlace() {\n        Book book = bookRepository.save(new Book(\"重构\", \"Martin Fowler\"));\n\n        int updated = bookRepository.markStatus(book.getId(), BookStatus.BORROWED);\n\n        assertThat(updated).isEqualTo(1);\n        assertThat(bookRepository.findById(book.getId()).orElseThrow().getStatus())\n                .isEqualTo(BookStatus.BORROWED);\n    }\n}\n",[72,2028,2029,2036,2040,2047,2054,2061,2068,2075,2082,2089,2093,2099,2103,2113,2117,2124,2134,2138,2145,2152,2156,2162,2169,2173,2180,2194,2204,2209,2236,2260,2264,2285,2304,2324,2328,2333,2357,2361,2366,2374,2389,2405,2422,2426,2430,2436,2449,2458,2482,2486,2513,2538,2562,2567,2572,2579,2593,2603,2631,2636,2659,2664,2681,2709,2719,2724],{"__ignoreMap":189},[193,2030,2031,2033],{"class":195,"line":196},[193,2032,418],{"class":203},[193,2034,2035],{"class":199}," com.example.library.repository;\n",[193,2037,2038],{"class":195,"line":219},[193,2039,253],{"emptyLinePlaceholder":252},[193,2041,2042,2044],{"class":195,"line":240},[193,2043,430],{"class":203},[193,2045,2046],{"class":199}," com.example.library.domain.Book;\n",[193,2048,2049,2051],{"class":195,"line":249},[193,2050,430],{"class":203},[193,2052,2053],{"class":199}," com.example.library.domain.BookStatus;\n",[193,2055,2056,2058],{"class":195,"line":256},[193,2057,430],{"class":203},[193,2059,2060],{"class":199}," com.example.library.domain.Category;\n",[193,2062,2063,2065],{"class":195,"line":263},[193,2064,430],{"class":203},[193,2066,2067],{"class":199}," org.junit.jupiter.api.DisplayName;\n",[193,2069,2070,2072],{"class":195,"line":276},[193,2071,430],{"class":203},[193,2073,2074],{"class":199}," org.junit.jupiter.api.Test;\n",[193,2076,2077,2079],{"class":195,"line":291},[193,2078,430],{"class":203},[193,2080,2081],{"class":199}," org.springframework.beans.factory.annotation.Autowired;\n",[193,2083,2084,2086],{"class":195,"line":304},[193,2085,430],{"class":203},[193,2087,2088],{"class":199}," org.springframework.boot.data.jpa.test.autoconfigure.DataJpaTest;\n",[193,2090,2091],{"class":195,"line":310},[193,2092,253],{"emptyLinePlaceholder":252},[193,2094,2095,2097],{"class":195,"line":315},[193,2096,430],{"class":203},[193,2098,486],{"class":199},[193,2100,2101],{"class":195,"line":321},[193,2102,253],{"emptyLinePlaceholder":252},[193,2104,2105,2107,2110],{"class":195,"line":335},[193,2106,430],{"class":203},[193,2108,2109],{"class":203}," static",[193,2111,2112],{"class":199}," org.assertj.core.api.Assertions.assertThat;\n",[193,2114,2115],{"class":195,"line":346},[193,2116,253],{"emptyLinePlaceholder":252},[193,2118,2119,2121],{"class":195,"line":351},[193,2120,495],{"class":199},[193,2122,2123],{"class":203},"DataJpaTest\n",[193,2125,2126,2129,2132],{"class":195,"line":356},[193,2127,2128],{"class":203},"class",[193,2130,2131],{"class":269}," BookRepositoryTest",[193,2133,512],{"class":199},[193,2135,2136],{"class":195,"line":370},[193,2137,253],{"emptyLinePlaceholder":252},[193,2139,2140,2142],{"class":195,"line":378},[193,2141,200],{"class":199},[193,2143,2144],{"class":203},"Autowired\n",[193,2146,2147,2149],{"class":195,"line":550},[193,2148,243],{"class":203},[193,2150,2151],{"class":199}," BookRepository bookRepository;\n",[193,2153,2154],{"class":195,"line":555},[193,2155,253],{"emptyLinePlaceholder":252},[193,2157,2158,2160],{"class":195,"line":563},[193,2159,200],{"class":199},[193,2161,2144],{"class":203},[193,2163,2164,2166],{"class":195,"line":568},[193,2165,243],{"class":203},[193,2167,2168],{"class":199}," CategoryRepository categoryRepository;\n",[193,2170,2171],{"class":195,"line":594},[193,2172,253],{"emptyLinePlaceholder":252},[193,2174,2175,2177],{"class":195,"line":615},[193,2176,200],{"class":199},[193,2178,2179],{"class":203},"Test\n",[193,2181,2182,2184,2187,2189,2192],{"class":195,"line":620},[193,2183,200],{"class":199},[193,2185,2186],{"class":203},"DisplayName",[193,2188,206],{"class":199},[193,2190,2191],{"class":233},"\"按分类查书：只返回该分类下的书，并按书名排序\"",[193,2193,237],{"class":199},[193,2195,2196,2199,2202],{"class":195,"line":630},[193,2197,2198],{"class":203},"    void",[193,2200,2201],{"class":269}," findsBooksByCategoryOrderedByTitle",[193,2203,367],{"class":199},[193,2205,2206],{"class":195,"line":635},[193,2207,2208],{"class":259},"        \u002F\u002F given\n",[193,2210,2211,2214,2216,2219,2221,2223,2226,2228,2230,2233],{"class":195,"line":640},[193,2212,2213],{"class":199},"        Category novel ",[193,2215,285],{"class":203},[193,2217,2218],{"class":199}," categoryRepository.",[193,2220,1937],{"class":269},[193,2222,206],{"class":199},[193,2224,2225],{"class":203},"new",[193,2227,509],{"class":269},[193,2229,206],{"class":199},[193,2231,2232],{"class":233},"\"小说\"",[193,2234,2235],{"class":199},"));\n",[193,2237,2238,2241,2243,2245,2247,2249,2251,2253,2255,2258],{"class":195,"line":656},[193,2239,2240],{"class":199},"        Category tech ",[193,2242,285],{"class":203},[193,2244,2218],{"class":199},[193,2246,1937],{"class":269},[193,2248,206],{"class":199},[193,2250,2225],{"class":203},[193,2252,509],{"class":269},[193,2254,206],{"class":199},[193,2256,2257],{"class":233},"\"技术\"",[193,2259,2235],{"class":199},[193,2261,2262],{"class":195,"line":669},[193,2263,253],{"emptyLinePlaceholder":252},[193,2265,2266,2269,2271,2273,2275,2277,2279,2282],{"class":195,"line":674},[193,2267,2268],{"class":199},"        bookRepository.",[193,2270,1937],{"class":269},[193,2272,206],{"class":199},[193,2274,2225],{"class":203},[193,2276,270],{"class":269},[193,2278,206],{"class":199},[193,2280,2281],{"class":233},"\"活着\"",[193,2283,2284],{"class":199},", novel));\n",[193,2286,2287,2289,2291,2293,2295,2297,2299,2302],{"class":195,"line":679},[193,2288,2268],{"class":199},[193,2290,1937],{"class":269},[193,2292,206],{"class":199},[193,2294,2225],{"class":203},[193,2296,270],{"class":269},[193,2298,206],{"class":199},[193,2300,2301],{"class":233},"\"许三观卖血记\"",[193,2303,2284],{"class":199},[193,2305,2306,2308,2310,2312,2314,2316,2318,2321],{"class":195,"line":685},[193,2307,2268],{"class":199},[193,2309,1937],{"class":269},[193,2311,206],{"class":199},[193,2313,2225],{"class":203},[193,2315,270],{"class":269},[193,2317,206],{"class":199},[193,2319,2320],{"class":233},"\"重构\"",[193,2322,2323],{"class":199},", tech));\n",[193,2325,2326],{"class":195,"line":702},[193,2327,253],{"emptyLinePlaceholder":252},[193,2329,2330],{"class":195,"line":714},[193,2331,2332],{"class":259},"        \u002F\u002F when\n",[193,2334,2335,2338,2340,2342,2344,2346,2349,2352,2354],{"class":195,"line":731},[193,2336,2337],{"class":199},"        List\u003C",[193,2339,164],{"class":203},[193,2341,604],{"class":199},[193,2343,285],{"class":203},[193,2345,1642],{"class":199},[193,2347,2348],{"class":269},"findByCategoryIdOrderByTitleAsc",[193,2350,2351],{"class":199},"(novel.",[193,2353,749],{"class":269},[193,2355,2356],{"class":199},"());\n",[193,2358,2359],{"class":195,"line":736},[193,2360,253],{"emptyLinePlaceholder":252},[193,2362,2363],{"class":195,"line":741},[193,2364,2365],{"class":259},"        \u002F\u002F then\n",[193,2367,2368,2371],{"class":195,"line":754},[193,2369,2370],{"class":269},"        assertThat",[193,2372,2373],{"class":199},"(books)\n",[193,2375,2376,2379,2382,2384,2387],{"class":195,"line":762},[193,2377,2378],{"class":199},"                .",[193,2380,2381],{"class":269},"hasSize",[193,2383,206],{"class":199},[193,2385,2386],{"class":209},"2",[193,2388,237],{"class":199},[193,2390,2391,2393,2396,2399,2402],{"class":195,"line":767},[193,2392,2378],{"class":199},[193,2394,2395],{"class":269},"extracting",[193,2397,2398],{"class":199},"(Book",[193,2400,2401],{"class":203},"::",[193,2403,2404],{"class":199},"getTitle)\n",[193,2406,2407,2409,2412,2414,2416,2418,2420],{"class":195,"line":772},[193,2408,2378],{"class":199},[193,2410,2411],{"class":269},"containsExactlyInAnyOrder",[193,2413,206],{"class":199},[193,2415,2281],{"class":233},[193,2417,585],{"class":199},[193,2419,2301],{"class":233},[193,2421,728],{"class":199},[193,2423,2424],{"class":195,"line":785},[193,2425,307],{"class":199},[193,2427,2428],{"class":195,"line":792},[193,2429,253],{"emptyLinePlaceholder":252},[193,2431,2432,2434],{"class":195,"line":797},[193,2433,200],{"class":199},[193,2435,2179],{"class":203},[193,2437,2438,2440,2442,2444,2447],{"class":195,"line":802},[193,2439,200],{"class":199},[193,2441,2186],{"class":203},[193,2443,206],{"class":199},[193,2445,2446],{"class":233},"\"Containing 会自动补通配符，Like 不会\"",[193,2448,237],{"class":199},[193,2450,2451,2453,2456],{"class":195,"line":819},[193,2452,2198],{"class":203},[193,2454,2455],{"class":269}," containingVersusLike",[193,2457,367],{"class":199},[193,2459,2460,2462,2464,2466,2468,2470,2472,2475,2477,2480],{"class":195,"line":833},[193,2461,2268],{"class":199},[193,2463,1937],{"class":269},[193,2465,206],{"class":199},[193,2467,2225],{"class":203},[193,2469,270],{"class":269},[193,2471,206],{"class":199},[193,2473,2474],{"class":233},"\"重构：改善既有代码的设计\"",[193,2476,585],{"class":199},[193,2478,2479],{"class":233},"\"Martin Fowler\"",[193,2481,2235],{"class":199},[193,2483,2484],{"class":195,"line":838},[193,2485,253],{"emptyLinePlaceholder":252},[193,2487,2489,2491,2494,2497,2499,2501,2504,2506,2508,2511],{"class":195,"line":2488},50,[193,2490,2370],{"class":269},[193,2492,2493],{"class":199},"(bookRepository.",[193,2495,2496],{"class":269},"findByTitleContaining",[193,2498,206],{"class":199},[193,2500,2320],{"class":233},[193,2502,2503],{"class":199},")).",[193,2505,2381],{"class":269},[193,2507,206],{"class":199},[193,2509,2510],{"class":209},"1",[193,2512,728],{"class":199},[193,2514,2516,2518,2520,2523,2525,2528,2530,2532,2534,2536],{"class":195,"line":2515},51,[193,2517,2370],{"class":269},[193,2519,2493],{"class":199},[193,2521,2522],{"class":269},"findByTitleLike",[193,2524,206],{"class":199},[193,2526,2527],{"class":233},"\"重构%\"",[193,2529,2503],{"class":199},[193,2531,2381],{"class":269},[193,2533,206],{"class":199},[193,2535,2510],{"class":209},[193,2537,728],{"class":199},[193,2539,2541,2543,2545,2547,2549,2551,2553,2556,2559],{"class":195,"line":2540},52,[193,2542,2370],{"class":269},[193,2544,2493],{"class":199},[193,2546,2522],{"class":269},[193,2548,206],{"class":199},[193,2550,2320],{"class":233},[193,2552,2503],{"class":199},[193,2554,2555],{"class":269},"isEmpty",[193,2557,2558],{"class":199},"();   ",[193,2560,2561],{"class":259},"\u002F\u002F 没带通配符就等同于完全相等\n",[193,2563,2565],{"class":195,"line":2564},53,[193,2566,307],{"class":199},[193,2568,2570],{"class":195,"line":2569},54,[193,2571,253],{"emptyLinePlaceholder":252},[193,2573,2575,2577],{"class":195,"line":2574},55,[193,2576,200],{"class":199},[193,2578,2179],{"class":203},[193,2580,2582,2584,2586,2588,2591],{"class":195,"line":2581},56,[193,2583,200],{"class":199},[193,2585,2186],{"class":203},[193,2587,206],{"class":199},[193,2589,2590],{"class":233},"\"@Modifying 更新之后，同一个事务里读到的是新值\"",[193,2592,237],{"class":199},[193,2594,2596,2598,2601],{"class":195,"line":2595},57,[193,2597,2198],{"class":203},[193,2599,2600],{"class":269}," updatesStatusInPlace",[193,2602,367],{"class":199},[193,2604,2606,2609,2611,2613,2615,2617,2619,2621,2623,2625,2627,2629],{"class":195,"line":2605},58,[193,2607,2608],{"class":199},"        Book book ",[193,2610,285],{"class":203},[193,2612,1642],{"class":199},[193,2614,1937],{"class":269},[193,2616,206],{"class":199},[193,2618,2225],{"class":203},[193,2620,270],{"class":269},[193,2622,206],{"class":199},[193,2624,2320],{"class":233},[193,2626,585],{"class":199},[193,2628,2479],{"class":233},[193,2630,2235],{"class":199},[193,2632,2634],{"class":195,"line":2633},59,[193,2635,253],{"emptyLinePlaceholder":252},[193,2637,2639,2642,2645,2647,2649,2652,2654,2656],{"class":195,"line":2638},60,[193,2640,2641],{"class":203},"        int",[193,2643,2644],{"class":199}," updated ",[193,2646,285],{"class":203},[193,2648,1642],{"class":199},[193,2650,2651],{"class":269},"markStatus",[193,2653,1676],{"class":199},[193,2655,749],{"class":269},[193,2657,2658],{"class":199},"(), BookStatus.BORROWED);\n",[193,2660,2662],{"class":195,"line":2661},61,[193,2663,253],{"emptyLinePlaceholder":252},[193,2665,2667,2669,2672,2675,2677,2679],{"class":195,"line":2666},62,[193,2668,2370],{"class":269},[193,2670,2671],{"class":199},"(updated).",[193,2673,2674],{"class":269},"isEqualTo",[193,2676,206],{"class":199},[193,2678,2510],{"class":209},[193,2680,728],{"class":199},[193,2682,2684,2686,2688,2691,2693,2695,2698,2701,2703,2706],{"class":195,"line":2683},63,[193,2685,2370],{"class":269},[193,2687,2493],{"class":199},[193,2689,2690],{"class":269},"findById",[193,2692,1676],{"class":199},[193,2694,749],{"class":269},[193,2696,2697],{"class":199},"()).",[193,2699,2700],{"class":269},"orElseThrow",[193,2702,1681],{"class":199},[193,2704,2705],{"class":269},"getStatus",[193,2707,2708],{"class":199},"())\n",[193,2710,2712,2714,2716],{"class":195,"line":2711},64,[193,2713,2378],{"class":199},[193,2715,2674],{"class":269},[193,2717,2718],{"class":199},"(BookStatus.BORROWED);\n",[193,2720,2722],{"class":195,"line":2721},65,[193,2723,307],{"class":199},[193,2725,2727],{"class":195,"line":2726},66,[193,2728,841],{"class":199},[13,2730,2731,2733],{},[72,2732,2003],{}," 帮你做了这些事：",[117,2735,2736,2749,2755,2765],{},[120,2737,2738,2741,2742,175,2745,2748],{},[28,2739,2740],{},"只加载 JPA 相关的部分","（实体、Repository、",[72,2743,2744],{},"DataSource",[72,2746,2747],{},"EntityManager","），不启动整个\nWeb 环境，所以很快；",[120,2750,2751,2754],{},[28,2752,2753],{},"默认把测试包在事务里","，每个测试方法结束后回滚，测试之间互不污染，不用手动清库；",[120,2756,2757,2762,2763,860],{},[28,2758,2759,2761],{},[72,2760,1832],{}," 已包含","，所以测试里可以直接调仓储方法，不会有\n",[72,2764,1824],{},[120,2766,2767,2768,2771],{},"默认会把配置的数据源替换成内嵌库。项目里已经有 H2 依赖时开箱可用；想在测试里用别的库，\n用 ",[72,2769,2770],{},"@AutoConfigureTestDatabase(replace = Replace.NONE)"," 关掉替换。",[1620,2773,2775],{"id":2774},"_91-和-springboottest-的区别","9.1 和 @SpringBootTest 的区别",[44,2777,2778,2794],{},[47,2779,2780],{},[50,2781,2782,2785,2788,2791],{},[53,2783,2784],{},"注解",[53,2786,2787],{},"加载范围",[53,2789,2790],{},"启动速度",[53,2792,2793],{},"用在哪",[60,2795,2796,2811,2826],{},[50,2797,2798,2802,2805,2808],{},[65,2799,2800],{},[72,2801,2003],{},[65,2803,2804],{},"只 JPA 层",[65,2806,2807],{},"快",[65,2809,2810],{},"仓储查询、实体映射",[50,2812,2813,2818,2821,2823],{},[65,2814,2815],{},[72,2816,2817],{},"@WebMvcTest",[65,2819,2820],{},"只 MVC 层（Controller），外部依赖要 Mock",[65,2822,2807],{},[65,2824,2825],{},"控制器、参数校验、状态码",[50,2827,2828,2833,2836,2839],{},[65,2829,2830],{},[72,2831,2832],{},"@SpringBootTest",[65,2834,2835],{},"整个应用上下文",[65,2837,2838],{},"慢",[65,2840,2841],{},"端到端集成测试",[13,2843,2844,2845,2848],{},"原则是",[28,2846,2847],{},"用能验证问题的最小切片","：测一条派生查询有没有写对，没必要起整个应用。",[1620,2850,2852],{"id":2851},"_92-断言用-assertj","9.2 断言用 AssertJ",[13,2854,2855],{},"Spring Boot 的测试起步依赖已经包含 AssertJ，链式写法比 JUnit 自带的断言更易读：",[184,2857,2859],{"className":186,"code":2858,"language":188,"meta":189,"style":189},"assertThat(books).hasSize(2);\nassertThat(book.getTitle()).isEqualTo(\"重构\");\nassertThat(book.getTitle()).startsWith(\"重\");\nassertThat(optional).isEmpty();\nassertThat(books).extracting(Book::getAuthor).containsOnly(\"Martin Fowler\");\n",[72,2860,2861,2877,2896,2916,2928],{"__ignoreMap":189},[193,2862,2863,2866,2869,2871,2873,2875],{"class":195,"line":196},[193,2864,2865],{"class":269},"assertThat",[193,2867,2868],{"class":199},"(books).",[193,2870,2381],{"class":269},[193,2872,206],{"class":199},[193,2874,2386],{"class":209},[193,2876,728],{"class":199},[193,2878,2879,2881,2883,2886,2888,2890,2892,2894],{"class":195,"line":219},[193,2880,2865],{"class":269},[193,2882,1676],{"class":199},[193,2884,2885],{"class":269},"getTitle",[193,2887,2697],{"class":199},[193,2889,2674],{"class":269},[193,2891,206],{"class":199},[193,2893,2320],{"class":233},[193,2895,728],{"class":199},[193,2897,2898,2900,2902,2904,2906,2909,2911,2914],{"class":195,"line":240},[193,2899,2865],{"class":269},[193,2901,1676],{"class":199},[193,2903,2885],{"class":269},[193,2905,2697],{"class":199},[193,2907,2908],{"class":269},"startsWith",[193,2910,206],{"class":199},[193,2912,2913],{"class":233},"\"重\"",[193,2915,728],{"class":199},[193,2917,2918,2920,2923,2925],{"class":195,"line":249},[193,2919,2865],{"class":269},[193,2921,2922],{"class":199},"(optional).",[193,2924,2555],{"class":269},[193,2926,2927],{"class":199},"();\n",[193,2929,2930,2932,2934,2936,2938,2940,2943,2946,2948,2950],{"class":195,"line":256},[193,2931,2865],{"class":269},[193,2933,2868],{"class":199},[193,2935,2395],{"class":269},[193,2937,2398],{"class":199},[193,2939,2401],{"class":203},[193,2941,2942],{"class":199},"getAuthor).",[193,2944,2945],{"class":269},"containsOnly",[193,2947,206],{"class":199},[193,2949,2479],{"class":233},[193,2951,728],{"class":199},[13,2953,2954,2956,2957,2960],{},[72,2955,2395],{}," + ",[72,2958,2959],{},"containsExactly"," 这一组特别适合验证「查出来的集合内容对不对」。",[17,2962,2964],{"id":2963},"_10-容易踩的坑","10 容易踩的坑",[44,2966,2967,2980],{},[47,2968,2969],{},[50,2970,2971,2974,2977],{},[53,2972,2973],{},"现象",[53,2975,2976],{},"原因",[53,2978,2979],{},"处理",[60,2981,2982,2996,3011,3027,3045,3065,3080,3099,3113,3127],{},[50,2983,2984,2987,2993],{},[65,2985,2986],{},"关联改了但数据库没变",[65,2988,2989,2990,2992],{},"改的是反方（",[72,2991,137],{}," 那一侧），维护方没动",[65,2994,2995],{},"只从维护方改，或用封装方法两边一起改",[50,2997,2998,3003,3006],{},[65,2999,3000,3001],{},"两边都写 ",[72,3002,125],{},[65,3004,3005],{},"出现两个「维护方」，行为不可预期",[65,3007,3008,3009],{},"反方必须用 ",[72,3010,137],{},[50,3012,3013,3018,3021],{},[65,3014,3015,3017],{},[72,3016,137],{}," 启动报错，说找不到属性",[65,3019,3020],{},"写成了表名或列名",[65,3022,3023,3024],{},"它要的是对方的",[28,3025,3026],{},"字段名",[50,3028,3029,3034,3037],{},[65,3030,3031,3032],{},"报 ",[72,3033,1824],{},[65,3035,3036],{},"事务结束后才访问未加载的关联",[65,3038,3039,3040,389,3042,3044],{},"在事务内用 ",[72,3041,1707],{},[72,3043,1768],{}," \u002F 投影取够数据",[50,3046,3047,3050,3058],{},[65,3048,3049],{},"列表页变慢，日志里一堆同构 SQL",[65,3051,3052,3054,3055,3057],{},[72,3053,1442],{}," 默认 ",[72,3056,1608],{}," 造成的 N+1",[65,3059,3060,3061,3064],{},"显式 ",[72,3062,3063],{},"fetch = FetchType.LAZY","，按需加载",[50,3066,3067,3072,3075],{},[65,3068,3069,3071],{},[72,3070,1707],{}," 查出的条数比预期多",[65,3073,3074],{},"一对多 join 导致主实体行重复",[65,3076,3077,3078],{},"加 ",[72,3079,1760],{},[50,3081,3082,3085,3092],{},[65,3083,3084],{},"删除父实体时子实体被「顺手删了」或外键置空",[65,3086,3087,3088,3091],{},"级联与 ",[72,3089,3090],{},"orphanRemoval"," 的作用范围没想清楚",[65,3093,3094,3095,389,3097],{},"只在真正的聚合关系上用 ",[72,3096,1880],{},[72,3098,3090],{},[50,3100,3101,3104,3107],{},[65,3102,3103],{},"JSON 序列化无限递归，或输出里带一堆不该暴露的字段",[65,3105,3106],{},"直接把实体返回给客户端",[65,3108,3109,3110,3112],{},"转 DTO 输出，或用 ",[72,3111,1977],{}," 控制",[50,3114,3115,3118,3123],{},[65,3116,3117],{},"多对多表里出现重复行",[65,3119,3120,3121],{},"集合用了 ",[72,3122,1388],{},[65,3124,1381,3125],{},[72,3126,1384],{},[50,3128,3129,3132,3138],{},[65,3130,3131],{},"测试之间数据互相影响",[65,3133,3134,3135,3137],{},"自己 ",[72,3136,2225],{}," 了上下文或关掉了回滚",[65,3139,3140,3142],{},[72,3141,2003],{}," 默认回滚，别关；需要提交的场景单独处理",[17,3144,3146],{"id":3145},"_11-小结","11 小结",[117,3148,3149,3161,3171,3174,3192,3195],{},[120,3150,3151,3154,3155,3157,3158,860],{},[28,3152,3153],{},"外键在哪张表，哪边就是维护方","；维护方用 ",[72,3156,125],{},"，反方用 ",[72,3159,3160],{},"mappedBy = \"对方字段名\"",[120,3162,3163,3164,3170],{},"一对多优先",[28,3165,3166,3167,3169],{},"只写 ",[72,3168,1442],{}," 这一侧","，要遍历再考虑加集合；",[120,3172,3173],{},"集合字段初始化、不暴露 setter、用封装方法保持双向一致；",[120,3175,3176,3186,3187,389,3189,3191],{},[28,3177,3178,3180,3181,3183,3184],{},[72,3179,1442],{}," 的默认 ",[72,3182,1608],{}," 是坑，显式改成 ",[72,3185,1618],{},"，再用 ",[72,3188,1707],{},[72,3190,1768],{}," \u002F\n投影解决 N+1；",[120,3193,3194],{},"级联只在真正的父子关系上用；关系带业务字段时，用「两个一对多 + 中间实体」替掉多对多；",[120,3196,3197,3198,3202,3203,3206],{},"仓储层用 ",[28,3199,3200],{},[72,3201,2003],{},"（快、默认回滚）+ ",[28,3204,3205],{},"AssertJ"," 断言来测。",[13,3208,3209],{},"到这里，数据层能跑、能查、能测了。接下来进入 Web 层：下一篇讲 Spring MVC 的请求流程和\nThymeleaf 模板，把数据呈现在页面上。",[3211,3212,3213],"style",{},"html pre.shiki code .sVt8B, html code.shiki .sVt8B{--shiki-default:#24292E;--shiki-dark:#E1E4E8}html pre.shiki code .szBVR, html code.shiki .szBVR{--shiki-default:#D73A49;--shiki-dark:#F97583}html pre.shiki code .sj4cs, html code.shiki .sj4cs{--shiki-default:#005CC5;--shiki-dark:#79B8FF}html pre.shiki code .sZZnC, html code.shiki .sZZnC{--shiki-default:#032F62;--shiki-dark:#9ECBFF}html pre.shiki code .sJ8bj, html code.shiki .sJ8bj{--shiki-default:#6A737D;--shiki-dark:#6A737D}html pre.shiki code .sScJk, html code.shiki .sScJk{--shiki-default:#6F42C1;--shiki-dark:#B392F0}html .default .shiki span {color: var(--shiki-default);background: var(--shiki-default-bg);font-style: var(--shiki-default-font-style);font-weight: var(--shiki-default-font-weight);text-decoration: var(--shiki-default-text-decoration);}html .shiki span {color: var(--shiki-default);background: var(--shiki-default-bg);font-style: var(--shiki-default-font-style);font-weight: var(--shiki-default-font-weight);text-decoration: var(--shiki-default-text-decoration);}html .dark .shiki span {color: var(--shiki-dark);background: var(--shiki-dark-bg);font-style: var(--shiki-dark-font-style);font-weight: var(--shiki-dark-font-weight);text-decoration: var(--shiki-dark-text-decoration);}html.dark .shiki span {color: var(--shiki-dark);background: var(--shiki-dark-bg);font-style: var(--shiki-dark-font-style);font-weight: var(--shiki-dark-font-weight);text-decoration: var(--shiki-dark-text-decoration);}html pre.shiki code .s4XuR, html code.shiki .s4XuR{--shiki-default:#E36209;--shiki-dark:#FFAB70}",{"title":189,"searchDepth":240,"depth":240,"links":3215},[3216,3217,3218,3219,3220,3221,3222,3225,3226,3227,3231,3232],{"id":19,"depth":219,"text":20},{"id":34,"depth":219,"text":35},{"id":104,"depth":219,"text":105},{"id":154,"depth":219,"text":155},{"id":884,"depth":219,"text":885},{"id":1432,"depth":219,"text":1433},{"id":1545,"depth":219,"text":1546,"children":3223},[3224],{"id":1622,"depth":240,"text":1623},{"id":1836,"depth":219,"text":1837},{"id":1961,"depth":219,"text":1962},{"id":1996,"depth":219,"text":1997,"children":3228},[3229,3230],{"id":2774,"depth":240,"text":2775},{"id":2851,"depth":240,"text":2852},{"id":2963,"depth":219,"text":2964},{"id":3145,"depth":219,"text":3146},"学习",null,"2026-10-15","一对多、多对多怎么映射，谁负责维护外键，FetchType 与 CascadeType 该怎么选，顺带用 @DataJpaTest 与 AssertJ 把仓储层测起来。","md",false,{},"\u002Fposts\u002Fjavaspring\u002Fspring-notes-04",{"title":5,"description":3236},"Spring 学习笔记","posts\u002Fjavaspring\u002Fspring-notes-04",[3245,3246,3247,3248],"Spring","Spring Data JPA","测试","学习笔记","MiSFRtHxepettebzLQCrpElBY6-AjnQwQAvYyIVrHak",[3251,3257,3262,3267,3272,3277,3282,3287,3292,3297,3302,3307,3312,3317,3322,3327,3332,3337,3342,3347,3352,3357,3362,3367,3372,3377,3382,3387,3392,3396,3400,3404,3405,3409,3413,3417,3421,3425,3429,3433,3437,3443,3447,3453,3457,3461,3465,3469,3473,3477,3481,3486,3490,3494,3499,3504,3510,3514,3519,3523],{"path":3252,"title":3253,"description":3254,"date":3255,"series":3256,"seriesOrder":640},"\u002Fposts\u002Fswdesign\u002Fsw-design-notes-28","软件分析与设计笔记 28：draw.io 作图手册：五张图的符号位置、落笔顺序与自检","不模拟软件界面，只讲清三件事：每个 UML 符号在 draw.io 里搜什么词、一张图该按什么顺序落笔、画完拿什么标准自查。覆盖用例图、活动图、顺序图、类图、界面草图五种图纸，末尾附「最容易画反的十二笔」总检表。","2026-11-19","软件分析与设计笔记",{"path":3258,"title":3259,"description":3260,"date":3261,"series":3256,"seriesOrder":635},"\u002Fposts\u002Fswdesign\u002Fsw-design-notes-27","软件分析与设计笔记 27：Paper 1 应试：从读案例到交卷的一次完整演算","拿一份完整的案例，从头到尾走一遍：怎么读案例列角色、怎么在四个小问之间分配时间、每一张图从哪一句话推出来、两张表怎么填、交卷前拿什么清单自查。附 C25 真题与样卷的题型分值对照。","2026-11-18",{"path":3263,"title":3264,"description":3265,"date":3266,"series":3256,"seriesOrder":630},"\u002Fposts\u002Fswdesign\u002Fsw-design-notes-26","软件分析与设计笔记 26：总结：把整条链路串起来","系列收尾：从需求到代码的完整链路回顾、每一环的产出与验收方式、六条贯穿全系列的原则，以及一份可以直接拿去做项目自查的清单。","2026-11-17",{"path":3268,"title":3269,"description":3270,"date":3271,"series":3256,"seriesOrder":620},"\u002Fposts\u002Fswdesign\u002Fsw-design-notes-25","软件分析与设计笔记 25：代码重构：坏味道与手法","重构不改变外部行为、只改善内部结构：四种常见坏味道与对应手法、为什么动手之前必须先有测试、什么时候该重构，以及重构和前面那些设计模型是什么关系。","2026-11-16",{"path":3273,"title":3274,"description":3275,"date":3276,"series":3256,"seriesOrder":615},"\u002Fposts\u002Fswdesign\u002Fsw-design-notes-24","软件分析与设计笔记 24：设计原则：内聚、耦合与多态","好设计怎么衡量：内聚与耦合分别在说什么、一次从「差设计」到「多态重设计」的完整对照、抽象类与接口的选择依据，以及复用为什么会从优势变成风险。","2026-11-15",{"path":3278,"title":3279,"description":3280,"date":3281,"series":3256,"seriesOrder":594},"\u002Fposts\u002Fswdesign\u002Fsw-design-notes-23","软件分析与设计笔记 23：设计建模：从对象模型到能实现的方案","分析模型能用但还不能直接开工：对象模型怎么映射到关系模型、继承映射的三种方案怎么选、数据访问层与 Data Broker 解决什么问题，以及三层架构与 MVC 的分工。","2026-11-14",{"path":3283,"title":3284,"description":3285,"date":3286,"series":3256,"seriesOrder":568},"\u002Fposts\u002Fswdesign\u002Fsw-design-notes-22","软件分析与设计笔记 22：分析建模：边界、实体与控制对象","不经过分析直接写代码会踩什么坑、三种分析对象各管一段、怎么用顺序图完成一次用例实现，以及从顺序图导出类的属性、操作与关系的方法。","2026-11-13",{"path":3288,"title":3289,"description":3290,"date":3291,"series":3256,"seriesOrder":563},"\u002Fposts\u002Fswdesign\u002Fsw-design-notes-21","软件分析与设计笔记 21：UML：类图与顺序图","为什么需要一种共同的画法：类符号三格怎么画、五种关系的符号与设计含义、每种关系落到 Java 是什么写法，顺序图的三要素怎么摆；以及这三种最容易找不到的符号在 draw.io 里各搜什么词。","2026-11-12",{"path":3293,"title":3294,"description":3295,"date":3296,"series":3256,"seriesOrder":555},"\u002Fposts\u002Fswdesign\u002Fsw-design-notes-20","软件分析与设计笔记 20：面向对象概念：对象、关系与多态","面向对象不是「用某个语言写代码」：对象与类的区别、封装真正在保护什么、五种关系（依赖、关联、聚合、组合、继承）各自何时用，以及多态为什么能减少条件判断。","2026-11-11",{"path":3298,"title":3299,"description":3300,"date":3301,"series":3256,"seriesOrder":550},"\u002Fposts\u002Fswdesign\u002Fsw-design-notes-19","软件分析与设计笔记 19：规范化与逻辑数据模型","一次改地址引发的数据不一致，讲清规范化的真正动机；UNF 到 3NF 每一步在解决什么问题，派生字段为什么不能存，优化为什么和规范化方向相反，以及逻辑数据模型在 3NF 之上补了什么。","2026-11-10",{"path":3303,"title":3304,"description":3305,"date":3306,"series":3256,"seriesOrder":378},"\u002Fposts\u002Fswdesign\u002Fsw-design-notes-18","软件分析与设计笔记 18：数据库分析：实体、关系与数据字典","为什么不能拿到需求就动手建表：实体与关系怎么判定、基数为什么必须由业务方确认、数据命名要遵守什么规则，以及数据字典四列里最容易被空着的那一列。","2026-11-09",{"path":3308,"title":3309,"description":3310,"date":3311,"series":3256,"seriesOrder":370},"\u002Fposts\u002Fswdesign\u002Fsw-design-notes-17","软件分析与设计笔记 17：用生成式 AI 做设计与开发：能用的场景与要防的坑","生成式 AI 在工作流里真正站得住的三个场景 —— 造示例数据、解释陌生代码、起草初稿；它为什么会复现社会偏见、为什么会编得像真的，以及哪些内容绝不能直接采用。","2026-11-08",{"path":3313,"title":3314,"description":3315,"date":3316,"series":3256,"seriesOrder":356},"\u002Fposts\u002Fswdesign\u002Fsw-design-notes-16","软件分析与设计笔记 16：从 persona 到 URS：一份交付物的完整骨架","把前面十几篇串成一份能交出去的东西：用户需求规格说明书的九个部分、每一章来自哪个环节、由谁确认，以及面向决策者的提案该讲清哪三件事。","2026-11-07",{"path":3318,"title":3319,"description":3320,"date":3321,"series":3256,"seriesOrder":351},"\u002Fposts\u002Fswdesign\u002Fsw-design-notes-15","软件分析与设计笔记 15：非功能需求：把「快、稳、安全」变成数字","为什么「系统要快」不是一条需求：非功能需求怎么量化，决定性能的用户量、交易量、数据量三张表怎么估，安全需求要写清什么，以及可靠性的「不可用」该怎么定义。","2026-11-06",{"path":3323,"title":3324,"description":3325,"date":3326,"series":3256,"seriesOrder":346},"\u002Fposts\u002Fswdesign\u002Fsw-design-notes-14","软件分析与设计笔记 14：软件测试：层级、方法与等价类划分","「我们测过了」这句话问题在哪：为什么静态评审和动态测试缺一不可，五个测试层级各自验证什么，白盒与黑盒的差别，以及用等价类划分和边界值把无限输入变成有限用例的方法。","2026-11-05",{"path":3328,"title":3329,"description":3330,"date":3331,"series":3256,"seriesOrder":335},"\u002Fposts\u002Fswdesign\u002Fsw-design-notes-13","软件分析与设计笔记 13：需求获取：五种调查方法与一场访谈","需求从哪里来：五种事实调查方法各自适合回答什么、代价是什么，访谈从准备到记录的全过程，问卷设计里最容易犯的错，以及访谈记录必须写下来的那几项信息。","2026-11-04",{"path":3333,"title":3334,"description":3335,"date":3336,"series":3256,"seriesOrder":321},"\u002Fposts\u002Fswdesign\u002Fsw-design-notes-12","软件分析与设计笔记 12：前端设计：把流程变成屏幕","界面设计不是「画得好看」：从活动图走到屏幕的三步，CRUD 任务流怎么当检查表，界面必须回答的三个问题，信息架构与五种导航类型，视觉一致性与留白，以及移动端与 Web 端的差别。","2026-11-03",{"path":3338,"title":3339,"description":3340,"date":3341,"series":3256,"seriesOrder":315},"\u002Fposts\u002Fswdesign\u002Fsw-design-notes-11","软件分析与设计笔记 11：Agentic AI 与业务流程自动化","AI agent 到底是什么：五项能力与四个核心组件，什么场景该用定时任务、什么该用程序、什么才值得用 agent，两种实现路径的差别，必须提前防住的四类失败模式，以及设计方法从提示词到编排的演进。","2026-11-02",{"path":3343,"title":3344,"description":3345,"date":3346,"series":3256,"seriesOrder":310},"\u002Fposts\u002Fswdesign\u002Fsw-design-notes-10","软件分析与设计笔记 10：精炼用例模型：include、extend 与泛化","三种精炼用例关系的判断标准：include 什么时候用、它和 extend 的区别到底在哪、参与者泛化与用例泛化各解决什么问题，以及精炼到什么程度就该停手。","2026-11-01",{"path":3348,"title":3349,"description":3350,"date":3351,"series":3256,"seriesOrder":304},"\u002Fposts\u002Fswdesign\u002Fsw-design-notes-09","软件分析与设计笔记 09：功能需求（二）：从流程到业务实体","活动图画完之后，怎么从流程里筛出真正的业务实体：三条判断标准、哪些名词其实不该建实体、属性从哪里来，以及领域模型和后面的数据模型分别负责什么。","2026-10-31",{"path":3353,"title":3354,"description":3355,"date":3356,"series":3256,"seriesOrder":291},"\u002Fposts\u002Fswdesign\u002Fsw-design-notes-08","软件分析与设计笔记 08：功能需求（一）：用例模型与活动图","从用户故事到用例的翻译过程：什么才算「好」的参与者，系统边界怎么划，用例描述必须写清哪四件事，活动图怎么把分支、并行与泳道画清楚，以及这两个图在 draw.io 里各用哪个形状、最容易画错哪一笔。","2026-10-30",{"path":3358,"title":3359,"description":3360,"date":3361,"series":3256,"seriesOrder":276},"\u002Fposts\u002Fswdesign\u002Fsw-design-notes-07","软件分析与设计笔记 07：用户故事：把需求写成可验收的一句话","用户故事的三段结构，怎么从用户画像和数据地图里把它提取出来，一条故事多大算合适、时序为什么也是需求的一部分，以及验收标准怎么写才算真的可验收。","2026-10-29",{"path":3363,"title":3364,"description":3365,"date":3366,"series":3256,"seriesOrder":263},"\u002Fposts\u002Fswdesign\u002Fsw-design-notes-06","软件分析与设计笔记 06：范围界定（数据侧）：用数据校验你的判断","访谈结论和数字对不上时该怎么办：分析的四个层次分别在回答什么问题、为什么不能跳级、数据准备与搜集怎么做成一张应用地图，以及数据最容易在哪几个地方骗你。","2026-10-28",{"path":3368,"title":3369,"description":3370,"date":3371,"series":3256,"seriesOrder":256},"\u002Fposts\u002Fswdesign\u002Fsw-design-notes-05","软件分析与设计笔记 05：范围界定（用户侧）：用户画像与客户旅程图","为什么「所有用户」是一个陷阱，项目边界该怎么划，用户画像要写哪五个要素、最容易写成什么废纸，客户旅程图怎么把阶段、想法和情绪画在同一张纸上，以及怎么从这两样东西一步步推到可验证的需求。","2026-10-27",{"path":3373,"title":3374,"description":3375,"date":3376,"series":3256,"seriesOrder":249},"\u002Fposts\u002Fswdesign\u002Fsw-design-notes-04","软件分析与设计笔记 04：接手陌生案例的第一步：三问与一张表","拿到一个完全陌生的业务案例，怎么从零开始不跑偏：只问 Who、What、Why 三个问题，把「问题—事实—假设—理由」记成一张四列表，说清为什么假设必须写明理由，以及带着问题进访谈为什么比空手去有效得多。","2026-10-26",{"path":3378,"title":3379,"description":3380,"date":3381,"series":3256,"seriesOrder":240},"\u002Fposts\u002Fswdesign\u002Fsw-design-notes-03","软件分析与设计笔记 03：Agile 与 Scrum：迭代、角色与产出物","从「三周做完交付，客户说这不是我要的」讲起，说清瀑布式与迭代式的根本差别，Scrum 的三个角色和三种产出物，一个 sprint 里四次固定活动各自在解决什么问题，以及敏捷里最常见的几个误解。","2026-10-25",{"path":3383,"title":3384,"description":3385,"date":3386,"series":3256,"seriesOrder":219},"\u002Fposts\u002Fswdesign\u002Fsw-design-notes-02","软件分析与设计笔记 02：设计思维、用户驱动与数据驱动","从「客服要一个能一次看完所有排期的界面」这个翻车案例讲起，说清设计思维到底是什么、用户驱动与数据驱动各自能看见什么又看不见什么、为什么真正的落点是把两者合起来的 data-informed design，以及访谈中怎么问出有用的信息。","2026-10-24",{"path":3388,"title":3389,"description":3390,"date":3391,"series":3256,"seriesOrder":196},"\u002Fposts\u002Fswdesign\u002Fsw-design-notes-01","软件分析与设计笔记 01：课程地图与从需求到设计的主干","这一篇不写具体方法，先把整条链路铺开：软件分析与设计到底要解决什么问题，从需求到设计分成哪两段，中间有哪十几个环节，一份完整的交付物里应该有什么，以及后面 27 篇笔记怎么排、新人该按什么顺序读。","2026-10-23",{"path":3393,"title":3394,"description":3395,"date":3235,"series":3242,"seriesOrder":196},"\u002Fposts\u002Fjavaspring\u002Fspring-notes-01","Spring 学习笔记 01：IoC、依赖注入与第一个 Spring Boot 应用","从「手写 new 出来的三层结构」的痛点讲起，说清 IoC 容器、Bean 与依赖注入是怎么回事，再用 Maven 与 Spring Initializr 跑起第一个能返回 JSON 的应用。",{"path":3397,"title":3398,"description":3399,"date":3235,"series":3242,"seriesOrder":219},"\u002Fposts\u002Fjavaspring\u002Fspring-notes-02","Spring 学习笔记 02：Spring Data JPA 基础——实体映射与 Repository","用 ORM 把类映射到表：@Entity、@Id、@GeneratedValue 怎么用，CrudRepository 三层接口怎么选，H2 与 MySQL 怎么配，以及 ddl-auto 到底该写哪个值。",{"path":3401,"title":3402,"description":3403,"date":3235,"series":3242,"seriesOrder":240},"\u002Fposts\u002Fjavaspring\u002Fspring-notes-03","Spring 学习笔记 03：JPA 查询——派生方法、JPQL 与分页排序","Repository 自带的方法只够按主键查，这篇讲三种自己造查询的方式：方法名派生、@Query 写 JPQL、原生 SQL，以及 Pageable 分页排序和投影返回。",{"path":3240,"title":5,"description":3236,"date":3235,"series":3242,"seriesOrder":249},{"path":3406,"title":3407,"description":3408,"date":3235,"series":3242,"seriesOrder":256},"\u002Fposts\u002Fjavaspring\u002Fspring-notes-05","Spring 学习笔记 05：Spring MVC 与 Thymeleaf 入门","一次页面请求在 Spring MVC 里走了哪几步，@Controller 怎么把数据交给模板，@PathVariable 与 @RequestParam 怎么取参数，以及控制器为什么必须是无状态的。",{"path":3410,"title":3411,"description":3412,"date":3235,"series":3242,"seriesOrder":263},"\u002Fposts\u002Fjavaspring\u002Fspring-notes-06","Spring 学习笔记 06：表单绑定与服务端校验","把表单字段绑到对象上，用 Bean Validation 注解做服务端校验，把错误信息显示回用户刚填的那一页，再补上自定义校验注解与全局异常处理。",{"path":3414,"title":3415,"description":3416,"date":3235,"series":3242,"seriesOrder":276},"\u002Fposts\u002Fjavaspring\u002Fspring-notes-07","Spring 学习笔记 07：Thymeleaf 片段复用与国际化","用 th:fragment 与 th:insert\u002Fth:replace 把导航栏、页脚抽成片段，页面套用统一布局，再用 messages.properties 与 LocaleResolver 做中英文切换。",{"path":3418,"title":3419,"description":3420,"date":3235,"series":3242,"seriesOrder":291},"\u002Fposts\u002Fjavaspring\u002Fspring-notes-08","Spring 学习笔记 08：HTTP Session 与会话状态管理","HTTP 无状态带来的问题，Cookie 与 Session 怎么分工，Spring 里读写会话的几种方式，以及会话固定、超时、多实例共享这些必须想清楚的事。",{"path":3422,"title":3423,"description":3424,"date":3235,"series":3242,"seriesOrder":304},"\u002Fposts\u002Fjavaspring\u002Fspring-notes-09","Spring 学习笔记 09：拦截器 HandlerInterceptor","登录检查、权限判断、请求耗时统计这类横切逻辑该放哪：HandlerInterceptor 的三个回调各在什么时机执行，怎么注册与排除路径，以及它和过滤器的分工。",{"path":3426,"title":3427,"description":3428,"date":3235,"series":3242,"seriesOrder":310},"\u002Fposts\u002Fjavaspring\u002Fspring-notes-10","Spring 学习笔记 10：Service 层与事务","一组数据库操作要么全成功要么全失败，靠 @Transactional 怎么做到；传播行为、隔离级别、回滚规则怎么选，以及自调用失效、乐观锁悲观锁这些实战里的坑。",{"path":3430,"title":3431,"description":3432,"date":3235,"series":3242,"seriesOrder":315},"\u002Fposts\u002Fjavaspring\u002Fspring-notes-11","Spring 学习笔记 11：REST API 基础——把 Service 暴露成接口","同一套 Service 怎么同时服务页面和客户端：@RestController 的路由与方法语义、状态码怎么给、DTO 怎么定，以及 MockMvc 端到端和切片测试的两种写法与各自的坑。",{"path":3434,"title":3435,"description":3436,"date":3235,"series":3242,"seriesOrder":321},"\u002Fposts\u002Fjavaspring\u002Fspring-notes-12","Spring 学习笔记 12：REST 进阶——版本、更新、跨域与文档化","接口能跑通之后要处理的几件事：URL 与响应结构怎么定、版本怎么演进、更新怎么做到可安全重试、CORS 怎么配、HATEOAS 与 Spring Data REST 该不该用。",{"path":3438,"title":3439,"description":3440,"date":3441,"series":3442,"seriesOrder":249},"\u002Fposts\u002Fjava\u002Fjava-functionalprogramming-notes","Java 函数式编程笔记：Lambda、函数式接口与 Streams","学习 Java 函数式编程时的个人笔记：Lambda 表达式的五步简写与方法引用、常见函数式接口、Stream 流水线与惰性求值，以及创建流、中间操作、终止操作与收集器的用法。示例代码均为自写，运行结果来自本地实测。","2026-10-13","Java 学习笔记",{"path":3444,"title":3445,"description":3446,"date":3441,"series":3442,"seriesOrder":240},"\u002Fposts\u002Fjava\u002Fjava-generics-notes","Java 泛型笔记：泛型方法、有界类型与泛型类","学习 Java 泛型时的个人笔记：从重复的重载方法与多态尝试讲起，再到泛型方法与类型推断、有界泛型类型、Comparable 与 Number 边界，以及泛型类在集合中的用法。示例代码均为自写，运行结果来自本地实测。",{"path":3448,"title":3449,"description":3450,"date":3451,"series":3452,"seriesOrder":249},"\u002Fposts\u002Fmysql\u002Fmysql-learning-04","MySQL 学习笔记 04：数据操纵（增删改）","MySQL 学习笔记第四篇：INSERT 插入（含批量插入与 INSERT ... SELECT）、UPDATE 更新、DELETE 删除，以及各自的坑。","2026-10-10","SQL 学习笔记",{"path":3454,"title":3455,"description":3456,"date":3451,"series":3452,"seriesOrder":256},"\u002Fposts\u002Fmysql\u002Fmysql-learning-05","MySQL 学习笔记 05：DDL 概览","MySQL 学习笔记第五篇：数据定义语言概览——Schema、Table、View、Index、Constraints 这几类对象分别对应哪些 CREATE \u002F ALTER \u002F DROP 命令。",{"path":3458,"title":3459,"description":3460,"date":3451,"series":3452,"seriesOrder":263},"\u002Fposts\u002Fmysql\u002Fmysql-learning-06","MySQL 学习笔记 06：CREATE TABLE 与数据类型","MySQL 学习笔记第六篇：CREATE TABLE 的完整语法、常用数据类型的选型建议，以及外键的两种定义方式。",{"path":3462,"title":3463,"description":3464,"date":3451,"series":3452,"seriesOrder":276},"\u002Fposts\u002Fmysql\u002Fmysql-learning-07","MySQL 学习笔记 07：索引","MySQL 学习笔记第七篇：索引的作用与 B+ 树结构、单列与复合索引的创建、索引的代价与适用场景。",{"path":3466,"title":3467,"description":3468,"date":3451,"series":3452,"seriesOrder":291},"\u002Fposts\u002Fmysql\u002Fmysql-learning-08","MySQL 学习笔记 08：DROP 与 ALTER","MySQL 学习笔记第八篇：DROP 删除数据库对象、ALTER 修改表结构，以及常见的坑。",{"path":3470,"title":3471,"description":3472,"date":3451,"series":3452,"seriesOrder":304},"\u002Fposts\u002Fmysql\u002Fmysql-learning-09","MySQL 学习笔记 09：约束与数据完整性","MySQL 学习笔记第九篇：SQL 约束——NOT NULL、CHECK、PRIMARY KEY、FOREIGN KEY 等如何保证数据完整性，以及它们的对比。",{"path":3474,"title":3475,"description":3476,"date":3451,"series":3452,"seriesOrder":310},"\u002Fposts\u002Fmysql\u002Fmysql-learning-10","MySQL 学习笔记 10：视图","MySQL 学习笔记第十篇：视图（Views）的定义与特性、和表的对比、创建与使用，以及可更新视图的限制。",{"path":3478,"title":3479,"description":3480,"date":3451,"series":3452,"seriesOrder":315},"\u002Fposts\u002Fmysql\u002Fmysql-learning-11","MySQL 学习笔记 11：存储过程","MySQL 学习笔记第十一篇：存储过程（Stored Procedures）的概念、创建与调用、带参数的写法，以及优缺点。",{"path":3482,"title":3483,"description":3484,"date":3485,"series":3452,"seriesOrder":196},"\u002Fposts\u002Fmysql\u002Fmysql-learning-01","MySQL 学习笔记 01：MySQL 基础知识","MySQL 学习笔记第一篇：MySQL Server 与 Workbench 的安装，数据库与 DBMS 的基本概念，关系模型、ACID 事务、常见数据类型，以及后续的学习路线。","2026-10-09",{"path":3487,"title":3488,"description":3489,"date":3485,"series":3452,"seriesOrder":219},"\u002Fposts\u002Fmysql\u002Fmysql-learning-02","MySQL 学习笔记 02：SELECT 查询与过滤","MySQL 学习笔记第二篇：SELECT 的基本语法与逻辑执行顺序，WHERE 过滤（比较、LIKE、IN、BETWEEN、NULL）、各种 JOIN、UNION 与子查询。",{"path":3491,"title":3492,"description":3493,"date":3485,"series":3452,"seriesOrder":240},"\u002Fposts\u002Fmysql\u002Fmysql-learning-03","MySQL 学习笔记 03：聚合、分组与输出控制","MySQL 学习笔记第三篇：COUNT\u002FSUM\u002FAVG 等聚合函数、GROUP BY 分组、HAVING 过滤、ORDER BY 与 LIMIT，以及一条 SELECT 的完整逻辑执行顺序。",{"path":3495,"title":3496,"description":3497,"date":3498,"series":3442,"seriesOrder":219},"\u002Fposts\u002Fjava\u002Fjava-oop-notes","Java 面向对象笔记：类、继承、多态与异常","学习 Java 面向对象编程时的个人笔记：类与对象、封装与访问修饰符、继承与多态、集合、抽象类与接口、异常处理。示例代码均为自写，运行结果来自本地实测。","2026-10-06",{"path":3500,"title":3501,"description":3502,"date":3503,"series":3442,"seriesOrder":196},"\u002Fposts\u002Fjava\u002Fjava-basics-notes","Java 基础语法笔记：从环境安装到方法与数组","学习 Java 基础语法时的个人笔记：JDK 安装、输入输出、变量与运算符、条件与循环、字符串、数组与方法。示例代码均为自写，运行结果来自本地实测。","2026-10-05",{"path":3505,"title":3506,"description":3507,"date":3508,"series":3509,"seriesOrder":196},"\u002Fposts\u002Fgit\u002Fgit-install-and-basics","Git 安装与基本使用","从装好 Git 到跑通日常提交流程，把最常用的几条命令和它们背后的三个区域讲清楚。","2026-10-04","Git 入门",{"path":3511,"title":3512,"description":3513,"date":3508,"series":3509,"seriesOrder":219},"\u002Fposts\u002Fgit\u002Flocal-remote-repository","本地远程仓库的部署","用自己的机器当 Git 服务器：建一个裸仓库、走 SSH 连上去，把本地提交推上去。",{"path":3515,"title":3516,"description":3517,"date":3508,"series":3518,"seriesOrder":196},"\u002Fposts\u002Fguide\u002Fguide-file-layout","文件都放在哪","文章、图片、封面分别放在仓库的什么位置，哪些目录可以放手改、哪些碰了会出问题。","博客使用指南",{"path":3520,"title":3521,"description":3522,"date":3508,"series":3518,"seriesOrder":219},"\u002Fposts\u002Fguide\u002Fguide-frontmatter","frontmatter 每个字段是什么意思","文章开头那段被横线包住的字段，逐个说明作用、默认行为和写错的后果。",{"path":3524,"title":3525,"description":3526,"date":3508,"series":3518,"seriesOrder":240},"\u002Fposts\u002Fguide\u002Fguide-markdown","正文怎么写 —— Markdown 与排版效果","标题、链接、图片、代码、表格分别怎么写，以及本站对它们做了哪些自定义处理。",1793268152668]