空污太嚴重 印度最高法院諭令禁焚農作物殘梗

摘錄自2019年11月5日中央社外電報導

有鑑於空汙嚴重,印度最高法院今天(5 日)下令首都新德里周邊地區全面停止焚燒農作物殘梗。法院指出,新德里居民持續因為空汙喪失寶貴生命歲月,「這根本不該發生在文明國家」。倘若禁令未貫徹執行,整個行政和警察體系都會被追究責任。

儘管焚燒農作物殘梗已屬非法,許多經濟困窘農民表示,他們別無其他選擇。印度政府曾試圖杜絕農民這個習慣,例如補助購買排除焚燒農作物殘梗必要性的設備,但效果不彰。此外,農民也可將殘梗轉化為生質能源團塊等較具實用價值物品,但成本偏高。

氣候政策研究員、「印度大霧霾」」(The Great Smog of India,暫譯)一書作者辛赫(Siddharth Singh)表示,政府應採取作為,賦予農作殘餘物價值,例如出資採購並以乾淨方式焚燒。

不過,印度智庫「政策研究中心」(Centre for Policy Research)研究員哈里希(Santosh Harish)認為,焚燒農作物對空汙的「貢獻」被高估了。

哈里希指出:「過去幾週,殘梗焚燒確實(對空汙形成)扮演重要角色,占比約40%,但我認為新德里市長高估農民能耐,對新德里境內空汙來源卻輕描淡寫…若新德里全面推卸責任,他們就搞錯重點了。」

根據哈里希的說法,新德里空汙危機的主要成因是交通運輸工具、工業設施和發電廠所排放廢氣,另外還有施工粉塵。此外,嚴重空汙範圍已超越首都區,涵蓋印度北部多數地區。

根據環保組織「綠色和平」(Greenpeace)3月發布的報告,全球汙染程度最高的30座城市中,印度就包辦其中22座。新德里市長克里瓦爾(Arvind Kejriwal)上週表示,新德里已成「毒氣室」,罪魁禍首就是「焚燒農作物產生的煙霧」。

新德里的空汙情形在3日急遽惡化,細懸浮微粒(PM2.5)濃度達近3年新高,接近每立方公尺1000微克,而世界衛生組織(WHO)建議的每日最高安全上限為每立方公尺25微克。空汙嚴重甚至導致飛機航班被迫延誤或改變起降地點,各級學校也關閉;經濟條件佳的居民多選擇待在有空氣清淨機運轉的住家裡。

本站聲明:網站內容來源環境資訊中心https://e-info.org.tw/,如有侵權,請聯繫我們,我們將及時處理

【其他文章推薦】

※為什麼 USB CONNECTOR 是電子產業重要的元件?

網頁設計一頭霧水該從何著手呢? 台北網頁設計公司幫您輕鬆架站!

※台北網頁設計公司全省服務真心推薦

※想知道最厲害的網頁設計公司"嚨底家"!

新北清潔公司,居家、辦公、裝潢細清專業服務

※推薦評價好的iphone維修中心

習近平馬克宏聯合聲明 強調巴黎氣候協定不可逆

摘錄自2019年11月6日中央社外電報導

美國本週向聯合國提交退出巴黎氣候協定的通知信函後,中國國家主席習近平和法國總統馬克宏(Emmanuel Macron)今天(6日)共同宣布,堅定支持巴黎氣候協定並強調協定「不可逆」。

法新社報導,儘管有越來越多證據顯示氣候變遷的事實和影響,美國總統川普仍執意退出巴黎氣候協定,世界各國紛紛表達遺憾及關切。

華府4日向聯合國(UN)提交退出巴黎氣候協定(Paris Agreement)的通知信函,讓世界最大經濟體美國成為至今唯一退出這項協定的國家。

根據美國前總統歐巴馬(Barack Obama)談成的這項氣候協定,11月4日是美方最早可以正式提出退出協定的首日。

習近平和馬克宏在北京舉行會談後的聯合聲明說,中法兩國領導人重申「他們堅定支持巴黎氣候協定,他們認為這項協定是不可逆轉的進程,也是針對氣候問題採取強而有力行動時的方針」。

兩人在北京會談後,馬克宏坐在習近平旁邊。當時馬克宏沒有直接指名美國,只說他「對於其他人所做的選擇深感遺憾」。

本站聲明:網站內容來源環境資訊中心https://e-info.org.tw/,如有侵權,請聯繫我們,我們將及時處理

【其他文章推薦】

USB CONNECTOR掌控什麼技術要點? 帶您認識其相關發展及效能

台北網頁設計公司這麼多該如何選擇?

※智慧手機時代的來臨,RWD網頁設計為架站首選

※評比南投搬家公司費用收費行情懶人包大公開

※幫你省時又省力,新北清潔一流服務好口碑

※回頭車貨運收費標準

緊張關係緩解 加拿大豬牛肉將恢復出口中國

摘錄自2019年11月6日中央社外電報導

加拿大總理杜魯道(Justin Trudeau)今天(6日)宣布,中國同意恢復進口加拿大牛肉和豬肉,顯示兩國緊張關係有所突破。

杜魯道發推文表示:「今天加拿大農民有個好消息:加拿大對中國的豬肉和牛肉出口將恢復。」他讚揚9月才上任的加拿大駐中國大使鮑達民(Dominic Barton)和加拿大肉品業,「為我們肉品生產商和其家人重新打開這個重要市場」所做的努力。

在中國和加拿大之間的外交紛爭升級下,中國6月停止進口加拿大牛肉和豬肉,指稱從加拿大進口的一批豬肉產品含有瘦肉精,又發現加拿大豬肉涉及偽造獸醫衛生證書,渥太華當局則否認這些指控。

目前還不清楚說服北京當局改弦易轍的原因為何。但肉品生產商表示,加拿大食品檢驗局(Canadian Food Inspection Agency)將立刻開始簽發銷往中國的出口證書。加拿大貿易部長卡爾(Jim Carr)和農業部長畢博(Marie-Claude Bibeau)在聲明中表示,加拿大外交部和食品檢驗局過去幾個月來就取消牛肉和豬肉禁令「與中國交涉」。「我們未來幾天和幾週,將繼續與牛肉和豬肉生產商和加工商密切合作,確保成功恢復貿易。」

本站聲明:網站內容來源環境資訊中心https://e-info.org.tw/,如有侵權,請聯繫我們,我們將及時處理

【其他文章推薦】

網頁設計一頭霧水該從何著手呢? 台北網頁設計公司幫您輕鬆架站!

網頁設計公司推薦不同的風格,搶佔消費者視覺第一線

※想知道購買電動車哪裡補助最多?台中電動車補助資訊懶人包彙整

南投搬家公司費用,距離,噸數怎麼算?達人教你簡易估價知識!

※教你寫出一流的銷售文案?

※超省錢租車方案

愛爾蘭擬課拿鐵稅 減少一次性咖啡杯

摘錄自2019年11月7日中央社報導

愛爾蘭氣候行動部長布魯敦(Richard Bruton)今(6日)表示,2021年前將對可拋式咖啡杯課徵所謂的「拿鐵稅」,試圖改變消費者習慣並削減使用一次性塑膠對環境的影響。

愛爾蘭去年連續第3年超出年度溫室氣體排放配額量後,開始對經濟採取削減環境影響的行動,盼透過徵收最高0.25歐元的擬議拿鐵稅,促進咖啡飲用者改攜帶環保杯,進一步推進都柏林在歐盟的法定承諾。

愛爾蘭最早於2002年推出塑膠袋稅,拿鐵稅則是為了鼓勵採取較永續行為而新增的賦稅之一。這類計畫還包括對超市櫃台販賣的較昂貴中量級塑膠袋增收0.25歐元稅金。

儘管某些商家已為攜帶環保杯的顧客打折,但去年當局資助的報告發現,愛爾蘭全國490萬人每年仍丟棄高達2億個一次性咖啡杯。

布魯敦說,因為零售業缺乏回收飲食包裝的基礎設備,可分解咖啡杯也會被課稅。

本站聲明:網站內容來源環境資訊中心https://e-info.org.tw/,如有侵權,請聯繫我們,我們將及時處理

【其他文章推薦】

網頁設計一頭霧水該從何著手呢? 台北網頁設計公司幫您輕鬆架站!

網頁設計公司推薦不同的風格,搶佔消費者視覺第一線

※Google地圖已可更新顯示潭子電動車充電站設置地點!!

※廣告預算用在刀口上,台北網頁設計公司幫您達到更多曝光效益

※別再煩惱如何寫文案,掌握八大原則!

川普政府下月出租阿拉斯加土地 開放挖石油

摘錄自2019年11月6日中央社外電報導

美國總統川普政府今天(6日)表示,下月將開放標租阿拉斯加州北極地區近400萬英畝(160萬公頃)的土地,供作石油開發用途。政府並承諾未來會開放更多土地以供開發。

路透社報導,美國土地管理局(Bureau of Land Management)宣布12月11日將舉行阿拉斯加國家石油儲備區(National Petroleum Reserve in Alaska)年度石油與天然氣租賃權拍賣。這將是土地管理局為該區舉行的第15場石油租賃權拍賣。

土地管理局也即將完成一項計畫草案,準備推翻前總統歐巴馬時期的保護措施。

相關保護措施讓這塊2300萬英畝(930萬公頃)的國家石油儲備區,有大約一半不得進行石油開發,理由為必須保護北美馴鹿、候鳥,以及對區內原住民和國家而言很重要的其他資源。

川普政府與石油產業則主張,歐巴馬政府計畫的限制過多,必須用新計畫加以取代。

阿拉斯加國家石油儲備區位於阿拉斯加州北坡地區(North Slope)西側,地處普魯赫灣油田(Prudhoe Bay)和庫帕勒克油田(Kuparuk)西方。

近期幾項新發現已促使北坡地區石油開發行動往西擴張。阿拉斯加國家石油儲備區作為單位面積最大的美國聯邦土地,也被視為阿拉斯加州新的產油潛力區。

本站聲明:網站內容來源環境資訊中心https://e-info.org.tw/,如有侵權,請聯繫我們,我們將及時處理

【其他文章推薦】

網頁設計公司推薦不同的風格,搶佔消費者視覺第一線

※廣告預算用在刀口上,台北網頁設計公司幫您達到更多曝光效益

※自行創業缺乏曝光? 網頁設計幫您第一時間規劃公司的形象門面

南投搬家公司費用需注意的眉眉角角,別等搬了再說!

新北清潔公司,居家、辦公、裝潢細清專業服務

不忍樹木被塑膠袋環繞 前進設計之都米蘭宣導

文:宋瑞文

本站聲明:網站內容來源環境資訊中心https://e-info.org.tw/,如有侵權,請聯繫我們,我們將及時處理

【其他文章推薦】

※自行創業缺乏曝光? 網頁設計幫您第一時間規劃公司的形象門面

網頁設計一頭霧水該從何著手呢? 台北網頁設計公司幫您輕鬆架站!

※想知道最厲害的網頁設計公司"嚨底家"!

※幫你省時又省力,新北清潔一流服務好口碑

※別再煩惱如何寫文案,掌握八大原則!

Golang 網絡編程

目錄

  • TCP網絡編程
  • UDP網絡編程
  • Http網絡編程
  • 理解函數是一等公民
  • HttpServer源碼閱讀
    • 註冊路由
    • 啟動服務
    • 處理請求
  • HttpClient源碼閱讀
    • DemoCode
    • 整理思路
    • 重要的struct
    • 流程
    • transport.dialConn
    • 發送請求

TCP網絡編程

存在的問題:

  • 拆包:
    • 對發送端來說應用程序寫入的數據遠大於socket緩衝區大小,不能一次性將這些數據發送到server端就會出現拆包的情況。
    • 通過網絡傳輸的數據包最大是1500字節,當TCP報文的長度 - TCP頭部的長度 > MSS(最大報文長度時)將會發生拆包,MSS一般長(1460~1480)字節。
  • 粘包:
    • 對發送端來說:應用程序發送的數據很小,遠小於socket的緩衝區的大小,導致一個數據包裏面有很多不通請求的數據。
    • 對接收端來說:接收數據的方法不能及時的讀取socket緩衝區中的數據,導致緩衝區中積壓了不同請求的數據。

解決方法:

  • 使用帶消息頭的協議,在消息頭中記錄數據的長度。
  • 使用定長的協議,每次讀取定長的內容,不夠的使用空格補齊。
  • 使用消息邊界,比如使用 \n 分隔 不同的消息。
  • 使用諸如 xml json protobuf這種複雜的協議。

實驗:使用自定義協議

整體的流程:

客戶端:發送端連接服務器,將要發送的數據通過編碼器編碼,發送。

服務端:啟動、監聽端口、接收連接、將連接放在協程中處理、通過解碼器解碼數據。

	//###########################
//######  Server端代碼  ###### 
//###########################

func main() {
	// 1. 監聽端口 2.accept連接 3.開goroutine處理連接
	listen, err := net.Listen("tcp", "0.0.0.0:9090")
	if err != nil {
		fmt.Printf("error : %v", err)
		return
	}
	for{
		conn, err := listen.Accept()
		if err != nil {
			fmt.Printf("Fail listen.Accept : %v", err)
			continue
		}
		go ProcessConn(conn)
	}
}

// 處理網絡請求
func ProcessConn(conn net.Conn) {
	defer conn.Close()
	for  {
		bt,err:=coder.Decode(conn)
		if err != nil {
			fmt.Printf("Fail to decode error [%v]", err)
			return
		}
		s := string(bt)
		fmt.Printf("Read from conn:[%v]\n",s)
	}
}

//###########################
//######  Clinet端代碼  ###### 
//###########################
func main() {
	conn, err := net.Dial("tcp", ":9090")
	defer conn.Close()
	if err != nil {
		fmt.Printf("error : %v", err)
		return
	}

	// 將數據編碼併發送出去
	coder.Encode(conn,"hi server i am here");
}

//###########################
//######  編解碼器代碼  ###### 
//###########################
/**
 * 	解碼:
 */
func Decode(reader io.Reader) (bytes []byte, err error) {
	// 先把消息頭讀出來
	headerBuf := make([]byte, len(msgHeader))
	if _, err = io.ReadFull(reader, headerBuf); err != nil {
		fmt.Printf("Fail to read header from conn error:[%v]", err)
		return nil, err
	}
	// 檢驗消息頭
	if string(headerBuf) != msgHeader {
		err = errors.New("msgHeader error")
		return nil, err
	}
	// 讀取實際內容的長度
	lengthBuf := make([]byte, 4)
	if _, err = io.ReadFull(reader, lengthBuf); err != nil {
		return nil, err
	}
	contentLength := binary.BigEndian.Uint32(lengthBuf)
	contentBuf := make([]byte, contentLength)
	// 讀出消息體
	if _, err := io.ReadFull(reader, contentBuf); err != nil {
		return nil, err
	}
	return contentBuf, err
}

/**
 *  編碼
 *  定義消息的格式: msgHeader + contentLength + content
 *  conn 本身實現了 io.Writer 接口
 */
func Encode(conn io.Writer, content string) (err error) {
	// 寫入消息頭
	if err = binary.Write(conn, binary.BigEndian, []byte(msgHeader)); err != nil {
		fmt.Printf("Fail to write msgHeader to conn,err:[%v]", err)
	}
	// 寫入消息體長度
	contentLength := int32(len([]byte(content)))
	if err = binary.Write(conn, binary.BigEndian, contentLength); err != nil {
		fmt.Printf("Fail to write contentLength to conn,err:[%v]", err)
	}
	// 寫入消息
	if err = binary.Write(conn, binary.BigEndian, []byte(content)); err != nil {
		fmt.Printf("Fail to write content to conn,err:[%v]", err)
	}
	return err

客戶端的conn一直不被Close 有什麼表現?

四次揮手各個狀態的如下:

主從關閉方						被動關閉方
established					established
Fin-wait1					
										closeWait
Fin-wait2
Tiem-wait						lastAck
Closed							Closed

如果客戶端的連接手動的關閉,它和服務端的狀態會一直保持established建立連接中的狀態。

MacBook-Pro% netstat -aln | grep 9090
tcp4       0      0  127.0.0.1.9090         127.0.0.1.62348        ESTABLISHED
tcp4       0      0  127.0.0.1.62348        127.0.0.1.9090         ESTABLISHED
tcp46      0      0  *.9090                 *.*                    LISTEN

服務端的conn一直不被關閉 有什麼表現?

客戶端的進程結束后,會發送fin數據包給服務端,向服務端請求斷開連接。

服務端的conn不關閉的話,服務端就會停留在四次揮手的close_wait階段(我們不手動Close,服務端就任務還有數據/任務沒處理完,因此它不關閉)。

客戶端停留在 fin_wait2的階段(在這個階段等着服務端告訴自己可以真正斷開連接的消息)。

MacBook-Pro% netstat -aln | grep 9090
tcp4       0      0  127.0.0.1.9090         127.0.0.1.62888        CLOSE_WAIT
tcp4       0      0  127.0.0.1.62888        127.0.0.1.9090         FIN_WAIT_2
tcp46      0      0  *.9090                 *.*                    LISTEN

什麼是binary.BigEndian?什麼是binary.LittleEndian?

對計算機來說一切都是二進制的數據,BigEndian和LittleEndian描述的就是二進制數據的字節順序。計算機內部,小端序被廣泛應用於現代性 CPU 內部存儲數據;大端序常用於網絡傳輸和文件存儲。

比如:

一個數的二進製表示為 	 0x12345678
BigEndian   表示為: 0x12 0x34 0x56 0x78 
LittleEndian表示為: 0x78 0x56 0x34 0x12

UDP網絡編程

思路:

UDP服務器:1、監聽 2、循環讀取消息 3、回複數據。

UDP客戶端:1、連接服務器 2、發送消息 3、接收消息。

// ################################
// ######## UDPServer #########
// ################################
func main() {
	// 1. 監聽端口 2.accept連接 3.開goroutine處理連接
	listen, err := net.Listen("tcp", "0.0.0.0:9090")
	if err != nil {
		fmt.Printf("error : %v", err)
		return
	}
	for{
		conn, err := listen.Accept()
		if err != nil {
			fmt.Printf("Fail listen.Accept : %v", err)
			continue
		}
		go ProcessConn(conn)
	}
}

// 處理網絡請求
func ProcessConn(conn net.Conn) {
	defer conn.Close()
	for  {
		bt,err:= coder.Decode(conn)
		if err != nil {
			fmt.Printf("Fail to decode error [%v]", err)
			return
		}
		s := string(bt)
		fmt.Printf("Read from conn:[%v]\n",s)
	}
}

// ################################
// ######## UDPClient #########
// ################################
func main() {

	udpConn, err := net.DialUDP("udp", nil, &net.UDPAddr{
		IP:   net.IPv4(127, 0, 0, 1),
		Port: 9091,
	})
	if err != nil {
		fmt.Printf("error : %v", err)
		return
	}

	_, err = udpConn.Write([]byte("i am udp client"))
	if err != nil {
		fmt.Printf("error : %v", err)
		return
	}
	bytes:=make([]byte,1024)
	num, addr, err := udpConn.ReadFromUDP(bytes)
	if err != nil {
		fmt.Printf("Fail to read from udp error: [%v]", err)
		return
	}
	fmt.Printf("Recieve from udp address:[%v], bytes:[%v], content:[%v]",addr,num,string(bytes))
}

Http網絡編程

思路整理:

HttpServer:1、創建路由器。2、為路由器綁定路由規則。3、創建服務器、監聽端口。 4啟動讀服務。

HttpClient: 1、創建連接池。2、創建客戶端,綁定連接池。3、發送請求。4、讀取響應。

func main() {
	mux := http.NewServeMux()
	mux.HandleFunc("/login", doLogin)
	server := &http.Server{
		Addr:         ":8081",
		WriteTimeout: time.Second * 2,
		Handler:      mux,
	}
	log.Fatal(server.ListenAndServe())
}

func doLogin(writer http.ResponseWriter,req *http.Request){
	_, err := writer.Write([]byte("do login"))
	if err != nil {
		fmt.Printf("error : %v", err)
		return
	}
}

HttpClient端

func main() {
	transport := &http.Transport{
    // 撥號的上下文
		DialContext: (&net.Dialer{
			Timeout:   30 * time.Second, // 撥號建立連接時的超時時間
			KeepAlive: 30 * time.Second, // 長連接存活的時間
		}).DialContext,
    // 最大空閑連接數
		MaxIdleConns:          100,  
    // 超過最大的空閑連接數的連接,經過 IdleConnTimeout時間後會失效
		IdleConnTimeout:       10 * time.Second, 
    // https使用了SSL安全證書,TSL是SSL的升級版
    // 當我們使用https時,這行配置生效
		TLSHandshakeTimeout:   10 * time.Second, 
		ExpectContinueTimeout: 1 * time.Second,  // 100-continue 狀態碼超時時間
	}

	// 創建客戶端
	client := &http.Client{
		Timeout:   time.Second * 10, //請求超時時間
		Transport: transport,
	}

	// 請求數據
	res, err := client.Get("http://localhost:8081/login")
	if err != nil {
		fmt.Printf("error : %v", err)
		return
	}
	defer res.Body.Close()

	bytes, err := ioutil.ReadAll(res.Body)
	if err != nil {
		fmt.Printf("error : %v", err)
		return
	}
	fmt.Printf("Read from http server res:[%v]", string(bytes))
}

理解函數是一等公民

點擊查看在github中函數相關的筆記

在golang中函數是一等公民,我們可以把一個函數當作普通變量一樣使用。

比如我們有個函數HelloHandle,我們可以直接使用它。

func HelloHandle(name string, age int) {
	fmt.Printf("name:[%v] age:[%v]", name, age)
}

func main() {
  HelloHandle("tom",12)
}

閉包

如何理解閉包:閉包本質上是一個函數,而且這個函數會引用它外部的變量,如下例子中的f3中的匿名函數本身就是一個閉包。 通常我們使用閉包起到一個適配的作用。

例1:

// f2是一個普通函數,有兩個入參數
func f2() {
	fmt.Printf("f2222")
}

// f1函數的入參是一個f2類型的函數
func f1(f2 func()) {
	f2()
}

func main() {
  // 由於golang中函數是一等公民,所以我們可以把f2同普通變量一般傳遞給f1
	f1(f2)
}

例2: 在上例中更進一步。f2有了自己的參數, 這時就不能直接把f2傳遞給f1了。

總不能傻傻的這樣吧f1(f2(1,2)) ???

而閉包就能解決這個問題。

// f2是一個普通函數,有兩個入參數
func f2(x int, y int) {
	fmt.Println("this is f2 start")
	fmt.Printf("x: %d y: %d \n", x, y)
	fmt.Println("this is f2 end")
}

// f1函數的入參是一個f2類型的函數
func f1(f2 func()) {
	fmt.Println("this is f1 will call f2")
	f2()
	fmt.Println("this is f1 finished call f2")
}

// 接受一個兩個參數的函數, 返回一個包裝函數
func f3(f func(int,int) ,x,y int) func() {
	fun := func() {
		f(x,y)
	}
	return fun
}

func main() {
	// 目標是實現如下的傳遞與調用
	f1(f3(f2,6,6))
}

實現方法的回調:

下面的例子中實現這樣的功能:就好像是我設計了一個框架,定好了整個框架運轉的流程(或者說是提供了一個編程模版),框架具體做事的函數你根據自己的需求自己實現,我的框架只是負責幫你回調你具體的方法。

// 自定義類型,handler本質上是一個函數
type HandlerFunc func(string, int)

// 閉包
func (f HandlerFunc) Serve(name string, age int) {
	f(name, age)
}

// 具體的處理函數
func HelloHandle(name string, age int) {
	fmt.Printf("name:[%v] age:[%v]", name, age)
}

func main() {
  // 把HelloHandle轉換進自定義的func中
	handlerFunc := HandlerFunc(HelloHandle)
  // 本質上會去回調HelloHandle方法
	handlerFunc.Serve("tom", 12)
  
  // 上面兩行效果 == 下面這行
  // 只不過上面的代碼是我在幫你回調,下面的是你自己主動調用
  HelloHandle("tom",12)
}

HttpServer源碼閱讀

註冊路由

直觀上看註冊路由這一步,就是它要做的就是將在路由器url pattern和開發者提供的func關聯起來。 很容易想到,它裏面很可能是通過map實現的。


func main() {
	// 創建路由器
	// 為路由器綁定路由規則
	mux := http.NewServeMux()
	mux.HandleFunc("/login", doLogin)
	...
}

func doLogin(writer http.ResponseWriter,req *http.Request){
	_, err := writer.Write([]byte("do login"))
	if err != nil {
		fmt.Printf("error : %v", err)
		return
	}
}

姑且將ServeMux當作是路由器。我們使用http包下的 NewServerMux 函數創建一個新的路由器對象,進而使用它的HandleFunc(pattern,func)函數完成路由的註冊。

跟進NewServerMux函數,可以看到,它通過new函數返回給我們一個ServeMux結構體。

func NewServeMux() *ServeMux {
  return new(ServeMux) 
}

這個ServeMux結構體長下面這樣:在這個ServeMux結構體中我們就看到了這個維護pattern和func的map

type ServeMux struct {
	mu    sync.RWMutex 
	m     map[string]muxEntry
	hosts bool // whether any patterns contain hostnames
}

這個muxEntry長下面這樣:

type muxEntry struct {
	h       Handler
	pattern string
}

type Handler interface {
	ServeHTTP(ResponseWriter, *Request)
}

看到這裏問題就來了,上面我們手動註冊進路由器中的僅僅是一個有規定參數的方法,到這裏怎麼成了一個Handle了?我們也沒有說去手動的實現Handler這個接口,也沒有重寫ServeHTTP函數啊, 在golang中實現一個接口不得像下面這樣搞嗎?**

type Handle interface {
	Serve(string, int, string)
}

type HandleImpl struct {

}

func (h HandleImpl)Serve(string, int, string){

}

帶着這個疑問看下面的方法:

	// 由於函數是一等公民,故我們將doLogin函數同普通變量一樣當做入參傳遞進去。
 	mux.HandleFunc("/login", doLogin)

  func doLogin(writer http.ResponseWriter,req *http.Request){
    ...
	}

跟進去看 HandleFunc 函數的實現:

首先:HandleFunc函數的第二個參數是接收的函數的類型和doLogin函數的類型是一致的,所以doLogin能正常的傳遞進HandleFunc中。

其次:我們的關注點應該是下面的HandlerFunc(handler)

// HandleFunc registers the handler function for the given pattern.
func (mux *ServeMux) HandleFunc(pattern string, handler func(ResponseWriter, *Request)) {
	if handler == nil {
		panic("http: nil handler")
	}
	mux.Handle(pattern, HandlerFunc(handler))
}

跟進這個HandlerFunc(handler) 看到下圖,真相就大白於天下了。golang以一種優雅的方式悄無聲息的為我們完成了一次適配。這麼看來上面的HandlerFunc(handler)並不是函數的調用,而是doLogin轉換成自定義類型。這個自定義類型去實現了Handle接口(因為它重寫了ServeHTTP函數)以閉包的形式完美的將我們的doLogin適配成了Handle類型。

在往下看Handle方法:

第一:將pattern和handler註冊進map中

第二:為了保證整個過程的併發安全,使用鎖保護整個過程。

// Handle registers the handler for the given pattern.
// If a handler already exists for pattern, Handle panics.
func (mux *ServeMux) Handle(pattern string, handler Handler) {
	mux.mu.Lock()
	defer mux.mu.Unlock()

	if pattern == "" {
		panic("http: invalid pattern")
	}
	if handler == nil {
		panic("http: nil handler")
	}
	if _, exist := mux.m[pattern]; exist {
		panic("http: multiple registrations for " + pattern)
	}

	if mux.m == nil {
		mux.m = make(map[string]muxEntry)
	}
	mux.m[pattern] = muxEntry{h: handler, pattern: pattern}

	if pattern[0] != '/' {
		mux.hosts = true
	}

啟動服務

概覽圖:

和java對比着看,在java一組複雜的邏輯會被封裝成一個class。在golang中對應的就是一組複雜的邏輯會被封裝成一個結構體。

對應HttpServer肯定也是這樣,http服務器在golang的實現中有自己的結構體。它就是http包下的Server。

它有一系列描述性屬性。如監聽的地址、寫超時時間、路由器。

	server := &http.Server{
		Addr:         ":8081",
		WriteTimeout: time.Second * 2,
		Handler:      mux,
	}
	log.Fatal(server.ListenAndServe())

我們看它啟動服務的函數:server.ListenAndServe()

實現的邏輯是使用net包下的Listen函數,獲取給定地址上的tcp連接。

再將這個tcp連接封裝進 tcpKeepAliveListenner 結構體中。

在將這個tcpKeepAliveListenner丟進Server的Serve函數中處理

// ListenAndServe 會監聽開發者給定網絡地址上的tcp連接,當有請求到來時,會調用Serve函數去處理這個連接。
// 它接收到所有連接都使用 TCP keep-alives相關的配置
// 
// 如果構造Server時沒有指定Addr,他就會使用默認值: “:http”
// 
// 當Server ShutDown或者是Close,ListenAndServe總是會返回一個非nil的error。
// 返回的這個Error是 ErrServerClosed
func (srv *Server) ListenAndServe() error {
	if srv.shuttingDown() {
		return ErrServerClosed
	}
	addr := srv.Addr
	if addr == "" {
		addr = ":http"
	}
  // 底層藉助於tcp實現
	ln, err := net.Listen("tcp", addr)
	if err != nil {
		return err
	}
	return srv.Serve(tcpKeepAliveListener{ln.(*net.TCPListener)})
}

// tcpKeepAliveListener會為TCP設置一個keep-alive 超時時長。
// 它通常被 ListenAndServe 和 ListenAndServeTLS使用。
// 它保證了已經dead的TCP最終都會消失。
type tcpKeepAliveListener struct {
	*net.TCPListener
}

接着去看看Serve方法,上一個函數中獲取到了一個基於tcp的Listener,從這個Listener中可以不斷的獲取出新的連接,下面的方法中使用無限for循環完成這件事。conn獲取到后將連接封裝進httpConn,為了保證不阻塞下一個連接到到來,開啟新的goroutine處理這個http連接。

func (srv *Server) Serve(l net.Listener) error {
  // 如果有一個包裹了 srv 和 listener 的鈎子函數,就執行它
	if fn := testHookServerServe; fn != nil {
		fn(srv, l) // call hook with unwrapped listener
	}
	
  // 將tcp的Listener封裝進onceCloseListener,保證連接不會被關閉多次。
	l = &onceCloseListener{Listener: l}
	defer l.Close()
 
  // http2相關的配置
	if err := srv.setupHTTP2_Serve(); err != nil {
		return err
	}

	if !srv.trackListener(&l, true) {
		return ErrServerClosed
	}
	defer srv.trackListener(&l, false)
	
  // 如果沒有接收到請求睡眠多久
	var tempDelay time.Duration     // how long to sleep on accept failure
	baseCtx := context.Background() // base is always background, per Issue 16220
	ctx := context.WithValue(baseCtx, ServerContextKey, srv)
  // 開啟無限循環,嘗試從Listenner中獲取連接。
	for {
		rw, e := l.Accept()
    // accpet過程中發生錯屋
		if e != nil {
			select {
        // 如果從server的doneChan中可以獲取內容,返回Server關閉了
			case <-srv.getDoneChan():
				return ErrServerClosed
			default:
			}
      // 如果發生了 net.Error 並且是臨時的錯誤就睡5毫秒,再發生錯誤睡眠的時間*2,上線是1s
			if ne, ok := e.(net.Error); ok && ne.Temporary() {
				if tempDelay == 0 {
					tempDelay = 5 * time.Millisecond
				} else {
					tempDelay *= 2
				}
				if max := 1 * time.Second; tempDelay > max {
					tempDelay = max
				}
				srv.logf("http: Accept error: %v; retrying in %v", e, tempDelay)
				time.Sleep(tempDelay)
				continue
			}
			return e
		}
    // 如果沒有發生錯誤,清空睡眠的時間
		tempDelay = 0
    // 將接收到連接封裝進httpConn
		c := srv.newConn(rw)
		c.setState(c.rwc, StateNew) // before Serve can return
    // 開啟一條新的協程處理這個連接
		go c.serve(ctx)
	}
}

處理請求

c.serve(ctx)中就會去解析http相關的報文信息~,將http報文解析進Request結構體中。

部分代碼如下:

		// 將 server 包裹為 serverHandler 的實例,執行它的 ServeHTTP 方法,處理請求,返迴響應。
		// serverHandler 委託給 server 的 Handler 或者 DefaultServeMux(默認路由器)
		// 來處理 "OPTIONS *" 請求。
		serverHandler{c.server}.ServeHTTP(w, w.req)
// serverHandler delegates to either the server's Handler or
// DefaultServeMux and also handles "OPTIONS *" requests.
type serverHandler struct {
	srv *Server
}

func (sh serverHandler) ServeHTTP(rw ResponseWriter, req *Request) {
  // 如果沒有定義Handler就使用默認的
	handler := sh.srv.Handler
	if handler == nil {
		handler = DefaultServeMux
	}
	if req.RequestURI == "*" && req.Method == "OPTIONS" {
		handler = globalOptionsHandler{}
	}
  // 處理請求,返迴響應。
	handler.ServeHTTP(rw, req)
}

可以看到,req中包含了我們前面說的pattern,叫做RequestUri,有了它下一步就知道該回調ServeMux中的哪一個函數。

HttpClient源碼閱讀

DemoCode

func main() {
	// 創建連接池
	// 創建客戶端,綁定連接池
	// 發送請求
	// 讀取響應
	transport := &http.Transport{
		DialContext: (&net.Dialer{
			Timeout:   30 * time.Second, // 連接超時
			KeepAlive: 30 * time.Second, // 長連接存活的時間
		}).DialContext,
    // 最大空閑連接數
		MaxIdleConns:          100,             
    // 超過最大空閑連接數的連接會在IdleConnTimeout后被銷毀
		IdleConnTimeout:       10 * time.Second, 
		TLSHandshakeTimeout:   10 * time.Second, // tls握手超時時間
		ExpectContinueTimeout: 1 * time.Second,  // 100-continue 狀態碼超時時間
	}

	// 創建客戶端
	client := &http.Client{
		Timeout:   time.Second * 10, //請求超時時間
		Transport: transport,
	}

	// 請求數據,獲得響應
	res, err := client.Get("http://localhost:8081/login")
	if err != nil {
		fmt.Printf("error : %v", err)
		return
	}
	defer res.Body.Close()
  // 處理數據
	bytes, err := ioutil.ReadAll(res.Body)
	if err != nil {
		fmt.Printf("error : %v", err)
		return
	}
	fmt.Printf("Read from http server res:[%v]", string(bytes))
}

整理思路

http.Client的代碼其實是很多的,全部很細的過一遍肯定也會難度,下面可能也是只能提及其中的一部分。

首先明白一件事,我們編寫的HttpClient是在干什麼?(雖然這個問題很傻,但是總得問一下)是在發送Http請求。

一般我們在開發的時候,更多的編寫的是HttpServer的代碼。是在處理Http請求, 而不是去發送Http請求,Http請求都是是前端通過ajax經由瀏覽器發送到後端的。

其次,Http請求實際上是建立在tcp連接之上的,所以如果我們去看http.Client肯定能找到net.Dial("tcp",adds)相關的代碼。

那也就是說,我們要看看,http.Client是如何在和服務端建立連接、發送數據、接收數據的。

重要的struct

http.Client中有機幾個比較重要的struct,如下

http.Client結構體中封裝了和http請求相關的屬性,諸如 cookie,timeout,redirect以及Transport。

type Client struct {
	Transport RoundTripper
	CheckRedirect func(req *Request, via []*Request) error
	Jar CookieJar
	Timeout time.Duration
}

Tranport實現了RoundTrpper接口:

 type RoundTripper interface {   
  // 1、RoundTrip會去執行一個簡單的 Http Trancation,併為requestt返回一個響應
  // 2、RoundTrip不會嘗試去解析response
  // 3、注意:只要返回了Reponse,無論response的狀態碼是多少,RoundTrip返回的結果:err == nil 
  // 4、RoundTrip將請求發送出去后,如果他沒有獲取到response,他會返回一個非空的err。
  // 5、同樣,RoundTrip不會嘗試去解析諸如重定向、認證、cookie這種更高級的協議。 
  // 6、除了消費和關閉請求體之外,RoundTrip不會修改request的其他字段
  // 7、RoundTrip可以在一個單獨的gorountine中讀取request的部分字段。一直到ResponseBody關閉之前,調用者都不能取消,或者重用這個request
  // 8、RoundTrip始終會保證關閉Body(包含在發生err時)。根據實現的不同,在RoundTrip關閉前,關閉Body這件事可能會在一個單獨的goroutine中去做。這就意味着,如果調用者想將請求體用於後續的請求,必須等待知道發生Close
  // 9、請求的URL和Header字段必須是被初始化的。 
	RoundTrip(*Request) (*Response, error)
}

看上面RoundTrpper接口,它裏面只有一個方法RoundTrip,方法的作用就是執行一次Http請求,發送Request然後獲取Response。

RoundTrpper被設計成了一個支持併發的結構體。

Transport結構體如下:

type Transport struct {
	idleMu     sync.Mutex
   // user has requested to close all idle conns
	wantIdle   bool
  // Transport的作用就是用來建立一個連接,這個idleConn就是Transport維護的空閑連接池。
	idleConn   map[connectMethodKey][]*persistConn // most recently used at end
	idleConnCh map[connectMethodKey]chan *persistConn
}

其中的connectMethodKey也是結構體:

type connectMethodKey struct {
  // proxy 代理的URL,當他不為空時,就會一直使用這個key 
  // scheme 協議的類型, http https
  // addr 代理的url,也就是下游的url
	proxy, scheme, addr string
}

persistConn是一個具體的連接實例,包含連接的上下文。

type persistConn struct {
  // alt可選地指定TLS NextProto RoundTripper。 
  // 這用於今天的HTTP / 2和以後的將來的協議。 如果非零,則其餘字段未使用。
	alt RoundTripper
	t         *Transport
	cacheKey  connectMethodKey
	conn      net.Conn
	tlsState  *tls.ConnectionState
  // 用於從conn中讀取內容
	br        *bufio.Reader       // from conn
  // 用於往conn中寫內容
	bw        *bufio.Writer       // to conn
	nwrite    int64               // bytes written
  // 他是個chan,roundTrip會將readLoop中的內容寫入到reqch中
	reqch     chan requestAndChan 
  // 他是個chan,roundTrip會將writeLoop中的內容寫到writech中
	writech   chan writeRequest  
	closech   chan struct{}       // closed when conn closed

另外補充一個結構體:Request,他用來描述一次http請求的實例,它定義於http包request.go, 裏面封裝了對Http請求相關的屬性

type Request struct {
   Method string
   URL *url.URL
   Proto      string // "HTTP/1.0"
   ProtoMajor int    // 1
   ProtoMinor int    // 0
   Header Header
   Body io.ReadCloser
   GetBody func() (io.ReadCloser, error)
   ContentLength int64
   TransferEncoding []string
   Close bool
   Host string
   Form url.Values
   PostForm url.Values
   MultipartForm *multipart.Form
   Trailer Header
   RemoteAddr string
   RequestURI string
   TLS *tls.ConnectionState
   Cancel <-chan struct{}
   Response *Response
   ctx context.Context
}

這幾個結構體共同完成如下圖所示http.Client的工作流程

流程

我們想發送一次Http請求。首先我們需要構造一個Request,Request本質上是對Http協議的描述(因為大家使用的都是Http協議,所以將這個Request發送到HttpServer后,HttpServer能識別並解析它)。

// 從這行代碼開始往下看
	res, err := client.Get("http://localhost:8081/login")

// 跟進Get
	req, err := NewRequest("GET", url, nil)
	if err != nil {
		return nil, err
	}
	return c.Do(req)

// 跟進Do
	func (c *Client) Do(req *Request) (*Response, error) {
	return c.do(req)
 } 

// 跟進do,do函數中有下面的邏輯,可以看到執行完send后已經拿到返回值了。所以我們得繼續跟進send方法
  if resp, didTimeout, err = c.send(req, deadline); err != nil 

// 跟進send方法,可以看到send中還有一send方法,入參分別是:request,tranpost,deadline
// 到現在為止,我們沒有看到有任何和服務端建立連接的動作發生,但是構造的req和擁有連接池的tranport已經見面了~
	resp, didTimeout, err = send(req, c.transport(), deadline)

// 繼續跟進這個send方法,看到了調用了rt的RoundTrip方法。
// 這個rt就是我們編寫HttpClient代碼時創建的,綁定在http.Client上的tranport實例。
// 這個RoundTrip方法的作用我們在上面已經說過了,最直接的作用就是:發送request 並獲取response。
	resp, err = rt.RoundTrip(req)

但是RoundTrip他是個定義在RoundTripper接口中的抽象方法,我們看代碼肯定是要去看具體的實現嘛
這裏可以使用斷點調試法:在上面最後一行上打上斷點,會進入到他的具體實現中。從圖中可以看到具體的實現在roundtrip中。

RoundTrip中調用的函數是我們自定義的transport的roundTrip函數, 跟進去如下:

緊接着我們需要一個conn,這個conn我們通過Transport可以獲取到。conn的類型為persistConn。

// roundTrip函數中又一個無限for循環
for {
    // 檢查請求的上下文是否關閉了
		select {
		case <-ctx.Done():
			req.closeBody()
			return nil, ctx.Err()
		default:
		}

    // 對傳遞進來的req進行了有一層的封裝,封裝后的這個treq可以被roundTrip修改,所以每次重試都會新建
		treq := &transportRequest{Request: req, trace: trace}
		cm, err := t.connectMethodForRequest(treq)
		if err != nil {
			req.closeBody()
			return nil, err
		}

    // 到這裏真的執行從tranport中獲取和對應主機的連接,這個連接可能是http、https、http代理、http代理的高速緩存, 但是無論如何我們都已經準備好了向這個連接發送treq
    // 這裏獲取出來的連接就是我們在上文中提及的persistConn
		pconn, err := t.getConn(treq, cm)
		if err != nil {
			t.setReqCanceler(req, nil)
			req.closeBody()
			return nil, err
		}

		var resp *Response
		if pconn.alt != nil {
			// HTTP/2 path.
			t.decHostConnCount(cm.key()) // don't count cached http2 conns toward conns per host
			t.setReqCanceler(req, nil)   // not cancelable with CancelRequest
			resp, err = pconn.alt.RoundTrip(req)
		} else {
      
      // 調用persistConn的roundTrip方法,發送treq並獲取響應。
			resp, err = pconn.roundTrip(treq)
		}
		if err == nil {
			return resp, nil
		}
		if !pconn.shouldRetryRequest(req, err) {
			// Issue 16465: return underlying net.Conn.Read error from peek,
			// as we've historically done.
			if e, ok := err.(transportReadFromServerError); ok {
				err = e.err
			}
			return nil, err
		}
		testHookRoundTripRetried()

		// Rewind the body if we're able to.  (HTTP/2 does this itself so we only
		// need to do it for HTTP/1.1 connections.)
		if req.GetBody != nil && pconn.alt == nil {
			newReq := *req
			var err error
			newReq.Body, err = req.GetBody()
			if err != nil {
				return nil, err
			}
			req = &newReq
		}
	}

整理思路:然後看上面代碼中獲取conn和roundTrip的實現細節。

我們需要一個conn,這個conn可以通過Transport獲取到。conn的類型為persistConn。但是不管怎麼樣,都得先獲取出 persistConn,才能進一步完成發送請求再得到服務端到響應。

然後關於這個persistConn結構體其實上面已經提及過了。重新貼在下面

type persistConn struct {
  // alt可選地指定TLS NextProto RoundTripper。 
  // 這用於今天的HTTP / 2和以後的將來的協議。 如果非零,則其餘字段未使用。
	alt RoundTripper
  
  conn      net.Conn
	t         *Transport
	br        *bufio.Reader  // 用於從conn中讀取內容
	bw        *bufio.Writer  // 用於往conn中寫內容
  // 他是個chan,roundTrip會將readLoop中的內容寫入到reqch中
	reqch     chan requestAndChan 
  // 他是個chan,roundTrip會將writeLoop中的內容寫到writech中
  
	nwrite    int64               // bytes written
	cacheKey  connectMethodKey
	tlsState  *tls.ConnectionState
	writech   chan writeRequest  
	closech   chan struct{}       // closed when conn closed

跟進 t.getConn(treq, cm)代碼如下:

	// 先嘗試從空閑緩衝池中取得連接
  // 所謂的空閑緩衝池就是Tranport結構體中的: idleConn map[connectMethodKey][]*persistConn 
  // 入參位置的cm如下:
  /* type connectMethod struct {
      // 代理的url,如果沒有代理的話,這個值為nil
			proxyURL     *url.URL 
			
			// 連接所使用的協議 http、https
			targetScheme string
      
	    // 如果proxyURL指定了http代理或者是https代理,並且使用的協議是http而不是https。
	    // 那麼下面的targetAddr就會不包含在connect method key中。
	    // 因為socket可以復用不同的targetAddr值
			targetAddr string
	}*/
	t.getIdleConn(cm);

	// 空閑緩衝池有的空閑連接的話返回conn,否則進行如下的select
	select {
    // todo 這裏我還不確定是在干什麼,目前猜測是這樣的:每個服務器能打開的socket句柄是有限的
    // 每次來獲取鏈接的時候,我們就計數+1。當整體的句柄在Host允許範圍內時我們不做任何干涉~
		case <-t.incHostConnCount(cmKey):
			// count below conn per host limit; proceed
    
    // 重新嘗試從空閑連接池中獲取連接,因為可能有的連接使用完后被放回連接池了
		case pc := <-t.getIdleConnCh(cm):
			if trace != nil && trace.GotConn != nil {
				trace.GotConn(httptrace.GotConnInfo{Conn: pc.conn, Reused: pc.isReused()})
			}
			return pc, nil
    // 請求是否被取消了
		case <-req.Cancel:
			return nil, errRequestCanceledConn
    // 請求的上下文是否Done掉了
		case <-req.Context().Done():
			return nil, req.Context().Err()
		case err := <-cancelc:
			if err == errRequestCanceled {
				err = errRequestCanceledConn
			}
			return nil, err
		}

	// 開啟新的gorountine新建連接一個連接
	go func() {
    /**
    *	新建連接,方法底層封裝了tcp client dial相關的邏輯
    *	conn, err := t.dial(ctx, "tcp", cm.addr())
    *	以及根據不同的targetScheme構建不同的request的邏輯。
    */
    // 獲取到persistConn
		pc, err := t.dialConn(ctx, cm)
    // 將persistConn寫到chan中
		dialc <- dialRes{pc, err}
	}()

	// 再嘗試從空閑連接池中獲取
  idleConnCh := t.getIdleConnCh(cm)
	select {
  // 如果上面的go協程撥號成功了,這裏就能取出值來
	case v := <-dialc:
		// Our dial finished.
		if v.pc != nil {
			if trace != nil && trace.GotConn != nil && v.pc.alt == nil {
				trace.GotConn(httptrace.GotConnInfo{Conn: v.pc.conn})
			}
			return v.pc, nil
		}
		// Our dial failed. See why to return a nicer error
		// value.
    // 將Host的連接-1
		t.decHostConnCount(cmKey)
		select {
    ...

transport.dialConn

下面代碼中的cm長這樣

// dialConn是Transprot的方法
// 入參:context上下文, connectMethod
// 出參:persisnConn
func (t *Transport) dialConn(ctx context.Context, cm connectMethod) (*persistConn, error) {
	// 構建將要返回的 persistConn
  pconn := &persistConn{
		t:             t,
		cacheKey:      cm.key(),
		reqch:         make(chan requestAndChan, 1),
		writech:       make(chan writeRequest, 1),
		closech:       make(chan struct{}),
		writeErrCh:    make(chan error, 1),
		writeLoopDone: make(chan struct{}),
	}
	trace := httptrace.ContextClientTrace(ctx)
	wrapErr := func(err error) error {
		if cm.proxyURL != nil {
			// Return a typed error, per Issue 16997
			return &net.OpError{Op: "proxyconnect", Net: "tcp", Err: err}
		}
		return err
	}
  
  // 判斷cm中使用的協議是否是https
	if cm.scheme() == "https" && t.DialTLS != nil {
		var err error
		pconn.conn, err = t.DialTLS("tcp", cm.addr())
		if err != nil {
			return nil, wrapErr(err)
		}
		if pconn.conn == nil {
			return nil, wrapErr(errors.New("net/http: Transport.DialTLS returned (nil, nil)"))
		}
		if tc, ok := pconn.conn.(*tls.Conn); ok {
			// Handshake here, in case DialTLS didn't. TLSNextProto below
			// depends on it for knowing the connection state.
			if trace != nil && trace.TLSHandshakeStart != nil {
				trace.TLSHandshakeStart()
			}
			if err := tc.Handshake(); err != nil {
				go pconn.conn.Close()
				if trace != nil && trace.TLSHandshakeDone != nil {
					trace.TLSHandshakeDone(tls.ConnectionState{}, err)
				}
				return nil, err
			}
			cs := tc.ConnectionState()
			if trace != nil && trace.TLSHandshakeDone != nil {
				trace.TLSHandshakeDone(cs, nil)
			}
			pconn.tlsState = &cs
		}
	} else {
    // 如果不是https協議就來到這裏,使用tcp向httpserver撥號,獲取一個tcp連接。
		conn, err := t.dial(ctx, "tcp", cm.addr())
		if err != nil {
			return nil, wrapErr(err)
		}
    // 將獲取到tcp連接交給我們的persistConn維護
		pconn.conn = conn
    
    // 處理https相關邏輯
		if cm.scheme() == "https" {
			var firstTLSHost string
			if firstTLSHost, _, err = net.SplitHostPort(cm.addr()); err != nil {
				return nil, wrapErr(err)
			}
			if err = pconn.addTLS(firstTLSHost, trace); err != nil {
				return nil, wrapErr(err)
			}
		}
	}

	// Proxy setup.
	switch {
  // 如果代理URL為空,不做任何處理  
	case cm.proxyURL == nil:
		// Do nothing. Not using a proxy.
  //   
	case cm.proxyURL.Scheme == "socks5":
		conn := pconn.conn
		d := socksNewDialer("tcp", conn.RemoteAddr().String())
		if u := cm.proxyURL.User; u != nil {
			auth := &socksUsernamePassword{
				Username: u.Username(),
			}
			auth.Password, _ = u.Password()
			d.AuthMethods = []socksAuthMethod{
				socksAuthMethodNotRequired,
				socksAuthMethodUsernamePassword,
			}
			d.Authenticate = auth.Authenticate
		}
		if _, err := d.DialWithConn(ctx, conn, "tcp", cm.targetAddr); err != nil {
			conn.Close()
			return nil, err
		}
	case cm.targetScheme == "http":
		pconn.isProxy = true
		if pa := cm.proxyAuth(); pa != "" {
			pconn.mutateHeaderFunc = func(h Header) {
				h.Set("Proxy-Authorization", pa)
			}
		}
	case cm.targetScheme == "https":
		conn := pconn.conn
		hdr := t.ProxyConnectHeader
		if hdr == nil {
			hdr = make(Header)
		}
		connectReq := &Request{
			Method: "CONNECT",
			URL:    &url.URL{Opaque: cm.targetAddr},
			Host:   cm.targetAddr,
			Header: hdr,
		}
		if pa := cm.proxyAuth(); pa != "" {
			connectReq.Header.Set("Proxy-Authorization", pa)
		}
		connectReq.Write(conn)

		// Read response.
		// Okay to use and discard buffered reader here, because
		// TLS server will not speak until spoken to.
		br := bufio.NewReader(conn)
		resp, err := ReadResponse(br, connectReq)
		if err != nil {
			conn.Close()
			return nil, err
		}
		if resp.StatusCode != 200 {
			f := strings.SplitN(resp.Status, " ", 2)
			conn.Close()
			if len(f) < 2 {
				return nil, errors.New("unknown status code")
			}
			return nil, errors.New(f[1])
		}
	}

	if cm.proxyURL != nil && cm.targetScheme == "https" {
		if err := pconn.addTLS(cm.tlsHost(), trace); err != nil {
			return nil, err
		}
	}

	if s := pconn.tlsState; s != nil && s.NegotiatedProtocolIsMutual && s.NegotiatedProtocol != "" {
		if next, ok := t.TLSNextProto[s.NegotiatedProtocol]; ok {
			return &persistConn{alt: next(cm.targetAddr, pconn.conn.(*tls.Conn))}, nil
		}
	}

	if t.MaxConnsPerHost > 0 {
		pconn.conn = &connCloseListener{Conn: pconn.conn, t: t, cmKey: pconn.cacheKey}
	}
  
  // 初始化persistConn的bufferReader和bufferWriter
	pconn.br = bufio.NewReader(pconn) // 可以從上面給pconn維護的tcpConn中讀數據
	pconn.bw = bufio.NewWriter(persistConnWriter{pconn})// 可以往上面pconn維護的tcpConn中寫數據 
  
  // 新開啟兩條和persistConn相關的go協程。
	go pconn.readLoop()
	go pconn.writeLoop()
	return pconn, nil
}

上面的兩條goroutine 和 br bw共同完成如下圖的流程

發送請求

發送req的邏輯在http包的下的tranport包中的func (t *Transport) roundTrip(req *Request) (*Response, error) {}函數中。

如下:

	// 發送treq
	resp, err = pconn.roundTrip(treq)

	// 跟進roundTrip
  // 可以看到他將一個writeRequest結構體類型的實例寫入了writech中
	// 而這個writech會被上圖中的writeLoop消費,藉助bufferWriter寫入tcp連接中,完成往服務端數據的發送。
	pc.writech <- writeRequest{req, writeErrCh, continueCh}

本站聲明:網站內容來源於博客園,如有侵權,請聯繫我們,我們將及時處理

【其他文章推薦】

網頁設計一頭霧水該從何著手呢? 台北網頁設計公司幫您輕鬆架站!

網頁設計公司推薦不同的風格,搶佔消費者視覺第一線

※Google地圖已可更新顯示潭子電動車充電站設置地點!!

※廣告預算用在刀口上,台北網頁設計公司幫您達到更多曝光效益

※別再煩惱如何寫文案,掌握八大原則!

在 Spring Boot 中使用 HikariCP 連接池

上次幫小王解決了如何在 Spring Boot 中使用 JDBC 連接 MySQL 后,我就一直在等,等他問我第三個問題,比如說如何在 Spring Boot 中使用 HikariCP 連接池。但我等了四天也沒有等到任何音訊,似乎他從我的世界里消失了,而我卻仍然沉醉在他拍我馬屁的美妙感覺里。

突然感覺,沒有小王的日子里,好空虛。怎麼辦呢?想來想去還是寫文章度日吧,积極創作的過程中,也許能夠擺脫對小王的苦苦思念。寫什麼好呢?

想來想去,就寫如何在 Spring Boot 中使用 HikariCP 連接池吧。畢竟實戰項目當中,肯定不能使用 JDBC,連接池是必須的。而 HikariCP 據說非常的快,快到 Spring Boot 2 默認的數據庫連接池也從 Tomcat 切換到了 HikariCP(喜新厭舊的臭毛病能不能改改)。

HikariCP 的 GitHub 地址如下:

https://github.com/brettwooldridge/HikariCP

目前星標 12K,被使用次數更是達到了 43.1K。再來看看它的自我介紹。

牛逼的不能行啊,原來 Hikari 來源於日語,“光”的意思,這意味着快得像光速一樣嗎?講真,看簡介的感覺就好像在和我的女神“湯唯”握手一樣刺激和震撼。

既然 Spring Boot 2 已經默認使用了 HikariCP,那麼使用起來也相當的輕鬆愜意,只需要簡單幾個步驟。

01、初始化 MySQL 數據庫

既然要連接 MySQL,那麼就需要先在電腦上安裝 MySQL 服務(本文暫且跳過),並且創建數據庫和表。

CREATE DATABASE `springbootdemo`;
DROP TABLE IF EXISTS `mysql_datasource`;
CREATE TABLE `mysql_datasource` (
  `id` varchar(64NOT NULL,
  PRIMARY KEY (`id`)
ENGINE=InnoDB DEFAULT CHARSET=utf8;

02、使用 Spring Initlallzr 創建 Spring Boot 項目

創建一個 Spring Boot 項目非常簡單,通過 Spring Initlallzr(https://start.spring.io/)就可以了。

勾選 Web、JDBC、MySQL Driver 等三個依賴。

1)Web 表明該項目是一個 Web 項目,便於我們直接通過 URL 來實操。

3)MySQL Driver:連接 MySQL 服務器的驅動器。

5)JDBC:Spring Boot 2 默認使用了 HikariCP,所以 HikariCP 會默認在 spring-boot-starter-jdbc 中附加依賴,因此不需要主動添加 HikariCP 的依賴。

PS:怎麼證明這一點呢?項目導入成功后,在 pom.xml 文件中,按住鼠標左鍵 + Ctrl 鍵訪問 spring-boot-starter-jdbc 依賴節點,可在 spring-boot-starter-jdbc.pom 文件中查看到 HikariCP 的依賴信息。

選項選擇完后,就可以點擊【Generate】按鈕生成一個初始化的 Spring Boot 項目了。生成的是一個壓縮包,導入到 IDE 的時候需要先解壓。

03、編輯 application.properties 文件

項目導入成功后,等待 Maven 下載依賴,完成后編輯 application.properties 文件,配置 MySQL 數據源信息。

spring.datasource.url=jdbc:mysql://127.0.0.1:3306/springbootdemo?useUnicode=true&characterEncoding=UTF-8&serverTimezone=UTC
spring.datasource.username=root
spring.datasource.password=123456

是不是有一種似曾相識的感覺(和[上一篇]()中的數據源配置一模一樣)?為什麼呢?答案已經告訴過大家了——默認、默認、默認,重要的事情說三遍,Spring Boot 2 默認使用了 HikariCP 連接池。

04、編輯 Spring Boot 項目

為了便於我們查看 HikariCP 的連接信息,我們對 SpringBootMysqlApplication 類進行編輯,增加以下內容。

@SpringBootApplication
public class HikariCpDemoApplication implements CommandLineRunner {
    @Autowired
    private DataSource dataSource;

    public static void main(String[] args) {
        SpringApplication.run(HikariCpDemoApplication.class, args);
    }

    @Override
    public void run(String... args) throws Exception {
        Connection conn = dataSource.getConnection();
        conn.close();
    }
}

HikariCpDemoApplication 實現了 CommandLineRunner 接口,該接口允許我們在項目啟動的時候加載一些數據或者做一些事情,比如說我們嘗試通過 DataSource 對象與數據源建立連接,這樣就可以在日誌信息中看到 HikariCP 的連接信息。CommandLineRunner 接口有一個方法需要實現,就是我們看到的 run() 方法。

通過 debug 的方式,我們可以看到,在項目運行的過程中,dataSource 這個 Bean 的類型為 HikariDataSource。

05、運行 Spring Boot 項目

接下來,我們直接運行 HikariCpDemoApplication 類,這樣一個 Spring Boot 項目就啟動成功了。

HikariDataSource 對象的連接信息會被打印出來。也就是說,HikariCP 連接池的配置啟用了。快給自己點個贊。

06、為什麼 Spring Boot 2.0 選擇 HikariCP 作為默認數據庫連接池

有幾種基準測試結果可用來比較HikariCP和其他連接池框架(例如c3p0dbcp2tomcatvibur)的性能。例如,HikariCP團隊發布了以下基準(可在此處獲得原始結果):

HikariCP 團隊為了證明自己性能最佳,特意找了幾個背景對比了下。不幸充當背景的有 c3p0、dbcp2、tomcat 等傳統的連接池。

從上圖中,我們能感受出背景的尷尬,HikariCP 鶴立雞群了。HikariCP 製作以如此優秀,原因大致有下面這些:

1)字節碼級別上的優化:要求編譯后的字節碼最少,這樣 CPU 緩存就可以加載更多的程序代碼。

HikariCP 優化前的代碼片段:

public final PreparedStatement prepareStatement(String sql, String[] columnNames) throws SQLException
{
    return PROXY_FACTORY.getProxyPreparedStatement(this, delegate.prepareStatement(sql, columnNames));
}

HikariCP 優化后的代碼片段:

public final PreparedStatement prepareStatement(String sql, String[] columnNames) throws SQLException
{
    return ProxyFactory.getProxyPreparedStatement(this, delegate.prepareStatement(sql, columnNames));
}

以上兩段代碼的差別只有一處,就是 ProxyFactory 替代了 PROXY_FACTORY,這個改動后的字節碼比優化前減少了 3 行指令。具體的分析參照 HikariCP 的 Wiki 文檔。

2)使用自定義的列表(FastStatementList)代替 ArrayList,可以避免 get() 的時候進行範圍檢查,remove() 的時候從頭到尾的掃描。

07、鳴謝

好了,各位讀者朋友們,答應小王的文章終於寫完了。能看到這裏的都是最優秀的程序員,升職加薪就是你了。如果覺得不過癮,還想看到更多,可以 star 二哥的 GitHub【itwanger.github.io】,本文已收錄。

PS:本文配套的源碼已上傳至 GitHub 【SpringBootDemo.SpringBootMysql】。

原創不易,如果覺得有點用的話,請不要吝嗇你手中點贊的權力;如果想要第一時間看到二哥更新的文章,請掃描下方的二維碼,關注沉默王二公眾號。我們下篇文章見!

本站聲明:網站內容來源於博客園,如有侵權,請聯繫我們,我們將及時處理

【其他文章推薦】

網頁設計公司推薦不同的風格,搶佔消費者視覺第一線

※廣告預算用在刀口上,台北網頁設計公司幫您達到更多曝光效益

※自行創業缺乏曝光? 網頁設計幫您第一時間規劃公司的形象門面

南投搬家公司費用需注意的眉眉角角,別等搬了再說!

新北清潔公司,居家、辦公、裝潢細清專業服務

STM32內存受限情況下攝像頭驅動方式與圖像裁剪的選擇

1、STM32圖像接收接口

使用stm32芯片,128kB RAM,512kB Rom,資源有限,接攝像頭採集圖像,這種情況下,內存利用制約程序設計。

STM32使用DCMI接口讀取攝像頭,協議如下。行同步信號指示了一行數據完成,場同步信號指示了一幀圖像傳輸完成。所以出現了兩種典型的數據接收方式,按照行信號一行一行處理,按照場信號一次接收一副圖像。

 

2、按行讀取

以網絡上流行的野火的demo為例,使用行中斷,用DMA來讀取一行數據。

//記錄傳輸了多少行
static uint16_t line_num =0;
//DMA傳輸完成中斷服務函數
void DMA2_Stream1_IRQHandler(void)
{
  if ( DMA_GetITStatus(DMA2_Stream1,DMA_IT_TCIF1) == SET )
  {
   /*行計數*/
  line_num++;
  if (line_num==img_height)
  {
  /*傳輸完一幀,計數複位*/
  line_num=0;
  }
  /*DMA 一行一行傳輸*/
  OV2640_DMA_Config(FSMC_LCD_ADDRESS+(lcd_width*2*(lcd_height-line_num-1)),img_width*2/4);
  DMA_ClearITPendingBit(DMA2_Stream1,DMA_IT_TCIF1);
  }
}

 //幀中斷服務函數,使用幀中斷重置line_num,可防止有時掉數據的時候DMA傳送行數出現偏移
void DCMI_IRQHandler(void)
{
  if ( DCMI_GetITStatus (DCMI_IT_FRAME) == SET )
  {
  /*傳輸完一幀,計數複位*/
  line_num=0;
  DCMI_ClearITPendingBit(DCMI_IT_FRAME);
  }
}

DMA中斷服務函數中主要是使用了一個靜態變量line_num來記錄已傳輸了多少行數據,每進一次DMA中斷時自加1,由於進入一次中斷就代表傳輸完一行數據,所以line_num的值等於lcd_height時(攝像頭輸出的數據行數),表示傳輸完一幀圖像,line_num複位為0,開始另一幀數據的傳輸。line_num計數完畢后利用前面定義的OV2640_DMA_Config函數配置新的一行DMA數據傳輸,它利用line_num變量計算顯存地址的行偏移,控制DCMI數據被傳送到正確的位置,每次傳輸的都是一行像素的數據量。

當DCMI接口檢測到攝像頭傳輸的幀同步信號時,會進入DCMI_IRQHandler中斷服務函數,在這個函數中不管line_num原來的值是什麼,它都把line_num直接複位為0,這樣下次再進入DMA中斷服務函數的時候,它會開始新一幀數據的傳輸。這樣可以利用DCMI的硬件同步信號,而不只是依靠DMA自己的傳輸計數,這樣可以避免有時STM32內部DMA傳輸受到阻塞而跟不上外部攝像頭信號導致的數據錯誤。

圖像按幀讀取比按行讀取效率更高,那麼為什麼要按行讀取呢?上面的例子是把圖像送到LCD,如果是送到內存,按幀讀取就需要芯片有很大的內存空間。以752*480的分辨率為例,需要360kB的RAM空間,遠遠超出了芯片RAM的大小。部分應用不需要攝像頭全尺寸的圖像,只需要中心區域,比如為了避免畸變影響一般只用圖像中間的部分,那麼按行讀取就有一個好處,讀到一行后,可以把不需要的丟棄,只保留中間部分的圖像像素。

那麼問題來了?為什麼不直接配置攝像頭的屬性,來實現只讀取圖像的中間部分呢,全部讀取出來然後在arm的內存中裁剪丟棄不要的像素,第一浪費了讀取時間,第二浪費了讀取的空間。更優的做法是直接配置攝像頭sensor,使用sensor的裁剪功能輸出需要的像素區域。

 

3、圖像裁剪–使用STM32 crop功能裁剪

STM32F4系列的DCMI接口支持裁剪功能,對攝像頭輸出的像素點進行截取,不需要的像素部分不被DCMI傳入內存,從硬件接口一側就丟棄了。

HAL_DCMI_EnableCrop(&hdcmi);
HAL_DCMI_ConfigCrop(&hdcmi, CAM_ROW_OFFSET, CAM_COL_OFFSET, IMG_ROW-1, IMG_COL-1);

裁剪的本質如下所述,從接收到的數據里選擇需要的矩形區域。所以STM32 DCMI裁剪功能可以完成節約內存,只選取需要的圖像存入內存的作用。

此方法相比於一次讀一行,然後丟棄首尾部分后把需要的區域圖像像素存入buffer后再讀下一行,避免了時序錯誤,代碼簡潔了,DCMI硬件計數丟掉不要的像素,也提高了程序可靠性、可讀性。

成也蕭何敗也蕭何,如上面所述,STM32的crop完成了選取特定區域圖像的功能,那麼也要付出代價,它是從接收到的圖像數據里進行選擇的,這意味着那些不需要的數據依然會傳輸到MCU一側,只不過MCU的DCMI對數據進行計數是忽略了它而已,那麼問題就來了,哪些不需要的數據的傳輸會帶來什麼問題呢?

有圖為證,下圖是使用了STM32 crop裁剪的時序圖,通道1啟動採集IO置高,frame中斷里拉低,由於使用dma傳輸,那麼被crop裁剪后dma計數的數據量變少,所以DCMI frame中斷能在行數據傳輸完成前到達,通道1高電平部分就代表一有效分辨率的幀的採集時間。通道2 曝光信號管腳,通道3是行掃描信號。其中通道1下降沿到通道3下降沿4.5ms。代表單片機已經收到crop指定尺寸的圖像,採集有效區域(crop區域)的圖像完成,但是line信號沒有結束還有很多行沒傳輸,即CMOS和DCMI接口要傳輸752*480圖像還沒完成。

 舉例說明,如果使用752*480分辨率採集圖像,你只取中間的360*360視野,有效分辨率是360*360,但是總線上的數據依然是752*480,所以幀率無法提高,多餘的數據按說就不應該傳輸出來,如何破解,問題追到這裏,STM32芯片已經無能為力了,接下來需要在CMOS一側發力了。

 

4、圖像裁剪–配置CMOS寄存器裁剪

下圖是MT9V034 攝像頭芯片的寄存器手冊,Reg1–4配置CMOS的行列起點和寬度高度。

修改寄存器后,攝像頭CMOS就不再向外傳輸多餘的數據,被裁剪丟棄的數據也不會反應在接口上,所以STM32 DCMI接收到的數據都是需要保留的有效區數據,極大地減少了數據輸出,提高了傳輸效率。本人也在STM324芯片上,實現了220*220分辨率120幀的連續採集。

下面是序圖,通道1高電平代表開始採集和一幀結束,不同於使用STM32 的crop裁剪,使用CMOS寄存器裁剪有效窗口,使得幀結束時行信號也同時結束,後續沒有任何需要傳輸的行數據。

 

5、一幀數據一次性傳輸

一幀數據一次全部讀入到MCU的方式,其實是最簡單的驅動編寫方式,缺點就是太占內存,但是對於沒有壓縮功能的cmos芯片來說,一般都無力實現。對部分有jpg壓縮功能的cmos芯片而言,比如OV2640可以使用這種方式,一次性讀出一幀圖像。

__align(4) u32 jpeg_buf[jpeg_buf_size];    //JPEG buffer
//JPEG 格式
const u16 jpeg_img_size_tbl[][2]=
{
    176,144,    //QCIF
    160,120,    //QQVGA
    352,288,    //CIF
    320,240,    //QVGA
    640,480,    //VGA
    800,600,    //SVGA
    1024,768,    //XGA
    1280,1024,    //SXGA
    1600,1200,    //UXGA
}; 

//DCMI 接收數據
void DCMI_IRQHandler(void)
{
  if(DCMI_GetITStatus(DCMI_IT_FRAME)==SET)// 一幀數據
  {
    jpeg_data_process();  
    DCMI_ClearITPendingBit(DCMI_IT_FRAME); 
  }
}

 

本站聲明:網站內容來源於博客園,如有侵權,請聯繫我們,我們將及時處理

【其他文章推薦】

※自行創業缺乏曝光? 網頁設計幫您第一時間規劃公司的形象門面

網頁設計一頭霧水該從何著手呢? 台北網頁設計公司幫您輕鬆架站!

※想知道最厲害的網頁設計公司"嚨底家"!

※幫你省時又省力,新北清潔一流服務好口碑

※別再煩惱如何寫文案,掌握八大原則!

HTML&CSS面試高頻考點(二)

HTML&CSS面試高頻考點(一)    

6. W3C盒模型與怪異盒模型

  • 標準盒模型(W3C標準)
  • 怪異盒模型(IE標準)

怪異盒模型下盒子的大小=width(content + border + padding) + margin,即真實大小

*參考標準模式與兼容模式的區別,兼容模式下為怪異盒模型。

*注意box-sizing可以改變盒模型(box-sizing:border-box即為怪異盒模型)。

7. 水平垂直居中的方法

(1)定寬居中

1. absolute + 負margin

//父元素
position: relative;
//子元素
position: absolute;
top: 50%;
left: 50%;
//margin設置自身一半的距離
margin-top: -50px;
margin-left: -50px;

2. absolute + margin: auto

//父元素
position: relative;
//子元素
position: absolute;
top: 0;
right: 0;
bottom: 0;
left: 0;
margin: auto;

 3. absolute + calc

//父元素
position: relative;
//子元素
position: absolute;
//減去自身一半的寬高
top: calc(50% - 50px);
left: calc(50% - 50px);

 *calc() 函數用於動態計算長度值。

 4. min-height: 100vh + flex + margin:auto

main{   min-height: 100vh;
   /* vh相對於視窗的高度,視窗高度是100vh */
  /* “視區”所指為瀏覽器內部的可視區域大小,   不包含任務欄標題欄以及底部工具欄的瀏覽器區域大小。 */   display: flex;
} div{   margin: auto;
}

(2)不定寬居中

1. absolute + transform

//父元素
position: relative;
//子元素
position: absolute;
top:50%;
left:50%;
transform:translate(-50%,-50%);

2. line-height

//父元素 .wp { text-align: center; line-height: 300px;
}
//子元素
.box { display: inline-block; vertical-align: middle; line-height: inherit; text-align: left; }

3. flex布局

display: flex;//flex布局
justify-content: center;//使子項目水平居中
align-items: center;//使子項目垂直居中

4. table-cell布局

因為table-cell相當與表格的td,無法設置寬和高,所以嵌套一層,嵌套一層必須設置display: inline-block

<div class="box">
    <div class="content">
        <div class="inner">
        </div>
    </div>
</div> .box { //只有這裏可以設置寬高 display: table; //這是嵌套的一層,會被table-cell覆蓋 } .content { display: table-cell; vertical-align: middle;//使子元素垂直居中 text-align: center;//使子元素水平居中 } .inner { display: inline-block; //子元素 }

8. BFC

 前文鏈接:點擊這裏

BFC:Block formatting context(塊級格式化上下文),是一個獨立的渲染區域,只有Block-level box參与,與外部區域毫不相干。

  • block-level box:display屬性為block, list-item, table的元素。
  • inline-level box:display屬性為inline, inline-box, inline-table的元素。

(1)BFC的布局規則

  • 內部box在垂直方向一個個放置;
  • 同一個BFC的兩個相鄰box的margin會發生重疊;
  • 每個盒子的margin左邊與包含塊的border左邊相接觸,即使存在浮動也是如此;
  • BFC區域不會和float box重疊;
  • 計算BFC高度時,浮動元素也參与計算。

(2)開啟BFC的方法

  • float的值不是none
  • position的值不是static或relative
  • display的值是inline-block, table-cell, flex, table-caption或inline-flex
  • overflow的值不是visible

(3)BFC的作用

1. 避免margin塌陷

根據BFC的布局規則2,我們可以通過設置兩個不同的BFC的方式解決margin塌陷的問題。

2. 自適應兩欄布局

根據BFC的布局規則3和4,我們將右側div開啟BFC就可以形成自適應兩欄布局。

.left { float: left; //左側浮動 }

.left { float: left;
} .right { overflow: hidden; //開啟BFC }

3. 清除浮動

當不給父節點設置高度的時候,如果子節點設置浮動,父節點會發生高度塌陷。這個時候就要清除浮動。

根據規則5,只需給父元素激活BFC就可以達到目的。

.par { overflow: hidden; //父元素開啟BFC } .child { float: left; //子元素浮動 }

9. 清除浮動

 這篇有寫:點這裏

10. position屬性

 這篇有寫:點這裏

本站聲明:網站內容來源於博客園,如有侵權,請聯繫我們,我們將及時處理

【【其他文章推薦】

※帶您來了解什麼是 USB CONNECTOR  ?

※自行創業缺乏曝光? 網頁設計幫您第一時間規劃公司的形象門面

※如何讓商品強力曝光呢? 網頁設計公司幫您建置最吸引人的網站,提高曝光率!

※綠能、環保無空污,成為電動車最新代名詞,目前市場使用率逐漸普及化

※廣告預算用在刀口上,台北網頁設計公司幫您達到更多曝光效益

※教你寫出一流的銷售文案?