当前位置:网站首页>16. Registration Center-consul
16. Registration Center-consul
2022-07-31 01:53:00 【Endless character】
目录
一、服务注册与发现
- 什么是服务注册和发现:假如这个产品已经在线上运行,有一天运营想搞一场促销活动,Then we correspond【用户服务】可能就要新开启三个微服务实例来支撑这场促销活动.而与此同时,作为苦逼程序员的你就只有手动去 API gateway 中添加新增的这三个微服务实例的 ip 与port ,一个真正在线的微服务系统可能有成百上千微服务,难道也要一个一个去手动添加吗?
- 解决方案:当我们新添加一个微服务实例的时候,微服务就会将自己的 ip 与 port 发送到注册中心,在注册中心里面记录起来.当 API gateway 需要访问某些微服务的时候,就会去注册中心取到相应的 ip 与 port.从而实现自动化操作
- 技术选型:Consul 与其他常见服务发现框架对比
二、consul安装与配置
三、consul服务注册与注销
1 - 注册服务
2 - 注销服务
- 注销服务:https://www.consul.io/api-docs/agent/service#deregister-service
- postman地址:http://192.168.78.131:8500/v1/agent/service/deregister/web_api
- 使用put请求
3 - 健康检查
4 - 获取服务
四、go中使用consul
- Register:注册服务
- AllServices:获取所有服务
- FilterService:过滤服务
package main
import (
"fmt"
"github.com/hashicorp/consul/api"
)
// Register 注册服务
func Register(address string, port int, name string, tags []string, id string) error {
cfg := api.DefaultConfig()
cfg.Address = "192.168.124.51:8500"
client, err := api.NewClient(cfg)
if err != nil {
panic(err)
}
//生成对应的检查对象
check := &api.AgentServiceCheck{
HTTP: "http://192.168.124.9:8081/health",
Timeout: "5s",
Interval: "5s",
DeregisterCriticalServiceAfter: "10s",
}
//生成注册对象
registration := new(api.AgentServiceRegistration)
registration.Name = name
registration.ID = id
registration.Port = port
registration.Tags = tags
registration.Address = address
registration.Check = check
err = client.Agent().ServiceRegister(registration)
if err != nil {
panic(err)
}
return nil
}
// AllServices 获取所有服务
func AllServices() {
cfg := api.DefaultConfig()
cfg.Address = "192.168.124.51:8500"
client, err := api.NewClient(cfg)
if err != nil {
panic(err)
}
data, err := client.Agent().Services()
if err != nil {
panic(err)
}
for key, _ := range data {
fmt.Println(key)
}
}
// FilterService 服务过滤
func FilterService() {
cfg := api.DefaultConfig()
cfg.Address = "192.168.124.51:8500"
client, err := api.NewClient(cfg)
if err != nil {
panic(err)
}
data, err := client.Agent().ServicesWithFilter(`Service == "user-web"`)
if err != nil {
panic(err)
}
for key, _ := range data {
fmt.Println(key)
}
}
func main() {
_ = Register("http://192.168.124.9", 8081, "user-web", []string{
"mxshop", "bobby"}, "user-web")
AllServices()
FilterService()
}
五、user_srv集成viper和zap
- user_srv/config/config.go:添加配置
package config
type MysqlConfig struct {
Host string `mapstructure:"host" json:"host"`
Port int `mapstructure:"port" json:"port"`
Name string `mapstructure:"db" json:"db"`
User string `mapstructure:"user" json:"user"`
Password string `mapstructure:"password" json:"password"`
}
type ServerConfig struct {
Name string `mapstructure:"name" json:"name"`
MysqlInfo MysqlConfig `mapstructure:"mysql" json:"mysql"`
}
- user_srv/global/global.go:Add a global objectServerConfig
package global
import (
"gorm.io/gorm"
"nd/user_srv/config"
)
var (
DB *gorm.DB
ServerConfig config.ServerConfig
)
- user_srv/initialize/init_config.go:初始化config配置
package initialize
import (
"fmt"
"github.com/spf13/viper"
"go.uber.org/zap"
"nd/user_srv/global"
)
func GetEnvInfo(env string) bool {
viper.AutomaticEnv()
return viper.GetBool(env)
//刚才设置的环境变量 想要生效 我们必须得重启goland
}
func InitConfig() {
//Read the corresponding configuration from the configuration file
debug := GetEnvInfo("DEV_CONFIG")
configFilePrefix := "config"
configFileName := fmt.Sprintf("%s_pro.yaml", configFilePrefix)
if debug {
configFileName = fmt.Sprintf("%s_debug.yaml", configFilePrefix)
}
v := viper.New()
//文件的路径如何设置
v.SetConfigFile(configFileName)
if err := v.ReadInConfig(); err != nil {
panic(err)
}
//这个对象如何在其他文件中使用 - 全局变量
if err := v.Unmarshal(&global.ServerConfig); err != nil {
panic(err)
}
zap.S().Infof("配置信息: %v", global.ServerConfig)
}
- user_srv/initialize/init_db.go:初始化db
package initialize
import (
"fmt"
"log"
"os"
"time"
"gorm.io/driver/mysql"
"gorm.io/gorm"
"gorm.io/gorm/logger"
"gorm.io/gorm/schema"
"nd/user_srv/global"
)
func InitDB() {
c := global.ServerConfig.MysqlInfo
dsn := fmt.Sprintf("%s:%[email protected](%s:%d)/%s?charset=utf8mb4&parseTime=True&loc=Local",
c.User, c.Password, c.Host, c.Port, c.Name)
newLogger := logger.New(
log.New(os.Stdout, "\r\n", log.LstdFlags), // io writer
logger.Config{
SlowThreshold: time.Second, // 慢 SQL 阈值
LogLevel: logger.Silent, // Log level
Colorful: true, // 禁用彩色打印
},
)
// 全局模式
var err error
global.DB, err = gorm.Open(mysql.Open(dsn), &gorm.Config{
NamingStrategy: schema.NamingStrategy{
SingularTable: true,
},
Logger: newLogger,
})
if err != nil {
panic(err)
}
}
- user_srv\initialize\init_logger.go:zap初始化
package initialize
import "go.uber.org/zap"
func InitLogger() {
logger, _ := zap.NewDevelopment()
zap.ReplaceGlobals(logger)
}
- yaml
//user_srv/config_pro.yaml
mysql:
host: '192.168.124.51'
port: '3306'
user: 'root'
password: 'jiushi'
db: 'mxshop_user_srv'
//user_srv/config_pro.yaml
mysql:
host: '192.168.124.51'
port: '3306'
user: 'root'
password: 'jiushi'
db: 'mxshop_user_srv'
- user_srv/main.go:Main logic modification
package main
import (
"flag"
"fmt"
"go.uber.org/zap"
"nd/user_srv/global"
"nd/user_srv/handler"
"nd/user_srv/initialize"
"nd/user_srv/proto"
"net"
"google.golang.org/grpc"
)
func main() {
IP := flag.String("ip", "0.0.0.0", "ip地址")
Port := flag.Int("port", 50051, "端口号")
//初始化
initialize.InitLogger()
initialize.InitConfig()
initialize.InitDB()
zap.S().Info(global.ServerConfig)
flag.Parse()
zap.S().Info("ip: ", *IP)
zap.S().Info("port: ", *Port)
server := grpc.NewServer()
proto.RegisterUserServer(server, &handler.UserServer{
})
lis, err := net.Listen("tcp", fmt.Sprintf("%s:%d", *IP, *Port))
if err != nil {
panic("failed to listen:" + err.Error())
}
err = server.Serve(lis)
if err != nil {
panic("failed to start grpc:" + err.Error())
}
}
六、grpc健康检查
- grpcHealth check official address:https://github.com/grpc/grpc/blob/master/doc/health-checking.md
grpc服务注册到consul
- user_srv/config/config.go:添加ConsulConfig配置对象
package config
type MysqlConfig struct {
Host string `mapstructure:"host" json:"host"`
Port int `mapstructure:"port" json:"port"`
Name string `mapstructure:"db" json:"db"`
User string `mapstructure:"user" json:"user"`
Password string `mapstructure:"password" json:"password"`
}
type ConsulConfig struct {
Host string `mapstructure:"host" json:"host"`
Port int `mapstructure:"port" json:"port"`
}
type ServerConfig struct {
Name string `mapstructure:"name" json:"name"`
MysqlInfo MysqlConfig `mapstructure:"mysql" json:"mysql"`
ConsulInfo ConsulConfig `mapstructure:"consul" json:"consul"`
}
- yaml
//user_srv/config_pro.yaml
mysql:
host: '192.168.124.51'
port: '3306'
user: 'root'
password: 'jiushi'
db: 'mxshop_user_srv'
name: 'user_srv'
consul:
host: '192.168.124.51'
port: '8500'
//user_srv/config_pro.yaml
mysql:
host: '192.168.124.51'
port: '3306'
user: 'root'
password: 'jiushi'
db: 'mxshop_user_srv'
name: 'user_srv'
consul:
host: '192.168.124.51'
port: '8500'
- user_srv/main.go:
- ①.注册健康检查
- ②.注册服务
package main
import (
"flag"
"fmt"
"github.com/hashicorp/consul/api"
"go.uber.org/zap"
"google.golang.org/grpc/health"
"google.golang.org/grpc/health/grpc_health_v1"
"nd/user_srv/global"
"nd/user_srv/handler"
"nd/user_srv/initialize"
"nd/user_srv/proto"
"net"
"google.golang.org/grpc"
)
func main() {
IP := flag.String("ip", "0.0.0.0", "ip地址")
Port := flag.Int("port", 50051, "端口号")
//初始化
initialize.InitLogger()
initialize.InitConfig()
initialize.InitDB()
zap.S().Info(global.ServerConfig)
flag.Parse()
zap.S().Info("ip: ", *IP)
zap.S().Info("port: ", *Port)
server := grpc.NewServer()
proto.RegisterUserServer(server, &handler.UserServer{
})
lis, err := net.Listen("tcp", fmt.Sprintf("%s:%d", *IP, *Port))
if err != nil {
panic("failed to listen:" + err.Error())
}
//Register service health check
grpc_health_v1.RegisterHealthServer(server, health.NewServer())
//服务注册
cfg := api.DefaultConfig()
cfg.Address = fmt.Sprintf("%s:%d", global.ServerConfig.ConsulInfo.Host,
global.ServerConfig.ConsulInfo.Port)
client, err := api.NewClient(cfg)
if err != nil {
panic(err)
}
//生成对应的检查对象
check := &api.AgentServiceCheck{
GRPC: fmt.Sprintf("192.168.124.9:%d", *Port),
Timeout: "5s",
Interval: "5s",
DeregisterCriticalServiceAfter: "15s",
}
//生成注册对象
registration := new(api.AgentServiceRegistration)
registration.Name = global.ServerConfig.Name
registration.ID = global.ServerConfig.Name
registration.Port = *Port
registration.Tags = []string{
"imooc", "bobby", "user", "srv"}
registration.Address = "192.168.124.9"
registration.Check = check
err = client.Agent().ServiceRegister(registration)
if err != nil {
panic(err)
}
err = server.Serve(lis)
if err != nil {
panic("failed to start grpc:" + err.Error())
}
}
七、gin集成consul
user_webThe layer needs both to accomplish service discovery、Service registration also needs to be completed
1 - UserSrvClient优化
- UserSrvClient优化:每个接口都需要UserSrvClient,It needs to be re-fetched every timetcp三次握手,Optimized to uniformly initialize the globalUserSrvClient;Of course there is still a problem here,That is, multiple connections are using the same oneUserSrvClient,这个后续再优化
- user_web/global/global.go:添加全局UserSrvClient对象
package global
import (
ut "github.com/go-playground/universal-translator"
"web_api/user_web/config"
"web_api/user_web/proto"
)
var (
Trans ut.Translator
ServerConfig *config.ServerConfig = &config.ServerConfig{
}
UserSrvClient proto.UserClient
)
- user_web/initialize/init_srv_conn.go:Add initialized business logic
package initialize
import (
"fmt"
"github.com/hashicorp/consul/api"
"go.uber.org/zap"
"google.golang.org/grpc"
"google.golang.org/grpc/credentials/insecure"
"web_api/user_web/global"
"web_api/user_web/proto"
)
func InitSrvConn() {
cfg := api.DefaultConfig()
consulInfo := global.ServerConfig.ConsulInfo
cfg.Address = fmt.Sprintf("%s:%d", consulInfo.Host, consulInfo.Port)
userSrvHost := ""
userSrvPort := 0
client, err := api.NewClient(cfg)
if err != nil {
panic(err)
}
data, err := client.Agent().ServicesWithFilter(fmt.Sprintf("Service == \"%s\"", global.ServerConfig.UserSrvInfo.Name))
if err != nil {
panic(err)
}
// Just get one
for _, value := range data {
userSrvHost = value.Address
userSrvPort = value.Port
break
}
if userSrvHost == "" {
zap.S().Fatal("[InitSrvConn] 连接 【用户服务失败】")
return
}
//拨号连接用户grpc服务器
userConn, err := grpc.Dial(fmt.Sprintf("%s:%d", userSrvHost, userSrvPort),
grpc.WithTransportCredentials(insecure.NewCredentials()))
if err != nil {
zap.S().Errorw("[GetUserList] 连接 【用户服务失败】", "msg", err.Error())
}
// 1. What to do if the user service goes offline subsequently 2. the port 3. 改ip了
// The link has been established in advance,This way you don't have to do it any moretcp的三次握手了
// 一个连接多个groutine公用,性能问题 - 连接池
userClient := proto.NewUserClient(userConn)
global.UserSrvClient = userClient
}
- user_web/main.go:添加初始化srv的连接
package main
import (
"fmt"
"web_api/user_web/global"
"web_api/user_web/initialize"
"github.com/gin-gonic/gin/binding"
ut "github.com/go-playground/universal-translator"
"github.com/go-playground/validator/v10"
"go.uber.org/zap"
myvalidator "web_api/user_web/validator"
)
func main() {
//1. 初始化logger
initialize.InitLogger()
//2. 初始化配置文件
initialize.InitConfig()
//3. 初始化routers
Router := initialize.Routers()
//4. 初始化翻译
if err := initialize.InitTrans("zh"); err != nil {
panic(err)
}
//5. 初始化srv的连接
initialize.InitSrvConn()
//注册验证器
if v, ok := binding.Validator.Engine().(*validator.Validate); ok {
_ = v.RegisterValidation("mobile", myvalidator.ValidateMobile)
_ = v.RegisterTranslation("mobile", global.Trans, func(ut ut.Translator) error {
return ut.Add("mobile", "{0} 非法的手机号码!", true) // see universal-translator for details
}, func(ut ut.Translator, fe validator.FieldError) string {
t, _ := ut.T("mobile", fe.Field())
return t
})
}
/* 1. S()可以获取一个全局的sugar,可以让我们自己设置一个全局的logger 2. 日志是分级别的,debug, info , warn, error, fetal debug最低,fetal最高,如果配置成info,所有比info低的都不会输出 NewProduction默认日志级别为info NewDevelopment默认日志级别为debug 3. S函数和L函数很有用, 提供了一个全局的安全访问logger的途径 */
zap.S().Debugf("启动服务器, 端口: %d", global.ServerConfig.Port)
if err := Router.Run(fmt.Sprintf(":%d", global.ServerConfig.Port)); err != nil {
zap.S().Panic("启动失败:", err.Error())
}
}
2 - gin集成consul
- yaml配置:添加user_srv的name,consul的host和ip
//user_web/config_debug.yaml
name: 'user-web'
port: '8081'
user_srv:
host: '127.0.0.1'
port: '50051'
name: 'user_srv'
jwt:
key: 'VYLDYq3&hGWjWqF$K1ih'
sms:
key: ''
secrect: ''
expire: 300
redis:
host: '192.168.124.51'
port: '6379'
consul:
host: '192.168.124.51'
port: '8500'
//user_web/config_pro.yaml
name: 'user-web'
port: '8081'
user_srv:
host: '127.0.0.1'
port: '50051'
name: 'user_srv'
jwt:
key: 'VYLDYq3&hGWjWqF$K1ih'
sms:
key: ''
secrect: ''
expire: 300
redis:
host: '192.168.124.51'
port: '6379'
consul:
host: '192.168.124.51'
port: '8500'
- user_web/api/api_user.go:Remove initializationUserSrvClient的操作,将对应的UserSrvClient都替换为global.UserSrvClient
package api
import (
"context"
"fmt"
"net/http"
"strconv"
"strings"
"time"
"github.com/dgrijalva/jwt-go"
"github.com/gin-gonic/gin"
"github.com/go-playground/validator"
"github.com/go-redis/redis"
"go.uber.org/zap"
"google.golang.org/grpc/codes"
"google.golang.org/grpc/status"
"web_api/user_web/forms"
"web_api/user_web/global"
"web_api/user_web/global/response"
"web_api/user_web/middlewares"
"web_api/user_web/models"
"web_api/user_web/proto"
)
func HandleGrpcErrorToHttp(err error, c *gin.Context) {
//将grpc的code转换成http的状态码
if err != nil {
if e, ok := status.FromError(err); ok {
switch e.Code() {
case codes.NotFound:
c.JSON(http.StatusNotFound, gin.H{
"msg": e.Message(),
})
case codes.Internal:
c.JSON(http.StatusInternalServerError, gin.H{
"msg:": "内部错误",
})
case codes.InvalidArgument:
c.JSON(http.StatusBadRequest, gin.H{
"msg": "参数错误",
})
case codes.Unavailable:
c.JSON(http.StatusInternalServerError, gin.H{
"msg": "用户服务不可用",
})
default:
c.JSON(http.StatusInternalServerError, gin.H{
"msg": e.Code(),
})
}
return
}
}
}
func HandleValidatorError(c *gin.Context, err error) {
errs, ok := err.(validator.ValidationErrors)
if !ok {
c.JSON(http.StatusOK, gin.H{
"msg": err.Error(),
})
}
c.JSON(http.StatusBadRequest, gin.H{
"error": removeTopStruct(errs.Translate(global.Trans)),
})
}
func removeTopStruct(fields map[string]string) map[string]string {
rsp := map[string]string{
}
for field, err := range fields {
rsp[field[strings.Index(field, ".")+1:]] = err
}
return rsp
}
func GetUserList(ctx *gin.Context) {
//拨号连接用户grpc服务器 跨域的问题 - 后端解决 It can also be solved by the front end
//claims, _ := ctx.Get("claims")
//currentUser := claims.(*models.CustomClaims)
//zap.S().Infof("访问用户: %d", currentUser.ID)
pn := ctx.DefaultQuery("pn", "0")
pnInt, _ := strconv.Atoi(pn)
pSize := ctx.DefaultQuery("psize", "10")
pSizeInt, _ := strconv.Atoi(pSize)
rsp, err := global.UserSrvClient.GetUserList(context.Background(), &proto.PageInfo{
Pn: uint32(pnInt),
PSize: uint32(pSizeInt),
})
if err != nil {
zap.S().Errorw("[GetUserList] 查询 【用户列表】 失败")
HandleGrpcErrorToHttp(err, ctx)
return
}
result := make([]interface{
}, 0)
for _, value := range rsp.Data {
user := response.UserResponse{
Id: value.Id,
NickName: value.NickName,
//Birthday: time.Time(time.Unix(int64(value.BirthDay), 0)).Format("2006-01-02"),
Birthday: response.JsonTime(time.Unix(int64(value.BirthDay), 0)),
Gender: value.Gender,
Mobile: value.Mobile,
}
result = append(result, user)
}
ctx.JSON(http.StatusOK, result)
}
func PassWordLogin(c *gin.Context) {
//表单验证
passwordLoginForm := forms.PassWordLoginForm{
}
if err := c.ShouldBind(&passwordLoginForm); err != nil {
HandleValidatorError(c, err)
return
}
if store.Verify(passwordLoginForm.CaptchaId, passwordLoginForm.Captcha, false) {
c.JSON(http.StatusBadRequest, gin.H{
"captcha": "验证码错误",
})
return
}
//登录逻辑
if rsp, err := global.UserSrvClient.GetUserByMobile(context.Background(), &proto.MobileRequest{
Mobile: passwordLoginForm.Mobile,
}); err != nil {
if e, ok := status.FromError(err); ok {
switch e.Code() {
case codes.NotFound:
c.JSON(http.StatusBadRequest, map[string]string{
"mobile": "用户不存在",
})
default:
c.JSON(http.StatusInternalServerError, map[string]string{
"mobile": "登录失败",
})
}
return
}
} else {
//只是查询到用户了而已,并没有检查密码
if passRsp, pasErr := global.UserSrvClient.CheckPassWord(context.Background(), &proto.PasswordCheckInfo{
Password: passwordLoginForm.PassWord,
EncryptedPassword: rsp.PassWord,
}); pasErr != nil {
c.JSON(http.StatusInternalServerError, map[string]string{
"password": "登录失败",
})
} else {
if passRsp.Success {
//生成token
j := middlewares.NewJWT()
claims := models.CustomClaims{
ID: uint(rsp.Id),
NickName: rsp.NickName,
AuthorityId: uint(rsp.Role),
StandardClaims: jwt.StandardClaims{
NotBefore: time.Now().Unix(), //签名的生效时间
ExpiresAt: time.Now().Unix() + 60*60*24*30, //30天过期
Issuer: "imooc",
},
}
token, err := j.CreateToken(claims)
if err != nil {
c.JSON(http.StatusInternalServerError, gin.H{
"msg": "生成token失败",
})
return
}
c.JSON(http.StatusOK, gin.H{
"id": rsp.Id,
"nick_name": rsp.NickName,
"token": token,
"expired_at": (time.Now().Unix() + 60*60*24*30) * 1000,
})
} else {
c.JSON(http.StatusBadRequest, map[string]string{
"msg": "登录失败",
})
}
}
}
}
func Register(c *gin.Context) {
//用户注册
registerForm := forms.RegisterForm{
}
if err := c.ShouldBind(®isterForm); err != nil {
HandleValidatorError(c, err)
return
}
//验证码
rdb := redis.NewClient(&redis.Options{
Addr: fmt.Sprintf("%s:%d", global.ServerConfig.RedisInfo.Host, global.ServerConfig.RedisInfo.Port),
})
value, err := rdb.Get(registerForm.Mobile).Result()
if err == redis.Nil {
c.JSON(http.StatusBadRequest, gin.H{
"code": "验证码错误",
})
return
} else {
if value != registerForm.Code {
c.JSON(http.StatusBadRequest, gin.H{
"code": "验证码错误",
})
return
}
}
user, err := global.UserSrvClient.CreateUser(context.Background(), &proto.CreateUserInfo{
NickName: registerForm.Mobile,
PassWord: registerForm.PassWord,
Mobile: registerForm.Mobile,
})
if err != nil {
zap.S().Errorf("[Register] 查询 【新建用户失败】失败: %s", err.Error())
HandleGrpcErrorToHttp(err, c)
return
}
j := middlewares.NewJWT()
claims := models.CustomClaims{
ID: uint(user.Id),
NickName: user.NickName,
AuthorityId: uint(user.Role),
StandardClaims: jwt.StandardClaims{
NotBefore: time.Now().Unix(), //签名的生效时间
ExpiresAt: time.Now().Unix() + 60*60*24*30, //30天过期
Issuer: "imooc",
},
}
token, err := j.CreateToken(claims)
if err != nil {
c.JSON(http.StatusInternalServerError, gin.H{
"msg": "生成token失败",
})
return
}
c.JSON(http.StatusOK, gin.H{
"id": user.Id,
"nick_name": user.NickName,
"token": token,
"expired_at": (time.Now().Unix() + 60*60*24*30) * 1000,
})
}
八、完整源码
边栏推荐
- Charging effect simulation
- 华为od 转骰子 js
- keep-alive cache component
- GCC Rust获批将被纳入主线代码库,或将于GCC 13中与大家见面
- Set the browser scrollbar style
- MySQL installation tutorial (detailed, package teaching package~)
- pycharm cannot run after renaming (error: can't open file...No such file or directory)
- keep-alive缓存组件
- 成为比开发硬气的测试人,我都经历了什么?
- MySql的安装配置超详细教程与简单的建库建表方法
猜你喜欢
随机推荐
Real-time image acquisition based on FPGA
vlan间路由+静态路由+NAT(PAT+静态NAT)综合实验
CV-Model [3]: MobileNet v2
怎样做好一个创业公司CTO?
"Cloud native's master, master and vulgar skills" - 2022 National New College Entrance Examination Volume I Composition
leetcode-1161:最大层内元素和
Arbitrum 专访 | L2 Summer, 脱颖而出的 Arbitrum 为开发者带来了什么?
Static route analysis (the longest mask matching principle + active and standby routes)
12张图带你彻底搞懂服务限流、熔断、降级、雪崩
What are the project management tools like MS Project
[1154]如何将字符串转换为datetime
mmdetection训练一个模型相关命令
mysql 索引
[Map and Set] LeetCode & Niu Ke exercise
Drools规则属性,高级语法
软件测试要达到一个什么水平才能找到一份9K的工作?
Crawler text data cleaning
软件测试基础接口测试-入门Jmeter,你要注意这些事
MySql installation and configuration super detailed tutorial and simple method of building database and table
Inner monologue from a female test engineer...