還在爲HttpUtils怎麼寫而煩惱嗎?看這一篇就足夠了

概述

作爲一個java開發,自己肯定寫過或者用過HttpUtils用來發送http請求吧,而且肯定也見過各種五花八門的工具類吧,而且每個都不一樣,內心有沒有寫一個相對標準的工具類的想法呢?反正我自己是有這種想法的,畢竟Http是有標準的,剛好機會來了,就按照自己理解的標準去寫了,分享一下,當然也會提供一些比較容易擴展的方式,畢竟每個人的需求都是不同的。
前面會用一定的篇幅講述一下我所依賴的“標準”到底是什麼樣的,後面再貼代碼。

注:我依賴的httpclient版本是org.apache.httpcomponents:httpclient:4.4.1。

關於Http的基本標準

這邊我對Http的基本知識不做太多普及,僅講述和我設計有關的相關知識。

Http的四要素

衆所周知,Http是基於TCP,而TCP是用來傳輸數據的,說得再通俗一些,就是用來傳遞字符串的。那麼這個字符串到底要如何傳遞以及如何解析,這就是應用層協議需要設計的,我們平時見到的應用層協議都是圍繞“如何來傳遞字符串”這個目標然後實現的(如何傳遞也意味着如何解析),Http也是,它的標準就是4要素,或者是4塊內容。

  1. 請求行
    請求行佔了一行,格式是:方法 請求地址 HTTP/版本號
    後面中間分別有1個空格
  2. 請求頭
    這個是多行,每一行都是key:value的形式
  3. 空行
    就是一個空行,用於區分請求頭和請求體
  4. 請求體
    請求體就是我們常說的body或者form部分了。這個行數不定,那如何知道結尾了呢?就要用請求頭裏面所標記的Content-Length來判斷了,用一些框架在解析的時候,如何發現Length的長度和請求體的長度不一致的時候,就會出現異常。包括接收Http請求或者請求http的時候。

對於Http的響應也是這4要素。

四要素舉例

光看上面的解釋,其實還是比較抽象的,我舉幾個平時最常見的例子,一目瞭然。對了,我都是用postman做的測試。

最常見的get請求

GET /http/get?arg=123 HTTP/1.1
name: xiaopang
Host: localhost:8080


get請求,沒有請求體。

普通字符串的post請求

POST /http/post/string?arg=123 HTTP/1.1
name: xiaopang
Content-Type: text/plain
Host: localhost:8080
content-length: 4

test

這個相對與之前的增加了3個地方。
請求頭增加了Content-Type:text/plain,這個就是告訴接收方我發送的就是普通的字符串,不用特殊處理。
關於這個參數多說一點點,這個看你使用的web框架,會不會用這個,有的根本不會用。
增加了請求體,因此也有了conten-length,這個長度它指的是字節

普通form表單的post請求

我們經常也會用這種的,就是以key-value的方式去提交表單。都是普通的字符串,這種form我們都稱之爲“application/x-www-form-urlencoded”。

POST /http/post/form-simple?arg=123&arg1=456 HTTP/1.1
name: xiaopang
content-type: application/x-www-form-urlencoded
content-length: 15

name=ywg&age=25

這種的其實還是比較常見的,這邊我傳遞了兩個參數,name和age,但是你注意看第一行,我還傳遞了兩個參數arg和arg1,你會發現和請求體裏面的格式特別像。這也就解釋了爲什麼叫做form-urlencoded了,form代表了它是表單形式,在請求體裏面,同時我還標記了一下它的方式仍然是按照url參數來傳遞的。
在url後面跟着的key-value形式的參數,我們一般稱之爲url參數戶或者查詢參數(query),我比較喜歡稱之爲查詢參數,叫做query。對於這些一般傳遞的時候都會encode一下,解析的時候需要decode一下。

multi-form(多表單)的post請求

我們這邊以傳遞一個字符串和一個文件爲例:

POST /http/post/form-multi?arg=123&arg1=456 HTTP/1.1
name: xiaopang
cache-control: no-cache
Host: localhost:8080
content-type: multipart/form-data; boundary=--------------------------400103441794568578082510
content-length: 350
Connection: keep-alive

----------------------------400103441794568578082510
Content-Disposition: form-data; name="name"

ywg
----------------------------400103441794568578082510
Content-Disposition: form-data; name="file"; filename="多表單測試文件.txt"
Content-Type: text/plain

多表單測試文件
----------------------------400103441794568578082510--

這個很明顯複雜了很多,我挨個說一下里面的點。
content-type是multipart/form-data,代表是多表單,然後後面多了一個boundary,也就是邊界,什麼的邊界呢?既然是多表單,那就是多個表單之間的邊界。這個是隨機生成的,後面的請求體的話以boundary開頭,中間以boundary分割,最後以boundary+“–”結尾
然後我們再仔細看一下form裏面的每個細節,你就會發現它完全就是http的3要素啊,請求頭+空行+請求體。請求頭裏面全都是對裏面內容的描述,比如它是個字符串,還是個普通文件,或者是圖片視頻之類的。不過它不需要content-length,因爲有boundary。
再看一下傳圖片的吧,裏面的我就不解釋了,也不用糾結裏面亂七八糟的符號是什麼了。

POST /http/post/form-multi?arg=123&arg1=456 HTTP/1.1
name: xiaopang
Host: localhost:8080
content-type: multipart/form-data; boundary=--------------------------345861699398287183379366
content-length: 2324

----------------------------345861699398287183379366
Content-Disposition: form-data; name="name"

ywg
----------------------------345861699398287183379366
Content-Disposition: form-data; name="file"; filename="多表單測試.png"
Content-Type: image/png


�PNG

IHDR   �      "]�Q   sRGB ���   gAMA  ���a   	pHYs  �  ��o�d  gIDATx^!�KF�o�G`�80X�$(4�E�Y�D�` 
���I0�y��u�����[KOW3�$_z�fz;u��V�	S�C'߿�������(�V�2����@� �7Ba�1��E{�����0�X�PA�o�
�|c T���0����@� �7Ba�1�PA�o�
BF�P�1?W�*"	������R!�a+���2 Te@�|ʀP� ���A(B�P��g7B�=[7	B-�ԦU�5���ҡv�R�Ku��ݡ�c�:mʀP� ���A(1�޾}K� ��P�߿'U�pL(��2 T�2 T�2 T�2 T�2 T�2 T�2 T����Dl���L�M�nk���C턞�,9Bw���i�v��3t���ӧO�ȶ �at�~��ux�����ÇW��� BF�۷oW?AWw��Ǐ�+ہ
P�х�r��$��߿?IJ�P���*]�Hb�Ʒ�#%�ԕ�2��w�ܹZ��#Uw���i��2*T�G_ԙw�-@(èB��]A˝��-o��0�P�f����{�T �aD��u!�Cmys�P���2 T��OM�5�P˔ڴ궔�::�N��&j:�A�ӂP�ʃP�ʃP�ʃP�ʃP�ʃP�ʃP�ʃP�ʃP�cB}���TA(1�Z{L�{pL(��2 T����Dl���L�M�nk���C턞�,9Bw���i� ��� ��� ��� ��� ��� �ad��s�z��-4( �at��{?T����*e�?T����*{Fm��!�aOB�3��f���Lܔ#�ci��*|��J�Cm��ڴ�Fz?_:TEK��zj�T�j:9�X�r T�d�oʷ�I �ad�F��������������������g7B�=[7	B-SjӪ���|�P;��6Ku��ݡ�c�5�j�1y�A(1�Z{L�{pL(��2 T�2 T�2 T�2 T�2 T�2 T�2 T�2 T�2 T�2A(�Zx���z��S��t⩣���G�l�Xo�!���Iz�M�nKY��CU���c]MOm6�P�1��(�v��w���;��@(èBic1mݣ-|F��
�{��;�P�ʃP�Q�������
U�2��;�eU(��.UK5��Pj �D�T�oʷ�I �at�F��������g7B�=[7	B-SjӪ���|�P;��6Ku��ݡ�c�:e@�e@�e@�e@�e8&YN�����YD�_.//�J�8{�"lu.�PA�o�|c T���0����꟯���PA�o�
�|c T���0����@� �7���/����w�W�    IEND�B`�
----------------------------3458616993982871

順便說一句,這就是http的標準,但這個標準定的是在太大了,也就是太不規範了,因此有了我們常說的REST協議了。

Http傳遞參數的方式

其實HttpUtils要解決的問題就是http參數傳遞的問題,因此想寫出好的工具類,那就必須知道所有的傳參方式,哪怕自己不用,也可以不寫,但得知道。上面花那麼多篇幅講4要素,順便也把所有的參數傳遞方式講了一遍,我這邊總結一下,下面的幾種參數,只有請求體裏面只能允許一種情況存在,其它的都可以存在。

  1. 請求方法,這也算參數吧,因爲在用的時候需要用。
  2. 請求地址。
  3. 路徑參數。
  4. 請求頭參數。
  5. 請求體參數。

關於請求體的話,有這麼幾種情況。
普通字符串。這個就包含我們常見的字符串,xml以及json,在我眼裏。都是字符串。
普通form。
多表單形式。
我覺得就這3種,但是在日常工作中可能提及的不止這麼幾種,但本質上就是它們。
而且可能更多人用流這個詞,這個也沒啥問題,因爲從Socket編程來講,確實是這樣,以字符串的形式說這些,我覺得更好理解吧。

不同的方法有不同的處理方式

對於Http來講,整體上就分爲兩種,有沒有請求體。沒有的,就是代表性的get,有請求體的就是post(這個也可以沒有)。
不要跟我糾結到底get能不能傳遞body,要嚴格來說的話,從Http角度來講,它確實可以傳。但是從應用角度來講,千萬不要這樣做,這不是一種主流,也不符合REST規範,而且最關鍵的在於,你所用的框架支持不支持,這是最重要的。
但是對於沒有body的情況,你可以認爲body是空,因此,它還是4要素,都是一樣的。因此我們工具類裏面就是分別處理一下上面說的5種參數,然後把它們按照不同的情況進行組合一下,就可以支持很多種情況了。

HttpUtils的設計思想

這邊可以把它分成3個層次。
最底下的層次就是剛纔說的處理參數的地方,它們都是獨立的,分別處理自己的參數。
中間的一層就是組合處理參數方法,把它們構成一個完整的http請求體。
最外層就是對外提供的API,就是我們的靜態方法了,這邊根據自己的實際情況隨意的進行封裝就行了。

特別是下面那兩層,我見得最多的就是把各個混爲一談,完全沒有章法,這也是形成不了標準的原因。

實現思路的話,既然我能夠組合成多種情況,那麼我希望有一個類能夠處理所有的情況,而且就只有一個出口,通過裏面的參數來標記你到底是什麼請求,然後我再處理。

HttpUtils的代碼

這邊有很多個類,分塊來看。

基礎類

我比較希望只有一個出口,一個參數,這個參數類如下:

public class HttpArgument{
    private String url;
    private HttpMethod method;
    private String contentType="application/json";
    private Map<String,String> header;
    private Map<String,String> query;
    private Map<String,String> form;
    private String body;

    public HttpArgument(String url, HttpMethod method) {
        this.url = url;
        this.method = method;
    }
}

//省略get和set方法。

這個就是入口的參數,它基本包含了所有的參數,除過多表單文件的,這個我這邊用的少,就沒寫。
通過method來標記是什麼方法。

public enum HttpMethod {
    OPTIONS,
    GET,
    HEAD,
    POST,
    PUT,
    DELETE,
    TRACE,
    CONNECT,
    PATCH,
    OTHER
}

常見的方法。

public class HttpException extends Exception {
    public HttpException(String message, Throwable cause) {
        super(message, cause);
    }

    public HttpException(String message) {
        super(message);
    }
}

普通的異常封裝類。

我把請求的結果做了一層封裝,對於正常返回的,沒有拋異常的,我都正常返回,包括400,404等,這個是我希望外界去處理這個,因此有了下面這個。

public class HttpResult {
    private int statusCode;
    private String response;

    public int getStatusCode() {
        return this.statusCode;
    }

    public void setStatusCode(int statusCode) {
        this.statusCode = statusCode;
    }

    public String getResponse() {
        return response;
    }

    public void setResponse(String response) {
        this.response = response;
    }

    public HttpResult() {
    }

    public HttpResult(int statusCode, String response) {
        this.statusCode = statusCode;
        this.response = response;
    }

    public boolean isOK() {
        return 200 == this.getStatusCode();
    }
}

這個返回值目前就是string。

真正的調用類

這個是真正的處理過程,入口是doAction()方法,細節我就不講了。

public class HttpClient {
	private Logger logger = LoggerFactory.getLogger(this.getClass().getName());
    private int connectionRequestTimeout=10*1000;
    private int connectTimeout=2*1000;
    private int socketTimeout=60*1000;
    private int maxPoolSize=200;

	private RequestConfig requestConfig;
	private PoolingHttpClientConnectionManager connectPool;
	private CloseableHttpClient httpClient;
	private String charset="UTF-8";
	public HttpClient() {
	}

	public HttpResult doAction(HttpArgument argument) throws HttpException{
		HttpResult result;
		HttpMethod method=argument.getMethod();
		switch (method){
			case POST:
				result =postOrPut(argument,createUri(argument),true);
				break;
			case PUT:
				result =postOrPut(argument,createUri(argument),false);
				break;
			case GET:
				result =getOrDelete(argument,createUri(argument),true);
				break;
			case DELETE:
				result =getOrDelete(argument,createUri(argument),false);
				break;
			default:
				throw new HttpException("not support method:"+method.name());
		}
		return result;
	}

	private URI createUri(HttpArgument argument) throws HttpException {
		QueryStringEncoder encoder=new QueryStringEncoder(argument.getUrl());
		Optional.ofNullable(argument.getQuery()).ifPresent(res->res.forEach((key, value)->encoder.addParam(key,value)));
		URI requestURI=null;
		try {
			requestURI = encoder.toUri();
		} catch (Exception e) {
			String message="非法的url:"+argument.getUrl();
			logger.error(message);
			throw new HttpException(message,e);
		}
		return  requestURI;
	}

	private HttpResult postOrPut(HttpArgument argument,URI requestURI,boolean post) throws HttpException{
		HttpEntityEnclosingRequestBase method;
		if (post){
			method=new HttpPost(requestURI);
		}else{
			method=new HttpPut(requestURI);
		}
		HttpEntity entity=null;
		if (argument.getForm()!=null){
			//form表單傳遞方式,
			List<NameValuePair> form=new ArrayList<>();
			argument.getForm().forEach((key,value)->form.add(new BasicNameValuePair(key,value)));
			try {
				entity=new UrlEncodedFormEntity(form,charset);
			} catch (UnsupportedEncodingException e) {
				//ignore
			}
		}else{
			//請求體的情況
			entity = new StringEntity(argument.getBody(), charset);
			((StringEntity)entity).setContentEncoding(new BasicHeader(HTTP.CONTENT_TYPE,
					argument.getContentType()));
		}
		method.setEntity(entity);

		return request(method,argument.getHeader());
	}


	private HttpResult getOrDelete(HttpArgument argument,URI requestURI,boolean get) throws HttpException {
		HttpRequestBase httpRequestBase;
		if (get){
			httpRequestBase=new HttpGet(requestURI);
		}else{
			httpRequestBase=new HttpDelete(requestURI);
		}
		return request(httpRequestBase,argument.getHeader());
	}

	/**
	 * 增加header
	 * @param httpUriRequest
	 * @param header
	 * @return
	 * @throws HttpException
	 */
	private HttpResult request(HttpRequestBase httpUriRequest,Map<String,String> header) throws HttpException {
		Optional.ofNullable(header).ifPresent(res->res.forEach((key, value)->httpUriRequest.addHeader(new BasicHeader(key,value))));
		return request(httpUriRequest);
	}

	/**
	 * 最終請求
	 * @param httpRequestBase
	 * @return
	 * @throws HttpException
	 */
	private HttpResult request(HttpRequestBase httpRequestBase)throws HttpException{
		CloseableHttpClient httpClient = getHttpClient();
		CloseableHttpResponse httpResponse = null;
		try {
			httpResponse = httpClient.execute(httpRequestBase);
			int statusCode = httpResponse.getStatusLine().getStatusCode();
			return new HttpResult(statusCode,EntityUtils.toString(httpResponse.getEntity(),charset));
		} catch (IOException e) {
			logger.error("訪問http失敗,url:"+httpRequestBase.getURI().getPath(), e);
			String message=e.getMessage();
			if (message==null&&e.getCause()!=null){
				message=e.getCause().getMessage();
			}
			throw new HttpException("url:"+httpRequestBase.getURI().getPath()+";"+message);
		} finally {
			if (httpResponse != null) {
				IOUtils.closeQuietly(httpResponse);
			}
		}
	}

	private CloseableHttpClient getHttpClient() {
		if (httpClient==null) {
			return createHttpClient();
		}
		return httpClient;
	}

	public void setConnectionRequestTimeout(int connectionRequestTimeout) {
		this.connectionRequestTimeout = connectionRequestTimeout;
	}

	public void setConnectTimeout(int connectTimeout) {
		this.connectTimeout = connectTimeout;
	}

	public void setSocketTimeout(int socketTimeout) {
		this.socketTimeout = socketTimeout;
	}

	public void setMaxPoolSize(int maxPoolSize) {
		this.maxPoolSize = maxPoolSize;
	}


	private CloseableHttpClient createHttpClient() {
		httpClient=null;
		SSLContext ctx = null;

		X509TrustManager xtm = new X509TrustManager() {
			@Override
			public void checkClientTrusted(X509Certificate[] x509Certificates, String s) throws CertificateException {
			}
			@Override
			public void checkServerTrusted(X509Certificate[] x509Certificates, String s) throws CertificateException {
			}
			@Override
			public X509Certificate[] getAcceptedIssuers() {
				return null;
			}
		};

		try {
			ctx = SSLContext.getInstance("TLS");
			// 使用TrustManager來初始化該上下文,TrustManager只是被SSL的Socket所使用
			ctx.init(null, new TrustManager[]{xtm}, null);
		} catch (NoSuchAlgorithmException e) {
			e.printStackTrace();
		} catch (KeyManagementException e) {
			e.printStackTrace();
		}


		ConnectionSocketFactory plainsf = PlainConnectionSocketFactory.getSocketFactory();
		LayeredConnectionSocketFactory sslsf = SSLConnectionSocketFactory.getSocketFactory();
		Registry<ConnectionSocketFactory> registry = RegistryBuilder.<ConnectionSocketFactory>create()
				.register("http", plainsf)
				.register("https", new SSLConnectionSocketFactory(ctx))
				.build();

		connectPool=new PoolingHttpClientConnectionManager(registry);
		connectPool.setMaxTotal(maxPoolSize);
		requestConfig=RequestConfig.custom()
				.setConnectionRequestTimeout(connectionRequestTimeout)
				.setConnectTimeout(connectTimeout)
				.setSocketTimeout(socketTimeout).build();
		httpClient=HttpClients.custom()
				.setConnectionManager(connectPool)
				.setDefaultRequestConfig(requestConfig)
				.setSslcontext(ctx)
				.setSSLHostnameVerifier((host, sslSession)->true)
				.build();
		return httpClient;
	}

	private  static  class QueryStringEncoder{
		private final String charsetName;
		private final StringBuilder uriBuilder;
		private boolean hasParams;

		public QueryStringEncoder(String uri) {
			this(uri, "UTF-8");
		}

		public QueryStringEncoder(String uri, String charsetName) {
			if (uri.contains("?")&&uri.contains("=")){
				this.hasParams=true;
			}
			this.uriBuilder = new StringBuilder(uri);
			this.charsetName = charsetName;
		}

		public void addParam(String name, String value) {
			Objects.requireNonNull(name, "name");
			if (hasParams) {
				uriBuilder.append('&');
			} else {
				uriBuilder.append('?');
				hasParams = true;
			}
			appendUrl(name, charsetName, uriBuilder);
			if (value != null) {
				uriBuilder.append('=');
				appendUrl(value, charsetName, uriBuilder);
			}
		}

		public URI toUri() throws URISyntaxException {
			return new URI(toString());
		}

		@Override
		public String toString() {
			return uriBuilder.toString();
		}

		private static void appendUrl(String s, String charset, StringBuilder sb) {
			try {
				s = URLEncoder.encode(s, charset);
			} catch (UnsupportedEncodingException ignored) {
				throw new UnsupportedCharsetException(charset);
			}
			sb.append(s);
		}
	}
}

後面的QueryStringEncoder是專門用來處理URLencode參數的。

工具類的封裝

簡單寫了下,其它需求的都可以補充。這個是我現寫的,我真正用的都直接調用HttpClient了。。。

public class HttpUtils {
    private static HttpClient httpClient=new HttpClient();

    public static HttpResult get(String url) throws HttpException {
        return get(url,null);
    };

    public static HttpResult get(String url,Map<String,String> header) throws HttpException {
        return get(url,header,null);
    };

    public static HttpResult get(String url, Map<String,String> header, Map<String,String> query) throws HttpException {
        HttpArgument argument=new HttpArgument(url,HttpMethod.GET);
        argument.setHeader(header);
        argument.setQuery(query);
        return httpClient.doAction(argument);
    }
}

總結

其實這個表面上看起來和別人的都差不多,但是我覺得區別很大。
我是按照Http協議本身的特點去思考如何更加合理的處理這些參數,這個思路我是看了apache-http的接口設計上面纔有的,有興趣的可以看看,它們的設計上完全是按照4要素去設計的,針對不同的請求體的類型,它們就做了不同的實現,但是接口定義就只有一個HttpEntity,它就代表的是請求體,不同的情況實現類去處理就行了,當然設計上還不止這一個,4要素都是由的。因此我借鑑了這個思路,但是並沒有按照它們的實現去做,感覺有點兒過度設計,實在沒有必要。
重複造輪子肯定是沒有必要的,但是前面的輪子覺得它不好,那我們就得站出來給它修一修。

發表評論
所有評論
還沒有人評論,想成為第一個評論的人麼? 請在上方評論欄輸入並且點擊發布.
相關文章