--- title: 'Http Header - Request、Response' disqus: kyleAlien --- Http Header - Request、Response === ## Overview of Content 如有引用參考請詳註出處,感謝 :cat: [TOC] ## Web 容器 - 判斷請求類型 Servlet 是如何處理使用者的 API 請求的呢?其實 **並不是 Servlet 先接觸到請求,而是 Web 容器會先接觸到請求**,**由容器判斷請求類型**(Get、Post... 等等) ### Web 容器 UML * 首先 HttpServlet 進入 1. HttpServlet 其實只是 **`Servlet` 的一個實做**,其實它表達出了它不只可以處理 Http 資料,也 **期望 `Servlet` 界面可以拓展出其他實做** > 這裡只列出部份常見方法 ```mermaid classDiagram Servlet <-- GenericServlet Servlet: +init(ServletConfig) Servlet: +service(ServletRequest, ServletResponse) Servlet: +getServletConfig() ServletConfig Servlet: +getServletInfo() String Servlet: +destroy() ServletConfig <-- GenericServlet ServletConfig: +getServletName() String ServletConfig: +getServletContext() ServletContext ServletConfig: +getInitParameter() String GenericServlet <|-- HttpServlet HttpServlet: +doGet(HttpServletRequest, HttpServletResponse) HttpServlet: +doPost(HttpServletRequest, HttpServletResponse) HttpServlet: +doPut(HttpServletRequest, HttpServletResponse) ``` 2. 在來看 Servlet#service 方法所關聯的兩個界面 `ServletRequest`、`ServletResponse`,**這兩個界面有基礎處理 Meta Data,跟與資料交流的方法** > 這裡只列出部份常見方法 * 使用者請求的相關訊息 | ServletRequest 方法 | 功能 | 補充 | | - | - | - | | `getCharacterEncoding()` | 使用者這次請求的字串格式 | 像是 `UTF8`/`16`、`Big5`.. | | `getContentLength()` | 以 Byte 為單位,返回這次資料的長度 | `-1` 代表未知長度 | | `getContentType()` | 表示 Body 的 MIME 類型 | 像是 `text/html`、`charset=UTF-8`、`null` | * 服務器端回應使用者 | ServletResponse 方法 | 功能 | 補充 | | - | - | - | | `getCharacterEncoding()` | 這次回應的字串格式 | 像是 `UTF8`/`16`、`Big5`.. | | `getContentType()` | 表示 Body 的 MIME 類型 | 像是 `text/html`、`charset=UTF-8`、`null` | | `getWriter()` | 返回一個可以傳送字符串給使用者的物件 | 字符類型可透過 `getCharacterEncoding()` 確認 | > MIME 會在下面小節再回顧並說明 ```mermaid classDiagram class Servlet { +init(ServletConfig) +service(ServletRequest, ServletResponse) +getServletConfig() ServletConfig +getServletInfo() String +destroy() } class ServletResponse { +getCharacterEncoding() String +getContentType() String +getWriter() PrintWriter } class ServletRequest { +getCharacterEncoding() String +getContentLength() int +getContentType() String } ServletResponse <-- Servlet ServletRequest <-- Servlet ``` 3. 接著看繼承上述界面的 **`HttpServletRequest`、`HttpServletResponse` 界面,這兩個界面有與 Http 協議更有相關的方法**(較細緻的抽象) > 這裡只列出部份常見方法 * 使用者請求的相關訊息 | HttpServletRequest 方法 | 功能 | 補充 | | - | - | - | | `getAuthType()` | 傳回用於保護 servlet 的身份驗證方案的名稱 | 像是 `BASIC_AUTH`, `FORM_AUTH`, `CLIENT_CERT_AUTH`, `DIGEST_AUTH` | | `getCookies()` | 取得使用者端的所有 Cookie | - | | `getDateHeader(String)` | 傳回指定請求 Header 的值作為表示 Date 物件的長整數值 | 將此方法用於包含日期的標頭,例如 `If-Modified-Since` | * 服務器端回應使用者 | HttpServletResponse 方法 | 功能 | 補充 | | - | - | - | | `addCookie(Cookie)` | 將服務器端的 Cookie 資訊傳給使用者 | 可存多個 Cookie | | `containsHeader(String)` | 判斷要返回的資料中是否已經有指定 Header | - | | `encodeURL(String)` | 對指定 URL 編碼 | 包含 Session | ```mermaid classDiagram class ServletResponse { +getCharacterEncoding() String +getContentType() String +getWriter() PrintWriter } class HttpServletResponse { +addCookie(Cookie) +containsHeader(String) boolean +encodeURL(String) String } ServletResponse <|-- HttpServletResponse class ServletRequest { +getCharacterEncoding() String +getContentLength() int +getContentType() String } class HttpServletRequest { +getAuthType() String +getCookies() Cookie[] +getDateHeader(String) long } ServletRequest <|-- HttpServletRequest ``` ### Web 容器判斷請求 - doXXX * 我們在繼承 `HttpServlet` 類後,會覆寫像是 `doGet()`、`doPost()`、`doPut()`... 等等不同的函數,它們分別對應了 Http 協議的方法 它的判斷方式其實是 ^1.^ 使用 HttpServletRequest# getMethod 方法,^2.^ 判斷請求的類型,並 ^3.^ 呼叫對應的 protected 方法(就是上面寫的 doXXX) ```java= public abstract class HttpServlet extends GenericServlet { // 處理 Http 請求 @Override public void service(ServletRequest req, ServletResponse res) throws ServletException, IOException { HttpServletRequest request; HttpServletResponse response; // 判斷請求、回應是否是 Http if (!(req instanceof HttpServletRequest && res instanceof HttpServletResponse)) { // 不是則拋出 // 這裡的拋出通常是協助 Web 容器開發者,一般使用者是不會接收到這種異常的 throw new ServletException("non-HTTP request or response"); } request = (HttpServletRequest) req; response = (HttpServletResponse) res; // 繼續往下分析 service(request, response); } protected void service(HttpServletRequest req, HttpServletResponse resp) throws ServletException, IOException { // 取得已經分析好的 MetaData(上層已經封裝為 HttpServletRequest) String method = req.getMethod(); // 判斷 Http 是哪個方法,並呼叫對應的 if (method.equals(METHOD_GET)) { long lastModified = getLastModified(req); if (lastModified == -1) { // -1 代表不支援 `if-modified-since 標頭 doGet(req, resp); } else { // Get 會判斷時間是否超時 long ifModifiedSince = req.getDateHeader(HEADER_IFMODSINCE); if (ifModifiedSince < lastModified) { // 時間未超過 maybeSetLastModified(resp, lastModified); doGet(req, resp); } else { // 超過時間則 返回錯誤 resp.setStatus(HttpServletResponse.SC_NOT_MODIFIED); } } } else if (method.equals(METHOD_HEAD)) { long lastModified = getLastModified(req); maybeSetLastModified(resp, lastModified); doHead(req, resp); } else if (method.equals(METHOD_POST)) { doPost(req, resp); } else if (method.equals(METHOD_PUT)) { doPut(req, resp); } else if (method.equals(METHOD_DELETE)) { doDelete(req, resp); } else if (method.equals(METHOD_OPTIONS)) { doOptions(req, resp); } else if (method.equals(METHOD_TRACE)) { doTrace(req, resp); } else { // ... 省略部份 } } protected void doGet(HttpServletRequest req, HttpServletResponse resp) throws ServletException, IOException { //... 省略部份 } protected void doPost(HttpServletRequest req, HttpServletResponse resp) throws ServletException, IOException { //... 省略部份 } } ``` ## HttpServletRequest 請求者資訊 當 HTTP 轉發 MetaData 給 Web 容器後,Web 容器會收集相關資訊,並產生 `HttpServletRequest` 物件給開發者使用(傳遞到指定的 Servlet),**透過它就可以獲取使用者的求請資訊** ### 伺服器處理 - 請求 Header * HTTP 協議的請求、響應資料中 **「必定」包含 HTTP 標頭**;它可以提供客戶端、服務器端所需的重要訊息(不管是請求、響應都有 Header),並且 **可以有多個 Header** ```shell= # 多個 Header 是使用逗號分開 `,` Accept-Charset: ISO-8859-1, UTF-8: ``` > 這邊要說的是客戶端請求服務器端的「請求 Header」(並由伺服器端來處理) ```mermaid graph LR; subgraph HTTP請求_Header 請求行 請求_Header 通用_Header 實體_Header 其他 end ``` * **請求 Header 字段**(`Request Header Fields`) 從客戶端向服務器端發送請求的 Header;也就是 **流「入」伺服器的資料,由請求者設定**(站在伺服器的角度) | Header Data | 概述 | | - | - | | `Accept` | 用戶代理可處理的 **媒體類型** | | `Accept-Charset` | 偏好(優先)的字符集 | | `Accept-Encoding` | 偏好(優先)的內容編碼 | | `Accept-Language` | 偏好(優先)的語言 | | `Authoriztion` | Web 應用的驗證訊息 | | `Expect` | 期待服務器的特定行為 | | `From` | 用戶來源 | | `Host` | 標示服務器的相關訊息 | | `If-Match` | 比較實體標記 | | `If-Modfified-Since` | 比較資源的更新時間 | | `If-None-Match` | 比較實體的記(與 `If-Match` 相反) | | `If-Range` | 資源未更新時發送實體 Byte 的範圍請由 | | `If-Unmodified-Since` | | | `Max-Forwards` | 最大傳輸 | | `Proxy-Authoriztion` | 給代理服務器的驗證 | | `Range` | 實體的字節請求範圍 | | `Referer` | 請求中的 URI 的原始獲取方 | | `TE` | 傳輸編碼的優先級別 | | `User-Agent` | HTTP 客戶端的程序訊息(如果是用遊覽器,那就是遊覽器訊息) | * 可以透過 JavaEE Servlet 提供的 `HttpServletRequest` 界面方法獲取請求(使用)者的 Header 參數,範例如下 > 這裡就不特別解說 API 了 ```java= @WebServlet( urlPatterns = { "/show.header" } ) public class ShowHeaderServlet extends HttpServlet { @Override protected void doGet(HttpServletRequest req, HttpServletResponse response) throws IOException { PrintWriter out = response.getWriter(); out.println("<html>"); out.println("<head>"); out.println("<title>Hello World</title>"); out.println("</head>"); out.println("<body>"); Enumeration<String> headers = req.getHeaderNames(); while (headers.hasMoreElements()) { String headerName = headers.nextElement(); out.println("<br> name: " + headerName + ", value: " + req.getHeader(headerName) + "</br>"); } out.println("</body>"); out.close(); } } ``` > ![image](https://hackmd.io/_uploads/SkiDCuiV6.png) ### 伺服器處理 - Parameter 參數 * 可以透過 `HttpServletRequest` 的幾個方法獲取 `Get` 參數 1. **參數可以重複,並帶不同的 Value** ```shell= ## Http 請求並帶有參數 https://hello.world/view?param=10&param=30&param=60 ## 其中參數為 param,並有多個不同數值 param=10&param=30&param=60 ``` 如果想取出 URL 中所有的「**param**」數值,那可以使用 `getParameterValues(...)` 取出指定參數的數值 ```shell= String[] values = request.getParameterValues("param"); ``` 2. **也可以取出所有參數名** ```shell= ## Http 請求並帶有參數 https://hello.world/view?apple=Red&banana=Yellow&car=Green ## 其中參數為 apple, banana, car apple=Red&banana=Yellow&car=Green ``` 如果想取出 URL 中所有的參數名稱,那可以使用 `getParameterNames()` 取出所有參數的名稱 ```shell= Enumeration[] names = request.getParameterNames(); ``` * **使用 JavaEE Servlet 取得 URI 的參數,範例如下** ```java= @WebServlet( urlPatterns = { "/show.params" } ) public class GetParamsServlet extends HttpServlet { @Override protected void doGet(HttpServletRequest req, HttpServletResponse response) throws IOException { PrintWriter out = response.getWriter(); out.println("<html>"); out.println("<head>"); out.println("<title>Hello World</title>"); out.println("</head>"); out.println("<body>"); Enumeration<String> parameterNames = req.getParameterNames(); while (parameterNames.hasMoreElements()) { String parameterName = parameterNames.nextElement(); for (String value : req.getParameterValues(parameterName)) { out.println("<br> name: " + parameterName + ", value: " + value + "</br>"); } } out.println("</body>"); out.close(); } } ``` > 訪問網址:`http://localhost:8080/JavaEEHelloWorld-1.0-SNAPSHOT/show.params?param=10&param=30&param=60&apple=Red&banana=Yellow&car=Green` > > ![image](https://hackmd.io/_uploads/rkyJyFsE6.png) ### 伺服器處理 - POST/GET 編碼問題 * Web 容器域會使用 `ISO-8859-1`(`Latin-1`),它是一個「**西歐語言**」的編碼,並且與 ASCII 相容; 如果使用者傳遞參數時使用 **非西歐語言** 會導致 Web 容器用錯誤的方式分析 Http 的 MetaData,導致 Servlet 處理資料時,會接收錯誤的資料 > ![image](https://hackmd.io/_uploads/rkrD9OsNT.png) * **Servlet 對於 Http 不同的請求方式會有不同的編碼處理方案** * **POST 請求**: **Server 端在取得客戶端 POST 的數值「之前」**,可使用 `setCharacterEncodeing(...)` 來設定對於數據的解碼方式 ```java= req.setCharacterEncoding(StandardCharsets.UTF_8.name()); ``` :::info * 源碼註解上有清楚寫,這個方法(`setCharacterEncoding`)設定取得「`Body`」的解碼方式 > 也就該方法 **只對 POST 數值有效**! ::: > ![image](https://hackmd.io/_uploads/SyKcgtj46.png) * **GET 請求**: 當請求使用 GET 發送時,使用 `setCharacterEncoding` 則不一定有效;建議直接「手動處理編碼」,**^1.^ 將字串轉為 Byte array,^2.^ 再指定編碼方案** ```java= String name = req.getParameter("namePost"); // ISO_8859_1 轉為 Big5 name = new String(name.getBytes(StandardCharsets.ISO_8859_1), "Big5"); ``` :::warning * 轉換後,在開發者工具上仍無法出現正確的文字? 因為我們還沒告訴遊覽器要如何解讀這段文字,這個部分可以透過 HTML 設定,也可以透過 Http 的 Header 設置 ```html= <meta http-equiv="Content-Type" content="text/html; charset=Big5"> ``` ::: * **Get、Post 範例** 1. **間單 HTML 文檔**:並寫兩個 form 表單 * 設定 Header 的 content 為 `text/html`、`charset=Big5` * 設定 form method 為 `get`、`post` * 設定 form action 為 `encoding`(使用同一個 URI 處理不同請求方法),對應到的是 Servlet URL 的處理 ```xml= <!DOCTYPE html> <html lang="en"> <head> <meta http-equiv="Content-Type" content = "text/html; charset=BIG5"> <title>Title</title> </head> <body> <form method="get" action="encoding"> <br>Get input: <label> <input type="text" name="nameGet"/> </label> <button>GET Request</button> </form> <form method="post" action="encoding"> <br>Put input: <label> <input type="text" name="namePost"/> </label> <button>POST Request</button> </form> </body> </html> ``` 2. **處理對應的 Servlet** 設定 form 表單的 action 的 `encoding` Servlet 處理 ```java= @WebServlet( "/encoding" ) public class EncodingServlet extends HttpServlet { @Override protected void doPost(HttpServletRequest req, HttpServletResponse resp) throws IOException { req.setCharacterEncoding("Big5"); String name = req.getParameter("namePost"); System.out.println("POST: " + name); } @Override protected void doGet(HttpServletRequest req, HttpServletResponse response) throws IOException { String name = req.getParameter("nameGet"); name = new String(name.getBytes(StandardCharsets.ISO_8859_1), "Big5"); System.out.println("GET: " + name); } } ``` ### 伺服器處理 - 讀取 POST 內容 * 我們這邊直接使用 JavaEE 的 Servlet 讀取 POST 的內容(就是 Body) 1. **間單 HTML 文檔**:並寫一個 form 表單 * 設定 Header 的 content 為 `text/html`、**`charset=UTF-8`** * 設定 form method 為 `post` * 設定 form action 為 `body.view`,對應到的是 Servlet URL 的處理 ```xml= <!DOCTYPE html> <html lang="en"> <head> <meta http-equiv="Content-Type" content = "text/html; charset=UTF-8"> <title>Title</title> </head> <body> <form method="post" action="body.view"> <br>Name: <label> <input type="text" name="userName"/> </label> <br>Password: <label> <input type="password" name="userPasswd"/> </label> <button>Submit</button> </form> </body> </html> ``` 2. **處理對應的 Servlet** 設定 form 表單的 action 的 `body.view` Servlet 處理 ```java= @WebServlet( "/body.view" ) public class BodyServlet extends HttpServlet { @Override protected void doPost(HttpServletRequest req, HttpServletResponse resp) throws IOException { // 取得 Post 資訊 BufferedReader reader = req.getReader(); String input; StringBuilder requestBody = new StringBuilder(); while ((input = reader.readLine()) != null) { requestBody.append(input).append("<br>"); } String body = requestBody.toString(); PrintWriter out = resp.getWriter(); out.println("<html>"); out.println("<head>"); out.println("<title>Hello World</title>"); out.println("</head>"); out.println("<body>"); out.println(body); out.println("</body>"); } } ``` * 輸入 ASCII 解碼的內容 > ![image](https://hackmd.io/_uploads/r19KnT24T.png) * 輸入 ASCII 不能正常解碼的內容(輸入中文) > ![image](https://hackmd.io/_uploads/rJCT2ThE6.png) ### 伺服器處理 - 使用 Http Inputstream 處理多媒體 MetaData :::warning 以下範例不一定可以在你的專案中運作,因為這是直接對 Http 協議來傳送、分析 MetaData(或是說`Raw Data` 分析) `Raw Data` 的分析往往需要雙方都了解細節協定才可以運作,所以以下我們先定義一個多媒體傳送的資料格式,接下來出現的 IMGIC Number 都是為了解析這個格式(format) ```shell= --***** Content-Disposition: form-data; Hello.pic filename="檔案名" 多媒體數據 --*****-- ``` ::: * 以下範例是使用 JavaEE 的 Servlet 處理 Http 請求(要處理的是 Body 的二進制資料),處理的主要步驟如下 1. **讀取 Http 請求的 Body** ```java= private byte[] readBody(HttpServletRequest req) throws IOException { // 使用 io 流,先取得資料的輸入流 // 再使用 io 輸出流收集 byte[] try (ServletInputStream inputStream = req.getInputStream(); BufferedInputStream bis = new BufferedInputStream(inputStream); ByteArrayOutputStream baos = new ByteArrayOutputStream()) { byte[] buffer = new byte[1024]; int bytesRead; while ((bytesRead = bis.read(buffer)) != -1) { baos.write(buffer, 0, bytesRead); } return baos.toByteArray(); } } ``` 2. 透過傳送 Http 結構的特性,取 Http Body 的 `filename` 取得傳送資料的名稱 > 以下的 `metaData` 是指 Http Body 中的資料 ```java= private String getFileName(String metaData) { // 利用 indexOf 找到 filename 的位置 int fileNameIndex = metaData.indexOf("filename=\""); if (fileNameIndex != -1) { // 截取 filename 開始到下一個雙引號的部分 int startQuote = fileNameIndex + "filename=\"".length(); int endQuote = metaData.indexOf("\"", startQuote); if (endQuote != -1) { // 提取文件名 return metaData.substring(startQuote, endQuote); } } // 如果未找到文件名,返回空字符串或者 null,視情況而定 return ""; } ``` 3. **取得傳輸書的二進位資料**: 重點就是擷取 `多媒體數據`(當要要除去 `Content-Disposition` 這行,) ```shell= --***** Content-Disposition: form-data; Hello.pic filename="檔案名" 多媒體數據 --*****-- ``` :::info * `boundary` 是只用來區分 Http Header 跟真正資料的間隔 在HTTP協議中,使用 `--*****\r\n` 的形式通常是模擬 `multipart/form-data` 格式的一部分,這種格式常用於上傳文件或二進制數據 > 這樣的形式通常就稱為 `boundary`,它在 `multipart/form-data` 中用於分隔不同的數據部分 **boundary 的選擇是開發者定義的**,**只要它是唯一的並且不會與實際數據中的內容衝突** > 所以 `--*****\r\n` 只是一個示例,**你可以選擇任何你認為合適的字串作為boundary** ::: ```java= private Position getFilePosition(HttpServletRequest req, String bodyMetaData) { String contentType = req.getContentType(); String boundaryText = contentType.substring(contentType.lastIndexOf("=") + 1); int startBoundaryPos = bodyMetaData.indexOf("filename=\""); startBoundaryPos = bodyMetaData.indexOf("\r\n", startBoundaryPos) + 2; int endBoundaryPos = bodyMetaData.indexOf(boundaryText, startBoundaryPos) - 2; int startQuote = bodyMetaData.substring(0, startBoundaryPos).trim().getBytes(StandardCharsets.ISO_8859_1).length + 2; int endQuote = bodyMetaData.substring(0, endBoundaryPos).trim().getBytes(StandardCharsets.ISO_8859_1).length; return new Position(startQuote, endQuote); } private void writeTo(String fileName, byte[] body, Position position) throws IOException { try (FileOutputStream fos = new FileOutputStream("/Users/Hy-KylePan/IdeaProjects/JavaEE-Web/" + fileName)) { int length = position.end - position.begin; fos.write(body, position.begin, length); fos.flush(); } } static class Position { final int begin; final int end; Position(int begin, int end) { this.begin = begin; this.end = end; } } ``` :::spoiler 完整程式 ```java= @WebServlet( "/upload.handle" ) public class UploadServlet extends HttpServlet { @Override protected void doPost(HttpServletRequest req, HttpServletResponse resp) throws IOException { // ----------------------------------------------------------------------- PrintWriter out = resp.getWriter(); byte[] body = readBody(req); String textBody = new String(body, StandardCharsets.ISO_8859_1); String fileName = getFileName(textBody); Position position = getFilePosition(req, textBody); // out.println("<pre>: " + fileName + "\n" + // new String(body, StandardCharsets.UTF_8) + "\n" + // "Position: " + position.begin + ", " + position.end + "</pre>"); out.println("<html>"); out.println("<head>"); out.println("<title>Hello World</title>"); out.println("</head>"); out.println("<body>"); out.println("<h3> fileName: " + fileName + "</h3>"); out.println("<h3> position start: " + position.begin + "</h3>"); out.println("<h3> position end: " + position.end + "</h3>"); out.println("</body>"); writeTo(fileName, body, position); } private byte[] readBody(HttpServletRequest req) throws IOException { try (ServletInputStream inputStream = req.getInputStream(); BufferedInputStream bis = new BufferedInputStream(inputStream); ByteArrayOutputStream baos = new ByteArrayOutputStream()) { byte[] buffer = new byte[1024]; int bytesRead; while ((bytesRead = bis.read(buffer)) != -1) { baos.write(buffer, 0, bytesRead); } return baos.toByteArray(); } } private String getFileName(String metaData) { // 利用 indexOf 找到 filename 的位置 int fileNameIndex = metaData.indexOf("filename=\""); if (fileNameIndex != -1) { // 截取 filename 開始到下一個雙引號的部分 int startQuote = fileNameIndex + "filename=\"".length(); int endQuote = metaData.indexOf("\"", startQuote); if (endQuote != -1) { // 提取文件名 return metaData.substring(startQuote, endQuote); } } // 如果未找到文件名,返回空字符串或者 null,視情況而定 return ""; } private Position getFilePosition(HttpServletRequest req, String bodyMetaData) { String contentType = req.getContentType(); String boundaryText = contentType.substring(contentType.lastIndexOf("=") + 1); int startBoundaryPos = bodyMetaData.indexOf("filename=\""); startBoundaryPos = bodyMetaData.indexOf("\r\n", startBoundaryPos) + 2; int endBoundaryPos = bodyMetaData.indexOf(boundaryText, startBoundaryPos) - 2; int startQuote = bodyMetaData.substring(0, startBoundaryPos).trim().getBytes(StandardCharsets.ISO_8859_1).length + 2; int endQuote = bodyMetaData.substring(0, endBoundaryPos).trim().getBytes(StandardCharsets.ISO_8859_1).length; return new Position(startQuote, endQuote); } private void writeTo(String fileName, byte[] body, Position position) throws IOException { try (FileOutputStream fos = new FileOutputStream("/Users/Hy-KylePan/IdeaProjects/JavaEE-Web/" + fileName)) { int length = position.end - position.begin; fos.write(body, position.begin, length); fos.flush(); } } static class Position { final int begin; final int end; Position(int begin, int end) { this.begin = begin; this.end = end; } } } ``` ::: * 客戶端訪問該 API 的測試程式如下(使用 Kotlin) ```java= fun uploadByHttpFormat(url: String, fileName: String, filePath: String, boundary: String = "*****") { val url = URL(url) val connection = url.openConnection().apply { doInput = true doOutput = true useCaches = false // Set Http request header setRequestProperty("Connection", "Keep-Alive") setRequestProperty("Charset", "UTF-8") setRequestProperty("Content-Type", "multipart/form-data; boundary=$boundary") } // Write data to http val outputStream = DataOutputStream(connection.getOutputStream()).apply { writeBytes("--$boundary\r\n") writeBytes("Content-Disposition: form-data; $fileName filename=\"$fileName\"\r\n") val inputStream = FileInputStream(filePath).let { val totalBytes = it.readBytes() this.write(totalBytes, 0, totalBytes.size) } writeBytes("\r\n--$boundary--\r\n") flush() } // Get http response val inputStream = connection.getInputStream().apply { val outPutStream = ByteArrayOutputStream() val buf = ByteArray(1024) while ((read(buf)) != -1) { outPutStream.write(buf) } println("Http response: \n${String(outPutStream.toByteArray(), Charset.defaultCharset())}") } } ``` 使用單元測試運行客戶端程式 ```java= @Test fun uploadFile() { UploadData().uploadByHttpFormat("http://127.0.0.1:8080/JavaEEHelloWorld-1.0-SNAPSHOT/upload.handle", "Hello.png", "/Users/Hy-KylePan/Desktop/2024-01-04.png") } ``` > ![螢幕截圖 2024-01-30 09.08.18](https://hackmd.io/_uploads/ry8F3pBqa.png) ### 取得上傳檔案 - getPart、getParts * 在 `Serlets 3.0` 中有新增 Part 界面,這個界面可以方便的進行檔案上傳處理; > ![image](https://hackmd.io/_uploads/HkIQFT4B6.png) * **`getPart` 範例如下**: 1. 從註解中我們可以看到,它接收的是一個 post 請求、`multipart/form-data` 2. 必須在設置的 Servlet 中,使用 `@MultipartConfig` 否則會拋出異常;其中可以設置的屬性如下 :::warning * 缺少 `@MultipartConfig` 則會在運行時拋出異常 > ![image](https://hackmd.io/_uploads/rJ-YfRNHp.png) ::: | 屬性 | 概述 | 預設值 | | - | - | - | | `fileSizeThreadshold` | 設定上傳檔案的大小;當超過設定門檻,就會寫入 **暫存檔案** | 0 | | `location` | 設定寫入檔案時的 **目錄**;可以搭配 Part#write 方法使用 | 預設為空字串 | | `maxFileSize` | 限制上傳檔案大小 | `-1`,代表不限制大小 | | `maxRequestSize` | 限制請求的數量 | `-1`,代表不限制次數 | ```java= @MultipartConfig @WebServlet( "/upload.part" ) public class UploadServletPart extends HttpServlet { @Override protected void doPost(HttpServletRequest req, HttpServletResponse resp) throws IOException, ServletException { // 既然是使用 POST!那就可以透過設定 `setCharacterEncoding` // 來處理中文檔名 req.setCharacterEncoding("UTF-8"); Part part = req.getPart("usePart"); String header = part.getHeader("Content-Disposition"); String fileName = header.substring(header.indexOf("filename=\"") + "filename=\"".length(), header.lastIndexOf("\"")); PrintWriter out = resp.getWriter(); out.println("<html>"); out.println("<head>"); out.println("<title>Hello World</title>"); out.println("</head>"); out.println("<body>"); out.println("<h3> fileName: " + fileName + "</h3>"); out.println("</body>"); writeTo(fileName, part); } private void writeTo(String fileName, Part part) throws IOException { try( InputStream inputStream = part.getInputStream(); OutputStream outputStream = new FileOutputStream("/home/alien/IdeaProjects/JavaEEHelloWorld/" + fileName); ) { byte[] bytes = new byte[1024]; int len; while ((len = inputStream.read(bytes)) != -1) { outputStream.write(bytes, 0, len); } } } } ``` > ![image](https://hackmd.io/_uploads/rJ4aATESp.png) * 如果要抓取上傳的 **多個檔案**,可以 **使用 `getParts`,範例如下**: 這裡透過設定 `@MultipartConfig`#location 來指定接下來處理的檔案要放置在哪裡 :::info * 如果不想這樣設置,也可以透過 `web.xml` 設置 ```xml= <servlet> <servlet-name>multiUploadServletParts</servlet-name> <servlet-class>com.example.javaeehelloworld.MultiUploadServletParts</servlet-class> <multipart-config> <location>/home/alien/IdeaProjects/JavaEEHelloWorld/</location> </multipart-config> </servlet> ```` ::: ```java= @MultipartConfig( location = "/home/alien/IdeaProjects/JavaEEHelloWorld/" ) @WebServlet( "/upload.parts" ) public class MultiUploadServletParts extends HttpServlet { @Override protected void doPost(HttpServletRequest req, HttpServletResponse resp) throws IOException, ServletException { req.setCharacterEncoding("UTF-8"); PrintWriter out = resp.getWriter(); out.println("<html>"); out.println("<head>"); out.println("<title>Hello World</title>"); out.println("</head>"); out.println("<body>"); // 取得所有 Parts for (Part part: req.getParts()) { String name = part.getName(); // 判斷目標 name if (!name.startsWith("usePart_")) { out.println("<h3> Not target part's name: " + name + "</h3>"); continue; } String fileName = getFileName(part); out.println("<h3> fileName_1: " + name + "</h3>"); part.write(fileName); } out.println("</body>"); } private String getFileName(Part part) { String header = part.getHeader("Content-Disposition"); return header.substring(header.indexOf("filename=\"") + "filename=\"".length(), header.lastIndexOf("\"")); } } ``` > ![image](https://hackmd.io/_uploads/Sy_XzfLHp.png) 上傳的對應的 Part name > ![image](https://hackmd.io/_uploads/SkBEMMLBT.png) 上傳結果 > ![image](https://hackmd.io/_uploads/Hk_rfzISp.png) ### Dispatcher 轉發請求 - include/forward * 我們可以透過處理需求的轉發,讓一個請求經過多個 Servlet 處理;接下來直接上 Servlet Dispatcher 轉發請求的範例 ```mermaid graph LR; 請求 --> 入口_Servlet 入口_Servlet -.-> |轉發| Servlet_1 入口_Servlet -.-> |轉發| Servlet_2 入口_Servlet -.-> |轉發| Servlet_3 ``` 1. **協作 `include` 方法** 透過 `HttpServletRequest`#getRequestDispatcher(...) 來指定要**包含**的地址;**`include` 可以將令一個 Servlet 的執行流程「包含進」這一次的流程** ```java= @WebServlet( "/dispatch.entry" ) public class EntryServlet extends HttpServlet { public void doGet(HttpServletRequest request, HttpServletResponse response) throws IOException, ServletException { PrintWriter writer = response.getWriter(); writer.println("Entry... start"); // 取得轉發的目標 // 其中也可以帶入 GET 請求的參數 // dispatch.include?weather=sunny // 之後可以透過 getParameter("weather") 取得參數 RequestDispatcher requestDispatcher = request.getRequestDispatcher("dispatch.include"); // 使用 include 轉發 requestDispatcher.include(request, response); writer.println("Entry... done"); writer.close(); } } // ---------------------------------------------------------- @WebServlet( "/dispatch.include" ) public class IncludeServlet extends HttpServlet { public void doGet(HttpServletRequest request, HttpServletResponse response) throws IOException { PrintWriter writer = response.getWriter(); writer.println("Include... handling"); } } ``` 從結果可以看到,是 **兩個 Servlet 共同處理** 的這次的請求 > ![image](https://hackmd.io/_uploads/BkX4qfIH6.png) 2. **`forward` 方法**(較常被使用到) 同樣透過 `HttpServletRequest`#getRequestDispatcher(...) 來指定要**轉發**的地址;**`forward` 可以將令一個 Servlet 的執行流程「轉發給」另一個 Servlet** ```java= @WebServlet( "/dispatch.entry2" ) public class EntryServlet2 extends HttpServlet { public void doGet(HttpServletRequest request, HttpServletResponse response) throws IOException, ServletException { PrintWriter writer = response.getWriter(); writer.println("Entry... start"); RequestDispatcher requestDispatcher = request.getRequestDispatcher("dispatch.forward"); requestDispatcher.forward(request, response); writer.println("Entry... done"); writer.close(); } } // ---------------------------------------------------------- @WebServlet( "/dispatch.include" ) public class IncludeServlet extends HttpServlet { public void doGet(HttpServletRequest request, HttpServletResponse response) throws IOException { PrintWriter writer = response.getWriter(); writer.println("Include... handling"); } } ``` 從結果可以看到,是 **被轉發的 Servlet 單獨處理** 的這次的請求 > ![image](https://hackmd.io/_uploads/rkHNyXUBT.png) ## 服務器響應 HttpServletResponse ### 響應者 - 響應 Header * 這邊要說的是服務端,如何透過 Servlet API 回應使用者請求的 Header ```mermaid graph LR; subgraph HTTP響應_Header 狀態行 響應_Header 通用_Header 實體_Header 其他 end ``` * **響應 Header 字段**(`Request Header Fields`) 服務器端要回應給客戶端的 Header;也就是 **流「出」伺服器的資料,由伺服器設定**(站在伺服器的角度) | Header Data | 概述 | | - | - | | `Accept-Ranges` | 是否接受字節範圍請求 | | `Age` | 可以用來推算資源創建後經歷的時間 | | `Etag` | 資源的匹配信息 | | `Location` | 令客戶端 **重新定向**,指定的 URI | | `Proxy-Authoriztion` | 給代理服務器的驗證 | | `Retry-After` | 如果要再次訪問伺服器,那要嘗試的時機 | | `Server` | Http 服務器的相關訊息 | | `Vary` | 代理服務器 **緩存的管理信息** | | `WWW-Authenticate` | 服務器對客戶端的驗證信息 | * JavaEE Servlet 可以透過 HttpServletResponse 的幾個方法來設定回應給使用者的標頭(Header) * **設定 `setHeader` / 添加`addHeader`** * **設定整數 `setIntHeader` / 添加整數 `addIntHeader`** * **設定日期 `setDateHeader` / 添加日期 `setDateHeader`** ### 回應緩衝區 * **容器可以對回應給使用的數據進行緩衝**(並非必須,通常容器會對回應進行緩衝),Servlet 有提供幾個緩衝相關的方法 * **緩衝大小控制** 取得緩衝大小 `getBufferSize()` / 設定緩衝大小 `setBufferSize()` :::warning * 要在呼叫 `getWriter`、`getOutStream` 方法之前呼叫 `setBufferSize()` 設置緩存才行,否則會拋出異常 ::: * `isCommited()`:**查看緩衝區是否已經被提交** 在緩衝區未滿之前,設定的回應內容不會立刻傳到客戶端(可以透過 `isCommited()` 確認) * `resetBuffer()`:**重設緩衝** :::danger * 重置回應內容,但不會清除已經設定的標頭(Header)內容 ::: * `reset()`:**不只重置內容,連同標頭一起重置** * `flushBuffer()`:**強制刷新緩衝到客戶端** ### 請求結束的時機 * HttpServletResponse 物件若被容器關閉,則會清除的回應內容;而 Serlvet 被關閉的時機有 * `service()` 方法結束 * 呼叫 `sendRedirect()` 方法 * 呼叫 `sendError()` 方法 * 呼叫 AsyncContext#`complete()` 方法 ### 正確輸出字元 - Local、CharacterEncoding、ContentType MIME * **「亂碼」問題**: 透過 HttpServletResponse#`getWriter()` 可以,在沒有任何內容型態、編碼之前,**預設是使用 `IOS-8859-1` 編碼** 所以如果在回應時回應中文則會 **導致「亂碼」問題**(因為使用者遊覽器不清楚你使用了非 `IOS-8859-1` 編碼,導致解碼錯誤),而在 JavaEE Servlet 解決問題的方法有三種 1. **Local 物件設定**(需要有設定 `Accept-Language`) 2. **CharacterEncoding 設定** 3. **ContentType 設定**(設定 MIME, `Multipuerpose Internet Mail Extensions`) * **Local 物件**: 1. 如果遊覽器端的 Header 有設置 `Accept-Language` 標頭,那就可以透過 HttpServletResponse#`getLocale` 方法取得物件 ```java= Locale locale = response.getLocale(); ``` 2. 可以透過 HttpServletResponse#`setLocale` 方法設定回應的標頭 ```java= response.setLocale(Locale.TAIWAN); ``` :::info * 瀏覽器端收到時會顯示在響應的 Header 的 `Content-Length` 中 ::: 透過這個設定,可以讓遊覽器知道要如何去判斷傳來的文字的編碼,這讓遊覽器可以正常輸出中文 ```java= public void doGet(HttpServletRequest request, HttpServletResponse response) throws IOException { response.setLocale(Locale.TAIWAN); PrintWriter out = response.getWriter(); out.println("<html>"); out.println("<head>"); out.println("<title>Hello World</title>"); out.println("</head>"); out.println("<body>"); out.println("<h1> After set, 你好!! </h1>"); out.println("<h3> response locale: " + response.getLocale() + "</h3>"); for (String headerName : response.getHeaderNames()) { for (String value : response.getHeaders(headerName)) { out.println("<h3> header: " + headerName + ", value" + value + "</h3>"); } } out.println("</body>"); out.close(); } ``` > 中文可以 正常顯示 > > ![image](https://hackmd.io/_uploads/r1nEoDPr6.png) * Character 設定,**使用 `setCharacterEncoding`、`setContentType` 方法來設定** :::warning * 如果使用 `setCharacterEncoding`、`setContentType` 設定,那 `setLocale` 就會失效!(也就是說這兩者的優先權更高) ::: * **`setCharacterEncoding` 設定**:透過這個方法可以內容編碼的方式 ```java= public void doGet(HttpServletRequest request, HttpServletResponse response) throws IOException { // 設定回覆時的編碼 response.setCharacterEncoding("Big5"); PrintWriter out = response.getWriter(); out.println("<html>"); out.println("<head>"); out.println("<title>Hello World</title>"); out.println("</head>"); out.println("<body>"); // 設定 CharacterEncoding out.println("<h1> After setCharacterEncoding, 你好!! </h1>"); out.println("<h3> response locale: " + response.getLocale() + "</h3>"); for (String headerName : response.getHeaderNames()) { for (String value : response.getHeaders(headerName)) { out.println("<h3> header: " + headerName + ", value" + value + "</h3>"); } } out.println("</body>"); out.close(); } ``` > ![image](https://hackmd.io/_uploads/ryGsswvr6.png) * **`setContentType` 設定**: 設定的是 **MIME**(`Multipurpose Internet Mail Extensions`) 的種類,用來告訴使用者返回的資料的種類 > 知道種類後,瀏覽器才能正確的去處理資料 常見的 **MIME** 如下… * `text/html` 可以順帶設定 `charset` * `application/pdf` * `application/x-zip` * `image/jpeg` > [**完整的 MIME 類型**](https://www.iana.org/assignments/media-types/media-types.xhtml) 請點連結 ```java= public void doGet(HttpServletRequest request, HttpServletResponse response) throws IOException { response.setContentType("text/html; charset=big5"); PrintWriter out = response.getWriter(); out.println("<html>"); out.println("<head>"); out.println("<title>Hello World</title>"); out.println("</head>"); out.println("<body>"); // 設定 ContentType out.println("<h1> After setContentType, 你好!! </h1>"); out.println("<h3> response locale: " + response.getLocale() + "</h3>"); for (String headerName : response.getHeaderNames()) { for (String value : response.getHeaders(headerName)) { out.println("<h3> header: " + headerName + ", value" + value + "</h3>"); } } out.println("</body>"); out.close(); } ``` :::warning * 其它非官方提供的 MIME 類型也是可以使用的,像是 `MessagePack` 傳輸方式,它常見的做法是使用 `x` 做延伸 它可能會帶出的 Header 為 `Content-Type: application/x-msgpack` ::: > ![image](https://hackmd.io/_uploads/B1ux6PPS6.png) ### Stream 串流輸出 - pdf / image * 我們也可以將 Stream 直接輸到瀏覽器中(這部份 Writer 做不到) Servlet 可以透過 HttpServletResponse 取得 `getOutputStream` 來進行 Stream 流輸出;範例如下 ```java= @WebServlet( urlPatterns = { "/download.pdf" } ) public class DownloadServlet extends HttpServlet { public void doGet(HttpServletRequest request, HttpServletResponse response) throws IOException { // MIME 要設定正確唷! response.setContentType("application/pdf"); try(InputStream in = getServletContext().getResourceAsStream("/WEB-INF/test.pdf"); OutputStream out = response.getOutputStream()) { byte[] tmp = new byte[1024]; int length; while ((length = in.read(tmp)) != -1) { out.write(tmp, 0, length); } } } } ``` > ![image](https://hackmd.io/_uploads/r1l7V_vHp.png) * 範例二: 客戶端使用流(Stream)讀取服務器端的圖片 ```java= @WebServlet("/image.get") public class GetImage extends HttpServlet { protected void doGet(HttpServletRequest request, HttpServletResponse response) throws ServletException, IOException { response.setContentType("image/jpeg"); try (InputStream inputStream = new BufferedInputStream( new FileInputStream("/home/alien/IdeaProjects/JavaEEHelloWorld/test_pic.png")); OutputStream outputStream = response.getOutputStream()) { byte[] buffer = new byte[1024]; int bytesRead; while ((bytesRead = inputStream.read(buffer)) != -1) { outputStream.write(buffer, 0, bytesRead); } } } } ``` > ![image](https://hackmd.io/_uploads/rJQfFWQva.png) ### 重新導向 3xx 、傳送錯誤 4xx * 向客戶端發出 **重新導向** * Servlet 的 `forward` 轉發行為並不會讓客戶端遊覽器感知到,也就是轉發過程仍算在「同一個請求」,不會觸發 3xx 狀態 ```mermaid graph RL; 客戶端 --> |請求| 伺服器 伺服器 -.-> |200| 客戶端 subgraph 伺服器 subgraph Web_容器 Servlet_A --> |forward| Servlet_B end end ``` * 如果要真正觸發則需要使用 HttpServletResponse#`sendRedirect` 方法 ```java= response.sendRedirect("https://...") ``` 1. 首先在發出重新導向時,客戶端會收到 3xx 的狀態碼 ```mermaid graph RL; 客戶端 --> |1. 請求| 伺服器_A 伺服器_A -.-> |1. 300| 客戶端 subgraph 伺服器_A subgraph Web_容器_A Servlet_A end end ``` 2. 客戶端收到重新導向後,會在重新發出請求 ```mermaid graph RL; 客戶端 --> |2. 請求| 伺服器_B 伺服器_B -.-> |2. 200| 客戶端 subgraph 伺服器_B subgraph Web_容器_B Servlet_B end end ``` > ![image](https://hackmd.io/_uploads/SJ_-Cm_rT.png) * 向客戶端發出 **傳送錯誤** 如果在客戶端處理請求時,發生一些錯誤(找不到資源、或是參數不合法);可以透過 HttpServletResponse#`sendError(...)` 方法來像客戶端發出 4xx 錯誤 ```java= // 發出 404 錯誤 response.sendError(HttpServletResponse.SC_NOT_FOUND); ``` > 除了 404 之外,以下還有其他 4xx 錯誤 > > ![image](https://hackmd.io/_uploads/rykS2Qura.png) ## HTTP 更多知識點 ### 通用、實體 Header 通用、實體 Header 是 「請求」、「響應」兩方都可以使用的 MetaData; * **通用 Header 字段**(`General Header Fields`) 「請求」(客戶端)、「響應」(伺服器端)兩方都會使用的 Header,可用來描述雙方的狀態 | Header Data | 概述 | | - | - | | `Cache-Control` | 控制緩存的行為 | | `Connection` | 連接管理、逐跳管理 | | `Date` | 創建資料的時間 | | `Pragma` | 請求、響應的指令 | | `Trailer` | 資料末端的首部訊息 | | `Transfer-Encoding` | 指定資料主體的 **編碼方式** | | `Upgrade` | 升級為其他協議 | | `Via` | 代理服務器的相關訊息 | | `Warning` | 警告通知 | * **實體 Header 字段**(`Entity Header Fields`) 針對「請求」、「響應」資料的 **實體部份**;用來補充資源內容更新時間、實體訊息 | Header Data | 概述 | | - | - | | `Allow` | 資源可以支持的 HTTP 方式(`Get`、`Post`... 等等) | | `Content-Encoding` | 內容主體通用的編碼方式 | | `Content-Language` | 內容語言(人們用得自然語言) | | `Content-Length` | 實體主體的大小,但為`Byte` | | `Content-Location` | 重新導向 URI 的位置 | | `Content-MD5` | 用於驗證訊息主體的完整性(通常 128 bit) | | `Content-Range` | 指定實體資料的範圍 | | `Content-Type` | 內容主體的媒體類型 | | `Expires` | 內容實體的過期時間 | | `Last-Modified` | 資源最後修改時間 | ### 非正式 Header * 在 Http 通訊協議交互時,也會使用到非協議中的 Header(這裡是指 `Http/1.1`,基於 [**RFC 2616**](https://www.w3.org/Protocols/rfc2616/rfc2616-sec14.html)),常見的像是 * `Cookie` * `Set-Cookie` * `Content-Disposition`:它通常用於指示應該如何處理收到的消息主體,特別是應該如何處理附件(`attachment`) > 主要使用在HTTP協議中,特別是在處理文件上傳或下載時 ```shell= Content-Disposition: attachment; filename="example.txt" ``` :::success * 這些非正式的 Header 都規範在 [**RFC 4229**](https://www.rfc-editor.org/rfc/rfc4229.html#section-2.1.96) 的 HTTP Header Field Registration 中 > ![image](https://hackmd.io/_uploads/rkWa9Vj4p.png) ::: ### HTTP Header 行為 - 端到端、逐跳 * HTTP 首部字段將定義有分為大致的兩個種類 1. **端到端的 Header**(`End-to-end Header`) 分別在此類別中的 Header **會將 Header 轉發給請求、響應端**,也就是服務器、客戶端都會收到資訊,**不會被中途劫走** > 2. **逐跳的 Header**(`Hop-by-hop Header`) 分別在此類別中的 Header,**只會對單次轉發有效**!如果中途有緩存、代理,就會被移除,不會再往下傳遞 > 如果要使用 `Hop-by-hop Header`,那就要設置 `Connection` 字段到 Header 中 ### HttpServletRequest 監聽器 * 我們可以對 HttpServletRequest 做監聽(請求、屬性操作),範例如下 1. 監聽 HttpServletRequest 請求 ```java= @WebListener public class ServletRequestListenerImpl implements ServletRequestListener { private static final String REQ_CATCHER_KEY = "RequestCatcher"; @Override public void requestInitialized(ServletRequestEvent sre) { sre.getServletRequest().setAttribute(REQ_CATCHER_KEY, "Hello, I am request catcher"); } @Override public void requestDestroyed(ServletRequestEvent sre) { sre.getServletRequest().removeAttribute(REQ_CATCHER_KEY); } } ``` 2. 監聽 HttpServletRequest 屬性操作(包括添加、移除、替代) ```java= @WebListener public class ServletRequestAttrsListenerImpl implements ServletRequestAttributeListener { public static final String REQ_ATTR_CATCHER_KEY = "RequestAttrsCatcher"; @Override public void attributeAdded(ServletRequestAttributeEvent srae) { srae.getServletContext().setAttribute(REQ_ATTR_CATCHER_KEY, "Hello, I am request attrs catcher"); } @Override public void attributeRemoved(ServletRequestAttributeEvent srae) { ServletRequestAttributeListener.super.attributeRemoved(srae); } @Override public void attributeReplaced(ServletRequestAttributeEvent srae) { ServletRequestAttributeListener.super.attributeReplaced(srae); } } ``` * 測試程式如下 ```java= @WebServlet("/show.req.attrs") public class ShowContextAttributeServlet extends HttpServlet { @Override protected void doGet(HttpServletRequest req, HttpServletResponse resp) throws ServletException, IOException { PrintWriter out = resp.getWriter(); out.println("<html>"); out.println("<head>"); out.println("<title>Hello World</title>"); out.println("</head>"); Enumeration<String> names = req.getAttributeNames(); while (names.hasMoreElements()) { String attrName = names.nextElement(); out.println("<h3> attrName : " + attrName + ", value: " + req.getAttribute(attrName) + "</h3>"); } out.println("<h3> context attrName : " + ServletRequestAttrsListenerImpl.REQ_ATTR_CATCHER_KEY + ", value: " + req.getServletContext().getAttribute(ServletRequestAttrsListenerImpl.REQ_ATTR_CATCHER_KEY) + "</h3>"); out.println("<body>"); out.println("</body>"); out.close(); } } ``` > ![image](https://hackmd.io/_uploads/SktsVOSP6.png) ## Appendix & FAQ :::info ::: ###### tags: `Web`