Go是一门通用的编程语言,想要学习 Go 语言的 Web 开发,就必须知道如何用 Go 启动一个 HTTP 服务器用于接收和响应来自客户端的 HTTP 请求。用 Go实现一个http server非常容易,Go 语言标准库net/http自带了一系列结构和方法来帮助开发者简化 HTTP 服务开发的相关流程。因此,我们不需要依赖任何第三方组件就能构建并启动一个高并发的 HTTP 服务器。这篇文章会学习如何用net/http自己编写实现一个HTTP Serve并探究其实现原理,以此来学习了解网络编程的常见范式以及设计思路。

HTTP 服务处理流程

基于HTTP构建的服务标准模型包括两个端,客户端(Client)和服务端(Server)。HTTP 请求从客户端发出,服务端接受到请求后进行处理然后将响应返回给客户端。所以http服务器的工作就在于如何接受来自客户端的请求,并向客户端返回响应。

典型的 HTTP 服务的处理流程如下图所示:

服务器在接收到请求时,首先会进入路由(router),也成为服务复用器(Multiplexe),路由的工作在于请求找到对应的处理器(handler),处理器对接收到的请求进行相应处理后构建响应并返回给客户端。Go实现的http server同样遵循这样的处理流程。

我们先看看Go如何实现一个简单的返回 "Hello World" 的http server

package main

import (
"fmt"
"net/http"
) func HelloHandler(w http.ResponseWriter, r *http.Request) {
fmt.Fprintf(w, "Hello World")
} func main () {
http.HandleFunc("/", HelloHandler)
http.ListenAndServe(":8000", nil)
}

运行代码之后,在浏览器中打开localhost:8000就可以看到Hello World。这段代码先利用http.HandleFunc在根路由/上注册了一个HelloHandler, 然后利用http.ListenAndServe启动服务器并监听本地的 8000 端口。当有请求过来时,则根据路由执行对应的handler函数。

我们再看一下另外一种常见的实现方式:

package main

import (
"fmt"
"net/http"
) type HelloHandlerStruct struct {
content string
} func (handler *HelloHandlerStruct) ServeHTTP(w http.ResponseWriter, r *http.Request) {
fmt.Fprintf(w, handler.content)
} func main() {
http.Handle("/", &HelloHandlerStruct{content: "Hello World"})
http.ListenAndServe(":8000", nil)
}

这段代码不再使用 http.HandleFunc 函数,取而代之的是直接调用 http.Handle 并传入我们自定义的 http.Handler 接口的实例。

Go实现的http服务步骤非常简单,首先注册路由,然后创建服务并开启监听即可。下文我们将从注册路由、开启服务、处理请求,以及关闭服务这几个步骤了解Go如何实现http服务。

路由注册

http.HandleFunchttp.Handle都是用于给路由规则指定处理器,http.HandleFunc的第一个参数为路由的匹配规则(pattern)第二个参数是一个签名为func(w http.ResponseWriter, r *http.Requests)的函数。而http.Handle的第二个参数为实现了http.Handler接口的类型的实例。

http.HandleFunchttp.Handle的源码如下:

func HandleFunc(pattern string, handler func(ResponseWriter, *Request)) {
DefaultServeMux.HandleFunc(pattern, 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))
} func Handle(pattern string, handler Handler) {
DefaultServeMux.Handle(pattern, handler)
}

可以看到这两个函数最终都由DefaultServeMux调用Handle方法来完成路由处理器的注册。
这里我们遇到两种类型的对象:ServeMuxHandler

Handler

http.Handler 是net/http中定义的接口用来表示 HTTP 请求:

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

Handler接口中声明了名为ServeHTTP的函数签名,也就是说任何结构只要实现了这个ServeHTTP方法,那么这个结构体就是一个Handler对象。其实go的http服务都是基于Handler进行处理,而Handler对象的ServeHTTP方法会读取Request进行逻辑处理然后向ResponseWriter中写入响应的头部信息和响应内容。

回到上面的HandleFunc函数,它调用了*ServeMux.HandleFunc将处理器注册到指定路由规则上:

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

注意一下这行代码:

mux.Handle(pattern, HandlerFunc(handler))

这里HandlerFunc实际上是将handler函数做了一个类型转换,将函数转换为了http.HandlerFunc类型(注意:注册路由时调用的是 http.HandleFunc,这里类型是http.HandlerFunc)。看一下HandlerFunc的定义:

type HandlerFunc func(ResponseWriter, *Request)

// ServeHTTP calls f(w, r).
func (f HandlerFunc) ServeHTTP(w ResponseWriter, r *Request) {
f(w, r)
}

HandlerFunc类型表示的是一个具有func(ResponseWriter, *Request)签名的函数类型,并且这种类型实现了ServeHTTP方法(在其实现的ServeHTTP方法中又调用了被转换的函数自身)。也就是说这个类型的函数其实就是一个Handler类型的对象。利用这种类型转换,我们可以将将具有func(ResponseWriter, *Request)签名的普通函数转换为一个Handler对象,而不需要定义一个结构体,再让这个结构实现ServeHTTP方法。

ServeMux(服务复用器)

上面的代码中可以看到不论是使用http.HandleFunc还是http.Handle注册路由的处理函数时最后都会用到ServerMux结构的Handle方法去注册路由处理函数。

我们先来看一下ServeMux的定义:

type ServeMux struct {
mu sync.RWMutex
m map[string]muxEntry
es []muxEntry // slice of entries sorted from longest to shortest.
hosts bool // whether any patterns contain hostnames
} type muxEntry struct {
h Handler
pattern string
}

ServeMux中的字段m,是一个mapkey是路由表达式,value是一个muxEntry结构,muxEntry结构体存储了路由表达式和对应的handler。字段m对应的 map用于路由的精确匹配而es字段的slice会用于路由的部分匹配,这个到了路由匹配部分再细讲。

ServeMux也实现了ServeHTTP方法:

func (mux *ServeMux) ServeHTTP(w ResponseWriter, r *Request) {
if r.RequestURI == "*" {
if r.ProtoAtLeast(1, 1) {
w.Header().Set("Connection", "close")
}
w.WriteHeader(StatusBadRequest)
return
}
h, _ := mux.Handler(r)
h.ServeHTTP(w, r)
}

也就是说ServeMux结构体也是Handler对象,只不过ServeMuxServeHTTP方法不是用来处理具体的request和构建response,而是用来通过路由查找对应的路由处理器Handler对象,再去调用路由处理器的ServeHTTP 方法去处理request和构建reponse

注册路由

搞明白HandlerServeMux之后,我们再回到之前的代码:

DefaultServeMux.Handle(pattern, handler)

这里的DefaultServeMux表示一个默认的ServeMux实例,在上面的例子中我们没有创建自定义的ServeMux,所以会自动使用DefaultServeMux

然后再看一下ServeMuxHandle方法是怎么注册路由的处理函数的:

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")
}
// 路由已经注册过处理器函数,直接panic
if _, exist := mux.m[pattern]; exist {
panic("http: multiple registrations for " + pattern)
} if mux.m == nil {
mux.m = make(map[string]muxEntry)
}
// 用路由的pattern和处理函数创建 muxEntry 对象
e := muxEntry{h: handler, pattern: pattern}
// 向ServeMux的m 字段增加新的路由匹配规则
mux.m[pattern] = e
if pattern[len(pattern)-1] == '/' {
// 如果路由patterm以'/'结尾,则将对应的muxEntry对象加入到[]muxEntry中,路由长的位于切片的前面
mux.es = appendSorted(mux.es, e)
} if pattern[0] != '/' {
mux.hosts = true
}
}

Handle方法注册路由时主要做了两件事情:一个就是向ServeMuxmap[string]muxEntry增加给定的路由匹配规则;然后如果路由表达式以'/'结尾,则将对应的muxEntry对象加入到[]muxEntry中,按照路由表达式长度倒序排列。前者用于路由精确匹配,后者用于部分匹配,具体怎么匹配的后面再看。

自定义 ServeMux

通过http.NewServeMux()可以创建一个ServeMux实例取代默认的DefaultServeMux

我们把上面输出Hello World的 http server再次改造一下,使用自定义的 ServeMux实例作为ListenAndServe()方法的第二个参数,并且增加一个/welcome路由(下面的代码主要是展示用Handle 和 HandleFunc 注册路由,实际使用的时候不必这么麻烦,选一种就好):

package main

import (
"fmt"
"net/http"
) type WelcomeHandlerStruct struct { } func HelloHandler(w http.ResponseWriter, r *http.Request) {
fmt.Fprintf(w, "Hello World")
} func (*WelcomeHandlerStruct) ServeHTTP(w http.ResponseWriter, r *http.Request) {
fmt.Fprintf(w, "Welcome")
} func main () {
mux := http.NewServeMux()
mux.HandleFunc("/", HelloHandler)
mux.Handle("/welcome", &WelcomeHandlerStruct{})
http.ListenAndServe(":8080", mux)
}

之前提到ServeMux也实现了ServeHTTP方法,因此mux也是一个Handler对象。对于ListenAndServe()方法,如果第二个参数是自定义ServeMux实例,那么Server实例接收到的ServeMux服务复用器对象将不再是DefaultServeMux而是mux

启动服务

路由注册完成后,使用http.ListenAndServe方法就能启动服务器开始监听指定端口过来的请求。

func ListenAndServe(addr string, handler Handler) error {
server := &Server{Addr: addr, Handler: handler}
return server.ListenAndServe()
} func (srv *Server) ListenAndServe() error {
if srv.shuttingDown() {
return ErrServerClosed
}
addr := srv.Addr
if addr == "" {
addr = ":http"
}
ln, err := net.Listen("tcp", addr)
if err != nil {
return err
}
return srv.Serve(tcpKeepAliveListener{ln.(*net.TCPListener)})
}

这先创建了一个Server对象,传入了地址和handler参数(这里的handler参数时 ServeMux 实例),然后调用Server对象ListenAndServe()方法。

Server(服务器对象)

先看一下Server这个结构体的定义,字段比较多,可以先大致了解一下:

type Server struct {
Addr string // TCP address to listen on, ":http" if empty
Handler Handler // handler to invoke, http.DefaultServeMux if nil
TLSConfig *tls.Config
ReadTimeout time.Duration
ReadHeaderTimeout time.Duration
WriteTimeout time.Duration
IdleTimeout time.Duration
MaxHeaderBytes int
TLSNextProto map[string]func(*Server, *tls.Conn, Handler)
ConnState func(net.Conn, ConnState)
ErrorLog *log.Logger disableKeepAlives int32 // accessed atomically.
inShutdown int32
nextProtoOnce sync.Once
nextProtoErr error mu sync.Mutex
listeners map[*net.Listener]struct{}
activeConn map[*conn]struct{}// 活跃连接
doneChan chan struct{}
onShutdown []func()
}

ServerListenAndServe方法中,会初始化监听地址Addr,同时调用Listen方法设置监听。最后将监听的TCP对象传入其Serve方法。Server 对象的 Serve 方法会接收 Listener 中过来的连接,为每个连接创建一个goroutine,在goroutine 中会用路由处理 Handler 对请求进行处理并构建响应。

func (srv *Server) Serve(l net.Listener) error {
......
   baseCtx := context.Background() // base is always background, per Issue 16220 
ctx := context.WithValue(baseCtx, ServerContextKey, srv)
for {
rw, e := l.Accept()// 接收 listener 过来的网络连接请求
......
c := srv.newConn(rw)
c.setState(c.rwc, StateNew) // 将连接放在 Server.activeConn这个 map 中
go c.serve(ctx)// 创建协程处理请求
}
}

这里隐去了一些细节,以便了解Serve方法的主要逻辑。首先创建一个上下文对象,然后调用ListenerAccept()接收监听到的网络连接;一旦有新的连接建立,则调用ServernewConn()创建新的连接对象,并将连接的状态标志为StateNew,然后开启一个goroutine处理连接请求。

处理连接

在开启的 goroutineconnserve()会进行路由匹配找到路由处理函数然后调用处理函数。这个方法很长,我们保留关键逻辑。

func (c *conn) serve(ctx context.Context) {

    ...

    for {
w, err := c.readRequest(ctx)
if c.r.remain != c.server.initialReadLimitSize() {
// If we read any bytes off the wire, we're active.
c.setState(c.rwc, StateActive)
} ...
serverHandler{c.server}.ServeHTTP(w, w.req)
w.cancelCtx()
if c.hijacked() {
return
}
w.finishRequest()
if !w.shouldReuseConnection() {
if w.requestBodyLimitHit || w.closedRequestBodyEarly() {
c.closeWriteAndWait()
}
return
}
c.setState(c.rwc, StateIdle)
c.curReq.Store((*response)(nil)) ...
}
}

当一个连接建立之后,该连接中所有的请求都将在这个协程中进行处理,直到连接被关闭。在serve()方法中会循环调用readRequest()方法读取下一个请求进行处理,其中最关键的逻辑是下面行代码:

serverHandler{c.server}.ServeHTTP(w, w.req)

serverHandler是一个结构体类型,它会代理Server对象:

type serverHandler struct {
srv *Server
} func (sh serverHandler) ServeHTTP(rw ResponseWriter, req *Request) {
handler := sh.srv.Handler
if handler == nil {
handler = DefaultServeMux
}
if req.RequestURI == "*" && req.Method == "OPTIONS" {
handler = globalOptionsHandler{}
}
handler.ServeHTTP(rw, req)
}

serverHandler实现的ServeHTTP()方法里的sh.srv.Handler就是我们最初在http.ListenAndServe()中传入的Handler参数,也就是我们自定义的ServeMux对象。如果该Handler对象为nil,则会使用默认的DefaultServeMux。最后调用ServeMuxServeHTTP()方法匹配当前路由对应的handler方法。

func (mux *ServeMux) ServeHTTP(w ResponseWriter, r *Request) {
if r.RequestURI == "*" {
if r.ProtoAtLeast(1, 1) {
w.Header().Set("Connection", "close")
}
w.WriteHeader(StatusBadRequest)
return
}
h, _ := mux.Handler(r)
h.ServeHTTP(w, r)
} func (mux *ServeMux) Handler(r *Request) (h Handler, pattern string) { if r.Method == "CONNECT" {
if u, ok := mux.redirectToPathSlash(r.URL.Host, r.URL.Path, r.URL); ok {
return RedirectHandler(u.String(), StatusMovedPermanently), u.Path
} return mux.handler(r.Host, r.URL.Path)
} // All other requests have any port stripped and path cleaned
// before passing to mux.handler.
host := stripHostPort(r.Host)
path := cleanPath(r.URL.Path) // If the given path is /tree and its handler is not registered,
// redirect for /tree/.
if u, ok := mux.redirectToPathSlash(host, path, r.URL); ok {
return RedirectHandler(u.String(), StatusMovedPermanently), u.Path
} if path != r.URL.Path {
_, pattern = mux.handler(host, path)
url := *r.URL
url.Path = path
return RedirectHandler(url.String(), StatusMovedPermanently), pattern
} return mux.handler(host, r.URL.Path)
} // handler is the main implementation of Handler.
// The path is known to be in canonical form, except for CONNECT methods.
func (mux *ServeMux) handler(host, path string) (h Handler, pattern string) {
mux.mu.RLock()
defer mux.mu.RUnlock() // Host-specific pattern takes precedence over generic ones
if mux.hosts {
h, pattern = mux.match(host + path)
}
if h == nil {
h, pattern = mux.match(path)
}
if h == nil {
h, pattern = NotFoundHandler(), ""
}
return
} // Find a handler on a handler map given a path string.
// Most-specific (longest) pattern wins.
func (mux *ServeMux) match(path string) (h Handler, pattern string) {
// Check for exact match first.
v, ok := mux.m[path]
if ok {
return v.h, v.pattern
} // Check for longest valid match. mux.es contains all patterns
// that end in / sorted from longest to shortest.
for _, e := range mux.es {
if strings.HasPrefix(path, e.pattern) {
return e.h, e.pattern
}
}
return nil, ""
}

match方法里我们看到之前提到的mux的m字段(类型为map[string]muxEntry)和es(类型为[]muxEntry)。这个方法里首先会利用进行精确匹配,在map[string]muxEntry中查找是否有对应的路由规则存在;如果没有匹配的路由规则,则会利用es进行近似匹配。

之前提到在注册路由时会把以'/'结尾的路由(可称为节点路由)加入到es字段的[]muxEntry中。对于类似/path1/path2/path3这样的路由,如果不能找到精确匹配的路由规则,那么则会去匹配和当前路由最接近的已注册的父节点路由,所以如果路由/path1/path2/已注册,那么该路由会被匹配,否则继续匹配下一个父节点路由,直到根路由/

由于[]muxEntry中的muxEntry按照路由表达式从长到短排序,所以进行近似匹配时匹配到的节点路由一定是已注册父节点路由中最相近的。

查找到路由实际的处理器Handler对象返回给调用者ServerMux.ServeHTTP方法后,最后在方法里就会调用处理器HandlerServeHTTP方法处理请求、构建写入响应:

h.ServeHTTP(w, r)

实际上如果根据路由查找不到处理器Handler那么也会返回NotFoundHandler:

func NotFound(w ResponseWriter, r *Request) { Error(w, "404 page not found", StatusNotFound) }

func NotFoundHandler() Handler { return HandlerFunc(NotFound) }

这样标准统一,在调用 h.ServeHTTP(w, r)后则会想响应中写入 404 的错误信息。

停止服务

我们写的http server已经能监听网络连接、把请求路由到处理器函数处理请求并返回响应了,但是还需要能优雅的关停服务,在生产环境中,当需要更新服务端程序时需要重启服务,但此时可能有一部分请求进行到一半,如果强行中断这些请求可能会导致意外的结果。

从 Go 1.8 版本开始,net/http原生支持使用http.ShutDown来优雅的关停HTTP 服务。这种方案同样要求用户创建自定义的 http.Server 对象,因为Shutdown方法无法通过其它途径调用。

我们来看下面的代码,这段代码通过结合捕捉系统信号(Signal)、goroutine 和管道(Channel)来实现服务器的优雅停止:

package main

import (
"context"
"fmt"
"log"
"net/http"
"os"
"os/signal"
"syscall"
) func main() {
mux := http.NewServeMux()
mux.Handle("/", &helloHandler{}) server := &http.Server{
Addr: ":8081",
Handler: mux,
} // 创建系统信号接收器
done := make(chan os.Signal)
signal.Notify(done, os.Interrupt, syscall.SIGINT, syscall.SIGTERM)
go func() {
<-done if err := server.Shutdown(context.Background()); err != nil {
log.Fatal("Shutdown server:", err)
}
}() log.Println("Starting HTTP server...")
err := server.ListenAndServe()
if err != nil {
if err == http.ErrServerClosed {
log.Print("Server closed under request")
} else {
log.Fatal("Server closed unexpected")
}
}
} type helloHandler struct{} func (*helloHandler) ServeHTTP(w http.ResponseWriter, r *http.Request) {
fmt.Fprintf(w, "Hello World")
}

这段代码通过捕捉 os.Interrupt 信号(Ctrl+C)和syscall,SIGTERM信号(kill 进程时传递给进程的信号)然后调用 server.Shutdown 方法告知服务器应停止接受新的请求并在处理完当前已接受的请求后关闭服务器。为了与普通错误相区别,标准库提供了一个特定的错误类型 http.ErrServerClosed,我们可以在代码中通过判断是否为该错误类型来确定服务器是正常关闭的还是意外关闭的。

用Go 编写http server的流程就大致学习完了,当然要写出一个高性能的服务器还有很多要学习的地方,net/http标准库里还有很多结构和方法来完善http server,学会这些最基本的方法后再看其他Web 框架的代码时就清晰很多。甚至熟练了觉得框架用着太复杂也能自己封装一个HTTP 服务的脚手架(我用echo 和 gin 觉得还挺简单的,跟PHP 的Laravel框架比起来他们也就算个脚手架吧,没黑 PHP,关注我的用 Laravel 的小伙伴可别取关【哈哈哈...嗝】)

深入学习用 Go 编写 HTTP 服务器的更多相关文章

  1. 实战WEB 服务器(JAVA编写WEB服务器)

    实战WEB 服务器(JAVA编写WEB服务器) 标签: web服务服务器javawebsockethttp服务器 2010-04-21 17:09 11631人阅读 评论(24) 收藏 举报  分类: ...

  2. 学习用Node.js和Elasticsearch构建搜索引擎(6):实际项目中常用命令使用记录

    1.检测集群是否健康. curl -XGET 'localhost:9200/_cat/health?v' #后面加一个v表示让输出内容表格显示表头 绿色表示一切正常,黄色表示所有的数据可用但是部分副 ...

  3. 用 PHP 编写 http 服务器

    概述 众所周知,我们一般使用 PHP 开发Web程序时需要使用到比如Apache或Nginx等Web服务器来支持,那么有没有办法直接使用PHP开发HTTP服务器,答案当然是可以的,最近看了一遍Work ...

  4. 学习用MaxScipt批处理Max文件

    学习用MaxScipt批处理Max文件 需求 对几百个.max文件中的指定指定名称的骨骼进行重命名. 解决 考虑到是一次性需求,花了两个钟用maxscript实现了功能,基本逻辑把改名规则做成配置文本 ...

  5. 学习用5W1H来管理自己的项目/工作

    学习用5W1H来管理自己的项目/工作   最近开始需要系统化的思维模型,这只是一个开始,一下用脑图的形式来简介5W1H的具体内容: 先写xmind思维树的文本导出,后面附上图片.^ _ ^ 5W1H ...

  6. 从0系统学Android--3.1编写UI界面

    从0系统学Android--3.1编写UI界面 本系列文章目录:更多精品文章分类 本系列持续更新中.... 界面设计和功能开发同样重要,界面美观的应用程序不仅可以大大增加用户粘性,还能帮我们吸引到更多 ...

  7. 学习用CMake来编写Qt程序

    最近开始学习CMake,因为项目需求需要用到Qt,自带的qmake会出现许多问题(比如文件修改之后有时候qmake不会侦测到不会重新编译,需要手动去编译等),于是开始尝试使用CMake来编写Qt程序, ...

  8. 学习用node.js建立一个简单的web服务器

    一.建立简单的Web服务器涉及到Node.js的一些基本知识点: 1.请求模块 在Node.js中,系统提供了许多有用的模块(当然你也可以用JavaScript编写自己的模块,以后的章节我们将详细讲解 ...

  9. 【原创】通过搬瓦工vps搭建SS环境,供学习用

    博主前段时间处于某些不可告人的目的,但又限于各类科学工具被禁的窘境,用搬瓦工的vps搭建了次SS环境,现在就来回顾并不知廉耻的传授下经验. 第一步:购买vps 1.登录官网 https://bwh1. ...

随机推荐

  1. 利用git上传文件到github

    git add 文件名称/. "."代表全部 git commit -m -a git push -u origin master 推送到远程仓库 ---------------- ...

  2. HUPO|PSI|PeptideAtlas|TPP|Partial submission|Complete submission|proteomeXchange

    蛋白质组实验数据提交 需要共享数据,共享要求: 质谱实验数据 HUPO Proteomics Standards Initiative (http://www.psidev.info/overview ...

  3. emacs 入门第一课:Emacs里的基本概念

    Table of Contents 无聊的开场白 buffer(缓冲区) window(窗口)与frame Emacs的mode Emacs Lisp 函数function.命令command.键绑定 ...

  4. [LC] 674. Longest Continuous Increasing Subsequence

    Given an unsorted array of integers, find the length of longest continuous increasing subsequence (s ...

  5. linux debain systemd 开机启动 nodejs 兼容原initd启动 forever 开机自启

    布署环境为debian 7.5 布署到一台新机器,系统版本为debian 8.0 原启动项 /etc/init.d/mongo_service 开机居然无法自起 开机自启动不能用了,看页面输出就发觉不 ...

  6. C语言学习笔记之获取文件长度

    本文为原创文章,转载请标明出处 #include <stdio.h> #include <stdlib.h> int main() { FILE *inputFile; inp ...

  7. javasc-正则表达式

    匹配中文字符的正则表达式: [\u4e00-\u9fa5]评注:匹配中文还真是个头疼的事,有了这个表达式就好办了 匹配双字节字符(包括汉字在内):[^\x00-\xff]评注:可以用来计算字符串的长度 ...

  8. java对象POJO和JavaBean的区别

    "Plain Ordinary Java Object",简单普通的java对象.主要用来指代那些没有遵循特定的java对象模型,约定或者框架的对象.POJO的内在含义是指那些:有 ...

  9. MySQL远程访问失败的解决办法

    SQL连接预备知识:转载自https://jingyan.baidu.com/article/3ea51489e6cfbe52e61bba25.html问题:我想在另一个电脑通过navicat登陆本机 ...

  10. [css-animation-101] 8 multiple transitions

    原文地址:css animation 101 #multiple-transitions 原文作者:Donovan Hutchinson 译者:JobbyM 到目前为止,我们已经讨论了一个过渡如何在一 ...