Java筆記26 - Spring開發 - 開發Web應用

  • JavaEE中Web開發的基礎: Servlet
    • Servlet規範定義了一種標準組件: Servlet, JSP, Filter和Listener;
    • Servlet的標準組件總是運行在Servlet容器中, 如Tomcat, Jetty, WebLogic等
  • 直接使用Servlet進行Web開發好比直接在JDBC上操作數據庫, 比較繁瑣
  • 更好的方法是在Servlet基礎上封裝MVC框架, 基於MVC開發Web應用
  • Spring MVC足夠我們不再去集成其他的框架.

使用Spring MVC

  • Servlet容器, 和標準的Servlet組件

    • Servlet: 能處理HTTP請求並將HTTP響應返回;
    • JSP: 一種嵌套Java代碼的HTML, 將被編譯爲Servlet;
    • Filter: 能過濾指定URL以實現攔截功能;
    • Listener: 監聽指定的事件, 如ServletContext, HttpSession的創建和銷燬;
  • Servlet容器爲每一個web容器自動創建一個唯一的ServletContext實例, 這個實例代表了Web應用程序本身

使用REST

集成Filter

<web-app>
    <filter>
        <filter-name>encodingFilter</filter-name>
        <filter-class>org.springframework.web.filter.CharacterEncodingFilter</filter-class>
        <init-param>
            <param-name>encoding</param-name>
            <param-value>UTF-8</param-value>
        </init-param>
        <init-param>
            <param-name>forceEncoding</param-name>
            <param-value>true</param-value>
        </init-param>
    </filter>

    <filter-mapping>
        <filter-name>encodingFilter</filter-name>
        <url-pattern>/*</url-pattern>
    </filter-mapping>
    ...
</web-app>
  • 問題:
    • 在Spring中創建一個AuthFilter是一個普通的Bean, Servlet容器不知道這個Bean的存在, 所以不會起作用
    • 如果我們直接在web.xml中聲明這個AuthFilter, 注意到AuthFilter的實例將由Servlet容器而不是Spring容器初始化
    • 所以Autowire不會生效, 用於登錄的UserService成員變量永遠是null.
  • 方法:
    • 讓Servlet容器實例化的Filter, 間接引用Spring容器實例化的AuthFilter.
    • Spring MVC提供了一個DelegatingFilterProxy, 處理
  • 原理:
    • Servlet容器從web.xml中讀取配置, 實例化DelegatingFilterProxy, 注意命名authFilter
    • Spring容器通過掃描@Component實例化AuthFilter
    • DelegatingFilterProxy生效後, 會自動查找註冊在Servlet上的Spring容器.
    • 再試圖從容器中查找名爲authFilter的Bean. 也就是我們用@Component聲明的AuthFilter
    <filter>
        <filter-name>authFilter</filter-name>
        <filter-class>org.springframework.web.filter.DelegatingFilterProxy</filter-class>
    </filter>

    <!-- 指定Bean名字 -->
    <filter>
        <filter-name>basicAuthFilter</filter-name>
        <filter-class>org.springframework.web.filter.DelegatingFilterProxy</filter-class>
        <!-- 指定Bean的名字 -->
        <init-param>
            <param-name>targetBeanName</param-name>
            <param-value>authFilter</param-value>
        </init-param>
    </filter>

    <filter-mapping>
        <filter-name>authFilter</filter-name>
        <url-pattern>/*</url-pattern>
    </filter-mapping>
  • 代理模式應用:
┌ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ┐ ┌ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─
  ┌─────────────────────┐        ┌───────────┐   │
│ │DelegatingFilterProxy│─│─│─ ─>│AuthFilter │
  └─────────────────────┘        └───────────┘   │
│ ┌─────────────────────┐ │ │    ┌───────────┐
  │  DispatcherServlet  │─ ─ ─ ─>│Controllers│   │
│ └─────────────────────┘ │ │    └───────────┘
                                                 │
│    Servlet Container    │ │  Spring Container
 ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─   ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ┘
  • 當一個Filter作爲Spring容器管理的Bean存在時, 可以通過DelegatingFilterProxy簡介的引用它, 並使其生效.

使用Interceptor

  • Filter2的攔截範圍
         │   ▲
         ▼   │
       ┌───────┐
       │Filter1│
       └───────┘
         │   ▲
         ▼   │
       ┌───────┐
┌ ─ ─ ─│Filter2│─ ─ ─ ─ ─ ─ ─ ─ ┐
       └───────┘
│        │   ▲                  │
         ▼   │
│ ┌─────────────────┐           │
  │DispatcherServlet│<───┐
│ └─────────────────┘    │      │
   │              ┌────────────┐
│  │              │ModelAndView││
   │              └────────────┘
│  │                     ▲      │
   │    ┌───────────┐    │
│  ├───>│Controller1│────┤      │
   │    └───────────┘    │
│  │                     │      │
   │    ┌───────────┐    │
│  └───>│Controller2│────┘      │
        └───────────┘
└ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ┘
  • Interceptor攔截範圍: 不是後續整個處理流程, 而是僅針對Controller攔截.
       │   ▲
       ▼   │
     ┌───────┐
     │Filter1│
     └───────┘
       │   ▲
       ▼   │
     ┌───────┐
     │Filter2│
     └───────┘
       │   ▲
       ▼   │
┌─────────────────┐
│DispatcherServlet│<───┐
└─────────────────┘    │
 │              ┌────────────┐
 │              │ModelAndView│
 │              └────────────┘
 │ ┌ ─ ─ ─ ─ ─ ─ ─ ─ ┐ ▲
 │    ┌───────────┐    │
 ├─┼─>│Controller1│──┼─┤
 │    └───────────┘    │
 │ │                 │ │
 │    ┌───────────┐    │
 └─┼─>│Controller2│──┼─┘
      └───────────┘
   └ ─ ─ ─ ─ ─ ─ ─ ─ ┘
  • 只攔截Controller方法, 返回ModelAndView後, 後續對View的渲染就脫離了interceptor的範圍
  • Interceptor好處:
    • 本身是Spring管理的Bean, 注入任意的Bean都很簡單
    • 可以應用多個Interceptor, 並通過簡單的@Order指定順序
@Order(1)
@Component
public class AuthInterceptor implements HandlerInterceptor{

  final Logger logger = LoggerFactory.getLogger(getClass());

  @Autowired
  UserService userService;

  @Override
  public boolean preHandle(HttpServletRequest request, HttpServletResponse response, Object handler) throws Exception {
    logger.info("pre authenticate {}...", request.getRequestURI());
    try {
      authenticateByHeader(request);
    } catch (Exception e) {
      logger.warn("login by authorization header failed", e);
    }
    return true;
  }

  private void authenticateByHeader(HttpServletRequest req) {
    String authHeader = req.getHeader("Authorization");
    if (authHeader != null && authHeader.startsWith("Basic ")) {
      logger.info("try authenticate by authorization header");
      String up = new String(Base64.getDecoder().decode(authHeader.substring(6)), StandardCharsets.UTF_8);
      int pos = up.indexOf(":");
      if (pos > 0) {
        String email = URLDecoder.decode(up.substring(0, pos), StandardCharsets.UTF_8);
        String password = URLDecoder.decode(up.substring(pos + 1), StandardCharsets.UTF_8);
        User user = userService.signin(email, password);
        req.getSession().setAttribute(UserController.KEY_USER, user);
        logger.info("user {} login by authorization header ok", email);
      }
    }
  }
}

  @Bean
  WebMvcConfigurer createWebMvcConfigurer(@Autowired HandlerInterceptor[] interceptors) {
    return new WebMvcConfigurer() {
      @Override
      public void addInterceptors(InterceptorRegistry registry) {
        for (HandlerInterceptor interceptor : interceptors) {
          registry.addInterceptor(interceptor);
        }
      }

      @Override
      public void addResourceHandlers(ResourceHandlerRegistry registry) {
        registry.addResourceHandler("/static/**").addResourceLocations("/static/");
      }
    };
  }

異常處理

  @ExceptionHandler(RuntimeException.class)
  public ModelAndView handleUnkAndView(Exception ex) {
    return new ModelAndView("500.html", Map.of("error", ex.getClass().getSimpleName(), "message", ex.getMessage()));
  }
  • 可以編寫多個錯誤處理方法, 每個方法針對特定的異常.
  • LoginException使得頁面可以自動跳轉到登錄頁.
  • ExceptionHandler僅作用於當前的Controller,

處理CORS

  • JavaScript和後端Api交互. 有很多安全限制.

  • 默認情況下, 瀏覽器按同源策略放行JavaScript調用API:

    • A站在域名a.com頁面的js調用A的api, 沒問題
    • A站在域名b.com頁面的js調用B站b.com的api, 被瀏覽器拒絕, 不滿足同源策略
  • 同源策略: 域名相同(a.com/www.a.com不同);協議相同(http/https不同);端口相同

  • 辦法: CORS: Cross-Origin Resource Sharing, HTML5規定的如何跨域訪問資源

  • A站的js訪問B站的api時, B站能夠返回響應頭Access-Control-Allow-Origin: http://a.com. 瀏覽器就運行訪問;

  • 跨域能否成功, 取決於B站是否願意給A站返回一個正確的Access-Control-Allow-Origin

使用@CrossOrigin

@CrossOrigin(origins = "https://local.aaa.com:8080")
@RestController
@RequestMapping("/api")
public class AipController {
}

使用CorsRegistry

  WebMvcConfigurer createWebMvcConfigurer(@Autowired HandlerInterceptor[] interceptors) {
    return new WebMvcConfigurer() {
      @Override
      public void addCorsMappings(CorsRegistry registry) {
        registry.addMapping("/api/**")
        .allowedOrigins("http://sugar.k.sohu.com", "https://www.baidu.com")
        .allowedMethods("GET", "POST")
        .maxAge(3600);
      }
    }
  }

使用CorsFilter

  • 需要配置web.xml, 不推薦.

國際化

  public static void main(String[] args) {
    double price = 123.5;
    int number = 10;
    Object[] arguments = {price, number};
    MessageFormat mfUS = new MessageFormat("Pay {0, number, currency} for {1} books.", Locale.US);
    System.out.println(mfUS.format(arguments));
    MessageFormat mfZH = new MessageFormat("{1}本書一共{0, number, currency}.", Locale.CHINA);
    System.out.println(mfZH.format(arguments));
  }

獲取Locale

  @Bean
  LocaleResolver createLocalResolver() {
    CookieLocaleResolver clr = new CookieLocaleResolver();
    clr.setDefaultLocale(Locale.ENGLISH);
    clr.setDefaultTimeZone(TimeZone.getDefault());
    return clr;
  }
  • 首先根據特定的Cookie判斷是否指定了Local
  • 如果沒有, 就從HTTP頭獲取, 如果還沒有, 就返回默認的Local
  • 用戶第一次訪問網帳時, CookieLocalResolver只能從HTTP頭獲取Local. 瀏覽器默認使用的語言
  • 網站讓用戶選擇自己的語言, 此時, CookieLocaleResolver就會把用戶選擇的語言存放在Cookie中.

提取資源文件

  • 第二步: 把寫死在模板中字符串以資源文件的方式存儲在外部.

創建MessageSource

  • 第三步: 創建一個Spring提供的MessageSource實例, 自動讀取.properties文件.

  • 並提供一個統一的接口來實現翻譯

  • 真費勁.. 越到後面越不想學, 越想寫自己的項目.. 還有最後一節課. 搞定一個聊天室就ok了. 美滋滋

異步處理

  • 在Servlet模型中, 每個請求都是由某個線程處理, 然後將響應寫入IO流, 發送給客戶端.

  • 從開始處理請求, 到寫入響應完成, 都是同一個線程中處理.

  • 實現Servlet容器, 只要每處理一個請求, 就創建一個新線程處理它, 就能保證正確實現了Servlet線程模型.

  • 例如Tomcat, 總是通過線程池來處理請求, 仍然符合一個請求從頭到尾都由某一個線程處理

  • 線程模型非常重要, 因爲Spring的JDBC事務是基於ThreadLocal實現.

  • 很多安全認證也是基於ThreadLocal實現, 可以保證在處理請求的過程中, 各個線程互不影響.

  • 如果請求處理的時間很長, 基於線程池的同步模型很快就回把所有線程耗盡, 導致服務器無法響應新的請求.

  • 如果長時間處理的請求改爲異步, 線程池的利用率就會大大提高.

  • Servlet從3.0開始添加了異步支持, 允許對一個請求進行異步處理.

  • 和不同的MVC程序相比, web.xml不同:

    • 聲明對Servlet3.1規範的支持
    • DispatcherServlet的配置多了一個<async-supported>
  • 配置web.xml

  @GetMapping("/users")
  public Callable<List<User>> users() {
    return () -> {
      try {
        Thread.sleep(3000);
      } catch (Exception e) {
      }
      return userService.getUsers();
    };
  }

  @GetMapping("/user/{id}")
  public DeferredResult<User> user(@PathVariable("id") long id) {
    DeferredResult<User> result = new DeferredResult<>(3000L); // 3秒超時
    new Thread(() -> {
      try {
        Thread.sleep(1000);
      } catch (Exception e) {
      }
      try {
        User user = userService.getQueryById(id);
        result.setResult(user);
      } catch (Exception e) {
        result.setErrorResult(Map.of("error", e.getClass().getSimpleName(), "message", e.getMessage()));
      }
    }).start();
    return result;
  }
  • 使用DeferredResult, 可以設置超時, 正常結果和錯誤結果.

  • 使用async處理異步響應時, 要牢記, 在另一個線程中的事務和Controller方法執行的事物不是同一個事務.

  • 在Controller中綁定的ThreadLocal信息也無法在異步線程中獲取.

    Servlet3.0規範添加的異步支持是針對同步模型打了一個補丁. 雖然可以異步處理請求, 但在高併發異步請求時, 效率不高.
    因爲沒有真正用到原生異步. Java標準庫封裝了操作系統的異步IO包java.nio, 是真正的多路複用IO模型, 可以用少量線程支持大量併發.
    NIO編程複雜高, 很少直接也能夠. 可以選用Netty框架.

使用WebSocket

  • WebSocket是一種基於HTTP的長連接技術.

  • 傳統的HTTP協議, 是一種請求-響應模型, 如果瀏覽器不發送請求, 那麼服務器無法主動給瀏覽器推送數據.

  • 基本靠輪詢.

  • HTTP本身基於TCP連接的, WebSocket在HTTP協議上做了一個簡單的升級, 即建立TCP連接後, 瀏覽器發送請求, 附帶:

GET /chat HTTP/1.1
Host: www.example.com
Upgrade: websocket
Connection: Upgrade
  • 表示客戶端希望升級連接, 變成長連接的WebSocket, 服務器返回升級成功的響應:
Http/1.1 101 Switching Protocols
Upgrade: websocket
Connection: Upgrade
  • 收到成功響應後表示WebSocket握手成功, 這樣, 代表WebSocket的這個TCP連接將不會被服務器關閉, 而是一直保持.

  • 兩步:

    • 嵌入式Tomcat支持WebSocket的組件
    • Spring封裝的支持WebSocket的接口
  • 加入配置

  @Bean
  WebSocketConfigurer createWebSocketConfigurer(@Autowired ChatHandler chatHandler,
      @Autowired ChatHandshakeInterceptor catInterceptor) {
    return new WebSocketConfigurer() {
      @Override
      public void registerWebSocketHandlers(WebSocketHandlerRegistry registry) {
        // 把URL與指定的WebSocketHandler關聯, 可關聯多個
        registry.addHandler(chatHandler, "/chat").addInterceptors(catInterceptor);
      }
    };
  }
  • 關鍵實現能處理WebSocket的handler, 和可選的WebSocket攔截器

  • TextWebSocketHandlerBinaryWebSocketHandler分別處理文本消息和二進制消息.

  • 我們選擇文本消息作爲聊天室協議, 所以ChatHandler繼承TextWebSocketHandler

@Component
public class ChatHandler extends TextWebSocketHandler {

  private final Logger logger = LoggerFactory.getLogger(getClass());

  @Autowired
  ChatHistory chatHistory;

  @Autowired
  ObjectMapper objectMapper;

  // 保存所有Client的WebSocket會話實例
  private Map<String, WebSocketSession> clients = new ConcurrentHashMap<>();

  // 傳播消息
  public void broadcastMessage(ChatMessage chat) throws IOException {
    // 首先拿到消息
    TextMessage message = toTextMessage(List.of(chat));
    // 取到所有客戶端的session id
    for (String id : clients.keySet()) {
      WebSocketSession session = clients.get(id);
      session.sendMessage(message);
    }
  }

  // 處理消息
  @Override
  protected void handleTextMessage(WebSocketSession session, TextMessage message) throws Exception {
    String s = message.getPayload().strip();
    if (s.isEmpty()) {
      return;
    }
    String name = (String) session.getAttributes().get("name");
    ChatText chat = objectMapper.readValue(s, ChatText.class);
    ChatMessage msg = new ChatMessage(name, chat.text);
    // 添加到歷史記錄
    chatHistory.addToHistory(msg);
    // 廣播消息
    broadcastMessage(msg);
  }

  // 開啓連接的時候
  @Override
  public void afterConnectionEstablished(WebSocketSession session) throws JsonProcessingException, IOException {
    // 新會話根據id放入Map
    clients.put(session.getId(), session);
    String name = null;
    // 根據session獲取用戶信息
    User user = (User) session.getAttributes().get("__user__");
    if (user != null) {
      name = user.getName();
    } else {
      name = initGuestName();
    }
    session.getAttributes().put("name", name);
    logger.info("websocket connection established: id = {}, name = {}", session.getId(), name);
    // 把歷史消息發送給新用戶
    List<ChatMessage> list = chatHistory.getHistory();
    session.sendMessage(toTextMessage(list));
    // 添加系統消息並廣播
    ChatMessage msg = new ChatMessage("SYSTEM MESSAGE: ", name + " joined the room");
    chatHistory.addToHistory(msg);
    broadcastMessage(msg);
  }

  @Override
  public void afterConnectionClosed(WebSocketSession session, CloseStatus status) {
    clients.remove(session.getId());
    logger.info("websocket connection closed: id = {}, close-status = {}", session.getId(), status);
  }

  private TextMessage toTextMessage(List<ChatMessage> messages) throws JsonProcessingException {
    String json = objectMapper.writeValueAsString(messages);
    return new TextMessage(json);
  }

  private String initGuestName() {
    return "Guest" + this.guestNumber.incrementAndGet();
  }

  private AtomicInteger guestNumber = new AtomicInteger();
}
  • 我們需要從http的session中複製信息到 websocket session
@Component
public class ChatHandshakeInterceptor extends HttpSessionHandshakeInterceptor {
  public ChatHandshakeInterceptor() {
    // 指定HttpSession複製屬性到WebSocketSession
    super(List.of(UserController.KEY_USER));
  }
}
  • 瀏覽器中js連接websocket的方法
    var ws = new WebSocket('ws://' + location.host + '/chat');
    // 連接打開
    ws.addEventListener('open', function (event) {
    });
    // 收到消息
    ws.addEventListener('message', function (event) {
    });
    // 連接關閉
    ws.addEventListener('close', function () {
    });
    // 綁定全局
    window.chatWs = ws;
發表評論
所有評論
還沒有人評論,想成為第一個評論的人麼? 請在上方評論欄輸入並且點擊發布.
相關文章