代码
调试技术
数据库
- 《Getting started with impala》
- 《mysql 必知必会》
- 《mysql 性能调优与架构实践》
- 《Mysql 技术内幕 InnoDB 存储引擎》
- 《Redis 实战》
- 《Redis 深度历险核心原理和应用实践》
- 《redis设计与实现》
- 《七周七数据库》
- 《深入浅出mysql》
- 《高性能mysql第三版》
- 《MySQL是怎样运行的》
前端
GOLANG
- 《1 The Go Programming Language》
- 《2 The Go Programming Language》
- 《3 The Go Programming Language》
- 《Build Web Application With Golang》
- 《Go101》
- 《Network Programming with go》
- 《Building Microservices With Go》
- 《Building Restful Web Services with Go》
- 《Concurrency In Go》
- 《Go In Action(Go 实战)》
- 《Go学习笔记语言详解》
- 《Go学习笔记源码剖析》
- 《Go语言编程》
JAVA
网络
心理学
PYTHON
创业
UNIX/LINUX
分布式
系统设计
搜索引擎
开发工具
- 《Practical Vim》
- 《Vim8文本处理实战》
- 《Learn vim scrpt the hard way》
- 《Pro Git》
- 《Mastering Vim》
- 《Mastering Vim Quickly》
思维
源码
网站架构微服务
- 《微服务架构设计模式》
- 《从0开始学架构》
- 《web scalavility for startup engineers》
- 《designdatainstensive_application》
- 《designdatainstensive_application2》
- 《clean_architecture》
- 《微服务实战》
- 《微服务设计》
软件工程/项目管理
运维
金融理财
写作
互联网
区块链
技术演讲网课
- 《哔哩哔哩的go微服务实战》
- 《go业务基础库之Error&Context》
- 《Go同步和并发设计模式》
- 《300分钟吃透分布式缓存》
- 《DDD实战课》
- 《分布式技术原理与实战45讲》
- 《架构设计面试精讲》
- 《高并发系统设计40问》
- 《java并发编程78讲》
- 《中间件核心技术与实战讲》
职场
《Building Restful Web Services with Go》
《Building RESTful Web services with GO》 涉及面比较广,但是讲得太浅
1. Getting started with REST API Development
Live reloading the application with supervisord and gulp
2. Handling Routing for Rest Srevices
ServeMux a basic router in Go
go get github.com/julienschmidt/httprouter
// execService.go
// Package main provides ...
package main
import (
"bytes"
"fmt"
"log"
"net/http"
"os/exec"
"github.com/julienschmidt/httprouter"
)
func getCommandOutput(command string, arguments ...string) string {
cmd := exec.Command(command, arguments...) //unpack
var out bytes.Buffer
var stderr bytes.Buffer
cmd.Stdout = &out
cmd.Stderr = &stderr
err := cmd.Start()
if err != nil {
log.Fatal(fmt.Sprint(err) + ": " + stderr.String())
}
err = cmd.Wait()
if err != nil {
log.Fatal(fmt.Sprint(err) + ": " + stderr.String())
}
return out.String()
}
func goVersion(w http.ResponseWriter, r *http.Request, params httprouter.Params) {
fmt.Fprintf(w, getCommandOutput("/usr/local/bin/go", "version"))
}
func getFileContent(w http.ResponseWriter, r *http.Request, params httprouter.Params) {
fmt.Fprintf(w, getCommandOutput("/bin/cat", params.ByName("name")))
}
func main() {
router := httprouter.New()
router.GET("/api/v1/go-version", goVersion)
router.GET("/api/v1/show-file/:name", getFileContent)
log.Fatal(http.ListenAndServe(":8000", router))
}
Building the simple static file server
package main
import (
"log"
"net/http"
"github.com/julienschmidt/httprouter"
)
func main() {
router := httprouter.New()
router.ServeFiles("/static/*filepath", http.Dir("/users/naren/static"))
log.Fatal(http.ListenAndServe(":8000", router))
}
Gorilla Mux, a powerful HTTP router
- Path-based matching
- Query-based matching
- Domain-based matching
- Sub-domain based matching
- Reverse URL generation
3. Working with Middleware and RPC
What is middleware?
When a piece of code needs to be executed for every request or subset of HTTP requests.
Creating a basic middleware
// custommiddleware.go
package main
func middleware(handler http.handler) http.handler {
return http.handlerfunc(func(w http.responsewriter, r *http.request) {
fmt.println("executing middleware before request phase!")
//pass control back to handler
handler.servehttp(w, r)
fmt.println("executing middleware after response phase!")
})
}
func mainlogic(w http.responsewriter, r *http.request) {
// business logic here
fmt.println("executing mainhandler")
w.write([]byte("ok"))
}
func main() {
manlogichandler := http.handlerfunc(mainlogic)
http.handler("/", middleware(manlogichandler))
http.listenandserve(":8000", nil)
}
Multiple middleware and chaining
// cityAPI.go
package main
import (
"encoding/json"
"fmt"
"net/http"
)
type city struct {
Name string
Area uint64
}
func mainLogic(w http.ResponseWriter, r *http.Request) {
if r.Method == "POST" {
var tempCity city
decoder := json.NewDecoder(r.Body)
err := decoder.Decode(&tempCity)
if err != nil {
panic(err)
}
defer r.Body.Close()
// creat logic
fmt.Printf("%s %d", tempCity.Name, tempCity.Area)
// tell everything is fine
w.WriteHeader(http.StatusOK)
w.Write([]byte("201 - Created"))
} else {
w.WriteHeader(http.StatusMethodNotAllowed)
w.Write([]byte("405 - Method Not Allowed"))
}
}
func main() {
http.HandleFunc("/city", mainLogic)
http.ListenAndServe(":8000", nil)
}
抽象出来俩middleware:
package main
import (
"encoding/json"
"fmt"
"log"
"net/http"
"strconv"
"time"
)
type city struct {
name string
area uint64
}
// middleware to check content type as json
func filtercontenttype(handler http.handler) http.handler {
return http.handlerfunc(func(w http.responsewriter, r *http.request) {
log.println("currently in the check content type middleware")
// filtering request by mime type
if r.header.get("content-type") != "application/json" {
w.writeheader(http.statusunsupportedmediatype)
w.write([]byte("415 - unsupported media type. please send json"))
return
}
handler.servehttp(w, r)
})
}
// middle to add server timestamp for response cookie
func setservertimecookie(handler http.handler) http.handler {
return http.handlerfunc(func(w http.responsewriter, r *http.request) {
handler.servehttp(w, r)
cookie := http.cookie{name: "server-time(utc)", value: strconv.formatint(time.now().unix(), 10)}
http.setcookie(w, &cookie)
log.println("currently in the set server time middleware")
})
}
func mainlogic(w http.responsewriter, r *http.request) {
if r.method == "post" {
var tempcity city
decoder := json.newdecoder(r.body)
err := decoder.decode(&tempcity)
if err != nil {
panic(err)
}
defer r.body.close()
// creat logic
fmt.printf("%s %d", tempcity.name, tempcity.area)
// tell everything is fine
w.writeheader(http.statusok)
w.write([]byte("201 - created"))
} else {
w.writeheader(http.statusmethodnotallowed)
w.write([]byte("405 - method not allowed"))
}
}
func main() {
mainlogichnadler := http.handlerfunc(mainlogic)
http.handler("/city", filtercontenttype(setservertimecookie(mainlogichnadler)))
http.listenandserve(":8000", nil)
}
Paniless middleware chaining with Alice
//go get github.com/justinas/alice
func main() {
mainLogicHandler := http.HandlerFunc(mainLogic)
chain := alice.New(filterContentType, setServerTimeCookie).Then(mainLogicHandler)
http.Handle("/city", chain)
http.ListenAndServe(":8000", nil)
}
Using Gorilla's Handlers middleware for Logging
- LoggingHandler
- CompressingHandler: for zipping the responses
- RecoveryHandler: for recovering from unexpected panics
What is RPC?
"net/rpc"
JSON RPC using Gorilla RPC
4. Simplifying RESTful Services with Popular Go Frameworks
go-restful, a framework for REST API creation
package main
import (
"fmt"
"io"
"net/http"
"time"
"github.com/emicklei/go-restful"
)
// brew install sqlite3
// go get github.com/emicklei/go-restful
// a simple ping server echoes the server time back to the client
func main() {
webservice := new(restful.WebService)
webservice.Route(webservice.GET("/ping").To(pingTime))
restful.Add(webservice)
http.ListenAndServe(":8000", nil)
}
func pingTime(req *restful.Request, resp *restful.Response) {
io.WriteString(resp, fmt.Sprintf("%s", time.Now()))
}
// curl -X GET "http://localhost:8000/ping"
CRUD and SQLite3 basics
// sqliteFundamentals.go
package main
import (
"database/sql"
"log"
_ "github.com/mattn/go-sqlite3"
)
// go get github.com/mattn/go-sqlite3
type Book struct {
id int
name string
author string
}
func main() {
db, err := sql.Open("sqlite3", "./books.db")
log.Println(db)
if err != nil {
log.Println(err)
}
//create table
statement, err := db.Prepare("create table if not exists books(id integer primary key, isbn integer, author(varchar(64), name varchar(64) NULL)")
if err != nil {
log.Println("Error in creating table")
} else {
log.Println("success created table books")
}
statement.Exec()
// Create
statement, _ = db.Prepare("INSERT INTO books (name, author, isbn) VALUES (?, ?, ?)")
statement.Exec("A Tale of Two Cities", "Charles Dickens", 140430547)
log.Println("Inserted the book into database!")
// Read
rows, _ := db.Query("SELECT id, name, author FROM books")
var tempBook Book
for rows.Next() {
rows.Scan(&tempBook.id, &tempBook.name, &tempBook.author)
log.Printf("ID:%d, Book:%s, Author:%s\n", tempBook.id,
tempBook.name, tempBook.author)
}
// Update
statement, _ = db.Prepare("update books set name=? where id=?")
statement.Exec("The Tale of Two Cities", 1)
log.Println("Successfully updated the book in database!")
//Delete
statement, _ = db.Prepare("delete from books where id=?")
statement.Exec(1)
log.Println("Successfully deleted the book in database!")
}
// 略坑啊:
// This program runs on Windows and Linux without any problem. In Go versions less than 1.8.1, you may see problems on macOS X such as Signal Killed. This is because of the Xcode version; please keep this in mind.
Building a Metro Rail APi with go-restful
// go get github.com/emicklei/go-restful
Building RESTful APIs with the Gin framework
// go get github.com/gin-gonic/gin
// ginBasic.go
package main
import (
"time"
"github.com/gin-gonic/gin"
)
func main() {
r := gin.Default()
r.GET("/pingTime", func(c *gin.Context) {
c.JSON(200, gin.H{"serverTime": time.Now().UTC()})
})
r.Run(":8000")
}
Building a RESTful API with Revel.go
Like Python's Django.
go get github.com/revel/revel
5. Working with MongoDB and Go to Create REST APIs
Introducig mgo, a MongoDB deiver for Go
get get gopkg.in/mgo.v2
6. Wokring with Protocol Bufffers and GRPC
Protocol buffer language
- Scalar values, Enumerations, Defalut values, Nested values, Unknown types
syntax 'proto3';
message NetworkInterface {
int index=1;
int mtu=2;
string name=3;
string hardwareaddr=4;
}
the protocol buffer message will be converted to a Go struct and fiels are filled with empty defaut values.
Compiling a protocol buffer with protoc
install protobuf compiler on your meachine. brew install protobuf
GRPC
go get google.golang.org/grpc
go get -u github.com/golang/protobuf/protoc-gen-go
Grpc has the following benefits over HTTP/REST/JSON architecture:
- GPRC use http/2, which is a binary protocol
- Header compression is possible in http2
- we can multiplex many requests on one connection
- Usage of protobufs for strict typing of data
- streaming of requests or responses is possible instead of request/response transactions
Bidirectional streaming with GRPC
7. Working with PostgreSQL, JSON, and Go
install PostgreSQL
brew install postgresql && brew services start postgresql // port 5432 // use psql shell create new use and database and
pq, a pure PostgreSQL database driver for Go
go get github.com/lib/pq
Exploring thj JSON store in PostgreSQL
PostgreSQL > 9.2 has a feature called the JSON store.
GORM, a powerful ORM for GO
go get -u github.com/jinzhu/gorm
8. Building a REST API Client in Go and Unit Testing
Basics for writing a command-line tool in Go
// flagExample.go
package main
import (
"flag"
"log"
)
var name = flag.String("name", "stranger", "you wonderfule name")
func main() {
flag.Parse()
log.Printf("Hello %s", *name)
}
// ./flagExample -name laowang or ./flagExample -name=laowang
CLI - a library for building beautiful clients
go get github.com/urfave/cli
// cliBasci.go
package main
import (
"log"
"os"
"github.com/urfave/cli"
)
func main() {
app := cli.NewApp()
app.Flags = []cli.Flag{
cli.StringFlag{
Name: "name",
Value: "stranger",
Usage: "you wonderful name",
},
cli.IntFlag{
Name: "age",
Value: 0,
Usage: "your graceful age",
},
}
app.Action = func(c *cli.Context) error {
log.Printf("Hello %s (%d years), Welcome to the command line world", c.String("name"), c.Int("age"))
return nil
}
app.Run(os.Args)
}
Collecting command-line arguments in CLI
// storeMarks.go
package main
import (
"log"
"os"
"github.com/urfave/cli"
)
func main() {
app := cli.NewApp()
// define flags
app.Flags = []cli.Flag{
cli.StringFlag{
Name: "save",
Value: "no",
Usage: "Should save to database (yes/no)",
},
}
app.Version = "1.0"
// define action
app.Action = func(c *cli.Context) error {
var args []string
if c.NArg() > 0 {
// Fetch arguments in a array
args = c.Args()
personName := args[0]
marks := args[1:len(args)]
log.Println("Person: ", personName)
log.Println("marks", marks)
}
// check the flag value
if c.String("save") == "no" {
log.Println("Skipping saving to the database")
} else {
// Add database logic here
log.Println("Saving to the database", args)
}
return nil
}
app.Run(os.Args)
}
// ./storeMarks --save=yes Albert 89 85 97
grequests - a REST API package for GO
go get -u github.com/levigross/grequests
package main
import (
"log"
"github.com/levigross/grequests"
)
func main() {
resp, err := grequests.GET("http://httpbin.org/get", nil)
if err != nil {
log.Fatalln("Unabled to make requests: ", err)
}
log.Println(resp.String())
}
package main
import (
"log"
"github.com/levigross/grequests"
)
func main() {
resp, err := grequests.GET("http://httpbin.org/get", nil)
if err != nil {
log.Fatalln("Unabled to make requests: ", err)
}
log.Println(resp.String())
}
/*
type Response struct {
Ok bool
Error error
RawResponse *http.Response
StatusCode int
Header http.Header
}
*/
Getting comfortable with the GitHub REST API
// https://developer.github.com/v3/
// export GITHUB_TOKEN=YOUR_GITHUB_ACCESS_TOKEN
// getRepos.go
package main
import (
"github.com/levigross/grequests"
"log"
"os"
)
var GITHUB_TOKEN = os.Getenv("GITHUB_TOKEN")
var requestOptions = &grequests.RequestOptions{Auth: []string{GITHUB_TOKEN, "x-oauth-basic"}}
type Repo struct {
ID int `json:"id"`
Name string `json:"name"`
FullName string `json:"full_name"`
Forks int `json:"forks"`
Private bool `json:"private"`
}
func getStats(url string) *grequests.Response{
resp, err := grequests.Get(url, requestOptions)
// You can modify the request by passing an optional RequestOptions struct
if err != nil {
log.Fatalln("Unable to make request: ", err)
}
return resp
}
func main() {
var repos []Repo
var repoUrl = "https://api.github.com/users/torvalds/repos"
resp := getStats(repoUrl)
resp.JSON(&repos)
log.Println(repos)
}
Using Redis for caching the API data
go get github.com/go-redis/redis
Creating a unit testing tool for our URL shortening service
package main_test
import (
"net/http"
"testing"
)
func TestGetoOriginalURL(t *testing.T) {
response, err := http.Get("http://localhost:8000/v1/short/1")
if http.StatusOK != response.StatusCode {
t.Errorf("Expected response code %d. Got %d\n", http.StatusOK)
}
if err != nil {
t.Errorf("encoutered an error:", err)
}
}
9. Scaling Our REST API Using Microservices
The disadvantage list of a monolithic application could be:
- Tightly coupled architecture
- Single point of failure
- Velocity of adding new features and components
- Fragmentation of work is limited to teams
- Continuous deployment is very tough because an entire application needs to be pushed
Microservices bring the following benefits to the plate:
- If the team is big, people can work on chunks of applications
- Adaptability is easy for the new developers
- Adopting best practices, such as Continuous Integration (CI) and Continuous Delivery (CD)
- Easily replaceable software with loosely coupled architecture
Go Kit, a package for building microservices
go get github.com/go-kit/kit
10. Deplying Our REST srevices
proxy server
The benefits of having a proxy server (Nginx):
- It can act as a load balancer
- It can sit in front of cluster of applications and redirect HTTP requests
- It can serve a filesystem with a good performance
- It streams media very well
Load balancing with Nginx
- Round Robin
- Least Coonection: send to the server that is currently serving the least number of clients
- IP HASH
- Least Time: send to the machine with the lowest average latency time
Rate limiting our REST API
limit_conn_zone
11. Using an API Gateway to Monitor and Metricize REST API
They are authentication, logging, rate limiting, and so on. The best way to add those features is to use an API gateway.
Why is an API gateway required?
An api gateway is a fine piece of middleware does these things:
- Loggig
- Security
- Traffic control
- Transformations
Kong, an open-source API gateway
Kong is a cloud-native, fast, scalable, and distributed Microservice Abstraction Layer (also known as an API Gateway, API Middleware or in some cases Service Mesh). Made available as an open-source project in 2015, its core values are high performance and extensibility. Combination of OpenResty and Nginx.
12 Handling Authentication for Our REST Services
- session-based authentication
- JSON Web Tokens (JWT)
- Authentication2(OAuth2)
//go get github.com/gorilla/sessions
var store = sessions.NewCookieStore([]byte("secret_key"))
session-based authentication
package main
import (
"log"
"net/http"
"os"
"time"
"github.com/gorilla/mux"
"github.com/gorilla/sessions"
)
// os.Getenv return empty string "" if not exists key
var store = sessions.NewCookieStore([]byte(os.Getenv("SESSION_SECRET")))
var users = map[string]string{"naren": "passme", "admin": "password"}
func HealthcheckHandler(w http.ResponseWriter, r *http.Request) {
session, _ := store.Get(r, "session.id")
if (session.Values["authenticated"] != nil) && session.Values["authenticated"] != false {
w.Write([]byte(time.Now().String()))
} else {
http.Error(w, "Forbidden", http.StatusForbidden)
}
}
func LoginHandler(w http.ResponseWriter, r *http.Request) {
session, _ := store.Get(r, "session.id")
err := r.ParseForm()
if err != nil {
http.Error(w, "pass the data as URL form encoded", http.StatusBadRequest)
return
}
username := r.PostForm.Get("username")
password := r.PostForm.Get("password")
if originalPassword, ok := users[username]; ok {
if password == originalPassword {
session.Values["authenticated"] = true
session.Save(r, w) // save to response
} else {
http.Error(w, "Invalid credentials", http.StatusUnauthorized)
return
}
} else {
http.Error(w, "user is not found", http.StatusNotFound)
return
}
w.Write([]byte("Logged In success"))
}
func LogoutHandler(w http.ResponseWriter, r *http.Request) {
session, _ := store.Get(r, "session.id")
session.Values["authenticated"] = false
session.Save(r, w)
w.Write([]byte(""))
}
func main() {
r := mux.NewRouter()
r.HandleFunc("/login", LogoutHandler)
r.HandleFunc("/health", HealthcheckHandler)
r.HandleFunc("/logout", LogoutHandler)
http.Handle("/", r)
srv := &http.Server{
Handler: r,
Addr: "127.0.0.1:8000",
WriteTimeout: 15 * time.Second,
ReadTimeout: 15 * time.Second,
}
log.Fatal(srv.ListenAndServe())
}
# test py code
import requests
resp = requests.post("http://localhost:8000/login", data={"username": "admin", "password": "password"})
print(resp.text)
assert resp.status_code == 200
cookies = resp.cookies
resp = requests.get("http://localhost:8000/health", cookies=cookies)
print(resp.text)
assert resp.status_code == 200
JWT
Json web token format
- Create a JWT header by doing Base64Url encoding on the header JSON.
- Create a JWT payload by doing Base64Url encoding on the payload JSON.
- Create a signature by encrypting the appended header and payload using a secret key.
- JWT string can be obtained by appending the header, payload, and signature.
signature = HMACSHA256( base64UrlEncode(header) + "." + base64UrlEncode(payload), secret) tokenString = base64UrlEncode(header) + "." + base64UrlEncode(payload) + "." + signature
go get github.com/dgrijalva/jwt-go
Auth2
Authentication is the process of identifying whether a client is genuine or not. When a server authenticates a client, it checks the username/password pair and creates session cookie/JWT.
Authorization is the process of differentiating one client from another after a successful authentication. In cloud services, the resources requested by a client need to be served by checking that the resources belong to that client but not the other client. The permissions and access to resources vary for different clients. For example, the admin has the highest privileges of resources. A normal user's access is limited.
OAuth2 is a protocol for authenticating multiple clients to a service, whereas the JWT is a token format. We need to encode/decode JWT tokens to implement the second stage (dashed lines in the following screenshot) of OAuth 2.
package main
import (
"encoding/json"
"fmt"
"log"
"net/http"
"os"
"time"
jwt "github.com/dgrijalva/jwt-go"
"github.com/dgrijalva/jwt-go/request"
"github.com/gorilla/mux"
)
var secretKey = []byte(os.Getenv("SESSION_SECRET"))
// var secretKey = []byte("test")
var users = map[string]string{"naren": "passme", "admin": "password"}
// json response for jwt
type Response struct {
Token string `json:"token"`
Status string `json:"status"`
}
func HealthcheckHandler(w http.ResponseWriter, r *http.Request) {
tokenString, err := request.HeaderExtractor{"access_token"}.ExtractToken(r)
token, err := jwt.Parse(tokenString, func(token *jwt.Token) (interface{}, error) {
// Don't forget to validate the alg is what you expect:
if _, ok := token.Method.(*jwt.SigningMethodHMAC); !ok {
return nil, fmt.Errorf("Unexpected signing method: %v", token.Header["alg"])
}
// hmacSampleSecret is a
return secretKey, nil
})
if err != nil {
w.WriteHeader(http.StatusForbidden)
w.Write([]byte("Access Denied; Please check access token"))
return
}
if claims, ok := token.Claims.(jwt.MapClaims); ok && token.Valid {
// If token is valid
response := make(map[string]string)
// response["user"] = claims["username"]
response["time"] = time.Now().String()
response["user"] = claims["username"].(string) // claims value is interface, convert to string
responseJSON, _ := json.Marshal(response)
w.Write(responseJSON)
} else {
w.WriteHeader(http.StatusForbidden)
w.Write([]byte(err.Error()))
}
}
// LoginHandler validates the user credentials
func getTokenHandler(w http.ResponseWriter, r *http.Request) {
err := r.ParseForm()
if err != nil {
http.Error(w, "Please pass the data as URL form encoded", http.StatusBadRequest)
return
}
username := r.PostForm.Get("username")
password := r.PostForm.Get("password")
if originalPassword, ok := users[username]; ok {
if password == originalPassword {
// Create a claims map
claims := jwt.MapClaims{
"username": username,
"ExpiresAt": 15000,
"IssuedAt": time.Now().Unix(),
}
token := jwt.NewWithClaims(jwt.SigningMethodHS256, claims)
tokenString, err := token.SignedString(secretKey)
if err != nil {
w.WriteHeader(http.StatusBadGateway)
w.Write([]byte(err.Error()))
}
response := Response{Token: tokenString, Status: "success"}
responseJSON, _ := json.Marshal(response)
w.WriteHeader(http.StatusOK)
w.Header().Set("Content-Type", "application/json")
w.Write(responseJSON)
} else {
http.Error(w, "Invalid Credentials", http.StatusUnauthorized)
return
}
} else {
http.Error(w, "User is not found", http.StatusNotFound)
return
}
}
func main() {
r := mux.NewRouter()
r.HandleFunc("/getToken", getTokenHandler)
r.HandleFunc("/healthcheck", HealthcheckHandler)
http.Handle("/", r)
srv := &http.Server{
Handler: r,
Addr: "127.0.0.1:8000",
// Good practice: enforce timeouts for servers you create!
WriteTimeout: 15 * time.Second,
ReadTimeout: 15 * time.Second,
}
log.Fatal(srv.ListenAndServe())
}
如果你对这篇内容有疑问,欢迎到本站社区发帖提问 参与讨论,获取更多帮助,或者扫码二维码加入 Web 技术交流群。
绑定邮箱获取回复消息
由于您还没有绑定你的真实邮箱,如果其他用户或者作者回复了您的评论,将不能在第一时间通知您!
发布评论