[{"categories":[],"content":"建站原因 建立此站，評估很多時間\n面對程式領域，有如一葉小舟飄盪在汪洋大海\n許多的知識與工具，過眼雲煙\n不僅如此，隨著時間的進展，技術的進步速度，真的讓人一日三秋，一不留神，恍如隔世\n憑著自己對著程式的熱情，也為了能夠與他人分享，能夠多交流\n最終決定利用 Blog 來收納自己的學習\n內容大多是蒐集而來，會盡量附上參考，同時增加一些自己的看法\n本就拋磚引玉，歡迎大家利用底下的評論，有想法就一起討論\n最後，各位看官與大佬們，多多指教\n願在程式的世界中，你我一同努力\n","permalink":"http://blog.codeicu.dev/about/origin/","tags":[],"title":"關於這裡"},{"categories":[],"content":"關注點 實作登入登出 API，包含 session 與 JWT 驗證 使用 spring security 加入相關依賴 spring-security-web： Web 安全功能，包含過濾器、攔截器與安全上下文管理等。 spring-security-config：允許以程式化方式定義安全規則與策略。 spring-security-core：提供核心安全功能，如認證、授權與密碼編碼等。 nimbus-jose-jwt：提供 JWT 的生成與驗證功能。 \u0026lt;dependency\u0026gt; \u0026lt;groupId\u0026gt;org.springframework.security\u0026lt;/groupId\u0026gt; \u0026lt;artifactId\u0026gt;spring-security-web\u0026lt;/artifactId\u0026gt; \u0026lt;version\u0026gt;7.0.3\u0026lt;/version\u0026gt; \u0026lt;/dependency\u0026gt; \u0026lt;dependency\u0026gt; \u0026lt;groupId\u0026gt;org.springframework.security\u0026lt;/groupId\u0026gt; \u0026lt;artifactId\u0026gt;spring-security-config\u0026lt;/artifactId\u0026gt; \u0026lt;version\u0026gt;7.0.3\u0026lt;/version\u0026gt; \u0026lt;/dependency\u0026gt; \u0026lt;dependency\u0026gt; \u0026lt;groupId\u0026gt;org.springframework.security\u0026lt;/groupId\u0026gt; \u0026lt;artifactId\u0026gt;spring-security-core\u0026lt;/artifactId\u0026gt; \u0026lt;version\u0026gt;7.0.3\u0026lt;/version\u0026gt; \u0026lt;/dependency\u0026gt; \u0026lt;dependency\u0026gt; \u0026lt;groupId\u0026gt;com.nimbusds\u0026lt;/groupId\u0026gt; \u0026lt;artifactId\u0026gt;nimbus-jose-jwt\u0026lt;/artifactId\u0026gt; \u0026lt;version\u0026gt;10.8\u0026lt;/version\u0026gt; \u0026lt;/dependency\u0026gt; 先瞭解發生什麼 我們登入做複合驗證(Composite Authentication)模式，同時支援傳統 Web 的 Session 機制與雙權杖(Double Tokens: Access Token \u0026amp; Refresh Token)：\n登入驗證與權杖發放(Login \u0026amp; Issue Tokens) 提交請求：使用者透過 POST /api/auth/login 提交帳號密碼。\n身分驗證：後端使用 AuthService 進行 BCrypt 密碼比對與資料庫查詢。\n核發權杖：\nAccess Token(AT)：一段短效期的 JWT，用於後續 API 請求授權。Refresh Token(RT)：一個隨機 UUID，儲存於資料庫中(效期通常為 7 天)，用於核發新的 Access Token。\n前端儲存：前端收到 200 OK 回應後，需將 accessToken 與 refreshToken 儲存於客戶端，後端 Set-Cookie 將 JSESSIONID 傳回給瀏覽器。\n一般授權操作 (JWT Validation) Spring security 的基礎設定外，還需實作一個 JWT 驗證\n攔截請求：JwtAuthenticationFilter (繼承自 OncePerRequestFilter) 攔截帶有 Bearer 標頭的請求。 解析驗證：過濾器解析 JWT 的簽章與有效期限。 上下文設定：驗證成功後，透過 SecurityContextHolder.getContext().setAuthentication() 將使用者資訊(email、權限)存入 SecurityContext 中。 抵達 Controller：請求通過過濾器後，業務邏輯即可從 SecurityContext 獲取當前使用者資訊。 權杖輪換機制 (Refresh Token Rotation) 為了提高安全性，系統實作了「權杖輪換」機制，防止 Refresh Token 被盜用：\nAT 過期：當 Access Token (AT-1) 過期時，API 回傳 401 Unauthorized。 要求刷新：前端發送 POST /api/auth/refresh 並攜帶 refreshToken (RT-1)。 安全性輪換： 後端驗證 RT-1 是否存在於資料庫且未過期。關鍵步驟：舊的 RT-1 會立即失效，系統產生全新的 RT-2 覆蓋資料庫中的舊值。 更新權杖：後端回傳新的 { AT-2, RT-2 }，前端必須同步更新本地儲存的權杖。 登出流程 登出操作需確保伺服器與客戶端狀態同步清除：\n提交登出：使用者點擊登出，發送 POST /api/auth/logout。 徹底清除：Session：執行 session.invalidate()。資料庫：將該使用者的 refreshToken 欄位設為 NULL，使所有舊權杖失效。SecurityContext：執行 SecurityContextHolder.clearContext()。 前端清理：前端收到成功回應後，必須主動刪除本地儲存的所有權杖資訊。 核心程式碼 SecurityConfig.java\nSecurityConfig 是整個 Spring Security 的核心設定中樞。\n在這個類別中，我們透過註冊不同的 Bean 來設定安全策略\nAuthenticationManager：負責處理認證請求，後續會在 AuthService 中被呼叫以驗證帳號密碼。\nUserDetailsService：自訂使用者資訊來源。這裡實作了 Lambda 表達式，從 UserRepository 透過 email 查詢使用者，並將其轉換為 Spring Security 內建的 User 物件，供框架進行密碼比對。\nSecurityFilterChain：設定 HTTP 請求的安全過濾規則。包含：\nCORS 與 CSRF：啟用跨域資源共用 (CORS)。考量到我們同時保留了 Session 機制，CSRF 防護未完全關閉，而是針對特定的無狀態 API(如 /api/auth/login)忽略 CSRF 檢查。\nSession 策略：設定為 SessionCreationPolicy.ALWAYS 以配合複合驗證模式。\n例外處理：覆寫了預設的 AuthenticationEntryPoint，確保未經授權的請求 (401) 會收到 JSON 格式的回應， 而非預設的 HTML 登入頁 面，這對前端串接非常重要。\n路徑授權：設定哪些 API 需要驗證(如 /api/admin/)，哪些可以直接放行(如：/api/auth/login**)。\n過濾器順序：透過 addFilterBefore 將自訂的 JwtAuthenticationFilter 插入至原生的 UsernamePasswordAuthenticationFilter 之前，確保 JWT 驗證能優先執行。\nPasswordEncoder：指定使用 BCryptPasswordEncoder 作為密碼雜湊演算法。\n@Configuration @EnableWebSecurity public class SecurityConfig { private final JwtAuthenticationFilter jwtAuthenticationFilter; private final UserRepository userRepository; public SecurityConfig(JwtAuthenticationFilter jwtAuthenticationFilter, UserRepository userRepository) { this.jwtAuthenticationFilter = jwtAuthenticationFilter; this.userRepository = userRepository; } @Bean public AuthenticationManager authenticationManager(AuthenticationConfiguration config) throws Exception { return config.getAuthenticationManager(); } @Bean public UserDetailsService userDetailsService() { return email -\u0026gt; userRepository.findByEmail(email) .map(user -\u0026gt; new org.springframework.security.core.userdetails.User( user.getEmail(), user.getPassword(), Collections.emptyList())) .orElseThrow(() -\u0026gt; new UsernameNotFoundException(\u0026#34;User not found: \u0026#34; + email)); } @Bean public SecurityFilterChain securityFilterChain(HttpSecurity http) throws Exception { CsrfTokenRequestAttributeHandler requestHandler = new CsrfTokenRequestAttributeHandler(); requestHandler.setCsrfRequestAttributeName(null); http.csrf(csrf -\u0026gt; csrf.ignoringRequestMatchers(\u0026#34;/api/auth/login\u0026#34;, \u0026#34;/api/auth/refresh\u0026#34;) .csrfTokenRepository(CookieCsrfTokenRepository.withHttpOnlyFalse()) .csrfTokenRequestHandler(requestHandler)) .sessionManagement(session -\u0026gt; session.sessionCreationPolicy(SessionCreationPolicy.ALWAYS)) .authorizeHttpRequests(auth -\u0026gt; auth .requestMatchers(\u0026#34;/api/auth/login\u0026#34;, \u0026#34;/api/auth/refresh\u0026#34;).permitAll() .requestMatchers(\u0026#34;/api/auth/logout\u0026#34;).authenticated() .requestMatchers(\u0026#34;/api/status/**\u0026#34;).permitAll() .requestMatchers(\u0026#34;/api/admin/**\u0026#34;).authenticated() .anyRequest().authenticated()) .addFilterBefore(jwtAuthenticationFilter, UsernamePasswordAuthenticationFilter.class); return http.build(); } @Bean public PasswordEncoder passwordEncoder() { return new BCryptPasswordEncoder(); } } JwtAuthenticationFilter.java\n此過濾器負責在每個 HTTP 請求抵達 Controller 之前，檢查並驗證 JWT。\n繼承 OncePerRequestFilter 確保該過濾器在單次請求的生命週期中只會被執行一次。\nSession 優先檢查：為了支援複合驗證，doFilterInternal 第一步會先檢查 SecurityContext 是否已有已認證的 Session。若有，則直接放行，避免重複驗證。\n提取與解析 Token：若無 Session，則從 HTTP 標頭 Authorization 提取以 Bearer 開頭的 Token。\n建立授權上下文：將提取出的 Token 交由 JwtUtil 解析。若解析出合法的 email，代表 Token 有效，便建立一個 UsernamePasswordAuthenticationToken 物件，並將其存入 SecurityContextHolder。\n這樣一來，後續的程式碼（包含 Controller）就能直接取得當前操作者的身分。\n@Component public class JwtAuthenticationFilter extends OncePerRequestFilter { private static final int BEARER_SPLIT_INDEX = 7; private final JwtUtil jwtUtil; public JwtAuthenticationFilter(JwtUtil jwtUtil) { this.jwtUtil = jwtUtil; } @Override protected void doFilterInternal(HttpServletRequest request, HttpServletResponse response, FilterChain filterChain) throws ServletException, IOException { if (isSessionAuthenticated()) { filterChain.doFilter(request, response); return; } String token = resolveRequest(request); if (token == null) { filterChain.doFilter(request, response); return; } String email = jwtUtil.getValueFromToken(token, \u0026#34;email\u0026#34;); if (email != null) { UsernamePasswordAuthenticationToken authentication = new UsernamePasswordAuthenticationToken( email, null, Collections.emptyList()); authentication.setDetails(new WebAuthenticationDetailsSource().buildDetails(request)); SecurityContextHolder.getContext().setAuthentication(authentication); } filterChain.doFilter(request, response); } private boolean isSessionAuthenticated() { return SecurityContextHolder.getContext().getAuthentication() != null \u0026amp;\u0026amp; SecurityContextHolder.getContext().getAuthentication().isAuthenticated(); } private String resolveRequest(HttpServletRequest request) { String authHeader = request.getHeader(\u0026#34;Authorization\u0026#34;); if (authHeader == null || !authHeader.startsWith(\u0026#34;Bearer \u0026#34;)) { return null; } try { return authHeader.substring(BEARER_SPLIT_INDEX); } catch (Exception e) { throw new RuntimeException(\u0026#34;Error extracting JWT from request\u0026#34;, e); } } } JwtUtil.java\n這個元件封裝了 nimbus-jose-jwt 套件的操作，專注於 JWT 的生成與驗證邏輯。\n密鑰管理：透過 Environment 從 application.properties 讀取 jwt.secret。為確保安全性，採用 HS256 演算法時，密鑰長度至少需要 32 個字元。\n生成 Token (generateToken)：建立 JWTClaimsSet，設定 Subject (使用者信箱)、自訂 Claim (email)、發行者 (issuer) 與過期時間。最後使用 MACSigner 進行簽名並序列化為字串。\n解析與驗證 Token (getValueFromToken)：使用 MACVerifier 驗證 Token 簽章是否遭到篡改。驗證通過後，進一步檢查 ExpirationTime 是否已過期。若一切合法，才回傳指定的 Claim 值。\n@Component @PropertySource(\u0026#34;classpath:application.properties\u0026#34;) public class JwtUtil { final static int ACCESS_TOKEN_EXPIRATION_MINUTES = 30; @Autowired private Environment env; private final long expirationTime = TimeUnit.MINUTES.toMillis(ACCESS_TOKEN_EXPIRATION_MINUTES); public String generateToken(String email) { try { JWSSigner signer = new MACSigner(getSecret().getBytes()); JWTClaimsSet claimsSet = new JWTClaimsSet.Builder() .subject(email) .claim(\u0026#34;email\u0026#34;, email) .issuer(\u0026#34;sparrow\u0026#34;) .expirationTime(new Date(System.currentTimeMillis() + expirationTime)) .build(); SignedJWT signedJWT = new SignedJWT(new JWSHeader(JWSAlgorithm.HS256), claimsSet); signedJWT.sign(signer); return signedJWT.serialize(); } catch (JOSEException e) { throw new IllegalStateException(\u0026#34;Error generating JWT\u0026#34;, e); } } public String getValueFromToken(String token, String claimKey) { try { SignedJWT signedJWT = SignedJWT.parse(token); validateTokenSignature(signedJWT); JWTClaimsSet claimsSet = signedJWT.getJWTClaimsSet(); Date expiration = claimsSet.getExpirationTime(); validateTokenExpiration(expiration); return claimsSet.getStringClaim(claimKey); } catch (Exception e) { throw new BadCredentialsException(\u0026#34;Error extracting claim value from JWT\u0026#34;, e); } } private String getSecret() { String secret = env.getProperty(\u0026#34;jwt.secret\u0026#34;); if (secret == null || secret.length() \u0026lt; 32) { return \u0026#34;default-secret-at-least-32-chars-long!\u0026#34;; } return secret; } private void validateTokenSignature(SignedJWT signedJWT) throws BadCredentialsException { try { JWSVerifier verifier = new MACVerifier(getSecret().getBytes()); if (!signedJWT.verify(verifier)) { throw new BadCredentialsException(\u0026#34;Invalid JWT signature\u0026#34;); } } catch (JOSEException e) { throw new BadCredentialsException(\u0026#34;Error validating JWT signature\u0026#34;, e); } } private void validateTokenExpiration(Date expiration) throws BadCredentialsException { try { if (expiration != null \u0026amp;\u0026amp; expiration.before(new Date())) { throw new BadCredentialsException(\u0026#34;JWT token has expired\u0026#34;); } } catch (Exception e) { throw new BadCredentialsException(\u0026#34;Error validating JWT expiration\u0026#34;, e); } } } AuthController.java\n對外開放的 API 入口，負責接收前端請求並轉交業務層次 (AuthService) 處理：\n登入 (/login)：接收帳號密碼，先觸發認證邏輯，成功後呼叫 Service 核發包含 Access Token 與 Refresh Token 的回應。\n刷新權杖 (/refresh)：當 Access Token 過期時，前端會攜帶舊的 Refresh Token 呼叫此端點，換取一組全新的權杖。\n登出 (/logout)：實作完整的清理機制。包含：\n將原有的 HTTP Session 無效化 (session.invalidate())。\n從 SecurityContext 中取得當前使用者，並清除資料庫中該使用者的 Refresh Token，確保舊權杖無法再被使用。\n清空伺服器端的 SecurityContext。\n@RestController @RequestMapping(\u0026#34;/api/auth\u0026#34;) public class AuthController { private final AuthService authService; public AuthController(AuthService authService) { this.authService = authService; } @PostMapping(\u0026#34;/login\u0026#34;) public AuthResponse login(@RequestBody LoginRequest loginRequest) { authService.authenticate(loginRequest.getEmail(), loginRequest.getPassword()); return authService.createAuthResponse(loginRequest.getEmail()); } @PostMapping(\u0026#34;/refresh\u0026#34;) public AuthResponse refresh(@RequestBody AuthResponse refreshRequest) { return authService.refreshToken(refreshRequest.getRefreshToken()); } @PostMapping(\u0026#34;/logout\u0026#34;) public AuthResponse logout(HttpServletRequest request) { HttpSession session = request.getSession(false); if (session != null) { session.invalidate(); } Authentication authentication = SecurityContextHolder.getContext().getAuthentication(); if (authentication != null \u0026amp;\u0026amp; authentication.isAuthenticated()) { authService.revokeUserToken(authentication.getName()); } SecurityContextHolder.clearContext(); AuthResponse response = new AuthResponse(); response.setMessage(\u0026#34;Logout successful\u0026#34;); return response; } } AuthService.java\n處理實際的認證與權杖派發邏輯，並與資料庫進行互動，方法皆標註 @Transactional 以確保資料一致性：\n密碼認證 (authenticate)：建構 UsernamePasswordAuthenticationToken 交由 AuthenticationManager 驗證。若密碼錯誤，此處會自動拋出例外。\n核發權杖 (createAuthResponse)：登入成功後，生成新的 Access Token 以及由 UUID 構成的 Refresh Token，計算過期時間後寫入資料庫並回傳。\n權杖輪換 (refreshToken)：此為雙權杖機制的核心。\n驗證傳入的 Refresh Token 是否存在於資料庫。\n檢查該 Refresh Token 是否已過期。若過期，強制清空資料庫欄位並要求重新登入。\n若驗證通過，生成全新的 Access Token 與全新的 Refresh Token。\n將新的 Refresh Token 覆蓋寫入資料庫（權杖輪換），這確保了每個 Refresh Token 只能被使用一次，大幅降低權杖被竊取的風險。\n撤銷權杖 (revokeUserToken)：登出時呼叫，將該使用者的 Refresh Token 與過期時間設為 null。\n@Service public class AuthService { @Autowired private Environment env; private final AuthenticationManager authenticationManager; private final JwtUtil jwtUtil; private final UserRepository userRepository; public AuthService(AuthenticationManager authenticationManager, JwtUtil jwtUtil, UserRepository userRepository) { this.authenticationManager = authenticationManager; this.jwtUtil = jwtUtil; this.userRepository = userRepository; } public void authenticate(String email, String password) { Authentication authenticationToken = new UsernamePasswordAuthenticationToken(email, password); Authentication authentication = authenticationManager.authenticate(authenticationToken); SecurityContextHolder.getContext().setAuthentication(authentication); } @Transactional public AuthResponse createAuthResponse(String email) { User user = userRepository.findByEmail(email) .orElseThrow(() -\u0026gt; new RuntimeException(\u0026#34;User not found\u0026#34;)); String accessToken = jwtUtil.generateToken(email); String refreshToken = UUID.randomUUID().toString(); user.setRefreshToken(refreshToken); user.setRefreshTokenExpiration( LocalDateTime.now().plusDays(env.getProperty(\u0026#34;jwt.refresh.token.expiration.days\u0026#34;, Integer.class, 7))); AuthResponse response = new AuthResponse(); response.setAccessToken(accessToken); response.setRefreshToken(refreshToken); response.setMessage(\u0026#34;Login successful\u0026#34;); return response; } @Transactional public AuthResponse refreshToken(String refreshToken) { User user = userRepository.findByRefreshToken(refreshToken) .orElseThrow(() -\u0026gt; new BadCredentialsException(\u0026#34;Invalid refresh token\u0026#34;)); if (user.getRefreshTokenExpiration().isBefore(LocalDateTime.now())) { user.setRefreshToken(null); user.setRefreshTokenExpiration(null); throw new BadCredentialsException(\u0026#34;Refresh token expired\u0026#34;); } String newAccessToken = jwtUtil.generateToken(user.getEmail()); String newRefreshToken = UUID.randomUUID().toString(); user.setRefreshToken(newRefreshToken); user.setRefreshTokenExpiration( LocalDateTime.now().plusDays(env.getProperty(\u0026#34;jwt.refresh.token.expiration.days\u0026#34;, Integer.class, 7))); AuthResponse response = new AuthResponse(); response.setAccessToken(newAccessToken); response.setRefreshToken(newRefreshToken); response.setMessage(\u0026#34;Token refreshed and rotated\u0026#34;); return response; } @Transactional public void revokeUserToken(String email) { userRepository.findByEmail(email).ifPresent(user -\u0026gt; { user.setRefreshToken(null); user.setRefreshTokenExpiration(null); }); } } 參考資料 Spring Security 官方文件 Nimbus JOSE + JWT 官方文件 ","permalink":"http://blog.codeicu.dev/posts/spring-sparrow/security/","tags":[{"LinkTitle":"Spring","RelPermalink":"/tags/spring/"}],"title":"Spring Sparrow Security"},{"categories":[],"content":"介紹 迪米特法則(Law of Demeter, LoD)是一項物件導向設計原則，強調物件應只與其直接相關的「鄰近」物件互動，以減少物件之間的耦合並提升系統的可維護性。\n此概念由 Karl Lieberherr 提出，並源自其研究專案「Demeter Project」因此得名。\n在後續研究中，Law of Demeter 被進一步拓展為「Law of Demeter for Concerns」並提出兩項實作的優點：\n資訊隱藏(Information Hiding)，可透過結構隱匿(Structure-shyness)或更一般化的關注點隱匿(Concern-shyness)等技術達成。\n降低軟體開發者需要處理的資訊量，減少資訊過載(Information Overload)。\n因此最少知識原則(Principle of Least Knowledge)也常被用來描述這項原則。\n迪米特法則(Law Of Demeter) Law Of Demeter 格言\nOnly talk to your friends\n只與你的朋友交談\n在此原則中，物件的「朋友」通常指的是：\n物件自身(self / this) 物件的成員變數(attributes) 物件的方法參數(parameters) 物件所創建的其他物件(objects created by the object) 也就是說，一個物件的方法應只與上述物件互動，而不應透過物件鏈結存取更深層的物件。\nLaw Of Demeter For Concerns 格言\nOnly talk to your friends who share your concerns\n只與具有相同關注點(concerns)的朋友交談\n此概念是延伸版本，強調物件之間的互動應限制在具有相同關注點(concern)的模組或元件之間，以進一步降低耦合並提升系統的模組化程度。\n程式舉例：火車鏈式(Train Wreck / Message Chain) // 違反 LoD 的例子：物件直接存取其他物件的內部結構 public class A { private B b; public void exec() { b.getC().getD().exec(); } } 上述程式碼違反了 Law of Demeter。物件 A 透過 B 取得 C，再透過 C 取得 D 並呼叫其方法，形成了多層的鏈式呼叫(message chain)。\n這代表 A 不僅知道 B 的存在，也知道 B 內部包含 C，以及 C 內部包含 D，使得物件之間的耦合度提高。\n可以透過 委派 delegation 的方式進行修改：\npublic class A { private B b; public void exec() { b.exec(); } } public class B { private C c; public void exec() { c.exec(); } } public class C { private D d; public void exec() { d.exec(); } } public class D { public void exec() { } } 在這個版本中，每個物件只與自己的成員物件互動，並將行為逐層委派下去。\n如此一來，每個類別只需要了解其直接關聯的物件，而不需要知道更深層的物件結構，這樣就符合了 Law of Demeter 的原則。\n此鏈非彼鏈 在上述的例子中，A 直接存取 B 的內部結構，形成了「火車鏈式(Train Wreck / Message Chain)」的反模式。\n然而，並非所有「鏈式呼叫」都違反 LoD。以下幾種常見設計雖然也使用鏈式呼叫，但概念不同。\nFluent Interface 透過方法鏈結（method chaining）設計 API，使程式碼具有良好的可讀性。 每個方法通常回傳物件本身，使呼叫可以連續進行，而呼叫者只需要與該物件互動，不需要了解其內部結構。 public class Main { public static void main(String[] args) { User user = new User(); user.setName(\u0026#34;Rootimes\u0026#34;) .setEmail(\u0026#34;rootimes8596@gmail.com\u0026#34;) .setMotto(\u0026#34;知道的越多，不知道的越多\u0026#34;) .output(); } } Builder Pattern（建造者模式）用於封裝複雜物件的建構過程，並透過鏈式方法逐步設定物件屬。 呼叫者只與 UserBuilder 互動，而不需要了解 User 的內部建構細節。 public class Main { public static void main(String[] args) { User user = new UserBuilder() .setName(\u0026#34;Rootimes\u0026#34;) .setEmail(\u0026#34;rootimes8596@gmail.com\u0026#34;) .setMotto(\u0026#34;知道的越多，不知道的越多\u0026#34;) .build(); } } Lambda Expression 或 Stream API：在某些語言中，使用 lambda 表達式或匿名函數來處理集合或流式操作。 呼叫者只與 Stream 介面互動，而不是存取多層物件結構。 List\u0026lt;String\u0026gt; names = Arrays.asList(\u0026#34;Lucas\u0026#34;, \u0026#34;Rootimes\u0026#34;, \u0026#34;遇如\u0026#34;); public class Main { public static void main(String[] args) { names.stream() .filter(name -\u0026gt; name.startsWith(\u0026#34;R\u0026#34;)) .map(String::toUpperCase) .forEach(System.out::println); } } 結論 迪米特法則，讓每個物件的知識範圍保持在最小，關注的是\n物件之間互動的直接性和局部性。\n思考 我們還可以進一步思考一些問題：\n貧血模型（Anemic Domain Model）是不是違反了 LoD？ 嚴格遵守 LoD 是否會導致什麼問題?如果物件關聯結構與方法穩定呢? 參考資料 Law of Demeter: Principle of Least Knowledge ","permalink":"http://blog.codeicu.dev/posts/principle/lod/","tags":[{"LinkTitle":"Code Principle","RelPermalink":"/tags/code-principle/"}],"title":"迪米特法則(Law of Demeter, LoD)"},{"categories":[],"content":"介紹 關注點分離（Separation of Concerns, SoC）是一個重要的軟體設計原則，由 Edsger W. Dijkstra 在 1974 年提出。\n在他的文章 EWD447 – On the role of scientific thought 中，Dijkstra 提出了「separation of concerns」這個概念，用來描述一種有效的思考方式：\n在研究複雜問題時，一次只專注於其中的一個切面（aspect）。\n原文：\n\u0026#34;Let me try to explain to you, what to my taste is characteristic for all intelligent thinking. It is, that one is willing to study in depth an aspect of one\u0026#39;s subject matter in isolation for the sake of its own consistency, all the time knowing that one is occupying oneself only with one of the aspects. We know that a program must be correct and we can study it from that viewpoint only; we also know that it should be efficient and we can study its efficiency on another day, so to speak. In another mood we may ask ourselves whether, and if so: why, the program is desirable. But nothing is gained —on the contrary!— by tackling these various aspects simultaneously. It is what I sometimes have called \u0026#34;the separation of concerns\u0026#34;, which, even if not perfectly possible, is yet the only available technique for effective ordering of one\u0026#39;s thoughts, that I know of. This is what I mean by \u0026#34;focussing one\u0026#39;s attention upon some aspect\u0026#34;: it does not mean ignoring the other aspects, it is just doing justice to the fact that from this aspect\u0026#39;s point of view, the other is irrelevant. It is being one- and multiple-track minded simultaneously. \u0026ldquo;The separation of concerns\u0026rdquo; 在原文當中的關注點分離，在於專注於某個特定的方面，並且將其他方面暫時忽略掉，以便更深入地研究該方面的內容。這種方法可以幫助我們更有效地組織思維，並且在不同的時間點專注於不同的方面，以達到更好的效果。\n關注點分離(Separation Of Concerns) 關注點分離，在軟體工程領域中帶來了諸多影響：\n建模：將系統分成不同的模型。\n模組化：將程式碼分成不同的模組。\n分層架構：將系統分成不同的層次。\n微服務架構：將系統拆分成小型的服務。\n任務拆分：將複雜的任務拆分成更小的子任務。\n以上只是部分例子。在軟體開發過程中，許多設計方式其實都體現了關注點分離的思想。\n其核心理念可以簡單整理為：\n一次只專注於一個切面（aspect） 這並不意味著忽略其他切面，而是暫時將注意力集中在當前問題 這是一種有效整理思考的方式 透過這種方式，我們可以逐步解決不同切面的問題，最終完成整體系統的設計與實作。\nSoC 與 SRP 關注點分離（SoC）與 單一職責原則（Single Responsibility Principle, SRP） 經常一起出現，但兩者其實並不完全相同。\n簡單來說：\nSoC 更關注 「問題切面（concern）」 的拆分\nSRP 更關注 「變動原因（reason to change）」 的拆分\n因此：\nSoC 偏向思考方式與設計理念\nSRP 偏向程式設計層級的設計原則\n結論 關注點分離不僅是一種軟體設計原則，也是一種思考方式。\n在軟體工程中，系統往往非常複雜，很難在同一時間處理所有問題。\nSoC 提醒我們，在特定階段應該專注於某一個問題切面，而不是試圖同時解決所有問題。\n透過將問題拆分並逐步處理，我們可以更有條理地理解系統，並最終完成整體設計。\n思考 我們還可以進一步思考一些問題：\n如何決定關注的切面範圍? 什麼時候切換到另一個切面? 如何平衡不同切面，確保整體系統的一致性? 參考資料 EWD 447 ","permalink":"http://blog.codeicu.dev/posts/principle/soc/","tags":[{"LinkTitle":"Code Principle","RelPermalink":"/tags/code-principle/"}],"title":"關注點分離 (Separation of Concerns, SoC)"},{"categories":[],"content":"關注點 CRUD 的整合測試 簡單介紹測試 軟體測試大致可以分為幾種類型：\n單元測試（Unit Test）\n測試單一方法或類別的功能，通常會使用模擬物件（Mock）來隔離外部依賴，以確保測試專注於單一邏輯單元。\n整合測試（Integration Test）\n測試多個組件或系統之間的交互，確保它們能夠正確協作，例如 Controller、Service、Repository 以及框架設定之間的整體運作。\n端對端測試（End-to-End Test, E2E）\n從使用者角度出發，測試整個應用程式的完整流程，驗證系統在真實使用情境下的行為是否符合預期。\n個人實務經驗中的測試使用情境 單元測試（Unit Test） 通常會用在：\n邏輯複雜度高 需要隔離外部依賴 失敗成本高 例如 身分驗證服務（Auth Server）。\n此類服務通常屬於核心基礎設施，包含：\n加密處理 權限判定 Token 驗證等核心邏輯 這些邏輯的變動頻率通常較低，但一旦出錯，可能導致整個系統無法正常運作。\n因此會透過單元測試，確保每個細節都具有高度穩定性。\n整合測試（Integration Test） 通常會用在關注以下情境：\n資料庫互動 框架設定 Controller / Service / Repository 之間的協作 例如 資源存取 API（CRUD Resource）。\n這類服務的業務邏輯通常較為直觀，透過整合測試即可在較低開發成本下，驗證整體 Feature 是否能正常運作。\n端對端測試（End-to-End Test） 目前個人在實務專案中尚未實際使用過。\n以網站應用為例，E2E 測試通常需要撰寫大量使用者行為腳本，透過瀏覽器模擬操作，例如：\n點擊按鈕 填寫表單 驗證畫面結果 某種程度上類似於自動化爬蟲操作 UI。\n然而這類測試的維護成本相當高，尤其是在前端畫面變動頻繁的專案中，測試腳本往往剛完成不久就需要重新調整。\n說這麼多，其實只是是想表達：\nSpring Sparrow 目前只撰寫整合測試，並不是偷懶，而是根據專案需求與關注點所做出的技術選擇。\n加入相關依賴 有部分的測試依賴在 REST Docs 那個篇章已經加入，這裡就不重複了。\njson-path：用於解析和驗證 JSON 回應。 hamcrest：提供更靈活的斷言語法。 \u0026lt;dependency\u0026gt; \u0026lt;groupId\u0026gt;com.jayway.jsonpath\u0026lt;/groupId\u0026gt; \u0026lt;artifactId\u0026gt;json-path\u0026lt;/artifactId\u0026gt; \u0026lt;version\u0026gt;3.0.0\u0026lt;/version\u0026gt; \u0026lt;scope\u0026gt;test\u0026lt;/scope\u0026gt; \u0026lt;/dependency\u0026gt; \u0026lt;dependency\u0026gt; \u0026lt;groupId\u0026gt;org.hamcrest\u0026lt;/groupId\u0026gt; \u0026lt;artifactId\u0026gt;hamcrest\u0026lt;/artifactId\u0026gt; \u0026lt;version\u0026gt;3.0\u0026lt;/version\u0026gt; \u0026lt;scope\u0026gt;test\u0026lt;/scope\u0026gt; \u0026lt;/dependency\u0026gt; 撰寫測試 以下舉一些測試方法的範例，實際上專案中還有更多的測試，涵蓋了各種不同的 API 行為與邏輯驗證。\n由於是整合測試，所以先設定一下測試環境，在 test/java/sparrow/resources ，\n建立一個 application-test.properties，裡面放一些測試專用的設定。\n測試類別設定 在測試類別上使用 @Transactional，確保每個測試方法執行完畢後會自動回滾（Rollback），\n維持資料庫的潔淨度，避免測試間的資料干擾。\n@Transactional public class PostTest extends RestDocsTest 建立文章 (Create Post) 測試 驗證點：請求發送後，伺服器是否回傳 201 Created，且 status 能正確套用預設值 DRAFT。\n@Test 測試方法宣告 createPost_shouldReturnCreated 是行為命名法，表達意思是「當呼叫 createPost API 時，應該回傳 Created 狀態碼」。 先建立一個使用者的 Entity 產生一個 unique slug 用來建立一個 Request 服務。\nprivate PostRequest buildRequest(Integer userId, String title, String slug, Meta meta, PostStatus status) { PostRequest request = new PostRequest(); request.setUserId(userId); request.setTitle(title); request.setSlug(slug); request.setMeta(meta); request.setStatus(status); return request; } 現在 PostRequest 相當於\n{ \u0026#34;userId\u0026#34;: 1, \u0026#34;title\u0026#34;: \u0026#34;title-1\u0026#34;, \u0026#34;slug\u0026#34;: \u0026#34;slug-1\u0026#34;, \u0026#34;meta\u0026#34;: { \u0026#34;meta-1\u0026#34;: \u0026#34;val-1\u0026#34; }, \u0026#34;status\u0026#34;: null //測試，預設會轉為 DRAFT } 使用 MockMvc 模擬 HTTP Request，不啟動真正 HTTP Server 的整合測試方式。\n.contentType 用來指定請求的內容類型，這裡是 application/json。 .content 用來設定請求的內容，這裡將 PostRequest 物件轉換為 JSON 字串。 .andExpect 用來驗證 API 回應和內容是否符合預期。 .andDo 用來生成 REST Docs 的文件片段，這裡指定了文件的名稱為 \u0026ldquo;post-create\u0026rdquo;。 @Test public void createPost_shouldReturnCreated() throws Exception { User user = createUser(); String slug = uniqueSlug(\u0026#34;slug-1\u0026#34;); PostRequest request = buildRequest(user.getId(), \u0026#34;title-1\u0026#34;, slug, buildMeta(\u0026#34;meta-1\u0026#34;, \u0026#34;val-1\u0026#34;), null); this.mockMvc.perform(post(\u0026#34;/api/posts\u0026#34;) .contentType(MediaType.APPLICATION_JSON) .content(objectMapper.writeValueAsString(request))) .andExpect(status().isCreated()) .andExpect(jsonPath(\u0026#34;$.id\u0026#34;).value(notNullValue())) .andExpect(jsonPath(\u0026#34;$.userId\u0026#34;).value(user.getId())) .andExpect(jsonPath(\u0026#34;$.title\u0026#34;).value(\u0026#34;title-1\u0026#34;)) .andExpect(jsonPath(\u0026#34;$.slug\u0026#34;).value(slug)) .andExpect(jsonPath(\u0026#34;$.meta[\u0026#39;meta-1\u0026#39;]\u0026#34;).value(\u0026#34;val-1\u0026#34;)) .andExpect(jsonPath(\u0026#34;$.status\u0026#34;).value(\u0026#34;DRAFT\u0026#34;)) .andDo(document(\u0026#34;post-create\u0026#34;)); } 分頁查詢 (Get List) 測試 驗證點：API 是否能正確回傳分頁結果，且包含剛剛建立的文章。\n準備測試資料\nprivate Post createPostEntity(User user, String title, String slug, Meta meta, PostStatus status) { Post post = new Post(); post.setUser(user); post.setTitle(title); post.setSlug(slug); post.setMeta(meta); post.setStatus(status); return postRepository.save(post); } .param 用來設定查詢參數，這裡設定了分頁和排序的參數。 @Test public void getAllPosts_shouldReturnPagedResult() throws Exception { User user = createUser(); String firstSlug = uniqueSlug(\u0026#34;slug-1\u0026#34;); String secondSlug = uniqueSlug(\u0026#34;slug-2\u0026#34;); createPostEntity(user, \u0026#34;title-1\u0026#34;, firstSlug, buildMeta(\u0026#34;meta-1\u0026#34;, \u0026#34;val-1\u0026#34;), PostStatus.DRAFT); createPostEntity(user, \u0026#34;title-2\u0026#34;, secondSlug, buildMeta(\u0026#34;meta-2\u0026#34;, \u0026#34;val-2\u0026#34;), PostStatus.PUBLISHED); this.mockMvc.perform(get(\u0026#34;/api/posts\u0026#34;) .param(\u0026#34;page\u0026#34;, \u0026#34;0\u0026#34;) .param(\u0026#34;size\u0026#34;, \u0026#34;20\u0026#34;) .param(\u0026#34;sort\u0026#34;, \u0026#34;id,desc\u0026#34;)) .andExpect(status().isOk()) .andExpect(jsonPath(\u0026#34;$.content.length()\u0026#34;).value(greaterThanOrEqualTo(2))) .andExpect(jsonPath(\u0026#34;$.content[?(@.slug == \u0026#39;\u0026#34; + firstSlug + \u0026#34;\u0026#39;)].title\u0026#34;).value(hasItem(\u0026#34;title-1\u0026#34;))) .andExpect(jsonPath(\u0026#34;$.content[?(@.slug == \u0026#39;\u0026#34; + secondSlug + \u0026#34;\u0026#39;)].title\u0026#34;).value(hasItem(\u0026#34;title-2\u0026#34;))) .andDo(document(\u0026#34;post-list\u0026#34;)); } 更新文章 (Update Post) 測試 驗證點：更新文章的 API，驗證更新後的資料是否正確替換掉原本的資料。\npublic void updatePost_withTags_shouldReplaceTags() throws Exception { User user = createUser(); Post created = createPostEntity(user, \u0026#34;title-1\u0026#34;, uniqueSlug(\u0026#34;slug-1\u0026#34;), buildMeta(\u0026#34;meta-1\u0026#34;, \u0026#34;val-1\u0026#34;), PostStatus.DRAFT); String updatedSlug = uniqueSlug(\u0026#34;slug-2\u0026#34;); PostRequest updateRequest = buildRequest(user.getId(), \u0026#34;title-2\u0026#34;, updatedSlug, buildMeta(\u0026#34;meta-2\u0026#34;, \u0026#34;val-2\u0026#34;), PostStatus.PUBLISHED); updateRequest.setTags(Set.of(\u0026#34;tag-2\u0026#34;, \u0026#34;tag-3\u0026#34;)); this.mockMvc.perform(put(\u0026#34;/api/posts/{id}\u0026#34;, created.getId()) .contentType(MediaType.APPLICATION_JSON) .content(objectMapper.writeValueAsString(updateRequest))) .andExpect(status().isOk()) .andExpect(jsonPath(\u0026#34;$.meta[\u0026#39;meta-2\u0026#39;]\u0026#34;).value(\u0026#34;val-2\u0026#34;)) .andExpect(jsonPath(\u0026#34;$.tags\u0026#34;).isArray()) .andExpect(jsonPath(\u0026#34;$.tags\u0026#34;, hasItem(\u0026#34;tag-2\u0026#34;))) .andExpect(jsonPath(\u0026#34;$.tags\u0026#34;, hasItem(\u0026#34;tag-3\u0026#34;))) .andDo(document(\u0026#34;post-update-with-tags\u0026#34;)); } 刪除文章 (Delete Post) 測試 驗證點：刪除文章後，確認該文章已經不存在。\n@Test public void deletePost_shouldRemovePost() throws Exception { User user = createUser(); Post created = createPostEntity(user, \u0026#34;title-1\u0026#34;, uniqueSlug(\u0026#34;slug-1\u0026#34;), null, PostStatus.DRAFT); this.mockMvc.perform(delete(\u0026#34;/api/posts/{id}\u0026#34;, created.getId())) .andExpect(status().isNoContent()) .andDo(document(\u0026#34;post-delete\u0026#34;)); // 驗證副作用：確認資源已不存在 this.mockMvc.perform(get(\u0026#34;/api/posts/{id}\u0026#34;, created.getId())) .andExpect(status().isNotFound()) .andExpect(jsonPath(\u0026#34;$.error\u0026#34;).value(\u0026#34;Post not found\u0026#34;)); } REST Docs 文件撰寫 Test-Driven Documentation：REST Docs 的一個重要特點是測試驅動文件撰寫，也就是說，文件的內容是從測試方法中自動生成的。\n核心理念是「唯有通過測試，才會有文件」。\n所以也要補一下相關的 adoc 文件片段。\n結構化文件定義 在專案中撰寫 index.adoc 與 post.adoc，透過 include 指令將測試生成的片段（Snippets）動態引入。\n= Spring Sparrow Legacy API Guide :toc: left :toclevels: 3 :sectnums: == Introduction Spring Sparrow Legacy 的 API Guide 文件。 === Base URL 所有 API 的 base URL 為： - http://localhost:8080 - === Request / Response 格式 * 所有請求與回應均使用 `application/json` * 時間格式為 ISO 8601（`yyyy-MM-dd\u0026#39;T\u0026#39;HH:mm:ss`） === HTTP 狀態碼 [cols=\u0026#34;1,3\u0026#34;] |=== | 狀態碼 | 說明 | `200 OK` | 請求成功 | `201 Created` | 資源建立成功 | `204 No Content` | 刪除成功，無回應內容 | `400 Bad Request` | 請求格式錯誤或參數驗證失敗 | `404 Not Found` | 指定資源不存在 | `409 Conflict` | 資源衝突，例如 slug 重複 |=== include::status.adoc[] include::post.adoc[] post.adoc\n== Post API This API manages posts including create, list, detail, update, and delete operations. === Create Post ==== 請求範例 (HTTP Request) include::{snippets}/post-create/http-request.adoc[] ==== 請求範例 (cURL) include::{snippets}/post-create/curl-request.adoc[] ==== 回應範例 (HTTP Response) include::{snippets}/post-create/http-response.adoc[] // 其他操作的文件片段同樣以此方式引入 參考資料 MockMvc 官方文件 Spring REST Docs 官方文件 Asciidoctor 官方文件 ","permalink":"http://blog.codeicu.dev/posts/spring-sparrow/test/","tags":[{"LinkTitle":"Spring","RelPermalink":"/tags/spring/"}],"title":"Spring Sparrow Integration Test"},{"categories":[],"content":"關注點 資料庫設計：設計適合的資料庫結構來支持 CRUD 操作，確保資料的完整性與一致性。 CRUD 實作：在 Spring Framework 中實現 CRUD 操作，使用 JPA 來與資料庫進行互動。 RESTFul API 設計：設計符合 RESTful 核心概念「資源」的 API。 CRUD \u0026amp; RESTFul API 這篇文章將介紹 CRUD（Create, Read, Update, Delete）操作在 Spring Framework 中的實作方式，並結合 RESTFul API。\n實作以下功能：\n文章創建：使用 POST 請求來創建新的文章，並將資料保存到資料庫中。 文章查詢：使用 GET 請求來查詢文章列表或特定文章的詳細資訊。 文章更新：使用 PUT 請求來更新現有文章的內容。 文章刪除：使用 DELETE 請求來刪除指定的文章。 文章標籤新增：實現多對多關聯，讓文章可以有多個標籤，並且可以查詢文章的標籤資訊。 ps. 權限管理、驗證與授權等功能將在後續章節中介紹，這裡先專注於 CRUD 操作的實作。\n加入相關依賴 jackson-datatype-jsr310：對日期與時間 API（如 LocalDate、LocalDateTime）的支援，確保這些類型能正確序列化與反序列化為 JSON。 jackson-databind：核心的 JSON 處理庫，提供對 Java 物件與 JSON 之間的轉換功能。 jakarta.validation-api：提供對 Java Bean 驗證的支援，確保資料的完整性與一致性。 \u0026lt;dependency\u0026gt; \u0026lt;groupId\u0026gt;com.fasterxml.jackson.datatype\u0026lt;/groupId\u0026gt; \u0026lt;artifactId\u0026gt;jackson-datatype-jsr310\u0026lt;/artifactId\u0026gt; \u0026lt;version\u0026gt;2.21.1\u0026lt;/version\u0026gt; \u0026lt;/dependency\u0026gt; \u0026lt;dependency\u0026gt; \u0026lt;groupId\u0026gt;com.fasterxml.jackson.core\u0026lt;/groupId\u0026gt; \u0026lt;artifactId\u0026gt;jackson-databind\u0026lt;/artifactId\u0026gt; \u0026lt;version\u0026gt;2.21.1\u0026lt;/version\u0026gt; \u0026lt;/dependency\u0026gt; \u0026lt;dependency\u0026gt; \u0026lt;groupId\u0026gt;jakarta.validation\u0026lt;/groupId\u0026gt; \u0026lt;artifactId\u0026gt;jakarta.validation-api\u0026lt;/artifactId\u0026gt; \u0026lt;version\u0026gt;3.1.1\u0026lt;/version\u0026gt; \u0026lt;/dependency\u0026gt; ERD 設計 資料庫關聯說明 一對一關聯：用在附加、隔離資訊，給主表格使用，例如文章來源。\n一對多關聯：使用者與文章的關聯，一個使用者可以有多篇文章。\n多對多關聯：文章與標籤的關聯，一篇文章可以有多個標籤，一個標籤也可以對應多篇文章。\n暫時不實作的關聯 越過過關聯(Through Association)系列：用在 A 與 B 之間沒有直接關聯，但都與 C 有關聯，透過 C 來間接連結 A 與 B。\nps. 雖說多對多也算是一種越過關聯，它是透過第三個表格來連結兩個表格的多對多關聯。\n但這裡的越過關聯，指的是更複雜的情況，例如：文章與使用者之間沒有直接關聯，但都與文章來源有關聯，透過文章來源來連結文章與使用者。\n設計開始 假定 spring sparrow 用來當作 blog 的後端，主要功能是管理文章、使用者。\n簡單設計一下資料庫結構，以下是各表說明：\nusers：使用者表，基本資訊，如名稱、密碼、電子郵件等，並與角色表建立關聯。 posts：文章表，包含文章的標題、狀態、相關的使用者與系列等資訊。 tags：標籤表，用來管理文章的標籤資訊。 post_tag：文章與標籤的關聯表，用來表示多對多的關係。 post_sources：文章來源表，用來管理文章的來源資訊，與 posts 表建立一對一的關聯。 使用 DBML 語法繪製 ERD 圖，定義資料庫的結構與關聯。\nTable users { id integer [primary key] name varchar(50) [not null, unique] password varchar(255) [not null] email varchar(255) [not null, unique] description varchar(500) } Table posts { id integer [primary key] user_id integer [not null] title varchar(100) [not null] slug varchar(100) [unique] meta json status tinyint [default: 0] } Table post_sources { post_id integer [primary key] source_type integer [not null] source_link json } Table tags { id integer [primary key] name varchar(20) [not null, unique] } Table post_tag { id integer [primary key] post_id integer [not null] tag_id integer [not null] } Ref user_posts: posts.user_id \u0026gt; users.id Ref post_source_post: post_sources.post_id \u0026gt; posts.id Ref post_tag_post_key: post_tag.post_id \u0026gt; posts.id Ref post_tag_tag_key: post_tag.tag_id \u0026gt; tags.id 調整專案結構 隨著功能的增加，調整一下專案結構。\n記得改變 Config 掃描路徑、import 路徑等，確保 Spring 能正確載入相關元件。\n專案「骨架」採用經典的「三層式架構垂直分層」（3-Tier Architecture），處理流程劃分如下：\nController 層：負責接收 HTTP 請求、驗證輸入參數（DTO）、呼叫 Service 層。\nService 層：核心業務邏輯的所在。負責處理資料運算、呼叫 Repository 存取資料庫，並管理 Transaction（交易）。\nRepository 層：負責與資料庫互動，透過 Spring Data JPA 提供的方法執行 SQL 操作。\nps. 檔案數量超過兩個再開資料夾，若只有一個檔案就放在模組根目錄下，設計理念，寫在文章最下方。\n以下示意，以此類推：\nsparrow ├─ src │ ├─ main │ │ ├─ asciidoc │ │ │ └─ index.adoc │ │ │ │ │ ├─ java │ │ │ └─ sparrow │ │ │ ├─ App.java │ │ │ ├─ MessageService.java │ │ │ │ │ │ │ ├─ aspect │ │ │ │ └─ LoggingAspect.java │ │ │ │ │ │ │ ├─ config │ │ │ │ ├─ AppConfig.java │ │ │ │ ├─ AppInitializer.java │ │ │ │ ├─ JPAMySqlConfig.java │ │ │ │ └─ WebConfig.java │ │ │ │ │ │ │ ├─ exception │ │ │ │ ├─ ConflictException.java │ │ │ │ ├─ GlobalExceptionHandler.java │ │ │ │ └─ ResourceNotFoundException.java │ │ │ │ │ │ │ ├─ external │ │ │ │ │ │ │ ├─ post │ │ │ │ ├─ PostController.java │ │ │ │ ├─ PostService.java │ │ │ │ │ │ │ │ │ ├─ converter │ │ │ │ │ ├─ PostStatusConverter.java │ │ │ │ │ └─ SourceTypeConverter.java │ │ │ │ │ │ │ │ │ ├─ dto │ │ │ │ │ ├─ PostListResponse.java │ │ │ │ │ ├─ PostRequest.java │ │ │ │ │ ├─ PostResponse.java │ │ │ │ │ ├─ PostSourceRequest.java │ │ │ │ │ └─ PostSourceResponse.java │ │ │ │ │ │ │ │ │ ├─ entity │ │ │ │ │ ├─ Post.java │ │ │ │ │ ├─ PostSource.java │ │ │ │ │ ├─ PostTag.java │ │ │ │ │ └─ Tag.java │ │ │ │ │ │ │ │ │ ├─ mapper │ │ │ │ │ ├─ PostMapper.java │ │ │ │ │ └─ PostSourceMapper.java │ │ │ │ │ │ │ │ │ ├─ repository │ │ │ │ │ ├─ PostRepository.java │ │ │ │ │ ├─ PostSourceRepository.java │ │ │ │ │ ├─ PostTagRepository.java │ │ │ │ │ └─ TagRepository.java │ │ │ │ │ │ │ │ │ └─ vo │ │ │ │ ├─ Meta.java │ │ │ │ ├─ SourceLink.java │ │ │ │ │ │ │ │ │ └─ enums │ │ │ │ ├─ PostStatus.java │ │ │ │ └─ SourceType.java │ │ │ │ │ │ │ ├─ status │ │ │ │ └─ StatusController.java │ │ │ │ │ │ │ └─ user │ │ │ ├─ User.java │ │ │ └─ UserRepository.java │ │ │ │ │ └─ resources │ │ ├─ application.properties │ │ ├─ application.properties.example │ │ └─ logback.xml 文章 Post Entity 類別實作 @Entity @Table(name = \u0026#34;posts\u0026#34;) public class Post { @Id @GeneratedValue(strategy = GenerationType.IDENTITY) private Integer id; @ManyToOne(fetch = FetchType.LAZY, optional = false) @JoinColumn(name = \u0026#34;user_id\u0026#34;, nullable = false) private User user; @Column(nullable = false, length = 100) private String title; @Column(length = 100, unique = true) private String slug; @JdbcTypeCode(SqlTypes.JSON) @Column(columnDefinition = \u0026#34;json\u0026#34;) private Meta meta; @Column(columnDefinition = \u0026#34;TINYINT\u0026#34;, nullable = false) private PostStatus status = PostStatus.DRAFT; @OneToOne(mappedBy = \u0026#34;post\u0026#34;, cascade = CascadeType.ALL, orphanRemoval = true, fetch = FetchType.LAZY) private PostSource postSource; @OneToMany(mappedBy = \u0026#34;post\u0026#34;, cascade = CascadeType.ALL, orphanRemoval = true, fetch = FetchType.LAZY) private Set\u0026lt;PostTag\u0026gt; postTags = new HashSet\u0026lt;\u0026gt;(); public Post() { } // getter 和 setter 方法，省略... public void clearTags() { this.postTags.clear(); } public void addTag(Tag tag) { PostTag postTag = new PostTag(); postTag.setPost(this); postTag.setTag(tag); this.postTags.add(postTag); } public Integer getUserId() { return this.user != null ? this.user.getId() : null; } public Set\u0026lt;String\u0026gt; getTagNames() { return this.postTags.stream() .map(pt -\u0026gt; pt.getTag().getName()) .collect(Collectors.toSet()); } public void updateSource(PostSource source) { if (source != null) { source.setPost(this); this.postSource = source; } } } 實作 Create 文章 API 將 Service 注入到 Controller 中，實作 CRUD 邏輯。\nSpring 標註說明：\n@RestController：表示該類別下的所有端點直接返回資料（JSON），不渲染視圖。\n@RequestBody：將 HTTP 請求的 JSON Body 反序列化為 Java 物件。\n@Valid：觸發 Jakarta Bean Validation（如 @NotNull），驗證失敗則拋出例外。\n@Transactional：確保資料庫操作在同一交易中執行，發生例外時自動 Rollback。\nCreate Post Controller @RestController @RequestMapping(\u0026#34;/api/posts\u0026#34;) public class PostController { private final PostService postService; public PostController(PostService postService) { this.postService = postService; } @PostMapping @ResponseStatus(HttpStatus.CREATED) public PostResponse createPost(@Valid @RequestBody PostRequest request) { return postService.createPost(request); } } Create Post Service @Service public class PostService { private final PostRepository postRepository; private final UserRepository userRepository; private final PostMapper postMapper; private final TagRepository tagRepository; public PostService( PostRepository postRepository, UserRepository userRepository, PostMapper postMapper, TagRepository tagRepository) { this.postRepository = postRepository; this.userRepository = userRepository; this.postMapper = postMapper; this.tagRepository = tagRepository; } @Transactional public PostResponse createPost(PostRequest request) { if (request.getSlug() != null \u0026amp;\u0026amp; postRepository.existsBySlug(request.getSlug())) { throw new ConflictException(\u0026#34;Slug already exists: \u0026#34; + request.getSlug()); } User user = userRepository.findById(request.getUserId()) .orElseThrow(() -\u0026gt; new ResourceNotFoundException(\u0026#34;User not found\u0026#34;)); Post post = new Post(); postMapper.updateEntity(request, post); post.setUser(user); applyTags(post, request.getTags()); Post savedPost = postRepository.save(post); return postMapper.toResponse(savedPost); } private void applyTags(Post post, Set\u0026lt;String\u0026gt; tagNames) { post.clearTags(); List\u0026lt;String\u0026gt; tagList = new ArrayList\u0026lt;\u0026gt;(); if (tagNames != null \u0026amp;\u0026amp; !tagNames.isEmpty()) { for (String name : tagNames) { Tag tag = findOrCreateTag(name); post.addTag(tag); tagList.add(name); } } updateMetaForTags(post, tagList); } private Tag findOrCreateTag(String name) { return tagRepository.findByName(name) .orElseGet(() -\u0026gt; { Tag newTag = new Tag(); newTag.setName(name); return tagRepository.save(newTag); }); } private void updateMetaForTags(Post post, List\u0026lt;String\u0026gt; tagList) { Meta meta = post.getMeta(); if (meta == null) { meta = new Meta(); post.setMeta(meta); } meta.setTags(tagList); } } Create Repository @Repository public interface PostRepository extends JpaRepository\u0026lt;Post, Integer\u0026gt; { boolean existsBySlug(String slug); boolean existsBySlugAndIdNot(String slug, int id); } @Repository public interface TagRepository extends JpaRepository\u0026lt;Tag, Integer\u0026gt; { Optional\u0026lt;Tag\u0026gt; findByName(String name); List\u0026lt;Tag\u0026gt; findByNameIn(Set\u0026lt;String\u0026gt; names); } Get Post List API 實作 GET 請求來查詢文章列表，並返回分頁結果。\nSpring 標註與參數說明：\n@EntityGraph：解決 JPA 的 N+1 查詢問題，透過指定 attributePaths = \u0026ldquo;user\u0026rdquo;，一次性利用 SQL JOIN 抓取關聯的使用者資料。\nPageable：Spring Web 自動將 URL 的查詢參數（如 ?page=0\u0026amp;size=10）轉換為分頁物件。\nGet Post List Controller public class PostController { @GetMapping public ResponseEntity\u0026lt;Page\u0026lt;PostListResponse\u0026gt;\u0026gt; getAllPosts(Pageable pageable) { Page\u0026lt;PostListResponse\u0026gt; posts = postService.getAllPosts(pageable); return ResponseEntity.ok(posts); } } Get Post List Service public class PostService { @Transactional(readOnly = true) public Page\u0026lt;PostListResponse\u0026gt; getAllPosts(Pageable pageable) { return postRepository.findAllForList(pageable) .map(postMapper::toListResponse); } } get post list repository public interface PostRepository extends JpaRepository\u0026lt;Post, Integer\u0026gt; { @EntityGraph(attributePaths = \u0026#34;user\u0026#34;) @Query(\u0026#34;select p from Post p\u0026#34;) Page\u0026lt;Post\u0026gt; findAllForList(Pageable pageable); } Get Post API 實作 GET 請求來查詢特定文章的詳細資訊。\n兩種路徑參數：id 與 slug，分別對應文章的 ID 與 slug 欄位。\nget post controller public class PostController { @GetMapping(\u0026#34;/{id:\\\\d+}\u0026#34;) public ResponseEntity\u0026lt;PostResponse\u0026gt; getPostById(@PathVariable(\u0026#34;id\u0026#34;) int id) { return ResponseEntity.ok(postService.getPostById(id)); } @GetMapping(\u0026#34;/{slug:[a-z0-9-]*[a-z][a-z0-9-]*}\u0026#34;) public ResponseEntity\u0026lt;PostResponse\u0026gt; getPostBySlug(@PathVariable(\u0026#34;slug\u0026#34;) String slug) { return ResponseEntity.ok(postService.getPostBySlug(slug)); } } Get Post Service public class PostService { @Transactional(readOnly = true) public PostResponse getPostById(int id) { Post post = postRepository.findById(id) .orElseThrow(() -\u0026gt; new ResourceNotFoundException(\u0026#34;Post not found\u0026#34;)); return postMapper.toResponse(post); } @Transactional(readOnly = true) public PostResponse getPostBySlug(String slug) { Post post = postRepository.findBySlug(slug) .orElseThrow(() -\u0026gt; new ResourceNotFoundException(\u0026#34;Post not found by slug\u0026#34;)); return postMapper.toResponse(post); } } Update Post API Update Post Controller public class PostController { @PutMapping(\u0026#34;/{id:\\\\d+}\u0026#34;) public ResponseEntity\u0026lt;PostResponse\u0026gt; updatePost( @PathVariable(\u0026#34;id\u0026#34;) int id, @Valid @RequestBody PostRequest request) { return ResponseEntity.ok(postService.updatePost(id, request)); } } Update Post Service public class PostService { @Transactional public PostResponse updatePost(int id, PostRequest request) { Post existingPost = postRepository.findById(id) .orElseThrow(() -\u0026gt; new ResourceNotFoundException(\u0026#34;Post not found\u0026#34;)); if (request.getSlug() != null \u0026amp;\u0026amp; postRepository.existsBySlugAndIdNot(request.getSlug(), id)) { throw new ConflictException(\u0026#34;Slug already exists: \u0026#34; + request.getSlug()); } User user = userRepository.findById(request.getUserId()) .orElseThrow(() -\u0026gt; new ResourceNotFoundException(\u0026#34;User not found\u0026#34;)); postMapper.updateEntity(request, existingPost); existingPost.setUser(user); applyTags(existingPost, request.getTags()); Post updatedPost = postRepository.save(existingPost); return postMapper.toResponse(updatedPost); } } Delete Post API Delete Post Controller public class PostController { @DeleteMapping(\u0026#34;/{id:\\\\d+}\u0026#34;) @ResponseStatus(HttpStatus.NO_CONTENT) public void deletePost(@PathVariable(\u0026#34;id\u0026#34;) int id) { postService.deletePost(id); } } Delete Post Service public class PostService { @Transactional public void deletePost(int id) { if (!postRepository.existsById(id)) { throw new ResourceNotFoundException(\u0026#34;Post not found\u0026#34;); } postRepository.deleteById(id); } } 專案結構說明 隨著專案功能增加，傳統的 Controller-Service-Repository 三層架構容易變得臃腫，導致各層堆積過多跨越職責的邏輯\n為了維持程式碼的高內聚與可維護性，sparrow 專案進一步細化了物件的職責分配：\nDTO (Data Transfer Object)：定義 API 請求與回應的資料格式。將其與資料庫的 Entity 徹底分離，確保內部資料結構的變動不會直接破壞外部 API 的合約。 VO (Value Object)：封裝特定的唯讀資料結構，通常用於回傳給客戶端的特定視圖模型，例如文章列表的 Meta 資訊。 Mapper：專門負責在 DTO、VO 與 Entity 之間進行物件屬性的映射轉換，抽離 Service 層中的轉換邏輯。 Converter：處理特定欄位或型別的轉換邏輯，如日期格式的解析或資料庫狀態碼的轉換。 Enum：集中定義系統中的固定狀態或類型（如文章狀態、文章來源等），消除程式碼中的 Magic Number。 Exception：自訂例外，配合全局例外攔截器（Global Exception Handler），統一封裝並拋出具體的錯誤訊息與 HTTP 狀態碼。 架構設計的啟發 借鑒了以下現代軟體架構的指導原則：\n領域驅動設計（Domain-Driven Design）的概念 核心域（Domain）：專注於業務邏輯與規則的實現，包含 Entity、Value Object等。 充血模型（Rich Model）：將業務邏輯封裝在 Entity 中，讓 Entity 不僅是資料結構，也包含行為。 整潔架構 (Clean Architecture) 的核心精神 依賴反轉與單向依賴：外層可以依賴內層（領域實體與業務邏輯），但內層絕不能依賴外層。 隔離變動：確保框架的升級或資料庫的抽換，不會影響到核心的業務邏輯。 實作上的權衡 (Trade-off) 考量到 Sparrow 專案目前的規模，並沒有完全按照 DDD 的嚴格分層， 但在設計上盡量遵循，保持程式碼的清晰與可維護性。\n如有需要或是專案規模擴大，未來可以進一步調整架構，增加更多的分層。\n因此，本專案在實作上保留了彈性，詳細可以參考原始程式碼中的實作。\n參考資料 Spring Guides - Building a RESTful Web Service Spring Data JPA Documentation DBML Documentation JpaRepository API Hibernate ORM Documentation MAVEN Dependency Clean Architecture ","permalink":"http://blog.codeicu.dev/posts/spring-sparrow/crud/","tags":[{"LinkTitle":"Spring","RelPermalink":"/tags/spring/"}],"title":"Spring Sparrow CRUD"},{"categories":[],"content":"關注點 Spring REST Docs 服務的啟動，生成 API 文件，並將文件部署到網頁上供外部訪問。 Spring REST Docs 什麼是 Spring REST Docs？ Spring REST Docs 是由 Spring 官方提供且維護的工具，\n結合了測試驅動開發（TDD）的理念，透過執行測試來自動生成準確的 RESTful API 文件，確保程式碼與文件始終保持同步。\n加入相關依賴 junit-jupiter: 提供 JUnit 5 的核心測試功能，支持撰寫與執行單元測試和整合測試。 spring-test: 提供對 Spring 應用程式的測試支援，包括模擬 Web 環境、測試上下文管理等。 spring-restdocs-mockmvc: Spring REST Docs 的 MockMvc 模組，允許在測試中截獲 API 請求與回應的細節，並生成 API 文件片段 (snippets)。 \u0026lt;dependency\u0026gt; \u0026lt;groupId\u0026gt;org.junit.jupiter\u0026lt;/groupId\u0026gt; \u0026lt;artifactId\u0026gt;junit-jupiter\u0026lt;/artifactId\u0026gt; \u0026lt;version\u0026gt;6.0.3\u0026lt;/version\u0026gt; \u0026lt;scope\u0026gt;test\u0026lt;/scope\u0026gt; \u0026lt;/dependency\u0026gt; \u0026lt;dependency\u0026gt; \u0026lt;groupId\u0026gt;org.springframework\u0026lt;/groupId\u0026gt; \u0026lt;artifactId\u0026gt;spring-test\u0026lt;/artifactId\u0026gt; \u0026lt;version\u0026gt;7.0.5\u0026lt;/version\u0026gt; \u0026lt;scope\u0026gt;test\u0026lt;/scope\u0026gt; \u0026lt;/dependency\u0026gt; \u0026lt;dependency\u0026gt; \u0026lt;groupId\u0026gt;org.springframework.restdocs\u0026lt;/groupId\u0026gt; \u0026lt;artifactId\u0026gt;spring-restdocs-mockmvc\u0026lt;/artifactId\u0026gt; \u0026lt;version\u0026gt;3.0.1\u0026lt;/version\u0026gt; \u0026lt;scope\u0026gt;test\u0026lt;/scope\u0026gt; \u0026lt;/dependency\u0026gt; 加入 Asciidoctor 套件 asciidoctor-maven-plugin: 將 AsciiDoc 文件轉換為 HTML、PDF 等格式的功能，支持在 Maven 建置過程中自動生成文件。 spring-restdocs-asciidoctor: 提供與 Asciidoctor 套件整合的功能，讓產生的片段能以 AsciiDoc 格式進行排版。 \u0026lt;properties\u0026gt; \u0026lt;snippetsDirectory\u0026gt;${project.build.directory}/generated-snippets\u0026lt;/snippetsDirectory\u0026gt; \u0026lt;/properties\u0026gt; \u0026lt;plugin\u0026gt; \u0026lt;groupId\u0026gt;org.asciidoctor\u0026lt;/groupId\u0026gt; \u0026lt;artifactId\u0026gt;asciidoctor-maven-plugin\u0026lt;/artifactId\u0026gt; \u0026lt;version\u0026gt;3.0.0\u0026lt;/version\u0026gt; \u0026lt;executions\u0026gt; \u0026lt;execution\u0026gt; \u0026lt;id\u0026gt;generate-docs\u0026lt;/id\u0026gt; \u0026lt;phase\u0026gt;prepare-package\u0026lt;/phase\u0026gt; \u0026lt;goals\u0026gt; \u0026lt;goal\u0026gt;process-asciidoc\u0026lt;/goal\u0026gt; \u0026lt;/goals\u0026gt; \u0026lt;configuration\u0026gt; \u0026lt;backend\u0026gt;html\u0026lt;/backend\u0026gt; \u0026lt;doctype\u0026gt;book\u0026lt;/doctype\u0026gt; \u0026lt;attributes\u0026gt; \u0026lt;snippets\u0026gt;${snippetsDirectory}\u0026lt;/snippets\u0026gt; \u0026lt;/attributes\u0026gt; \u0026lt;sourceDirectory\u0026gt;src/main/asciidoc\u0026lt;/sourceDirectory\u0026gt; \u0026lt;outputDirectory\u0026gt;target/generated-docs\u0026lt;/outputDirectory\u0026gt; \u0026lt;/configuration\u0026gt; \u0026lt;/execution\u0026gt; \u0026lt;/executions\u0026gt; \u0026lt;dependencies\u0026gt; \u0026lt;dependency\u0026gt; \u0026lt;groupId\u0026gt;org.springframework.restdocs\u0026lt;/groupId\u0026gt; \u0026lt;artifactId\u0026gt;spring-restdocs-asciidoctor\u0026lt;/artifactId\u0026gt; \u0026lt;version\u0026gt;4.0.0\u0026lt;/version\u0026gt; \u0026lt;/dependency\u0026gt; \u0026lt;/dependencies\u0026gt; \u0026lt;/plugin\u0026gt; 撰寫測試範例 RestDocsTest 父類別實作 抽象出共用的測試設定與工具方法。\n設定 MockMvc 並整合 Spring REST Docs。\nimport static org.springframework.restdocs.mockmvc.MockMvcRestDocumentation.documentationConfiguration; import org.junit.jupiter.api.BeforeEach; import org.junit.jupiter.api.extension.ExtendWith; import org.springframework.restdocs.RestDocumentationContextProvider; import org.springframework.restdocs.RestDocumentationExtension; import org.springframework.test.context.junit.jupiter.SpringExtension; import org.springframework.test.context.web.WebAppConfiguration; import org.springframework.test.web.servlet.MockMvc; import org.springframework.test.web.servlet.setup.MockMvcBuilders; import org.springframework.web.context.WebApplicationContext; @ExtendWith({ SpringExtension.class, RestDocumentationExtension.class }) @WebAppConfiguration public class RestDocsTest { protected MockMvc mockMvc; @BeforeEach public void setUp(WebApplicationContext webApplicationContext, RestDocumentationContextProvider restDocumentation) { this.mockMvc = MockMvcBuilders.webAppContextSetup(webApplicationContext) .apply(documentationConfiguration(restDocumentation)) .build(); } } AppTest 類別實作 撰寫具體的測試方法，使用 MockMvc 執行 API 請求並自動產生文件片段。\nimport static org.springframework.restdocs.mockmvc.MockMvcRestDocumentation.document; import static org.springframework.restdocs.payload.PayloadDocumentation.fieldWithPath; import static org.springframework.restdocs.payload.PayloadDocumentation.responseFields; import static org.springframework.test.web.servlet.request.MockMvcRequestBuilders.get; import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.status; import sparrow.config.WebConfig; import org.junit.jupiter.api.Test; import org.springframework.context.annotation.ComponentScan; import org.springframework.context.annotation.Configuration; import org.springframework.web.servlet.config.annotation.EnableWebMvc; import sparrow.controller.Controller; @ContextConfiguration(classes = { WebConfig.class }) public class AppTest extends RestDocsTest { @Test public void documentGetStatusApi() throws Exception { this.mockMvc.perform(get(\u0026#34;/api/status\u0026#34;)) .andExpect(status().isOk()) .andDo(document(\u0026#34;status-get\u0026#34;, responseFields( fieldWithPath(\u0026#34;status\u0026#34;).description(\u0026#34;API 處理狀態\u0026#34;), fieldWithPath(\u0026#34;message\u0026#34;).description(\u0026#34;詳細回應訊息\u0026#34;)))); } } AsciiDoc 文件撰寫 在專案根目錄建立 src/main/asciidoc/ 資料夾，並新增 index.adoc 檔案。這個檔案是測試片段的模板文件，包含了目錄結構與內容說明。\n設定好後，當執行 Maven 的 package 階段時，asciidoctor-maven-plugin 會自動將 index.adoc 轉換為 HTML 文件，並將測試中產生的片段 (snippets) 插入對應位置。\n在 target/generated-docs/ 目錄下會生成最終的 index.html，用於本地預覽或部署到伺服器。\n標籤說明 toc: left：將目錄放置在頁面左側。 toclevels: 3：設定目錄的層級深度為 3 sectnums：啟用章節自動編號。 = Sparrow Legacy API Guide :toc: left :toclevels: 3 :sectnums: == Introduction Sparrow 的 API 文件 == Status API This API returns the current status of the application. === HTTP Request 範例 include::{snippets}/status-get/http-request.adoc[] === cURL Request 範例 include::{snippets}/status-get/curl-request.adoc[] === HTTP Response 範例 include::{snippets}/status-get/http-response.adoc[] === 回應欄位說明 include::{snippets}/status-get/response-fields.adoc[] 文件網頁 加入資源管理套件 maven-resources-plugin: 將 Asciidoctor 生成的 HTML 文件複製到 Spring MVC 的靜態資源目錄中。 \u0026lt;plugin\u0026gt; \u0026lt;artifactId\u0026gt;maven-resources-plugin\u0026lt;/artifactId\u0026gt; \u0026lt;version\u0026gt;3.4.0\u0026lt;/version\u0026gt; \u0026lt;executions\u0026gt; \u0026lt;execution\u0026gt; \u0026lt;id\u0026gt;copy-resources\u0026lt;/id\u0026gt; \u0026lt;phase\u0026gt;prepare-package\u0026lt;/phase\u0026gt; \u0026lt;goals\u0026gt; \u0026lt;goal\u0026gt;copy-resources\u0026lt;/goal\u0026gt; \u0026lt;/goals\u0026gt; \u0026lt;configuration\u0026gt; \u0026lt;outputDirectory\u0026gt;${project.build.outputDirectory}/static/docs\u0026lt;/outputDirectory\u0026gt; \u0026lt;resources\u0026gt; \u0026lt;resource\u0026gt; \u0026lt;directory\u0026gt;${project.build.directory}/generated-docs\u0026lt;/directory\u0026gt; \u0026lt;/resource\u0026gt; \u0026lt;/resources\u0026gt; \u0026lt;/configuration\u0026gt; \u0026lt;/execution\u0026gt; \u0026lt;/executions\u0026gt; \u0026lt;/plugin\u0026gt; 在 WebConfig 中加入資源處理器 @EnableWebMvc 註解會啟動一套預設的 Web 設定。\n為了讓 Spring MVC 能對外提供我們打包好的靜態 HTML 文件，必須實作 WebMvcConfigurer 來註冊靜態資源路徑。\nimport org.springframework.context.annotation.ComponentScan; import org.springframework.context.annotation.Configuration; import org.springframework.web.servlet.config.annotation.EnableWebMvc; import org.springframework.web.servlet.config.annotation.ResourceHandlerRegistry; import org.springframework.web.servlet.config.annotation.WebMvcConfigurer; @Configuration @EnableWebMvc @ComponentScan(basePackages = \u0026#34;sparrow\u0026#34;) public class WebConfig implements WebMvcConfigurer { @Override public void addResourceHandlers(ResourceHandlerRegistry registry) { registry.addResourceHandler(\u0026#34;/docs/**\u0026#34;) .addResourceLocations(\u0026#34;classpath:/static/docs/\u0026#34;); } } 測試網頁 在瀏覽器中訪問 http://localhost:8080/docs/index.html 就可以看到生成的 API 文件網頁了。\n參考資料 Spring REST Docs 官方文件 Asciidoctor 語法快速參考指南 (AsciiDoc Syntax Quick Reference) JUnit 5 User Guide (官方測試框架文件) Spring Web MVC 官方文件 (靜態資源處理與 WebMvcConfigurer) Apache Maven Resources Plugin 官方文件 Asciidoctor Maven Plugin 官方文件 ","permalink":"http://blog.codeicu.dev/posts/spring-sparrow/rest-docs/","tags":[{"LinkTitle":"Spring","RelPermalink":"/tags/spring/"}],"title":"Spring Sparrow REST Docs"},{"categories":[],"content":"關注點 Spring Framework Api Server 的啟動，實作健康檢查 API，確保服務正常運行。 建立 API 加入相關依賴 在 pom.xml 中加入 spring-webmvc 相關依賴：\nspring-webmvc：Spring 框架的一部分，提供用於構建基於 Servlet 的 Web 應用程式的功能，包括 RESTful API 的開發支援。 jackson-databind：Jackson 的核心模組，提供將 Java 物件與 JSON 之間進行序列化和反序列化的功能。 jakarta.servlet-api：提供 Servlet API 的定義，允許開發者使用 Servlet 技術來處理 HTTP 請求和響應，適用於基於 Servlet 的 Web 應用程式開發。 ps. 移除 spring-webmvc 已包含的相關依賴，例： spring-context 避免版本衝突。\n\u0026lt;dependency\u0026gt; \u0026lt;groupId\u0026gt;org.springframework\u0026lt;/groupId\u0026gt; \u0026lt;artifactId\u0026gt;spring-webmvc\u0026lt;/artifactId\u0026gt; \u0026lt;version\u0026gt;7.0.5\u0026lt;/version\u0026gt; \u0026lt;/dependency\u0026gt; \u0026lt;dependency\u0026gt; \u0026lt;groupId\u0026gt;com.fasterxml.jackson.core\u0026lt;/groupId\u0026gt; \u0026lt;artifactId\u0026gt;jackson-databind\u0026lt;/artifactId\u0026gt; \u0026lt;version\u0026gt;2.21.1\u0026lt;/version\u0026gt; \u0026lt;/dependency\u0026gt; \u0026lt;dependency\u0026gt; \u0026lt;groupId\u0026gt;jakarta.servlet\u0026lt;/groupId\u0026gt; \u0026lt;artifactId\u0026gt;jakarta.servlet-api\u0026lt;/artifactId\u0026gt; \u0026lt;version\u0026gt;6.0.0\u0026lt;/version\u0026gt; \u0026lt;scope\u0026gt;provided\u0026lt;/scope\u0026gt; \u0026lt;/dependency\u0026gt; 新增 web 設定類別 使用 Java Config 的方式，取代傳統的 XML 設定，\n初始化 Spring MVC 的相關設定。\nimport org.springframework.web.servlet.support.AbstractAnnotationConfigDispatcherServletInitializer; public class AppInitializer extends AbstractAnnotationConfigDispatcherServletInitializer { @Override protected Class\u0026lt;?\u0026gt;[] getRootConfigClasses() { return new Class[] { AppConfig.class }; } @Override protected Class\u0026lt;?\u0026gt;[] getServletConfigClasses() { return new Class[] { WebConfig.class }; } @Override protected String[] getServletMappings() { return new String[] { \u0026#34;/\u0026#34; }; } } 啟用 Spring MVC 功能並掃描指定的 base package。\nimport org.springframework.context.annotation.ComponentScan; import org.springframework.context.annotation.Configuration; import org.springframework.web.servlet.config.annotation.EnableWebMvc; import org.springframework.web.servlet.config.annotation.WebMvcConfigurer; @Configuration @EnableWebMvc @ComponentScan(basePackages = \u0026#34;sparrow\u0026#34;) public class WebConfig implements WebMvcConfigurer { } JPA 資料庫設定，\u0026lsquo;資料庫驅動\u0026rsquo;與\u0026rsquo;設定讀取\u0026rsquo;application.properties。\n追加 Class.forName(\u0026ldquo;com.mysql.cj.jdbc.Driver\u0026rdquo;); // 確保 MySQL 驅動程式被載入。\n當應用程式部署到 Tomcat 時，Tomcat 為了確保不同的 Web 應用程式 (WAR) 之間不會互相干擾，採用了階層式的類別載入器 (ClassLoader) 機制。\nBootstrap ClassLoader (底層)：載入 Java 核心類別，包含 java.sql.DriverManager。\nWebapp ClassLoader (應用層)：載入你 WAR 檔裡 WEB-INF/lib 的套件，包含 mysql-connector-j.jar\npublic DataSource dataSource() { try { Class.forName(\u0026#34;com.mysql.cj.jdbc.Driver\u0026#34;); } catch (ClassNotFoundException e) { throw new RuntimeException(\u0026#34;無法載入 MySQL 驅動程式\u0026#34;, e); } HikariConfig config = new HikariConfig(); config.setJdbcUrl(env.getProperty(\u0026#34;spring.datasource.url\u0026#34;)); config.setUsername(env.getProperty(\u0026#34;spring.datasource.username\u0026#34;)); config.setPassword(env.getProperty(\u0026#34;spring.datasource.password\u0026#34;)); return new HikariDataSource(config); } 實作 Controller 類別 import org.springframework.web.bind.annotation.GetMapping; import org.springframework.web.bind.annotation.RequestMapping; import org.springframework.web.bind.annotation.RestController; import java.util.HashMap; import java.util.Map; @RestController @RequestMapping(\u0026#34;/api\u0026#34;) public class Controller { @GetMapping(\u0026#34;/status\u0026#34;) public Map\u0026lt;String, String\u0026gt; getStatus() { Map\u0026lt;String, String\u0026gt; status = new HashMap\u0026lt;\u0026gt;(); status.put(\u0026#34;status\u0026#34;, \u0026#34;ok\u0026#34;); return status; } } Tomcat 服務器 打包類型改變 jar -\u0026gt; war\n\u0026lt;packaging\u0026gt;war\u0026lt;/packaging\u0026gt; 加入 war 套件 為了部署到 Tomcat，我們需要修改 Maven 的打包類型，並加入 maven-war-plugin 外掛。\n由於採用了全 Java 設定（無 web.xml），需要將 failOnMissingWebXml 設為 false。\n\u0026lt;plugin\u0026gt; \u0026lt;groupId\u0026gt;org.apache.maven.plugins\u0026lt;/groupId\u0026gt; \u0026lt;artifactId\u0026gt;maven-war-plugin\u0026lt;/artifactId\u0026gt; \u0026lt;version\u0026gt;3.4.0\u0026lt;/version\u0026gt; \u0026lt;configuration\u0026gt; \u0026lt;failOnMissingWebXml\u0026gt;false\u0026lt;/failOnMissingWebXml\u0026gt; \u0026lt;/configuration\u0026gt; \u0026lt;/plugin\u0026gt; 使用 Docker 部署 Tomcat 服務器 使用 Docker 與 Docker Compose 來建立本地的 Tomcat 執行環境\nDockerfile 設定 使用 OpenJDK 提供的 Ubuntu 基礎映像檔（temurin-noble）。\nFROM tomcat:11.0.18-jdk21-temurin-noble EXPOSE 8080 CMD [ \u0026#34;catalina.sh\u0026#34;, \u0026#34;run\u0026#34; ] Docker Compose 設定 建立服務容器，將本機編譯產出的 WAR 檔掛載到 Tomcat 的 webapps 目錄下並命名為 ROOT.war（作為根目錄應用程式）。\n額外掛載的 context.xml 和 server.xml 用於自訂 Tomcat 設定。\n將 server.xml 中的 autoDeploy 設為 true，Tomcat 將自動部署更新的 WAR 檔而無須重啟容器。\nlegacy: build: ./legacy ports: - \u0026#34;${LEGACY_PORT}:8080\u0026#34; networks: - spring-sparrow volumes: - ./legacy/target/legacy-1.0.war:/usr/local/tomcat/webapps/ROOT.war - ./legacy/context.xml:/usr/local/tomcat/conf/context.xml:ro - ./legacy/server.xml:/usr/local/tomcat/conf/server.xml:ro env_file: - .env networks: spring-sparrow: driver: bridge 啟動 docker compose up -d mvn clean package 測試服務 啟動 Docker 容器後，開啟瀏覽器或使用 Postman 訪問 API 端點（假設 LEGACY_PORT 為 8080）：\nurl: http://localhost:8080/api/status { \u0026#34;status\u0026#34;: \u0026#34;ok\u0026#34; } 參考資料 Spring MVC 官方文件 Tomcat Image Docker Compose 官方文件 Dockerfile Reference ","permalink":"http://blog.codeicu.dev/posts/spring-sparrow/api/","tags":[{"LinkTitle":"Spring","RelPermalink":"/tags/spring/"}],"title":"Spring Sparrow API"},{"categories":[],"content":"介紹 SOLID 是由 Robert Cecil Martin (Uncle Bob) 等人倡議的五項核心原則。\n它們並非刻板的硬性規定，而更像是一種精神指引，幫助開發者建立易於維護、具備彈性的系統：\nSRP：單一職責原則 (Single Responsibility Principle) OCP：開放封閉原則 (Open-Closed Principle) LSP：里氏替換原則 (Liskov Substitution Principle) ISP：介面隔離原則 (Interface Segregation Principle) DIP：依賴反轉原則 (Dependency Inversion Principle) 本篇將聚焦於最後一個原則：依賴反轉原則 (DIP)。\nDependency Inversion Principle 高階模組（核心邏輯）不應依賴低階模組（細節實作），兩者都應依賴抽象。 (High-level modules should not depend on low-level modules. Both should depend on abstractions.) 抽象不應依賴細節。細節應依賴抽象。 (Abstractions should not depend on details. Details should depend on abstractions.) 我們似乎又換了一個說法，講著同樣的事\nProgramming to an abstraction, not an implementation\n程式舉例：依賴、持有、組合? 在探討反轉之前，我們需要先釐清什麼是「依賴」。在物件導向設計中，物件之間的關係強度各有不同。\n依賴 (Dependency - \u0026ldquo;uses-a\u0026rdquo;) // 依賴的範例 public class A { public void call() {/*do something*/}; } public class B { public void call(A inj) { A.call(); }; } 當一個類別的方法使用了另一個類別，但沒有將其儲存為狀態，這是一種較弱的關聯。\n聚合 / 持有 (Aggregation - \u0026ldquo;has-a\u0026rdquo;) // 持有的範例 public class B { private A myA; public B (A inj) { this.myA = inj; } public void call() { this.myA.call(); } } 這時候是依賴嗎?還是?\nAns: 這是 \u0026ldquo;has-a\u0026rdquo; 持有(has-a)是更強的依賴\n持有，多數情況下也被稱作，聚合(aggregation)\n組合 (Composition - \u0026ldquo;contains-a\u0026rdquo;) // 組合的範例 public class B { final private myA = new A(); public void call() { this.myA.call(); } } 這是 \u0026ldquo;composition\u0026rdquo;\n組合的依賴，影響生命週期 B 不僅擁有 A，還負責 A 的創建與銷毀。\n試問：這是什麼關聯? 刀在人在，刀不在人不在\n程式範例：依賴反轉 什麼才是依賴反轉?\n我們將原本的程式調整一下，來作說明\npublic interface Contract { public void call(); } // A 相對不穩(實作) public class A implements Contract { @Override public void call() { /* do something */ } } // B 相對穩定(控制) public class B { private Contract myContract; public B (Contract inj) { this.myContract = inj; } public void call() { this.myContract.call(); } } 在這個結構中，A 類別與 B 類別同時依賴 Contract 這個抽象介面。\nClass B 不再關心 A 的具體實作，\n只要 Contract 這個「抽象」保持不變，B 就能穩定運作。\n除非有跳脫抽象意義的實作(違反 LSP)，\n這就是依賴反轉。\n結論 DIP 關注不僅只有依賴強度，更重要的是：\n軟體是否依賴於不穩定的細節？還是依賴於穩定的抽象？\n思考 抽象是什麼? 對我而言，「抽象」是軟體工程裡「說好的規範與合約」。 維持軟體穩定的「抽象」，只有在只有在程式碼層級中嗎? 參考資料 Clean Architecture - Robert C. Martin ","permalink":"http://blog.codeicu.dev/posts/solid/dip/","tags":[{"LinkTitle":"Basic","RelPermalink":"/tags/basic/"}],"title":"SOLID：依賴反轉原則 (DIP)"},{"categories":[],"content":"介紹 SOLID 是由 Robert Cecil Martin (Uncle Bob) 等人倡議的五項核心原則。\n它們並非刻板的硬性規定，而更像是一種精神指引，幫助開發者建立易於維護、具備彈性的系統：\nSRP：單一職責原則 (Single Responsibility Principle) OCP：開放封閉原則 (Open-Closed Principle) LSP：里氏替換原則 (Liskov Substitution Principle) ISP：介面隔離原則 (Interface Segregation Principle) DIP：依賴反轉原則 (Dependency Inversion Principle) 這篇將聚焦於第四個原則：介面隔離原則 (ISP)。\n這項原則的存在感比較小，用來對「介面」的設計進行規範，\n在物件導向設計中，遵循 ISP 往往能自然引導我們走向另一項重要的設計準則：「組合取代繼承」\nISP：介面隔離原則 (Interface Segregation Principle) 不應強迫客戶端依賴它們不使用的方法。 (No client should be forced to depend on methods it does not use. )\n這項原則強調應該將大型、臃腫的介面（Fat Interface）拆分成更小、更專注的介面。\n在系統設計上，ISP 對於應對未來的潛在改變非常重要；\n若能妥善規劃介面，未來的重構與擴展將會更加順利。\n程式舉例 假設今天我們需要設計一套多功能印表機的系統：\npublic interface IMachine { void print(); void scan(); void fax(); } public class SmartPrinter implements IMachine { public void print() { /* do something */ } public void scan() { /* do something */ } public void fax() { /* do something */ } } public class BasicPrinter implements IMachine { public void print() { /* do something */ } public void scan() { throw new UnsupportedOperationException(); } public void fax() { throw new UnsupportedOperationException(); } } 上述例子中，BasicPrinter（基本印表機）被迫實作了它根本不需要的 scan 與 fax 方法。這會導致以下問題：\n程式碼冗餘：出現許多拋出例外的無效實作。 不同介面耦合過高：如果 scan 方法的介面改變，連帶不需要該方法的 BasicPrinter 也可能需要重新編譯或修改。 根據 ISP，我們可以將介面拆分如下：\npublic interface IPrinter { void print(); } public interface IScanner { void scan(); } public interface IFax { void fax(); } // 智慧型印表機實作所需的多個介面 public class SmartPrinter implements IPrinter, IScanner, IFax { public void print() { /* do something */ } public void scan() { /* do something */ } public void fax() { /* do something */ } } // 基本印表機僅實作自己需要的介面 public class BasicPrinter implements IPrinter { public void print() { /* do something */ } } 組合取代繼承(Composition over Inheritance) ISP 將臃腫的介面拆分為多個專一的小介面， 這種「模組化」的思維與《設計模式》中的核心準則高度契合:\n「優先使用物件組合，而不是類別繼承」 (Favor object composition over class inheritance)\n什麼是組合 組合是一種 \u0026ldquo;has-a\u0026rdquo; 的關係，\n將多個物件 (通常宣告 interface) 組合在一起，來實現複雜功能。\n被稱為黑箱復用(Black-box reuse)，\n因為物件之間只透過介面互動，無須知道彼此的內部細節。\n透過組合來構建類別，可以帶來以下優勢：\n低耦合：物件之間維持獨立，修改一個類別對其他類別的影響降到最低。 動態性：可以在執行時期更換組合的物件，靈活改變行為。 職責單純：各介面與類別的職責簡潔化，避免了繼承體系過深或類別爆炸的問題。 結論 ISP 表面上關注的是消除冗餘的程式碼與方法，但其最深層的目的是：\n確保介面設計具備高度的靈活性與內聚力\n當介面被正確隔離，系統的組件就能像樂高積木一樣，透過「組合」靈活應對各種需求變化。\n參考 Clean Architecture - Robert C. Martin Design Patterns: Elements of Reusable Object-Oriented Software - Gang of Four, GoF ","permalink":"http://blog.codeicu.dev/posts/solid/isp/","tags":[{"LinkTitle":"Basic","RelPermalink":"/tags/basic/"}],"title":"SOLID：介面隔離原則 (ISP)"},{"categories":[],"content":"關注點 使用 JPA 連接 MySql，連通創建資料。 使用 JPA 連接 MySql 加入相關依賴 以下是我們需要在 pom.xml 中加入的相關依賴：\nlogback-classic：提供高效能與靈活的日誌記錄功能，為 SLF4J (Simple Logging Facade for Java) 的標準實作。 hibernate-core：提供 Java 物件與關聯式資料庫之間的映射和管理功能（ORM）。它支援 JPA 規範，包含快取、事務管理與查詢語言等功能。spring-orm 和 spring-data-jpa 皆可透過 Hibernate 作為底層實作。 mysql-connector-j：MySQL 的 JDBC 驅動程式，允許 Java 應用程式與 MySQL 資料庫進行連線與互動。 HikariCP：高效能的 JDBC 連線池實作，提供優異的連線管理與效能最佳化。 spring-orm：Spring 框架的一部分，提供對 ORM 框架的整合支援，結合 Spring 的依賴注入與事務管理功能。 spring-data-jpa：Spring 提供用來簡化 JPA 使用的抽象層，具備自動生成查詢方法、分頁與排序等功能，大幅降低資料庫互動的開發成本。 \u0026lt;dependency\u0026gt; \u0026lt;groupId\u0026gt;ch.qos.logback\u0026lt;/groupId\u0026gt; \u0026lt;artifactId\u0026gt;logback-classic\u0026lt;/artifactId\u0026gt; \u0026lt;version\u0026gt;1.5.32\u0026lt;/version\u0026gt; \u0026lt;/dependency\u0026gt; \u0026lt;dependency\u0026gt; \u0026lt;groupId\u0026gt;org.hibernate.orm\u0026lt;/groupId\u0026gt; \u0026lt;artifactId\u0026gt;hibernate-core\u0026lt;/artifactId\u0026gt; \u0026lt;version\u0026gt;7.2.5.Final\u0026lt;/version\u0026gt; \u0026lt;/dependency\u0026gt; \u0026lt;dependency\u0026gt; \u0026lt;groupId\u0026gt;com.mysql\u0026lt;/groupId\u0026gt; \u0026lt;artifactId\u0026gt;mysql-connector-j\u0026lt;/artifactId\u0026gt; \u0026lt;version\u0026gt;9.6.0\u0026lt;/version\u0026gt; \u0026lt;/dependency\u0026gt; \u0026lt;dependency\u0026gt; \u0026lt;groupId\u0026gt;com.zaxxer\u0026lt;/groupId\u0026gt; \u0026lt;artifactId\u0026gt;HikariCP\u0026lt;/artifactId\u0026gt; \u0026lt;version\u0026gt;5.1.0\u0026lt;/version\u0026gt; \u0026lt;/dependency\u0026gt; \u0026lt;dependency\u0026gt; \u0026lt;groupId\u0026gt;org.springframework\u0026lt;/groupId\u0026gt; \u0026lt;artifactId\u0026gt;spring-orm\u0026lt;/artifactId\u0026gt; \u0026lt;version\u0026gt;7.0.5\u0026lt;/version\u0026gt; \u0026lt;/dependency\u0026gt; \u0026lt;dependency\u0026gt; \u0026lt;groupId\u0026gt;org.springframework.data\u0026lt;/groupId\u0026gt; \u0026lt;artifactId\u0026gt;spring-data-jpa\u0026lt;/artifactId\u0026gt; \u0026lt;version\u0026gt;4.0.3\u0026lt;/version\u0026gt; \u0026lt;/dependency\u0026gt; 設定資料庫連線 資料庫連線資訊通常統一設定在 application.properties 中。\n以下是相關的參數定義：\nJDBC 與 DataSource 設定 spring.datasource.url：資料庫的連接 URL，格式為 jdbc:mysql://hostname:port/databaseName?param1=value1\u0026amp;param2=value2。 spring.datasource.username：資料庫的使用者名稱。 spring.datasource.password：資料庫的密碼。 spring.datasource.driver-class-name：JDBC 驅動程式的類名，對於 MySQL 是 com.mysql.cj.jdbc.Driver。 Hibernate 與 JPA 設定 spring.jpa.hibernate.ddl-auto：Hibernate 的 DDL (Data Definition Language) 自動生成策略。設為 update 代表會根據 Entity 自動更新資料庫結構。 spring.jpa.show-sql：設為 true 時，會在控制台印出 Hibernate 實際執行的 SQL 語句，方便 Debug。 spring.datasource.url=jdbc:mysql://localhost:3306/dev?useUnicode=true\u0026amp;characterEncoding=UTF-8 spring.datasource.username=account spring.datasource.password=password spring.datasource.driver-class-name=com.mysql.cj.jdbc.Driver spring.jpa.hibernate.ddl-auto=update spring.jpa.show-sql=true 讀取資料庫連線資訊與設定 Spring 容器 在純 Spring 環境下讀取外部屬性檔案有幾種常見做法：\nEnvironment 物件：透過注入 Spring 的 Environment 物件，使用 getProperty 方法讀取參數值。\n@Value 註解：直接在屬性上標記並讀取 application.properties 中的值。\n@ConfigurationProperties：將屬性綁定到特定的 Java 類別中（較常用於 Spring Boot）。\n此處我們採用 Environment 物件 的方式設定基礎建設。\nEnvironment 允許從 application.properties 讀取屬性，也支援系統環境變數等來源。\n以下是相關程式碼：\nimport java.util.Properties; import javax.sql.DataSource; import org.springframework.beans.factory.annotation.Autowired; import org.springframework.context.annotation.Bean; import org.springframework.context.annotation.Configuration; import org.springframework.context.annotation.PropertySource; import org.springframework.context.support.PropertySourcesPlaceholderConfigurer; import org.springframework.core.env.Environment; import org.springframework.data.jpa.repository.config.EnableJpaRepositories; import org.springframework.orm.jpa.JpaTransactionManager; import org.springframework.orm.jpa.LocalContainerEntityManagerFactoryBean; import org.springframework.orm.jpa.vendor.HibernateJpaVendorAdapter; import org.springframework.transaction.PlatformTransactionManager; import org.springframework.transaction.annotation.EnableTransactionManagement; import com.zaxxer.hikari.HikariConfig; import com.zaxxer.hikari.HikariDataSource; import jakarta.persistence.EntityManagerFactory; @Configuration @PropertySource(\u0026#34;classpath:application.properties\u0026#34;) @EnableTransactionManagement @EnableJpaRepositories(basePackages = \u0026#34;sparrow.repository\u0026#34;) public class JPAMySqlConfig { @Bean public static PropertySourcesPlaceholderConfigurer propertySourcesPlaceholderConfigurer() { return new PropertySourcesPlaceholderConfigurer(); } @Autowired private Environment env; @Bean public DataSource dataSource() { HikariConfig config = new HikariConfig(); config.setJdbcUrl(env.getProperty(\u0026#34;spring.datasource.url\u0026#34;)); config.setUsername(env.getProperty(\u0026#34;spring.datasource.username\u0026#34;)); config.setPassword(env.getProperty(\u0026#34;spring.datasource.password\u0026#34;)); return new HikariDataSource(config); } @Bean public LocalContainerEntityManagerFactoryBean entityManagerFactory() { LocalContainerEntityManagerFactoryBean factoryBean = new LocalContainerEntityManagerFactoryBean(); factoryBean.setDataSource(dataSource()); factoryBean.setPackagesToScan(\u0026#34;sparrow.entity\u0026#34;); HibernateJpaVendorAdapter vendorAdapter = new HibernateJpaVendorAdapter(); factoryBean.setJpaVendorAdapter(vendorAdapter); Properties props = new Properties(); props.put(\u0026#34;hibernate.show_sql\u0026#34;, env.getProperty(\u0026#34;spring.jpa.show-sql\u0026#34;)); props.put(\u0026#34;hibernate.hbm2ddl.auto\u0026#34;, env.getProperty(\u0026#34;spring.jpa.hibernate.ddl-auto\u0026#34;)); props.put(\u0026#34;hibernate.dialect\u0026#34;, \u0026#34;org.hibernate.dialect.MySQLDialect\u0026#34;); factoryBean.setJpaProperties(props); // 設定 Hibernate 的相關屬性，顯示 SQL、DDL 自動生成策略和方言等 return factoryBean; } @Bean public PlatformTransactionManager transactionManager( EntityManagerFactory emf) { if (emf != null) { return new JpaTransactionManager(emf); } throw new IllegalArgumentException(\u0026#34;EntityManagerFactory cannot be null\u0026#34;); } } 新增一個使用者 Entity 類別實作 Entity 是記憶體中的 Java 類別，透過 JPA 註解與資料庫中的資料表進行映射綁定。\n@Entity @Table(name = \u0026#34;users\u0026#34;) public class User { @Id @GeneratedValue(strategy = GenerationType.IDENTITY) private Long id; @Column(nullable = false, length = 50) private String name; @Column(nullable = false, length = 50) private String password; @Column(nullable = false, length = 50) private String email; @Column(columnDefinition = \u0026#34;TEXT\u0026#34;) private String description; // 必須提供無參數的建構子，JPA 需要使用反射來創建實體對象 public User() { } // getter 和 setter 方法，用於訪問和修改實體在記憶體中的屬性 // ... 省略 getter 和 setter 方法 ... } Repository 類別實作 宣告 Repository 介面並繼承 JpaRepository。\nSpring Data JPA 會在執行時期自動提供其實作類別，開發者無須手動撰寫基礎的 CRUD 邏輯。\nimport org.springframework.data.jpa.repository.JpaRepository; import org.springframework.stereotype.Repository; import sparrow.entity.User; @Repository public interface UserRepository extends JpaRepository\u0026lt;User, Long\u0026gt; { // 預設已繼承標準的 CRUD 方法 // 若有複雜查詢需求，可依照 Spring Data 命名規範自訂方法名稱 } 實際運作 這是一個將 Entity 寫入資料庫的簡單範例。\nGenericApplicationContext context = new AnnotationConfigApplicationContext(AppConfig.class); try { UserRepository userRepository = context.getBean(UserRepository.class); System.out.println(\u0026#34;正在執行...\u0026#34;); User newUser = new User(); newUser.setPassword(\u0026#34;password\u0026#34;); newUser.setName(\u0026#34;Tester\u0026#34;); newUser.setEmail(\u0026#34;test@sparrow.dev\u0026#34;); newUser.setDescription(\u0026#34;這是一個測試使用者\u0026#34;); userRepository.save(newUser); System.out.println(\u0026#34;使用者已儲存，ID 為: \u0026#34; + newUser.getId()); List\u0026lt;User\u0026gt; users = userRepository.findAll(); System.out.println(\u0026#34;目前資料庫中的使用者總數: \u0026#34; + users.size()); users.forEach(u -\u0026gt; System.out.println(\u0026#34; - \u0026#34; + u.getName() + \u0026#34; (\u0026#34; + u.getEmail() + \u0026#34;)\u0026#34;)); } catch (Exception e) { System.err.println(\u0026#34;發生錯誤:\u0026#34;); e.printStackTrace(); } finally { System.out.println(\u0026#34;關閉 Spring 容器...\u0026#34;); context.close(); } 參考資料 Spring Data JPA Documentation JpaRepository API Hibernate ORM Documentation MAVEN Dependency ","permalink":"http://blog.codeicu.dev/posts/spring-sparrow/jpa/","tags":[{"LinkTitle":"Spring","RelPermalink":"/tags/spring/"}],"title":"Spring Sparrow JPA"},{"categories":[],"content":"關注點 Spring Framework 的 AOP 功能與應用舉例。 使用 AOP AOP (Aspect-Oriented Programming, 切面導向程式設計) 允許開發者將「切面關注點」（如日誌、安全、事務管理）從業務邏輯中分離。\n在開始之前，我們先釐清幾個核心術語：\nAspect (切面)：橫切關注點的模組化（如：LoggingAspect）。 Join Point (連接點)：程式執行中的特定點（在 Spring 中通常指方法呼叫）。 Advice (通知)：在連接點執行的動作（如：@Before, @After, @Around）。 Pointcut (切入點)：定義 Advice 應該在哪些連接點執行的表達式。 加入 Spring AOP 依賴 \u0026lt;dependencies\u0026gt; \u0026lt;dependency\u0026gt; \u0026lt;groupId\u0026gt;org.springframework\u0026lt;/groupId\u0026gt; \u0026lt;artifactId\u0026gt;spring-aop\u0026lt;/artifactId\u0026gt; \u0026lt;version\u0026gt;7.0.5\u0026lt;/version\u0026gt; \u0026lt;/dependency\u0026gt; \u0026lt;/dependencies\u0026gt; 定義一個切面 package com.example; import org.aspectj.lang.annotation.Aspect; import org.aspectj.lang.annotation.Before; import org.springframework.stereotype.Component; @Aspect @Component public class LoggingAspect { // 定義切入點：當 sparrow 套件下的 MessageService 類別的 getMessage() 被呼叫前 @Before(\u0026#34;execution(* sparrow.MessageService.getMessage(..))\u0026#34;) public void logBefore() { System.out.println(\u0026#34;LoggingAspect: Before executing getMessage()\u0026#34;); } } 開啟 AOP 功能 @Configuration @ComponentScan(basePackages = \u0026#34;sparrow\u0026#34;) // 掃描 sparrow 套件下的元件 @EnableAspectJAutoProxy // 啟用 AOP 功能 public class AppConfig { @Bean public MessageService messageService() { return new MessageService(); } } AOP 的運作流程 當 messageService.getMessage() 被呼叫時，Spring AOP 會攔截這個方法呼叫。 在方法執行前，LoggingAspect.logBefore() 會被執行，輸出 \u0026ldquo;LoggingAspect: Before executing getMessage()\u0026quot;。 最後，執行 messageService.getMessage() 的原始邏輯。 設計模式 Proxy Design Pattern (代理模式) AOP 的實作原理是基於代理模式。Spring AOP 會在執行時期根據情況選擇不同的代理機制：\nJDK Dynamic Proxy：若目標類別實作了介面，Spring 預設使用 JDK 動態代理。 CGLIB：若目標類別沒有實作介面，Spring 會透過 CGLIB 在記憶體中建立目標類別的子類別來實作代理。 代理物件會攔截對目標物件的方法呼叫，並在適當的時機執行切面邏輯。\n衍伸應用 事務管理 (Transaction Management) AOP 可以用來實作事務管理，當方法執行時自動開始事務，執行完成後自動提交或回滾。\n@Transactional public void exec() { // 這裡的邏輯會在事務中執行 // 如果發生例外，事務會自動回滾 } 快取處理 (Caching) AOP 可以實作快取機制，呼叫方法前先檢查快取，若有結果則直接回傳。\n@Cacheable：方法結果會被快取。 @CacheEvict：清除特定快取資料。 @CachePut：更新快取資料。 權限控制 結合 Spring Security 實作細粒度的權限控制：\n@PreAuthorize：方法執行前檢查權限。 @PostAuthorize：方法執行後檢查回傳結果。 @Secured：基於角色的傳統權限檢查。 註：在 Spring Boot 3 / Spring Security 6 中，建議使用 @EnableMethodSecurity 來啟用此功能。\n@PreAuthorize(\u0026#34;hasRole(\u0026#39;ADMIN\u0026#39;)\u0026#34;) public void adminOnlyMethod() { // 只有具備 ADMIN 角色的使用者才能執行 } 非同步處理 透過 AOP 實作非同步呼叫，方法會在新的執行緒中執行，不會阻塞主程式。\n@Async public void asyncMethod() { // 此邏輯會非同步執行 } 監控統計 利用 @Around 通知來記錄方法的執行時間。\n@Aspect @Component public class PerformanceAspect { @Around(\u0026#34;execution(* sparrow..*(..))\u0026#34;) public Object monitorPerformance(ProceedingJoinPoint joinPoint) throws Throwable { long start = System.currentTimeMillis(); try { return joinPoint.proceed(); // 執行初始方法 } finally { long executionTime = System.currentTimeMillis() - start; System.out.println(joinPoint.getSignature() + \u0026#34; 執行耗時: \u0026#34; + executionTime + \u0026#34;ms\u0026#34;); } } } 參考資料 Spring AOP 官方文件 ","permalink":"http://blog.codeicu.dev/posts/spring-sparrow/aop/","tags":[{"LinkTitle":"Spring","RelPermalink":"/tags/spring/"}],"title":"Spring Sparrow AOP"},{"categories":[],"content":"關注點 Spring Framework 的服務啟動流程：從建立 ApplicationContext 到註冊 Bean，再到啟動服務的整個過程。 啟動專案 maven cli 建立 mvn archetype:generate -DgroupId=sparrow -DartifactId=legacy -DarchetypeArtifactId=maven-archetype-quickstart -DinteractiveMode=false DgroupId : 組織名稱 DartifactId : 專案名稱 DarchetypeArtifactId : 專案模板 DinteractiveMode : 跳出詢問視窗 加入 Spring Framework 相關依賴 在 pom.xml 中加入 spring-context， 它會自動包含 spring-core、spring-beans 與 spring-aop 等核心模組：\n\u0026lt;dependencies\u0026gt; \u0026lt;dependency\u0026gt; \u0026lt;groupId\u0026gt;org.springframework\u0026lt;/groupId\u0026gt; \u0026lt;artifactId\u0026gt;spring-context\u0026lt;/artifactId\u0026gt; \u0026lt;version\u0026gt;7.0.5\u0026lt;/version\u0026gt; \u0026lt;/dependency\u0026gt; \u0026lt;/dependencies\u0026gt; 嘗試編譯 cd legacy # 進入 artifactId 定義的資料夾 mvn compile 創建一個 Bean Spring 的核心在於 IoC (Inversion of Control, 控制反轉) 與 DI (Dependency Injection, 依賴注入)。\n開發者將物件的生命週期管理交給容器（IoC），並透過注入的方式解決物件間的依賴關係（DI）。\nGenericApplicationContext 最基礎、最通用的 Spring 容器實作\nGenericApplicationContext context = new GenericApplicationContext(); // 建立容器 context.registerBean(String.class, () -\u0026gt; \u0026#34;Hello Bean\u0026#34;); // 手動 registerBean context.refresh(); // 刷新容器，完成註冊 String bean = context.getBean(String.class); // 獲取 Bean context.close(); AnnotationConfigApplicationContext 專門用來處理 Annotation 的 Spring 容器實作\n支持 @Configuration、@Component、@Bean、@ComponentScan、@Autowired 等註解\n註解稍後再說明\n// App.java AnnotationConfigApplicationContext context = new AnnotationConfigApplicationContext(AppConfig.class); HelloBean bean = context.getBean(HelloBean.class); System.out.println(bean.helloBean()); context.close(); // AppConfig.java @Configuration public class AppConfig { @Bean public HelloBean helloBean() { return new HelloBean(); } } // HelloBean.java public class HelloBean { public String helloBean() { return \u0026#34;Hello Bean\u0026#34;; } } ClassPathXmlApplicationContext 專門用來處理 XML 設定的 Spring 容器實作\nApplicationContext context = new ClassPathXmlApplicationContext(\u0026#34;beans.xml\u0026#34;); \u0026lt;bean id=\u0026#34;userService\u0026#34; class=\u0026#34;sparrow.MessageService\u0026#34;/\u0026gt; FileSystemXmlApplicationContext 專門用來處理 XML 設定的 Spring 容器實作，與 ClassPathXmlApplicationContext 類似，但從文件系統路徑讀取設定文件\nApplicationContext context = new FileSystemXmlApplicationContext(\u0026#34;config/beans.xml\u0026#34;); WebApplicationContext spring web 模組專用的 Spring 容器實作，提供了 Web 相關的功能\nWebApplicationContext context = new AnnotationConfigWebApplicationContext(); context.register(AppConfig.class); Spring Framework 的註解 @Configuration 表示這個類是 Spring 的設定類，類似於 XML 設定文件，可以用來定義 Bean 和其他設定\n@Configuration public class AppConfig { @Bean public HelloBean helloBean() { return new HelloBean(); } } @Bean 表示這個方法會返回一個 Bean，Spring 會將這個 Bean 註冊到容器中，方法名稱默認為 Bean 的名稱\n@Bean public HelloBean helloBean() { return new HelloBean(); } @Component 表示這個類是一個 Spring 管理的組件，Spring 會自動掃描並註冊這個類為 Bean\n@Component public class HelloBean { public String helloBean() { return \u0026#34;Hello Bean\u0026#34;; } } @ComponentScan 表示 Spring 需要掃描指定 package 下的類，並將帶有以下註解的類註冊為 Bean：\n@Component：最基礎的組件註解。 @Service：表示業務邏輯層。 @Repository：表示數據訪問層（DAO），並具備數據庫異常轉換功能。 @Controller：表示控制層（MVC）。 這些註解本質上都是 @Component 的特化（Stereotype）。\n@Configuration @ComponentScan(basePackages = \u0026#34;sparrow\u0026#34;) public class AppConfig { } @Autowired 表示 Spring 會自動注入依賴的 Bean。\n建議優先使用 建構子注入 (Constructor Injection)，因為它能確保依賴項不為 null 且方便進行單元測試。\n註：從 Spring 4.3 開始，如果類別只有一個建構子，@Autowired 註解可以省略。\n@Service public class OrderService { private final UserService userService; // 只有一個建構子時，@Autowired 可省略 public OrderService(UserService userService) { this.userService = userService; } } 參考 Spring Beans 官方文檔 ","permalink":"http://blog.codeicu.dev/posts/spring-sparrow/start/","tags":[{"LinkTitle":"Spring","RelPermalink":"/tags/spring/"}],"title":"Spring Sparrow Start"},{"categories":[],"content":"介紹 SOLID 是由 Robert Cecil Martin (Uncle Bob) 等人倡議的五項核心原則。\n它們並非刻板的硬性規定，而更像是一種精神指引，幫助開發者建立易於維護、具備彈性的系統：\nSRP：單一職責原則 (Single Responsibility Principle) OCP：開放封閉原則 (Open-Closed Principle) LSP：里氏替換原則 (Liskov Substitution Principle) ISP：介面隔離原則 (Interface Segregation Principle) DIP：依賴反轉原則 (Dependency Inversion Principle) 這篇將聚焦於第三個原則：里氏替換原則 (LSP)。\n這項原則的存在感可能不如 SRP 和 OCP 那麼強烈，甚至在某些情況下會被忽略。\n但它卻是物件導向設計中非常重要的一環，因為它直接關係到繼承和多型的正確使用。\nLiskov Substitution Principle (LSP) 里氏替換原則 若對型態 S 的每一個物件 o1，都存在一個型態為 T 的物件 o2，使得在所有針對 T 撰寫的程式 P 中，用 o1 替換 o2 後，程式 P 的行為功能不變，則 S 是 T 的子型態。 (If for each object o1 of type S there is an object o2 of type T such that for all programs P defined in terms of T, the behavior of P is unchanged when o1 is substituted for o2, then S is a subtype of T.)\nLSP 是「針對抽象寫程式」這項概念的具體說明，確保程式在使用繼承和多型時能保持正確性和一致性。\n程式舉例：違反 LSP 的例子 // 這是一個違反 LSP 的例子 public class Rectangle { protected int width; protected int height; public void setWidth(int width) { this.width = width; } public void setHeight(int height) { this.height = height; } public int getArea() { return width * height; } } public class Square extends Rectangle { @Override public void setWidth(int width) { this.width = width; this.height = width; // 正方形強制寬高一致 } @Override public void setHeight(int height) { this.height = height; this.width = height; // 正方形強制寬高一致 } } public void testArea() { Rectangle rect = new Square(); rect.setWidth(5); rect.setHeight(10); // 呼叫端預期的行為是面積為 50 (5 * 10) // 但實際上會得到 100，因為 Square 的 setHeight 覆寫了 Rectangle 的預期行為，連帶改變了 width assert rect.getArea() == 50; // 這裡會失敗 } 上述是一個經典的 LSP 違反例子。Square 類別繼承自 Rectangle，\n但其 setWidth 和 setHeight 方法的行為違反了外界對 Rectangle 行為的預期。\n語意背叛 在開發套件（Library）時，LSP 的違反往往更隱晦。例如：\n父類別契約：定義一個測量方法，預期回傳攝氏溫度。\n子類別實作：卻因為內部邏輯或硬體差異，回傳了華氏溫度。\n雖然型別對了，但抽象的意義改變了。\n更好的做法：提取更純粹的抽象（抽離不適合的行為，改為不可變或建立共通介面）：\npublic interface Shape { int getArea(); } public class Rectangle implements Shape { private int width; private int height; public Rectangle(int width, int height) { this.width = width; this.height = height; } @Override public int getArea() { return width * height; } } public class Square implements Shape { private int side; public Square(int side) { this.side = side; } @Override public int getArea() { return side * side; } } 型態(type) 與介面的約定 常有人的誤解是，LSP 只生效在「類別繼承 (Class Inheritance)」的關係中。\n但如果我們回頭看 LSP 的基本定義，它強調的是「子型態 (Subtype)」。\n這意味著，即使是針對 Interface（介面）的實作，為了維持系統穩定，我們仍然必須遵守 LSP 的要求。\npublic interface Shape { void setWidth(int width); void setHeight(int height); int getArea(); } public class Rectangle implements Shape { private int width; private int height; @Override public void setWidth(int width) { this.width = width; } @Override public void setHeight(int height) { this.height = height; } @Override public int getArea(){ return this.height * this.width; } } public class Square implements Shape { private int side; @Override public void setWidth(int width) { this.side = width; } @Override public void setHeight(int height) { this.side = height; // 強制連動 } @Override public int getArea() { return this.side * this.side; } } // 測試介面的合約 public void testShapeArea(Shape shape) { shape.setWidth(5); shape.setHeight(10); // 根據 Shape 介面的隱含合約，寬與高應該是獨立設置的 if (shape.getArea() != 50) { System.out.println(\u0026#34;違反 LSP！預期 50，實際得到: \u0026#34; + shape.getArea()); } else { System.out.println(\u0026#34;符合預期。\u0026#34;); } } 即便只是實作同一個 Shape 介面，Square 的內部邏輯依然破壞了呼叫端對於「設定寬不影響高」的合理預期。\n在抽象上的程式 軟體開發中有一項重要的概念：\n針對抽象寫程式，而非針對實作寫程式 (Programming to an abstraction, not an implementation)\n堅守 LSP 能帶來以下好處：\n行為預測性：呼叫者不需要查看子類別原始碼，也能確定行為。\n多型穩定性：可以放心地更換不同的實作（感測器、資料庫、圖形元件）而不需要修改主邏輯。\n解耦：模組之間透過「契約」對話，而非「細節」對話。\n結論 LSP 關注的重點不在於語法上的繼承與否，而是：\n我們是否有確實遵守並實現基礎型態的「行為約定 (Design by Contract)」\n參考 Clean Architecture - Robert C. Martin Behavioral Subtyping Using Invariants and Constraints ","permalink":"http://blog.codeicu.dev/posts/solid/lsp/","tags":[{"LinkTitle":"Basic","RelPermalink":"/tags/basic/"}],"title":"SOLID：里氏替換原則 (LSP)"},{"categories":[],"content":"介紹 SOLID 是由 Robert Cecil Martin (Uncle Bob) 等人倡議的五項核心原則。\n它們並非刻板的硬性規定，而更像是一種精神指引，幫助開發者建立易於維護、具備彈性的系統：\nSRP：單一職責原則 (Single Responsibility Principle) OCP：開放封閉原則 (Open-Closed Principle) LSP：里氏替換原則 (Liskov Substitution Principle) ISP：介面隔離原則 (Interface Segregation Principle) DIP：依賴反轉原則 (Dependency Inversion Principle) 本篇將聚焦於第二個原則：開放封閉原則 (OCP)。\nOpen-Closed Principle (OCP) 開放封閉原則 軟體實體應該對擴展開放，對修改封閉。 (Software entities should be open for extension, but closed for modification.)\n軟體的唯一不變，正是「變」。\nOCP 的核心目標是：當需求變更時，透過增加新程式碼來解決，而不是修改舊有且穩定運作的程式碼。\n程式舉例：變動的演算法 承接上回 SRP 的例子，如果我們將邏輯寫死在一個方法內\n// 違反 OCP 的例子：每增加一種會員類型或折扣邏輯，就必須修改這個方法 public double calcUserOrder(User user, double price, int quantity) { if (user.isVIP()) { return price * quantity * VIP_DISCOUNT; } else { return price * quantity; } } 如果今天要變動 VIP 的計算行為，必須修改函式內容。\n作為既有的程式碼，我們應予以「封閉」，\n避免因為修改，導致重新編義與重新測試。\n除了按照上回的方式處理職責，我們是否有其他辦法呢?\n當然有的，這裡舉其中一個辦法。\n假設今天變動的地方是：計算行為。\n我們可以這麼做：\npublic interface OrderStrategy { double calc(double price, int quantity); } public class NormalStrategy implements OrderStrategy { public double calc(double price, int quantity) { return price * quantity; } } public class VIPStrategy implements OrderStrategy { private final double vipDiscount; public VIPStrategy(double discount) { this.vipDiscount = discount; } public double calc(double price, int quantity) { return price * quantity * vipDiscount; } } public class Order { private double price; private int quantity; public double calculateOrder(OrderStrategy strategy) { return strategy.calc(this.price, this.quantity); } } 未來如果有「節慶促銷」或「新會員制度」，我們只需要新增一個實作 OrderStrategy 的類別。\n完全不需要更動到 Order 類別的原始碼，只要擴充 OrderStrategy 種類就可以。\n這個實作的解法，也是其中一項設計模式，策略模式 (Strategy Design Pattern)。\n無止盡的擴充 事實上擴充方向非常多，也因此常常造成過度設計（Over-Engineering）。\n「封閉」與「開放」是一個相對的概念。\n只有需求真實存在時，「開放」才有意義。\n擴充是基於需求，而非預測。\n擴充的邊界，通常以「編譯單元」為界，目標是新增功能時不需要重新編譯或重新發布核心模組。\n在函式層面，盡量保持主流程穩定，將變動的部分委派給外部物件。\n核心目的：擴充 降低風險：不修改既有程式碼，就不會影響原本已經測試過的穩定邏輯。\n擴充性高：面對新需求時，只需專注於編寫新的實作類別。\n結論 實踐 OCP 時，我們最該關注的是：\n這段程式碼哪裡會改變？\n參考 Clean Architecture - Robert C. Martin ","permalink":"http://blog.codeicu.dev/posts/solid/ocp/","tags":[{"LinkTitle":"Basic","RelPermalink":"/tags/basic/"}],"title":"SOLID：開放封閉原則 (OCP)"},{"categories":[],"content":"介紹 SOLID 是由 Robert Cecil Martin (Uncle Bob) 等人倡議的五項核心原則。\n它們並非刻板的硬性規定，而更像是一種精神指引，幫助開發者建立易於維護、具備彈性的系統：\nSRP：單一職責原則 (Single Responsibility Principle) OCP：開放封閉原則 (Open-Closed Principle) LSP：里氏替換原則 (Liskov Substitution Principle) ISP：介面隔離原則 (Interface Segregation Principle) DIP：依賴反轉原則 (Dependency Inversion Principle) 本篇將聚焦於最容易被誤解，卻也最基礎的原則：單一職責原則 (SRP)。\nSingle-responsibility principle (SRP) 單一職責原則 一個類別應僅有一個理由使其改變。 (A class should have only one reason to change.)\n從微觀的函式、更大的類別，到宏觀的模組或服務，SRP 都適用。\nUncle Bob 後來更精確地定義了所謂的「理由」：\n一個模組應僅對一個利益關係人 (Actor) 負責。 (A module should be responsible to one, and only one, actor.)\n程式舉例：糾纏的職責 // 這是一個違反 SRP 的函式例子 public double calcUserOrder(User user, double price, int quantity) { if (user.isVIP()) { return price * quantity * VIP_DISCOUNT; } else { return price * quantity; } } 這個例子中我們看見了什麼?\n一個函式因為區別了 VIP 身份，因此有兩種的計算方式\n當今天我們要修改 VIP 規則時，我們就需要對函式做調整\n理想上我們應該這樣寫\npublic double calcVipOrder(User user, double price, int quantity) { return price * quantity * VIP_DISCOUNT; } public double calcUserOrder(User user, double price, int quantity) { return price * quantity; } 怎麼少了分開 VIP 與一般用戶的 if 函式？\n如果可以，這個區別身份的邏輯會被實作在「更上層」的地方。\n確保實作層的計算邏輯純粹且單一。\n說不準的職責 最容易讓人困惑的地方：\n那件事，到底可以有多大？ 職責可以多廣？\n當尺度放大到類別時，又或是套件時，職責往往與「誰在對這段程式碼提要求」有關。\n// ❌ 類別違反 SRP 的例子 public class OrderService { public double calculateOrder(User user, double price, int quantity) { // 邏輯 1：如果是 VIP，給予折扣（這是「銷售部」的規則） if (user.isVIP()) { return price * quantity * VIP_DISCOUNT; } else { return price * quantity; } } public void saveToDatabase(Order order) { // 邏輯 2：儲存到資料庫（這是「工程部」關注的範圍） // ... DB 連線與寫入邏輯 ... } } 當銷售部想改折扣，或者工程部想換資料庫時，都會動到 OrderService。\n這代表：\n同一個模組，同時對兩個不同的 Actor 負責。\n如果可以，我們應該將這兩個職責分開：\n// 專注於定價規則（服務於銷售部門） public class PricingCalculator { public double calculateVipPrice(double price, int quantity) { return price * quantity * VIP_DISCOUNT; } public double calculateStandardPrice(double price, int quantity) { return price * quantity; } } // 專注於資料持久化（服務於工程部門） public class OrderRepository { public void save(Order order) { // 僅處理 DB 邏輯 } } 依照 Uncle Bob 的說明 只為一個利益關係人（Actor）負責\n因此單一職責，取決於你的軟體架構與組織需求。\n例如：\n財務部 → 關注計算規則與稅務 行銷部 → 關注報表格式與呈現 設計部 → 關注回傳格式與互動 這代表要考量組織結構 在小團隊中，行銷與財務可能是同一人。這時候，你的類別可能不需要拆得太細。\n但當組織成長、需求來源變多， 原本的「單一職責」可能會變得臃腫。\n此時，就必須隨著時間與組織演進，重新調整邊界。\n核心目的：內聚 (Cohesion) SRP 的目的不是要把程式拆到支離破碎，而是為了讓程式足夠「內聚」。\n如果兩段代碼總是因為同一個原因同時修改，那它們應該待在一起。\n如果兩段代碼變動的頻率不同、來源不同，那它們就應該被分開。\n結論 許多人會將 SRP 誤解為「一個類別只做一件事」。\n但 SRP 真正關注的不是「做幾件事」，而是：\n為什麼這段程式碼會改變？\n參考 Clean Architecture - Robert C. Martin 單一職責原則 (SRP) - Uncle Bob 單一職責定義補充 - Uncle Bob 再次強調「理由」的定義，以及「利益關係人」的概念，是在 Clean Architecture 這本書中。 ","permalink":"http://blog.codeicu.dev/posts/solid/srp/","tags":[{"LinkTitle":"Basic","RelPermalink":"/tags/basic/"}],"title":"SOLID：單一職責原則 (SRP)"},{"categories":[{"LinkTitle":"自我修練","RelPermalink":"/categories/%E8%87%AA%E6%88%91%E4%BF%AE%E7%B7%B4/"}],"content":"為什麼要不停地輸出？ 我是一個愛發表「想法」的人。\n無論是技術討論還是生活哲學，我總習慣將想法轉化為文字。\n就像現在，我透過 Blog 來闡述想法。在我的觀念裡，這是成長進步的必要過程。\n軟體世界的「盲人摸象」 在廣袤且快速變動的軟體世界中，每個人在某種程度上都是「盲人」。\n有的人摸到大象的耳朵，會說這世界就是平面且薄的。 有的人摸到大象的鼻子，會說這世界就是可以彎曲的水管。 有的人摸到大象的大腿，會說這世界就是四根柱子，強壯不可動搖。 但我們都知道，大象的真面目遠遠不止於此。\n如果每個人都保持沈默，我們永遠只能守著自己手中的那一塊碎片。 唯有通過不停地輸入（學習）與輸出（交流），我們才能拼湊出全貌，做出更正確的判斷。\n真正的成長，來自於持續的修正 常有人問：「如果輸出的內容不正確、不夠專業怎麼辦？」\n在我的觀念裡，這世界不存在絕對且永恆的正確。 我們所能觸及的真相，往往只是當下環境與資訊下的「區域最佳解」（Local Optimum）。\n輸出不是為了證明自己正確，而是為了吸引「修正」的到來。\n歷史給我們的啟示 1. 地心說的演進 百年前，人類觀念的天頂就是「太陽繞著地球轉」。直到科學觀測技術提升，哥白尼與伽利略挑戰了當時的「正確」。我們能說古人無知嗎？不，那是他們當時觀測條件下的區域最佳解。\n2. 太陽升起的真理 「太陽從東方升起」是一句真理嗎？\n初步觀測： 東方升起。 進階研究： 隨季節不同，升起角度在東南與東北間偏移。 科學實證： 地球磁極會反轉，且太陽升起本質上是地球自轉的結果。 每一次的「輸出」都被後人「修正」，人類的文明才因此進步。\n如何接近「答案」？ 如果你想跳脫目前的觀念框架，去尋找那個更接近真理的「全域最佳解」：\n持續輸入： 保持謙卑，承認自己正摸著象腿。 樂於輸出： 把你摸到的形狀大聲說出來，讓別人看見。 勇於修正： 當別人的資訊挑戰了你的觀念，欣然接受並更新你的觀念。 輸出，就是對世界發出的探針。\n唯有碰撞，才有回饋；唯有回饋，才能修正。\n結語 這篇宣言是我對自己的提醒。不要害怕錯誤，要害怕的是停止更新。\n今日的輸出，是為了成就明日更正確的自己。\n參考 地球磁極反轉 ","permalink":"http://blog.codeicu.dev/posts/thinks/one/","tags":[],"title":"輸出的宣言"},{"categories":[{"LinkTitle":"AI","RelPermalink":"/categories/ai/"}],"content":"📘 提示語言設計技巧 無需客氣，直入主題\n避免「請」「謝謝」等禮貌詞。\n說明：模型不需人類禮貌引導，直接明確下指令更有效。\n設定目標受眾\n請用資深數據科學家能理解的方式說明 Transformer\n說明：能讓模型調整語氣、專業深度與術語層級。\n拆解複雜任務\n第一步，請列出五種推薦系統演算法名稱。\n等我確認後再說明第二步。\n說明：分步設計讓模型專注、準確，並減少錯誤傳遞\n正向動詞，避免否定語\n✅「請列出你推薦的三種策略」 ❌「不要給我太通用的建議」\n說明：正向語句更易理解，避免模型誤解否定詞。\n使用簡化語句提示\n用簡單英文說明什麼是 NLP，就像對 5 歲小孩說\n以初學者解釋機器學習\n說明：引導模型使用更口語化、淺顯的語言結構。\n給正面回饋\n做得很好，我會給你小費，讓模型優化輸出。\n說明：某些模型可能回應更用心（未必有效，僅作心理暗示測試）\n少樣本提示（Few-shot Prompting）\n給一兩個例子做引導，穩定輸出格式。\n範例：使用者：如何減肥？ 回答：少吃多動。現在回答：如何養狗？\n說明：讓模型模仿例子格式與語氣，提升一致性。\n使用結構標題分隔提示內容\n### 任務 ### 請撰寫一段段落介紹人工智慧 ### 問題 ### 什麼是強 AI 與弱 AI 的差別？ 說明：有助模型辨識任務區段、減少混淆。\n明確角色語句：「你必須\u0026hellip;」\n任務是協助我生成 SQL 查詢語法，你必須確保語法能執行成功。\n說明：明確建立責任與目標，有助模型聚焦。\n懲罰語句（慎用）：「你若違規將受懲罰」\n若你提供錯誤格式的回答，會導致回合失敗，請特別注意格式。\n說明：用於遊戲、評測或角色模擬，有行為限制需求時使用。\n要求自然語言回應\n請用自然、口語的語氣說明這個問題。\n說明：提升可讀性，特別適用於對話、文章、自動客服等情境。\n引導模型「一步一步思考」\n請一步一步思考以下邏輯題。\n說明：觸發 Chain-of-Thought 機制，幫助模型做出更精準推理。\n要求去偏見與無刻板印象\n請確保你的回答中立、不包含性別或種族刻板印象。\n說明：對於敏感領域、教育或公共資訊特別重要。\n允許模型反問以補全任務資訊\n從現在開始，請在資訊不足時主動詢問我直到能完成任務。\n說明：轉變為互動引導式流程，適合複雜需求。\n教學後加測驗不給答案\n教我什麼是熵，並在最後出一道選擇題。不要給答案，請等我回答再判斷正確與否。\n說明：適合學習輔助與自我評量。\n賦予模型角色 你是一位資深 Python 開發者，請用專業術語解釋這段程式碼。\n說明：角色扮演有助模型調整語氣與內容深度。\n使用冒號劃分結構\n使用者資料： 姓名：小明 年齡：12歲 說明：方便模型識別資料區塊，特別適合處理複雜輸入。\n重複關鍵詞以加強模型識別 回覆重點、重點、重點要清楚(重複關鍵詞)\n說明：強調關鍵詞有助模型聚焦於重要資訊。\n結合 Chain-of-Thought + Few-shot 提示 給一個「逐步推理 + 範例」的任務，引導模型學習與解題。\n說明：進階提示技巧，效果優於單一策略。\n使用輸出引導語（output primer） 首先，\u0026hellip;\u0026hellip;\n說明：模型會根據這樣的「開頭語」自動延伸格式。\n✍️ 文字生成與內容調整 詳細寫作任務語句\n詳細撰寫關於[主題]的段落，包括背景、觀點與範例。\n說明：讓模型展開敘述，避免過於簡略。\n風格不變的修正文案\n改善下面段落的文法與用字，但不要改變它原本的正式語氣。\n說明：用於文本潤飾、翻譯風格維持等任務。\n跨多檔案生成提示\n若產出多個檔案的代碼，請同時產出能自動建立檔案的 shell script\n說明：協助模型產出整套可執行結構，減少人工拆分。\n從特定文字開始補全句子/段落\n這是故事開頭：『夜色中，他提著箱子走入廢墟……』，請延續故事\n說明：可用於歌詞、小說、腳本等連續性創作任務。\n條列模型必須遵守的規則\n產出的內容需包含關鍵字：AI、安全性、未來趨勢；且每段不超過100字\n說明：加強控制力，特別在模板輸出時適用。\n模仿範例風格撰寫新段落\n根據以下段落風格與語氣，寫一段新文字：『科技的本質，是延伸人類的能力……』\n說明：用於仿寫、品牌文案、學術風格統一。\n🧩 附加建議 可將這些技巧當作 Prompt Toolkit，針對不同任務混合搭配。 建議實作筆記：每次測試用不同技巧，記錄效果好壞。 可建立「Prompt 模板資料庫」頁面，分類： 客服文案 技術說明 創作寫作 學術改寫 References 來源論文 ","permalink":"http://blog.codeicu.dev/posts/ai-prompt/tips-26/","tags":[{"LinkTitle":"Basic","RelPermalink":"/tags/basic/"}],"title":"AI Prompt 26 tips"},{"categories":[],"content":"清單 基礎 git 使用 gitlab 使用 github 使用 Blog 架設 hugo 的 cheat sheet Markdown 語法 訂閱服務 原碼解析 Laravel Framework Java Sprint 程式碼學習 java 學習 spring boot 學習 WEB 解說 RFC 解說 RFC 追蹤 RESTful 設計 資料庫 SQL index 邏輯 Lock 機制 Redis 快取策略 服務部署 k8s 部署 Laravel 服務 圖床架設服務 演算法 LeetCode 150 題 B+ Tree 原理 完成 hugo 網站模板 每天打字練習 ","permalink":"http://blog.codeicu.dev/about/todo-list/","tags":[],"title":"待辦清單"},{"categories":[{"LinkTitle":"Coding-Style","RelPermalink":"/categories/coding-style/"}],"content":"PHP Clean Code Clean Code 是一種精神，本篇主要蒐集在 PHP 這個語法的實踐\n變數使用 使用有意義且常見的變數名稱\n舉例 $ymdstr = $moment-\u0026gt;format(\u0026#39;y-m-d\u0026#39;); 調整 $currentDate = $moment-\u0026gt;format(\u0026#39;y-m-d\u0026#39;); 相同的實體使用相同的變數\n舉例 getUserInfo(); getUserData(); getUserRecord(); getUserProfile(); 調整 getUser(); 使用易讀和容易搜尋的名稱，取代特定的數值\n舉例 (一) // What the heck is 448 for? $json = $serializer-\u0026gt;serialize($data, 448); 調整 $json = $serializer-\u0026gt;serialize($data, JSON_UNESCAPED_SLASHES | JSON_PRETTY_PRINT | JSON_UNESCAPED_UNICODE); 舉例 (二) class User { // What the heck is 8 for? public $access = 8; } // What the heck is 4 for? if ($user-\u0026gt;access \u0026amp; 4) { // ... } // What\u0026#39;s going on here? $user-\u0026gt;access ^= 2; 調整 class User { public const ACCESS_READ = 1; // 0001 public const ACCESS_CREATE = 2; // 0010 public const ACCESS_UPDATE = 4; // 0100 public const ACCESS_DELETE = 8; // 1000 // User as default can read, create and update something public $access = self::ACCESS_READ | self::ACCESS_CREATE | self::ACCESS_UPDATE; } if ($user-\u0026gt;access \u0026amp; User::ACCESS_UPDATE) { // do edit ... } // Deny access rights to create something $user-\u0026gt;access ^= User::ACCESS_CREATE; 使用有意義單詞作為 Array 的 Key\n舉例 $address = \u0026#39;One Infinite Loop, Cupertino 95014\u0026#39;; $cityZipCodeRegex = \u0026#39;/^[^,]+,\\s*(.+?)\\s*(\\d{5})$/\u0026#39;; preg_match($cityZipCodeRegex, $address, $matches); saveCityZipCode($matches[1], $matches[2]); 調整 $address = \u0026#39;One Infinite Loop, Cupertino 95014\u0026#39;; $cityZipCodeRegex = \u0026#39;/^[^,]+,\\s*(?\u0026lt;city\u0026gt;.+?)\\s*(?\u0026lt;zipCode\u0026gt;\\d{5})$/\u0026#39;; preg_match($cityZipCodeRegex, $address, $matches); saveCityZipCode($matches[\u0026#39;city\u0026#39;], $matches[\u0026#39;zipCode\u0026#39;]); 減少不需要的贅詞命名\n舉例 class Car { public $carMake; public $carModel; public $carColor; //... } 調整 class Car { public $make; public $model; public $color; //... } if 使用 避免過深的巢狀迴圈\n舉例 (一) function isShopOpen($day): bool { if ($day) { if (is_string($day)) { $day = strtolower($day); if ($day === \u0026#39;friday\u0026#39;) { return true; } elseif ($day === \u0026#39;saturday\u0026#39;) { return true; } elseif ($day === \u0026#39;sunday\u0026#39;) { return true; } return false; } return false; } return false; } 調整 使用提前回傳和 Array 鍵判斷\nfunction isShopOpen(string $day): bool { if (empty($day)) { return false; } $openingDays = [\u0026#39;friday\u0026#39;, \u0026#39;saturday\u0026#39;, \u0026#39;sunday\u0026#39;]; return in_array(strtolower($day), $openingDays, true); } 舉例 (二) function fibonacci(int $n) { if ($n \u0026lt; 50) { if ($n !== 0) { if ($n !== 1) { return fibonacci($n - 1) + fibonacci($n - 2); } return 1; } return 0; } return \u0026#39;Not supported\u0026#39;; } 調整 使用提前回傳和整合 if 判斷條件\nfunction fibonacci(int $n): int { if ($n === 0 || $n === 1) { return $n; } if ($n \u0026gt;= 50) { throw new Exception(\u0026#39;Not supported\u0026#39;); } return fibonacci($n - 1) + fibonacci($n - 2); } 封裝判斷式\n舉例 if ($article-\u0026gt;state === \u0026#39;published\u0026#39;) { // ... } 調整 if ($article-\u0026gt;isPublished()) { // ... } 避免過多的條件聲明，改變函式目的維持單一職責原則\n舉例 class Airplane { // ... public function getCruisingAltitude() { switch (this.type) { case \u0026#39;777\u0026#39;: return $this-\u0026gt;getMaxAltitude() - $this-\u0026gt;getPassengerCount(); case \u0026#39;Air Force One\u0026#39;: return $this-\u0026gt;getMaxAltitude(); case \u0026#39;Cessna\u0026#39;: return $this-\u0026gt;getMaxAltitude() - $this-\u0026gt;getFuelExpenditure(); } } } 調整 class Airplane { // ... } class Boeing777 extends Airplane { // ... public function getCruisingAltitude() { return $this-\u0026gt;getMaxAltitude() - $this-\u0026gt;getPassengerCount(); } } class AirForceOne extends Airplane { // ... public function getCruisingAltitude() { return $this-\u0026gt;getMaxAltitude(); } } class Cessna extends Airplane { // ... public function getCruisingAltitude() { return $this-\u0026gt;getMaxAltitude() - $this-\u0026gt;getFuelExpenditure(); } } 避免類型檢查，使用類型聲明\n舉例 function combine($val1, $val2) { if (is_numeric($val1) \u0026amp;\u0026amp; is_numeric(val2)) { return val1 + val2; } throw new \\Exception(\u0026#39;Must be of type Number\u0026#39;); } 調整 function combine(int $val1, int $val2) { return $val1 + $val2; } 避免反向的判斷情形，嘗試語意化封裝\n舉例 if (! isDOMNodeNotPresent($node)) { // ... } 調整 if (isDOMNodePresent($node)) { // ... } 參考資料 piotrplenik/clean-code-php ","permalink":"http://blog.codeicu.dev/posts/php-clean-code/one/","tags":[{"LinkTitle":"Php","RelPermalink":"/tags/php/"}],"title":"PHP Clean Code One"},{"categories":[{"LinkTitle":"Coding-Style","RelPermalink":"/categories/coding-style/"}],"content":"For 迴圈使用 避免使用意義不明的 Array\n舉例 $l = [\u0026#39;Austin\u0026#39;, \u0026#39;New York\u0026#39;, \u0026#39;San Francisco\u0026#39;]; for ($i = 0; $i \u0026lt; count($l); $i++) { $li = $l[$i]; doStuff(); doSomeOtherStuff(); // ... // ... // ... // Wait, what is `$li` for again? dispatch($li); } 調整 使用 foreach 來遍歷 Array\n$locations = [\u0026#39;Austin\u0026#39;, \u0026#39;New York\u0026#39;, \u0026#39;San Francisco\u0026#39;]; foreach ($locations as $location) { doStuff(); doSomeOtherStuff(); // ... // ... // ... dispatch($location); } 命名 Array 變數要有意義\n$locations = [\u0026#39;Austin\u0026#39;, \u0026#39;New York\u0026#39;, \u0026#39;San Francisco\u0026#39;]; for ($i = 0; $i \u0026lt; count($locations); $i++) { $location = $locations[$i]; doStuff(); doSomeOtherStuff(); // ... // ... // ... dispatch($location); } Comparison 型別轉換會導致比較失敗，字串轉為整數後比較，但事實字串不是數字\n舉例 (一) $x = \u0026#39;0\u0026#39;; $y = 0; if ($x != $y) { // 不會執行 // The expression will always pass } 調整 強制比較型別和值\n$x = \u0026#39;0\u0026#39;; $y = 0; if ($x !== $y) { // 會執行 // This ensures the comparison is accurate by type and value } 舉例 (二) $arr = null; $str = \u0026#39;\u0026#39;; if ($arr != $str) { // 不會執行 // The expression will always pass } 調整 強制比較型別和值\n$arr = null; $str = \u0026#39;\u0026#39;; if ($arr !== $str) { // 會執行 // This ensures the comparison is accurate by type and value } 使用判斷語法糖來簡化語法 舉例 if (isset($_GET[\u0026#39;name\u0026#39;])) { $name = $_GET[\u0026#39;name\u0026#39;]; } else { $name = \u0026#39;nobody\u0026#39;; } 調整 $name = $_GET[\u0026#39;name\u0026#39;] ?? \u0026#39;nobody\u0026#39;; 函式用法 需使用 type hinting 限定參數值，避免內部還需要判斷\n舉例 function createMicrobrewery($breweryName = \u0026#39;Hipster Brew Co.\u0026#39;): void { // ... } 調整 function createMicrobrewery(string $breweryName = \u0026#39;Hipster Brew Co.\u0026#39;): void { // ... } 減少函式參數，最好少於 2 個\n舉例 class Questionnaire { public function __construct( string $firstName, string $lastName, string $patronymic, string $region, string $district, string $city, string $phone, string $email ) { // ... } } 調整 class Name { private $firstName; private $lastName; private $patronymic; public function __construct(string $firstName, string $lastName, string $patronymic) { $this-\u0026gt;firstName = $firstName; $this-\u0026gt;lastName = $lastName; $this-\u0026gt;patronymic = $patronymic; } } class City { private $region; private $district; private $city; public function __construct(string $region, string $district, string $city) { $this-\u0026gt;region = $region; $this-\u0026gt;district = $district; $this-\u0026gt;city = $city; } } class Contact { private $phone; private $email; public function __construct(string $phone, string $email) { $this-\u0026gt;phone = $phone; $this-\u0026gt;email = $email; } } class Questionnaire { public function __construct(Name $name, City $city, Contact $contact) { // ... } } function 名稱要表達目的性\n舉例 class Email { //... public function handle(): void { mail($this-\u0026gt;to, $this-\u0026gt;subject, $this-\u0026gt;body); } } $message = new Email(...); // What is this? A handle for the message? Are we writing to a file now? $message-\u0026gt;handle(); 調整 class Email { //... public function send(): void { mail($this-\u0026gt;to, $this-\u0026gt;subject, $this-\u0026gt;body); } } $message = new Email(...); $message-\u0026gt;send(); function 內部盡量減少抽象層(abstraction)\n程式碼的問題在於抽象層次混亂和函式責任不明確\n舉例 tokenize 的細節過多，目的不夠明確 function tokenize(string $code): ~~array~~ { $regexes = [ // ... ]; $statements = explode(\u0026#39; \u0026#39;, $code); $tokens = []; foreach ($regexes as $regex) { foreach ($statements as $statement) { $tokens[] = /* ... */; } } return $tokens; } 似乎是為了進行 lexical analysis，但其實它在做的是把 token 轉換為 AST 節點 function lexer(array $tokens): array { $ast = []; foreach ($tokens as $token) { $ast[] = /* ... */; } return $ast; } function parseBetterPHPAlternative(string $code): void { $tokens = tokenize($code); $ast = lexer($tokens); foreach ($ast as $node) { } } 調整 class Tokenizer { public function tokenize(string $code): array { $regexes = [ // ... ]; $statements = explode(\u0026#39; \u0026#39;, $code); $tokens = []; foreach ($regexes as $regex) { foreach ($statements as $statement) { $tokens[] = /* ... */; } } return $tokens; } } class Lexer { public function lexify(array $tokens): array { $ast = []; foreach ($tokens as $token) { $ast[] = /* ... */; } return $ast; } } class BetterPHPAlternative { private $tokenizer; private $lexer; public function __construct(Tokenizer $tokenizer, Lexer $lexer) { $this-\u0026gt;tokenizer = $tokenizer; $this-\u0026gt;lexer = $lexer; } public function parse(string $code): void { $tokens = $this-\u0026gt;tokenizer-\u0026gt;tokenize($code); $ast = $this-\u0026gt;lexer-\u0026gt;lexify($tokens); foreach ($ast as $node) { } } } 函式內不要使用 boolean 作為區分，函式功能違反單一職責原則\n舉例 function createFile(string $name, bool $temp = false): void { if ($temp) { touch(\u0026#39;./temp/\u0026#39; . $name); } else { touch($name); } } 調整 function createFile(string $name): void { touch($name); } function createTempFile(string $name): void { touch(\u0026#39;./temp/\u0026#39; . $name); } 函式內不僅僅有回傳，還有改變外部的值，這將更難維護\n函式目的請單一化\n舉例 // Global variable referenced by following function. // If we had another function that used this name, now it\u0026#39;d be an array and it could break it. $name = \u0026#39;Rootimes\\\u0026#39; blog\u0026#39;; function splitIntoFirstAndLastName(): void { global $name; $name = explode(\u0026#39; \u0026#39;, $name); } splitIntoFirstAndLastName(); var_dump($name); // [\u0026#39;Rootimes\u0026#39;, \u0026#39;blog\u0026#39;]; 調整 function splitIntoFirstAndLastName(string $name): array { return explode(\u0026#39; \u0026#39;, $name); } $name = \u0026#39;Rootimes blog\u0026#39;; $newName = splitIntoFirstAndLastName($name); var_dump($name); // \u0026#39;Rootimes blog\u0026#39;; var_dump($newName); // [\u0026#39;Rootimes\u0026#39;, \u0026#39;blog\u0026#39;]; 避免使用全域函式， php 本身沒有 namespace 會導致衝突\n舉例 function config(): array { return [ \u0026#39;foo\u0026#39; =\u0026gt; \u0026#39;bar\u0026#39;, ]; } 調整 利用類別封裝\nclass Configuration { private $configuration = []; public function __construct(array $configuration) { $this-\u0026gt;configuration = $configuration; } public function get(string $key): ?string { // null coalescing operator return $this-\u0026gt;configuration[$key] ?? null; } } # 使用 class $configuration = new Configuration([ \u0026#39;foo\u0026#39; =\u0026gt; \u0026#39;bar\u0026#39;, ]); 避免使用 singleton pattern\n隱藏了應用的依賴關係 不僅負責自身的邏輯，還負責自己的創建和生命週期管理 舉例 class DBConnection { private static $instance; private function __construct(string $dsn) { // ... } public static function getInstance(): self { if (self::$instance === null) { self::$instance = new self(); } return self::$instance; } // ... } $singleton = DBConnection::getInstance(); 調整 明確創建 DBConnection 的實例\nclass DBConnection { public function __construct(string $dsn) { // ... } // ... } $connection = new DBConnection($dsn); 物件與資料結構 使用物件封裝 舉例 class BankAccount { public $balance = 1000; } $bankAccount = new BankAccount(); // Buy shoes... $bankAccount-\u0026gt;balance -= 100; 調整 class BankAccount { private $balance; public function __construct(int $balance = 1000) { $this-\u0026gt;balance = $balance; } public function withdraw(int $amount): void { if ($amount \u0026gt; $this-\u0026gt;balance) { throw new \\Exception(\u0026#39;Amount greater than available balance.\u0026#39;); } $this-\u0026gt;balance -= $amount; } public function deposit(int $amount): void { $this-\u0026gt;balance += $amount; } public function getBalance(): int { return $this-\u0026gt;balance; } } $bankAccount = new BankAccount(); $bankAccount-\u0026gt;withdraw($shoesPrice); $balance = $bankAccount-\u0026gt;getBalance(); 參考資料 piotrplenik/clean-code-php ","permalink":"http://blog.codeicu.dev/posts/php-clean-code/two/","tags":[{"LinkTitle":"Php","RelPermalink":"/tags/php/"}],"title":"PHP Clean Code Two"},{"categories":[{"LinkTitle":"Coding-Style","RelPermalink":"/categories/coding-style/"}],"content":"Laravel coding style 的一些實踐 這篇主要整理各種在 Laravel 框架上的良好風格實踐\n單一職責原則 一個類別與方法應只有一個職責\n舉例 : 拆分複雜判斷 public function getFullNameAttribute(): string { if (auth()-\u0026gt;user() \u0026amp;\u0026amp; auth()-\u0026gt;user()-\u0026gt;hasRole(\u0026#39;client\u0026#39;) \u0026amp;\u0026amp; auth()-\u0026gt;user()-\u0026gt;isVerified()) { return \u0026#39;Mr. \u0026#39; . $this-\u0026gt;first_name . \u0026#39; \u0026#39; . $this-\u0026gt;middle_name . \u0026#39; \u0026#39; . $this-\u0026gt;last_name; } else { return $this-\u0026gt;first_name[0] . \u0026#39;. \u0026#39; . $this-\u0026gt;last_name; } } 調整 使用語意化命名 function 使程式目的更清晰 縮減每一個 function 的用途，簡化職責 public function getFullNameAttribute(): string { return $this-\u0026gt;isVerifiedClient() ? $this-\u0026gt;getFullNameLong() : $this-\u0026gt;getFullNameShort(); } public function isVerifiedClient(): bool { return auth()-\u0026gt;user() \u0026amp;\u0026amp; auth()-\u0026gt;user()-\u0026gt;hasRole(\u0026#39;client\u0026#39;) \u0026amp;\u0026amp; auth()-\u0026gt;user()-\u0026gt;isVerified(); } public function getFullNameLong(): string { return \u0026#39;Mr. \u0026#39; . $this-\u0026gt;first_name . \u0026#39; \u0026#39; . $this-\u0026gt;middle_name . \u0026#39; \u0026#39; . $this-\u0026gt;last_name; } public function getFullNameShort(): string { return $this-\u0026gt;first_name[0] . \u0026#39;. \u0026#39; . $this-\u0026gt;last_name; } 舉例 : 程式碼中註釋 // 確定是否有任何 Join if (count((array) $builder-\u0026gt;getQuery()-\u0026gt;joins) \u0026gt; 0) 調整 if ($this-\u0026gt;hasJoins()) 降低 Controller 複雜度 筆者認為這些技巧可以按情形調整，避免過度設計問題\n一些本身很簡單的的功能(行數本身少)，仍然僵硬的套用這些原則\n最終反而維護困難(程式碼散落在專案各處)\n以下拆分目的主要是示範\n舉例 : Service 將商業邏輯移到 Service 層當中，Controller 只保留選擇使用 Service 和取得參數方法\npublic function store(Request $request) { if ($request-\u0026gt;hasFile(\u0026#39;image\u0026#39;)) { $request-\u0026gt;file(\u0026#39;image\u0026#39;)-\u0026gt;move(public_path(\u0026#39;images\u0026#39;) . \u0026#39;temp\u0026#39;); } ... } 調整 public function store(Request $request) { $this-\u0026gt;articleService-\u0026gt;handleUploadedImage($request-\u0026gt;file(\u0026#39;image\u0026#39;)); ... } class ArticleService { public function handleUploadedImage($image) { if (!is_null($image)) { $image-\u0026gt;move(public_path(\u0026#39;images\u0026#39;) . \u0026#39;temp\u0026#39;); } } } 舉例 : Query 使用 Query Builder 或是 Raw SQL 時，將這部分程式放置在 Model 當中，也可自訂一個 Repository 層\npublic function index() { $clients = Client::verified() -\u0026gt;with([\u0026#39;orders\u0026#39; =\u0026gt; function ($q) { $q-\u0026gt;where(\u0026#39;created_at\u0026#39;, \u0026#39;\u0026gt;\u0026#39;, Carbon::today()-\u0026gt;subWeek()); }]) -\u0026gt;get(); return view(\u0026#39;index\u0026#39;, [\u0026#39;clients\u0026#39; =\u0026gt; $clients]); } 調整 public function index() { return view(\u0026#39;index\u0026#39;, [\u0026#39;clients\u0026#39; =\u0026gt; $this-\u0026gt;client-\u0026gt;getWithNewOrders()]); } class Client extends Model { public function getWithNewOrders() { return $this-\u0026gt;verified() -\u0026gt;with([\u0026#39;orders\u0026#39; =\u0026gt; function ($q) { $q-\u0026gt;where(\u0026#39;created_at\u0026#39;, \u0026#39;\u0026gt;\u0026#39;, Carbon::today()-\u0026gt;subWeek()); }]) -\u0026gt;get(); } } 舉例 : Validate 需要驗證的資料，驗證方法可以移到 RequestForm 類別內\npublic function store(Request $request) { $request-\u0026gt;validate([ \u0026#39;title\u0026#39; =\u0026gt; \u0026#39;required|unique:posts|max:255\u0026#39;, \u0026#39;body\u0026#39; =\u0026gt; \u0026#39;required\u0026#39;, \u0026#39;publish_at\u0026#39; =\u0026gt; \u0026#39;nullable|date\u0026#39;, ]); ... } 調整 public function store(PostRequest $request) { ... } class PostRequest extends Request { public function rules() { return [ \u0026#39;title\u0026#39; =\u0026gt; \u0026#39;required|unique:posts|max:255\u0026#39;, \u0026#39;body\u0026#39; =\u0026gt; \u0026#39;required\u0026#39;, \u0026#39;publish_at\u0026#39; =\u0026gt; \u0026#39;nullable|date\u0026#39;, ]; } } 舉例 : Resource public function index(Request $request) { ... $message = $this-\u0026gt;postService-\u0026gt;getPosts(); return response()-\u0026gt;json($message, 200); } 調整 public function index(Request $request) { ... $message = $this-\u0026gt;postService-\u0026gt;getPosts(); # model collection return new PostResource::collection($message); } public function show(Request $request) { ... $message = $this-\u0026gt;postService-\u0026gt;getPosts(); # model instances return new PostResource($message); } DRY 原則 - 不要重覆自己 通過 SRP (單一職責原則)，先行簡化程式碼，再將部分封裝\n重複使用程式碼，這並不是目的，它是一個整理結果\n筆者自己認為，隨著 AI 發展\n重複使用程式碼的部分可以在特定的類別或是命名空間內即可\n未必要追求大規模的重用，高度的重用程式碼\n有時帶來很高的耦合性，在開發上反而更加困難\n舉例 : Eloquent Scope public function getActive() { return $this-\u0026gt;where(\u0026#39;verified\u0026#39;, 1)-\u0026gt;whereNotNull(\u0026#39;deleted_at\u0026#39;)-\u0026gt;get(); } public function getArticles() { return $this-\u0026gt;whereHas(\u0026#39;user\u0026#39;, function ($q) { $q-\u0026gt;where(\u0026#39;verified\u0026#39;, 1)-\u0026gt;whereNotNull(\u0026#39;deleted_at\u0026#39;); })-\u0026gt;get(); } 調整 public function scopeActive($q) { return $q-\u0026gt;where(\u0026#39;verified\u0026#39;, 1)-\u0026gt;whereNotNull(\u0026#39;deleted_at\u0026#39;); } public function getActive() { return $this-\u0026gt;active()-\u0026gt;get(); } public function getArticles() { return $this-\u0026gt;whereHas(\u0026#39;user\u0026#39;, function ($q) { $q-\u0026gt;active(); })-\u0026gt;get(); } 大量賦值，使用 ORM 方法 Laravel 為避免批量賦值導致非預期變更，提供了 $fillable 和 $guarded 的限制\n如果使用手動賦值，不受此限\n舉例 $article = new Article; $article-\u0026gt;title = $request-\u0026gt;title; $article-\u0026gt;content = $request-\u0026gt;content; $article-\u0026gt;verified = $request-\u0026gt;verified; // Add category to article $article-\u0026gt;category_id = $category-\u0026gt;id; $article-\u0026gt;save(); 調整 $category-\u0026gt;article()-\u0026gt;create($request-\u0026gt;validated()); 使用 Eager Loading 避免 N+1 問題 使用 with() 同時取得關聯模型\n舉例 假設 User 有 100，則會執行 101 次 DB 查詢\n每個次取得 profile 都會執行一次\n$users = User::all(); @foreach ($users as $user) {{ $user-\u0026gt;profile-\u0026gt;name }} @endforeach 調整 僅執行 2 次查詢\n$users = User::with(\u0026#39;profile\u0026#39;)-\u0026gt;get(); @foreach ($users as $user) {{ $user-\u0026gt;profile-\u0026gt;name }} @endforeach 使用 config 和 enum 代替重複性文字 舉例 public function isNormal() { return $article-\u0026gt;type === \u0026#39;normal\u0026#39;; } return back()-\u0026gt;with(\u0026#39;message\u0026#39;, \u0026#39;Your article has been added!\u0026#39;); 調整 public function isNormal() { return $article-\u0026gt;type === Article::TYPE_NORMAL; } return back()-\u0026gt;with(\u0026#39;message\u0026#39;, __(\u0026#39;app.article_added\u0026#39;)); 簡短且可讀性更好的語法 ps. $request laravel 的官方範例使用 input 取值\n範例 調整 Session::get('cart') session('cart') $request-\u0026gt;session()-\u0026gt;get('cart') session('cart') Session::put('cart', $data) session(['cart' =\u0026gt; $data]) $request-\u0026gt;input('name'), Request::get('name') $request-\u0026gt;name, request('name') return Redirect::back() return back() is_null($object-\u0026gt;relation) ? null : $object-\u0026gt;relation-\u0026gt;id optional($object-\u0026gt;relation)-\u0026gt;id return view('index')-\u0026gt;with('title', $title)-\u0026gt;with('client', $client) return view('index', compact('title', 'client')) $request-\u0026gt;has('value') ? $request-\u0026gt;value : 'default'; $request-\u0026gt;get('value', 'default') Carbon::now(), Carbon::today() now(), today() App::make('Class') app('Class') -\u0026gt;where('column', '=', 1) -\u0026gt;where('column', 1) -\u0026gt;orderBy('created_at', 'desc') -\u0026gt;latest() -\u0026gt;orderBy('age', 'desc') -\u0026gt;latest('age') -\u0026gt;orderBy('created_at', 'asc') -\u0026gt;oldest() -\u0026gt;select('id', 'name')-\u0026gt;get() -\u0026gt;get(['id', 'name']) -\u0026gt;first()-\u0026gt;name -\u0026gt;value('name') 使用 IoC Container 或 Facade 而不是直接 new Class 舉例 $user = new User; $user-\u0026gt;create($request-\u0026gt;validated()); 調整 public function __construct(User $user) { $this-\u0026gt;user = $user; } ... $this-\u0026gt;user-\u0026gt;create($request-\u0026gt;validated()); 使用 config 統一取得 .env 參數 舉例 $apiUrl = env(\u0026#39;API_URL\u0026#39;); 調整 // config/api.php \u0026#39;url\u0026#39; =\u0026gt; env(\u0026#39;API_URL\u0026#39;), // Use the config $apiUrl = config(\u0026#39;api.url\u0026#39;); 用 Carbon 操作日期時間，model 可用 casts 做轉換 舉例 {{ Carbon::createFromFormat(\u0026#39;Y-d-m H-i\u0026#39;, $object-\u0026gt;ordered_at)-\u0026gt;toDateString() }} {{ Carbon::createFromFormat(\u0026#39;Y-d-m H-i\u0026#39;, $object-\u0026gt;ordered_at)-\u0026gt;format(\u0026#39;m-d\u0026#39;) }} 調整 // Model protected $casts = [ \u0026#39;ordered_at\u0026#39; =\u0026gt; \u0026#39;datetime\u0026#39;, // 自動將 \u0026#39;ordered_at\u0026#39; 轉換為 Carbon 實例 ]; // 使用 carbon 方法操作時間 public function getSomeDateAttribute($date) { return $date-\u0026gt;format(\u0026#39;m-d\u0026#39;); } // View Blade {{ $object-\u0026gt;ordered_at-\u0026gt;toDateString() }} {{ $object-\u0026gt;ordered_at-\u0026gt;some_date }} 參考資料 Laravel best practices ","permalink":"http://blog.codeicu.dev/posts/laravel-style/","tags":[{"LinkTitle":"Laravel","RelPermalink":"/tags/laravel/"}],"title":"Laravel Style"},{"categories":[],"content":"清單 當沒有 description 時，在索引頁，會出現跑版錯誤 自動增加延伸閱讀的連結 ","permalink":"http://blog.codeicu.dev/about/wishlist/","tags":[],"title":"願望清單"},{"categories":[],"content":"","permalink":"http://blog.codeicu.dev/manifest.json","tags":[],"title":""},{"categories":[],"content":"","permalink":"http://blog.codeicu.dev/search/_index.de/","tags":[],"title":""},{"categories":[],"content":"","permalink":"http://blog.codeicu.dev/search/_index.es/","tags":[],"title":""},{"categories":[],"content":"","permalink":"http://blog.codeicu.dev/search/_index.fr/","tags":[],"title":""},{"categories":[],"content":"","permalink":"http://blog.codeicu.dev/search/_index.hi/","tags":[],"title":""},{"categories":[],"content":"","permalink":"http://blog.codeicu.dev/search/_index.jp/","tags":[],"title":""},{"categories":[],"content":"","permalink":"http://blog.codeicu.dev/search/_index.nl/","tags":[],"title":""},{"categories":[],"content":"","permalink":"http://blog.codeicu.dev/search/_index.pl/","tags":[],"title":""},{"categories":[],"content":"","permalink":"http://blog.codeicu.dev/search/_index.ru/","tags":[],"title":""},{"categories":[],"content":"","permalink":"http://blog.codeicu.dev/search/_index.zh-cn/","tags":[],"title":""},{"categories":[],"content":"Why I Built This Site It took considerable time to evaluate before establishing this site.\nFacing the field of programming feels like being a small boat adrift on a vast ocean.\nSo much knowledge and so many tools pass by like fleeting clouds.\nMoreover, as time progresses and technology advances at such a rapid pace, it truly makes one feel that each day is like three autumns—one moment of inattention and it feels like a lifetime has passed.\nDriven by my passion for programming and the desire to share and communicate with others,\nI finally decided to use this blog to document my learning journey.\nMost of the content is collected from various sources. I will do my best to provide references and add my own perspectives.\nThis is meant to spark discussion. Everyone is welcome to use the comments below to share your thoughts and join the conversation.\nFinally, to all readers and experts, your guidance is much appreciated.\nMay we continue to strive together in the world of programming.\n","permalink":"http://blog.codeicu.dev/en/about/origin/","tags":[],"title":"About This Site"},{"categories":[{"LinkTitle":"Self-Development","RelPermalink":"/en/categories/self-development/"}],"content":"Why Should We Keep Producing Output? I am someone who enjoys expressing thoughts.\nWhether in technical discussions or reflections on life, I have always made it a habit to translate my thoughts into words.\nLike now—through this blog, I articulate what I believe. In my view, this is a necessary process for growth and progress.\nThe “Blind Men and the Elephant” in the Software World In the vast and rapidly evolving world of software, each of us is, to some extent, blind.\nSome people touch the elephant’s ear and conclude the world is flat and thin. Some touch the trunk and say the world is like a flexible hose. Others touch the legs and believe the world consists of four immovable pillars. But we all know the elephant is far more than any single part.\nIf everyone remains silent, we are condemned to cling to the fragment in our own hands.\nOnly through continuous input (learning) and output (communication) can we assemble a more complete picture and make better judgments.\nTrue Growth Comes from Continuous Refinement People often ask, “What if what I produce is wrong or not professional enough?”\nIn my view, there is no such thing as absolute and eternal correctness. What we grasp at any given moment is merely a local optimum—a solution bounded by current information and environmental constraints.\nOutput is not about proving ourselves right; it is about inviting correction.\nLessons from History 1. The Evolution from Geocentrism Centuries ago, humanity’s intellectual horizon was defined by the belief that the Sun revolved around the Earth. With improved observational tools, thinkers like Nicolaus Copernicus and Galileo Galilei challenged what was once considered unquestionable truth.\nWere earlier generations ignorant? No. Their model was simply the local optimum permitted by their observational limits.\n2. The “Truth” That the Sun Rises in the East Is it true that “the Sun rises in the east”?\nInitial observation: It rises in the east. More advanced study: Its rising position shifts between the northeast and southeast depending on the season. Scientific understanding: The phenomenon is a consequence of Earth’s rotation—and even Earth’s magnetic poles can reverse over geological time. Each wave of output is corrected by the next. That is how civilization advances.\nHow Do We Approach the “Answer”? If you want to break free from your current cognitive framework and move closer to a global optimum:\nKeep learning. Stay humble. Acknowledge that you may only be holding an elephant’s leg. Be willing to speak. Describe what you feel and observe so others can respond. Embrace correction. When new information challenges your perspective, update your model. Output is a probe sent into the world.\nWithout collision, there is no feedback.\nWithout feedback, there is no refinement.\nConclusion This manifesto is a reminder to myself: do not fear being wrong—fear stagnation.\nToday’s output exists to make tomorrow’s self more accurate.\nReference Geomagnetic reversal ","permalink":"http://blog.codeicu.dev/en/posts/thinks/one/","tags":[],"title":"A Manifesto of Output"}]