前言
作為一個JAVA開發,之前有好幾次出去面試,面試官都問我,JAVAWeb掌握的怎麼樣,我當時就不知道怎麼回答,Web,日常開發中用的是什麼?今天我們來說說JAVAWeb最應該掌握的三個內容。
發展歷程
1、很久很久以前,Web 基本上就是文件的瀏覽而已, 既然是瀏覽,作為伺服器, 不需要記錄誰在某一段時間裡都瀏覽了什麼文件,每次請求都是一個新的HTTP協議, 就是請求加響應, 尤其是我不用記住是誰剛剛發了HTTP請求, 每個請求對我來說都是全新的。
2、但是隨著互動式Web應用的興起,像線上購物網站,需要登入的網站等等,馬上就面臨一個問題,那就是要管理會話,必須記住哪些人登入系統, 哪些人往自己的購物車中放商品, 也就是說我必須把每個人區分開,這就是一個不小的挑戰,因為HTTP請求是無狀態的,所以想出的辦法就是給大家發一個會話標識(session id), 說白了就是一個隨機的字串,每個人收到的都不一樣, 每次大家向我發起HTTP請求的時候,把這個字串給一併捎過來, 這樣我就能區分開誰是誰了。
3、這樣大家很嗨皮了,可是伺服器就不嗨皮了,每個人只需要儲存自己的session id,而伺服器要儲存所有人的session id ! 如果訪問伺服器多了, 就得由成千上萬,甚至幾十萬個。
這對伺服器說是一個巨大的開銷 , 嚴重的限制了伺服器擴充套件能力, 比如說我用兩個機器組成了一個叢集, 小F通過機器A登入了系統, 那session id會儲存在機器A上, 假設小F的下一次請求被轉發到機器B怎麼辦? 機器B可沒有小F的 session id啊。
有時候會採用一點小伎倆: session sticky , 就是讓小F的請求一直粘連在機器A上, 但是這也不管用, 要是機器A掛掉了, 還得轉到機器B去。
那隻好做session 的複製了, 把session id 在兩個機器之間搬來搬去, 快累死了。
後來有個叫Memcached的支了招: 把session id 集中儲存到一個地方, 所有的機器都來訪問這個地方的資料, 這樣一來,就不用複製了, 但是增加了單點失敗的可能性, 要是那個負責session 的機器掛了, 所有人都得重新登入一遍, 估計得被人罵死。
也嘗試把這個單點的機器也搞出叢集,增加可靠性, 但不管如何, 這小小的session 對我來說是一個沉重的負擔
4 於是有人就一直在思考, 我為什麼要儲存這可惡的session呢, 只讓每個客戶端去儲存該多好?
可是如果不儲存這些session id , 怎麼驗證客戶端發給我的session id 的確是我生成的呢? 如果不去驗證,我們都不知道他們是不是合法登入的使用者, 那些不懷好意的傢伙們就可以偽造session id , 為所欲為了。
嗯,對了,關鍵點就是驗證 !
比如說, 小F已經登入了系統, 我給他發一個令牌(token), 裡邊包含了小F的 user id, 下一次小F 再次通過Http 請求訪問我的時候, 把這個token 通過Http header 帶過來不就可以了。
不過這和session id沒有本質區別啊, 任何人都可以可以偽造, 所以我得想點兒辦法, 讓別人偽造不了。
那就對資料做一個簽名吧, 比如說我用HMAC-SHA256 演算法,加上一個只有我才知道的金鑰, 對資料做一個簽名, 把這個簽名和資料一起作為token , 由於金鑰別人不知道, 就無法偽造token了。
這個token 我不儲存, 當小F把這個token 給我發過來的時候,我再用同樣的HMAC-SHA256 演算法和同樣的金鑰,對資料再計算一次簽名, 和token 中的簽名做個比較, 如果相同, 我就知道小F已經登入過了,並且可以直接取到小F的user id , 如果不相同, 資料部分肯定被人篡改過, 我就告訴傳送者: 對不起,沒有認證。
Token 中的資料是明文儲存的(雖然我會用Base64做下編碼, 但那不是加密), 還是可以被別人看到的, 所以我不能在其中儲存像密碼這樣的敏感資訊。
當然, 如果一個人的token 被別人偷走了, 那我也沒辦法, 我也會認為小偷就是合法使用者, 這其實和一個人的session id 被別人偷走是一樣的。
這樣一來, 我就不儲存session id 了, 我只是生成token , 然後驗證token , 我用我的CPU計算時間獲取了我的session 儲存空間 !
解除了session id這個負擔, 可以說是無事一身輕, 我的機器叢集現在可以輕鬆地做水平擴充套件, 使用者訪問量增大, 直接加機器就行。 這種無狀態的感覺實在是太好了!
Cookie
1.什麼是Cookie
Cookie翻譯成中文的意思是‘小甜餅’,是由W3C組織提出,最早由Netscape社群發展的一種機制。目前Cookie已經成為標準,所有的主流瀏覽器如IE、Netscape、Firefox、Opera等都支援Cookie。
伺服器單從網路連線上無從知道客戶身份。怎麼辦呢?就給客戶端們頒發一個通行證吧,每人一個,無論誰訪問都必須攜帶自己通行證。這樣伺服器就能從通行證上確認客戶身份了。這就是Cookie的工作原理。
Cookie是客戶端儲存使用者資訊的一種機制,用來記錄使用者的一些資訊,也是實現Session的一種方式。Cookie儲存的資料量有限,且都是儲存在客戶端瀏覽器中。不同的瀏覽器有不同的儲存大小,但一般不超過4KB。因此使用Cookie實際上只能儲存一小段的文字資訊(key-value格式)。
2.Cookie的機制
當用戶第一次訪問並登陸一個網站的時候,cookie的設定以及傳送會經歷以下4個步驟:
客戶端傳送一個請求到伺服器;
伺服器傳送一個HttpResponse響應到客戶端,其中包含Set-Cookie的頭部;
客戶端儲存cookie,之後向伺服器傳送請求時,HttpRequest請求中會包含一個Cookie的頭部;
伺服器返回響應資料。
為了探究這個過程,寫了程式碼進行測試,如下:
我在doGet方法中,new了一個Cookie物件並將其加入到了HttpResponse物件中
@RestController
public class TestController {
@GetMapping(value = "/doGet")
public void doGet(HttpServletRequest req, HttpServletResponse resp) throws ServletException, IOException {
Cookie cookie = new Cookie("jiangwang",System.currentTimeMillis()+"");
// 設定生命週期為MAX_VALUE
cookie.setMaxAge(Integer.MAX_VALUE);
resp.addCookie(cookie);
}
}
瀏覽器輸入地址進行訪問,結果如圖所示:
可見Response Headers中包含Set-Cookie頭部,而Request Headers中包含了Cookie頭部。name和value正是上述設定的。
3.Cookie的屬性
Expires
該屬性用來設定Cookie的有效期。Cookie中的maxAge用來表示該屬性,單位為秒。Cookie中通過getMaxAge()和setMaxAge(int maxAge)來讀寫該屬性。maxAge有3種值,分別為正數,負數和0。
如果maxAge屬性為正數,則表示該Cookie會在maxAge秒之後自動失效。瀏覽器會將maxAge為正數的Cookie持久化,即寫到對應的Cookie檔案中(每個瀏覽器儲存的位置不一致)。無論客戶關閉了瀏覽器還是電腦,只要還在maxAge秒之前,登入網站時該Cookie仍然有效。下面程式碼中的Cookie資訊將永遠有效。
Cookie cookie = new Cookie("jiangwang",System.currentTimeMillis()+"");
// 設定生命週期為MAX_VALUE,永久有效
cookie.setMaxAge(Integer.MAX_VALUE);
resp.addCookie(cookie);
當maxAge屬性為負數,則表示該Cookie只是一個臨時Cookie,不會被持久化,僅在本瀏覽器視窗或者本視窗開啟的子視窗中有效,關閉瀏覽器後該Cookie立即失效。
Cookie cookie = new Cookie("jiangwang",System.currentTimeMillis()+"");
// 設定生命週期為MAX_VALUE,永久有效
cookie.setMaxAge(-1);
resp.addCookie(cookie);
當maxAge為0時,表示立即刪除Cookie。
Cookie[] cookies = req.getCookies();
Cookie cookie = null;
// get Cookie
for (Cookie ck : cookies) {
if ("jiangwang".equals(ck.getName())) {
cookie = ck;
break;
}
}
if (null != cookie) {
// 刪除一個cookie
cookie.setMaxAge(0);
resp.addCookie(cookie);
}
修改或者刪除Cookie
HttpServletResponse提供的Cookie操作只有一個addCookie(Cookie cookie),所以想要修改Cookie只能使用一個同名的Cookie來覆蓋原先的Cookie。如果要刪除某個Cookie,則只需要新建一個同名的Cookie,並將maxAge設定為0,並覆蓋原來的Cookie即可。
新建的Cookie,除了value、maxAge之外的屬性,比如name、path、domain都必須與原來的一致才能達到修改或者刪除的效果。否則,瀏覽器將視為兩個不同的Cookie不予覆蓋。
Cookie的域名
Cookie是不可以跨域名的,隱私安全機制禁止網站非法獲取其他網站的Cookie。
正常情況下,同一個一級域名下的兩個二級域名也不能互動使用Cookie,比如a1.jiangwang.com
和a2.jiangwang.com
,因為二者的域名不完全相同。如果想要jiangwnag.com
名下的二級域名都可以使用該Cookie,需要設定Cookie的domain引數為.jiangwang.com
,這樣使用a1.jiangwang.com
和a2.jiangwang.com
就能訪問同一個cookie
一級域名又稱為頂級域名,一般由字串+字尾組成。熟悉的一級域名有baidu.com,qq.com。com,cn,net等均是常見的字尾。
二級域名是在一級域名下衍生的,比如有個一級域名為abc.com
,則blog.abc.com
和www.abc.com
均是其衍生出來的二級域名。
Cookie的路徑
path屬性決定允許訪問Cookie的路徑。比如,設定為"/"表示允許所有路徑都可以使用Cookie
4.應用
Cookies最典型的應用是判定註冊使用者是否已經登入網站,使用者可能會得到提示,是否在下一次進入此網站時保留使用者資訊以便簡化登入手續,這些都是Cookies的功用。另一個重要應用場合是“購物車”之類處理。使用者可能會在一段時間內在同一家網站的不同頁面中選擇不同的商品,這些資訊都會寫入Cookies,以便在最後付款時提取資訊。
Session
1.什麼是Session
在WEB開發中,伺服器可以為每個使用者瀏覽器建立一個會話物件(session物件),注意:一個瀏覽器獨佔一個session物件(預設情況下)。因此,在需要儲存使用者資料時,伺服器程式可以把使用者資料寫到使用者瀏覽器獨佔的session中,當用戶使用瀏覽器訪問其它程式時,其它程式可以從使用者的session中取出該使用者的資料,為使用者服務。
2.Session實現原理
伺服器建立session出來後,會把session的id號,以cookie的形式回寫給客戶機,這樣,只要客戶機的瀏覽器不關,再去訪問伺服器時,都會帶著session的id號去,伺服器發現客戶機瀏覽器帶session id過來了,就會使用記憶體中與之對應的session為之服務。可以用如下的程式碼證明:
@RestController
public class TestController {
@GetMapping(value = "/doGet")
public void doGet(HttpServletRequest request, HttpServletResponse response)
throws ServletException, IOException {
response.setCharacterEncoding("UTF-8");
response.setContentType("text/html;charset=UTF-8");
//使用request物件的getSession()獲取session,如果session不存在則建立一個
HttpSession session = request.getSession();
//將資料儲存到session中
session.setAttribute("mayun", "馬雲");
//獲取session的Id
String sessionId = session.getId();
//判斷session是不是新建立的
if (session.isNew()) {
response.getWriter().print("session建立成功,session的id是:"+sessionId);
}else {
response.getWriter().print("伺服器已經存在該session了,session的id是:"+sessionId);
}
}
}
第一次訪問時,伺服器會建立一個新的sesion,並且把session的Id以cookie的形式傳送給客戶端瀏覽器,如下圖所示:
再次請求伺服器,此時就可以看到瀏覽器再請求伺服器時,會把儲存到cookie中的session的Id一起傳遞到伺服器端了,如下圖所示:
3.session建立和銷燬
在程式中第一次呼叫request.getSession()方法時就會建立一個新的Session,可以用isNew()方法來判斷Session是不是新建立的
//使用request物件的getSession()獲取session,如果session不存在則建立一個
HttpSession session = request.getSession();
//獲取session的Id
String sessionId = session.getId();
//判斷session是不是新建立的
if (session.isNew()) {
response.getWriter().print("session建立成功,session的id是:"+sessionId);
}else {
response.getWriter().print("伺服器已經存在session,session的id是:"+sessionId);
}
session物件預設30分鐘沒有使用,則伺服器會自動銷燬session,也可以手工配置session的失效時間,例如:
session.setMaxInactiveInterval(10*60);//10分鐘後session失效
當需要在程式中手動設定Session失效時,可以手工呼叫session.invalidate方法,摧毀session。
HttpSession session = request.getSession();
//手工呼叫session.invalidate方法,摧毀session
session.invalidate();
面試題:瀏覽器關閉,session就銷燬了? 不對.
Session生成後,只要使用者繼續訪問,伺服器就會更新Session的最後訪問時間,並維護該Session。為防止記憶體溢位,伺服器會把長時間內沒有活躍的Session從記憶體刪除。這個時間就是Session的超時時間。如果超過了超時時間沒訪問過伺服器,Session就自動失效了。
Token
1.什麼是Token
token的意思是“令牌”,是服務端生成的一串字串,作為客戶端進行請求的一個標識。
當用戶第一次登入後,伺服器生成一個token並將此token返回給客戶端,以後客戶端只需帶上這個token前來請求資料即可,無需再次帶上使用者名稱和密碼。
簡單token的組成;uid(使用者唯一的身份標識)、time(當前時間的時間戳)、sign(簽名,token的前幾位以雜湊演算法壓縮成的一定長度的十六進位制字串。為防止token洩露)。
2.Token的原理
- 使用者通過使用者名稱和密碼傳送請求
- 程式校驗
- 程式返回一個Token給客戶端
- 客戶端儲存Token,並且每次傳送請求攜帶Token
- 服務端驗證Token,並返回資料
3.Token的使用
Spring Boot和Jwt整合示例
專案依賴 pom.xml
<dependency>
<groupId>com.alibaba</groupId>
<artifactId>fastjson</artifactId>
<version>1.2.47</version>
</dependency>
<dependency>
<groupId>com.auth0</groupId>
<artifactId>java-jwt</artifactId>
<version>3.10.3</version>
</dependency>
自定義註解
//需要登入才能進行操作的註解LoginToken
@Target({ElementType.METHOD, ElementType.TYPE})
@Retention(RetentionPolicy.RUNTIME)
public @interface LoginToken {
boolean required() default true;
}
//用來跳過驗證的PassToken
@Target({ElementType.METHOD, ElementType.TYPE})
@Retention(RetentionPolicy.RUNTIME)
public @interface PassToken {
boolean required() default true;
}
使用者實體類、及查詢service
public class User {
private String userID;
private String userName;
private String passWord;
public String getUserID() {
return userID;
}
public void setUserID(String userID) {
this.userID = userID;
}
public String getUserName() {
return userName;
}
public void setUserName(String userName) {
this.userName = userName;
}
public String getPassWord() {
return passWord;
}
public void setPassWord(String passWord) {
this.passWord = passWord;
}
}
@Service
public class UserService {
public User getUser(String userid, String password){
if ("admin".equals(userid) && "admin".equals(password)){
User user=new User();
user.setUserID("admin");
user.setUserName("admin");
user.setPassWord("admin");
return user;
}
else{
return null;
}
}
public User getUser(String userid){
if ("admin".equals(userid)){
User user=new User();
user.setUserID("admin");
user.setUserName("admin");
user.setPassWord("admin");
return user;
}
else{
return null;
}
}
}
Token生成
@Service
public class TokenService {
/**
* 過期時間10分鐘
*/
private static final long EXPIRE_TIME = 10 * 60 * 1000;
public String getToken(User user) {
Date date = new Date(System.currentTimeMillis() + EXPIRE_TIME);
String token="";
token= JWT.create().withAudience(user.getUserID()) // 將 user id 儲存到 token 裡面
.withExpiresAt(date) //十分鐘後token過期
.sign(Algorithm.HMAC256(user.getPassWord())); // 以 password 作為 token 的金鑰
return token;
}
}
攔截器攔截token
package com.jw.interceptor;
import com.auth0.jwt.JWT;
import com.auth0.jwt.JWTVerifier;
import com.auth0.jwt.algorithms.Algorithm;
import com.auth0.jwt.exceptions.JWTDecodeException;
import com.auth0.jwt.exceptions.JWTVerificationException;
import com.jw.annotation.LoginToken;
import com.jw.annotation.PassToken;
import com.jw.entity.User;
import com.jw.service.UserService;
import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.web.method.HandlerMethod;
import org.springframework.web.servlet.HandlerInterceptor;
import org.springframework.web.servlet.ModelAndView;
import javax.servlet.http.HttpServletRequest;
import javax.servlet.http.HttpServletResponse;
import java.lang.reflect.Method;
public class JwtInterceptor implements HandlerInterceptor{
@Autowired
private UserService userService;
@Override
public boolean preHandle(HttpServletRequest httpServletRequest, HttpServletResponse httpServletResponse, Object object) throws Exception {
String token = httpServletRequest.getHeader("token");// 從 http 請求頭中取出 token
// 如果不是對映到方法直接通過
if(!(object instanceof HandlerMethod)){
return true;
}
HandlerMethod handlerMethod=(HandlerMethod)object;
Method method=handlerMethod.getMethod();
//檢查是否有passtoken註釋,有則跳過認證
if (method.isAnnotationPresent(PassToken.class)) {
PassToken passToken = method.getAnnotation(PassToken.class);
if (passToken.required()) {
return true;
}
}
//檢查有沒有需要使用者許可權的註解
if (method.isAnnotationPresent(LoginToken.class)) {
LoginToken loginToken = method.getAnnotation(LoginToken.class);
if (loginToken.required()) {
// 執行認證
if (token == null) {
throw new RuntimeException("無token,請重新登入");
}
// 獲取 token 中的 user id
String userId;
try {
userId = JWT.decode(token).getAudience().get(0);
} catch (JWTDecodeException j) {
throw new RuntimeException("401");
}
User user = userService.getUser(userId);
if (user == null) {
throw new RuntimeException("使用者不存在,請重新登入");
}
// 驗證 token
JWTVerifier jwtVerifier = JWT.require(Algorithm.HMAC256(user.getPassWord())).build();
try {
jwtVerifier.verify(token);
} catch (JWTVerificationException e) {
throw new RuntimeException("401");
}
return true;
}
}
return true;
}
@Override
public void postHandle(HttpServletRequest httpServletRequest, HttpServletResponse httpServletResponse, Object o, ModelAndView modelAndView) throws Exception {
}
@Override
public void afterCompletion(HttpServletRequest httpServletRequest, HttpServletResponse httpServletResponse, Object o, Exception e) throws Exception {
}
}
註冊攔截器
package com.jw.config;
import com.jw.interceptor.JwtInterceptor;
import org.springframework.context.annotation.Bean;
import org.springframework.context.annotation.Configuration;
import org.springframework.web.servlet.config.annotation.InterceptorRegistry;
import org.springframework.web.servlet.config.annotation.WebMvcConfigurer;
@Configuration
public class InterceptorConfig implements WebMvcConfigurer{
@Override
public void addInterceptors(InterceptorRegistry registry) {
registry.addInterceptor(jwtInterceptor())
.addPathPatterns("/**"); // 攔截所有請求,通過判斷是否有 @LoginRequired 註解 決定是否需要登入
//註冊TestInterceptor攔截器
// InterceptorRegistration registration = registry.addInterceptor(jwtInterceptor());
// registration.addPathPatterns("/**"); //新增攔截路徑
// registration.excludePathPatterns( //新增不攔截路徑
// "/**/*.html", //html靜態資源
// "/**/*.js", //js靜態資源
// "/**/*.css", //css靜態資源
// "/**/*.woff",
// "/**/*.ttf",
// "/swagger-ui.html"
// );
}
@Bean
public JwtInterceptor jwtInterceptor() {
return new JwtInterceptor();
}
}
登入Controller
@RestController
public class LoginController {
@Autowired
private UserService userService;
@Autowired
private TokenService tokenService;
@PostMapping("login")
public Object login(String username, String password){
JSONObject jsonObject=new JSONObject();
User user=userService.getUser(username, password);
if(user==null){
jsonObject.put("message","登入失敗!");
return jsonObject;
}else {
String token = tokenService.getToken(user);
jsonObject.put("token", token);
jsonObject.put("user", user);
return jsonObject;
}
}
@LoginToken
@GetMapping("/getMessage")
public String getMessage(){
return "你已通過驗證";
}
}
配置全域性異常捕獲
@RestControllerAdvice
public class GlobalExceptionHandler {
@ResponseBody
@ExceptionHandler(Exception.class)
public Object handleException(Exception e) {
String msg = e.getMessage();
if (msg == null || msg.equals("")) {
msg = "伺服器出錯";
}
JSONObject jsonObject = new JSONObject();
jsonObject.put("code", 1000);
jsonObject.put("message", msg);
return jsonObject;
}
}
postman測試
獲取token
無token登入
有token登入
錯誤token登入
4.Token的優缺點
優點:
- 支援跨域訪問: Cookie是不允許垮域訪問的,token支援;
- 無狀態: token無狀態,session有狀態的;
- 去耦: 不需要繫結到一個特定的身份驗證方案。Token可以在任何地方生成,只要在 你的API被呼叫的時候, 你可以進行Token生成呼叫即可;
- 更適用於移動應用: Cookie不支援手機端訪問的;
- 效能: 在網路傳輸的過程中,效能更好;
- 基於標準化: 你的API可以採用標準化的 JSON Web Token (JWT). 這個標準已經存在 多個後端庫(.NET, Ruby, Java,Python, PHP)和多家公司的支援(如: Firebase,Google, Microsoft)。
缺點:
- 佔頻寬,正常情況下要比 session_id 更大,需要消耗更多流量,擠佔更多頻寬,假如你的網站每月有 10 萬次的瀏覽器,就意味著要多開銷幾十兆的流量。聽起來並不多,但日積月累也是不小一筆開銷。實際上,許多人會在 JWT 中儲存的資訊會更多;
- 無法在服務端登出,那麼久很難解決劫持問題;
- 效能問題,JWT 的賣點之一就是加密簽名,由於這個特性,接收方得以驗證 JWT 是否有效且被信任。但是大多數 Web 身份認證應用中,JWT 都會被儲存到 Cookie 中,這就是說你有了兩個層面的簽名。聽著似乎很牛逼,但是沒有任何優勢,為此,你需要花費兩倍的 CPU 開銷來驗證簽名。對於有著嚴格效能要求的 Web 應用,這並不理想,尤其對於單執行緒環境。
結尾
我是一個正在被打擊還在努力前進的碼農。如果文章對你有幫助,記得點贊、關注喲,謝謝!