热门标签 | HotTags
当前位置:  开发笔记 > 编程语言 > 正文

jupyter运行时in[*]是什么原因_gochassis运行时做了什么?

什么是chassis?Chassis,是一种微服务模式。在这种模式中,用户并不需要自己去处理构建微服务过程中外部配置、日志、健康检查、分布式追踪等&#x

什么是 chassis?

Chassis,是一种微服务模式。在这种模式中,用户并不需要自己去处理构建微服务过程中外部配置、日志、健康检查、分布式追踪等,而是将他们交给专门的框架来处理。用户可以更聚焦业务逻辑本身,简单、快速的开发微服务。

阅读此文,你可以得到什么?

1.chassis 运行时做了什么2.chassis 运行时的隐藏操作。3.chassis 设计思路的一些理解

Go-Chassis 是什么?

Go-Chassis 是一个go语言的微服务开发框架,采用插件化设计,原生提供了可插拔的注册发现,加密解密,调用链追踪等组件。协议也是插件化的,支持http和grpc,也支持开发者定制私有协议, 开发者只需要专注于实现云原生应用即可。

云原生应用,基于云服务开发或者针对云服务开发部署的应用。

3e27f64029ef15c5a7e302de2d2170db.png

上图是go-chassis 的架构图,可以看出配置管理(Archaius)、服务注册(Registry)、Metrics、日志(Logger)都是独立的组件,分布式追踪、负载均衡、限流等都是以中间件(Handler Chain)的方式实现的。一个请求进来后会先通过server 转换成chassis invoker,然后经过Handler Chain,最后由Transport 转换成对应协议的response返回。

此篇文章主要关注go-chassis 启动过程时做了什么,以及做这些事情的用途。

一个例子

首先从 hello world 开始, 目录结构如下:

. ├── conf # 配置目录,必须 │ ├── chassis.yaml # │ ├── microservice.yaml # 微服务相关配置,比如server name,注册中心地址 └── rest └── main.go

chassis.yaml 内容为:

--- cse: protocols: rest: listenAddress: "127.0.0.1:5001" transport: timeout: rest: 1 handler: chain: Provider: default: tracing-provider

microservice.yaml 内容为:

cse: service: registry: address: http://127.0.0.1:30100 service_description: name: test-rest-server

main.go

package mainimport ( rf "github.com/go-chassis/go-chassis/v2/server/restful" "log" "net/http" "github.com/go-chassis/go-chassis/v2" )//RestFulHello is a struct used for implementation of restfull hello programtype RestFulHello struct {}//Sayhi is a method used to reply user with hello world textfunc (r *RestFulHello) Sayhi(b *rf.Context) { b.Write([]byte( "hello world")) return}//URLPatterns helps to respond for corresponding API callsfunc (r *RestFulHello) URLPatterns() []rf.Route { return []rf.Route{ {Method: http.MethodGet, Path: "/sayhi", ResourceFunc: r.Sayhi, Returns: []*rf.Returns{{Code: 200}}}, }}func main() { chassis.RegisterSchema("rest", &RestFulHello{}) if err := chassis.Init(); err != nil { log.Fatal("Init failed." + err.Error()) return } chassis.Run()}

先来看一下这段代码具体做了什么。

•11~27 声明了 一个 RestFulHello struct,这个struct 有两个方法 Sayhi 和 URLPatterns,其中URLPatterns 返回一个 Route 列表。这段代码声明了一个http handler 和 对应的路由,那具体为什么这么写等下再做说明。

type Schema struct { serverName string schema interface{} opts []server.RegisterOption}

30行 chassis.RegisterSchema("rest", &RestFulHello{}) 将前面声明的 RestFulHello 注册到 "rest" 服务。

这里内部只是简单的使用传入的参数创建一个 chassis.Schema 然后append到 chassis.schemas 中。

31行 chassis运行前的初始化工作。

35行 运行chassis 服务。

8e3e1e0f0a4b8947d780275190f50ae1.png

执行 go run rest/main.go 运行代码,会发现启动失败,日志输出内容为:

INFO: Install client plugin, protocol: rest INFO: Install Provider Plugin, name: default INFO: Installed Server Plugin, protocol:rest ERROR: add file source error [[/var/folders/rr/rzqnl9h10y577rch1nsx_jww0000gp/T/go-build725280265/b001/exe/conf/chassis.yaml] file not exist]. file:go-chassis@v1.8.3/chassis_init.go:106,msg:failed to initialize conf: [/var/folders/rr/rzqnl9h10y577rch1nsx_jww0000gp/T/go-build725280265/b001/exe/conf/chassis.yaml] file not exist init chassis fail: [/var/folders/rr/rzqnl9h10y577rch1nsx_jww0000gp/T/go-build725280265/b001/exe/conf/chassis.yaml] file not exist Init failed.[/var/folders/rr/rzqnl9h10y577rch1nsx_jww0000gp/T/go-build725280265/b001/exe/conf/chassis.yaml] file not exist

通过日志可以看到两个问题:

1.为什么添加了配置还会提示配置找不到?2.为什么配置没有加载成功插件却可以安装成功?

chassis init

下图是chassis init 的执行流程:

a393360c1178137f9a584041ece91d20.png

公号内输入 【chassis_init】 获取原图

配置初始化

首先看一下chassis 初始化的过程中配置是如何加载的。

555603c063cba9287772faf0dba2def8.png

查看 config.Init() 代码可以看到 配置目录是通过 fileutil.RouterConfigPath() 来获取的,目录初始化方法为:

func initDir() { if h := os.Getenv(ChassisHome); h != "" { homeDir = h } else { wd, err := GetWorkDir() if err != nil { panic(err) } homeDir = wd } // set conf dir, CHASSIS_CONF_DIR has highest priority if confDir := os.Getenv(ChassisConfDir); confDir != "" { configDir = confDir } else { // CHASSIS_HOME has second most high priority configDir = filepath.Join(homeDir, "conf") } }

如果使用 ChassisHome 环境变量指定应用目录,chassis 运行时,会从该目录下的 ChassisHome/conf/ 目录中读取配置

也可以使用 ChassisConfDir 直接指定配置目录,ChassisConfDir 优先级高于 ChassisHome/conf

chassis 使用 archaius 来管理配置,archaius 初始化时,会从文件、环境变量、命令行、内存中初始化配置。

// InitArchaius initialize the archaius func InitArchaius() error { var err error requiredFiles := []string{ fileutil.GlobalConfigPath(), fileutil.MicroServiceConfigPath(), } optionalFiles := []string{ fileutil.CircuitBreakerConfigPath(), fileutil.LoadBalancingConfigPath(), fileutil.RateLimitingFile(), fileutil.TLSConfigPath(), fileutil.MonitoringConfigPath(), fileutil.AuthConfigPath(), fileutil.TracingPath(), fileutil.LogConfigPath(), fileutil.RouterConfigPath(), } err = archaius.Init( // 初始化配置 archaius.WithCommandLineSource(), archaius.WithMemorySource(), archaius.WithENVSource(), archaius.WithRequiredFiles(requiredFiles), archaius.WithOptionalFiles(optionalFiles)) return err

从代码可以看出,global config 和 microservice config 是必须要有的,

global config 对应 conf_path/chassis.yaml

microservice config 对应 conf_path/microservice.yaml

接下来读出配置后,给初始化runtime 的值:

runtime 中的数据可以认为是运行时的全局变量

... // runtime 中的数据可以认为是运行时的全局变量 runtime.ServiceName = MicroserviceDefinition.ServiceDescription.Name runtime.Version = MicroserviceDefinition.ServiceDescription.Version runtime.Environment = MicroserviceDefinition.ServiceDescription.Environment runtime.MD = MicroserviceDefinition.ServiceDescription.Properties if MicroserviceDefinition.AppID != "" { //microservice.yaml has first priority runtime.App = MicroserviceDefinition.AppID } else if GlobalDefinition.AppID != "" { //chassis.yaml has second priority runtime.App = GlobalDefinition.AppID } if runtime.App == "" { runtime.App = common.DefaultApp } runtime.HostName = MicroserviceDefinition.ServiceDescription.Hostname ...

archaius 也支持从配置中心读取配置,通过这种方式,chassis 也提供了运行时配置热加载的功能。

对于第二个问题,为什么插件会先于配置安装?

插件初始化

0ec2d108786e296336385ab83fef063c.png

从图中可以看出init 做了预先初始化了很多的插件,比如 client、provider、server、log、router rule、register、load balance、service discover、treporter等,并且chassis init 方法中并没有做显式的初始化调用。通过查看代码会发现,这个步骤是使用各自的init 方法自动执行的,类似这样:

// restful serverfunc init() { server.InstallPlugin(Name, newRestfulServer)}// route rule pluginfunc init() { router.InstallRouterService("cse", newRouter)}// init initialize the plugin of service center registryfunc init() { registry.InstallRegistrator(ServiceCenter, NewRegistrator) registry.InstallServiceDiscovery(ServiceCenter, NewServiceDiscovery) registry.InstallContractDiscovery(ServiceCenter, newContractDiscovery)}// init install plugin of new file registryfunc init() { registry.InstallRegistrator(Name, newFileRegistry) registry.InstallServiceDiscovery(Name, newDiscovery)}

之所以隐式加载是因为 chassis 是插件式设计,使用 init 方式加载插件,可以做到对插件的即插即用,需要使用的插件只需要在代码中添加包的import 即可,比如加载grpc 插件,只需要在main.go 中添加

import _ "github.com/go-chassis/go-chassis-extension/protocol/grpc/server"

从这一系列插件安装方式也能看出,对于chassis 来说,注册中心,协议,负载均衡等都是插件,这也就意味着这些插件都是可替换的,方便二次开发。

以上两个问题现在都解决了,现在执行以下命令运行服务:

CHASSIS_CONF_DIR=`pwd`/conf go run rest/main.go

初始化handler chain

Handler是微服务在运行过程中在框架层面里的一个最小处理单元。go chassis通过handler和handler的组装实现组件化的运行模型架构。其基本的使用方式就是实现接口、注册逻辑:

Handler 定义非常简单,实现了Handler 接口就可以认为创建了一个Handler。

// Handler interface for handlerstype Handler interface { // handle invocation transportation,and tr response Handle(*Chain, *invocation.Invocation, invocation.ResponseCallBack) Name() string}

使用RegisterHandler 函数将添加到HandlerFuncMap 中即可在CreateHandler 调用时使用。

// RegisterHandler Let developer custom handlerfunc RegisterHandler(name string, f func() Handler) error { if stringutil.StringInSlice(name, buildIn) { return errViolateBuildIn } _, ok := HandlerFuncMap[name] if ok { return ErrDuplicatedHandler } HandlerFuncMap[name] = f return nil}

对于chassis 来说,协议转换,权限验证,全链路追踪等都可以认为是一个handler(中间件),这里会从配置中读取声明的handler,并且初始化。请求调用时,会按照配置文件中的定义的顺序进入handler进行处理。

c2f0e7fae9aec945f00ff773dcac4ab6.png

在服务初始化的过程中,go-chassis 会根据配置文件中的定义加载需要的handler,handler 分为provider、consumer和 default 三种,配置内容示例如下:

handler: chain: Provider: default: tracing-provider rest: jwt

如果配置了非default 的type,服务启动的时候只会执行此特定的handler,比如上述配置,handler 只会执行 jwt,而忽略tracing-provider

这是因为chassis 使用map存储 handler chain,map 的key 为 chainType+chainName, default 也是一种chainType,如果name(即chain type)有值则使用对应的 chain,否则使用default。

type Chain struct { ServiceType string Name string Handlers []Handler}// GetChain is to get chainfunc GetChain(serviceType string, name string) (*Chain, error) { if name == "" { name = common.DefaultChainName } origin, ok := ChainMap[serviceType+name] if !ok { return nil, fmt.Errorf("get chain [%s] failed", serviceType+name) } return origin, nil}// chainMap := chaninMap[strint]*Chain{ "Provider+rest": &Chain{ ServiceType: "Provider", Name: "rest", Handlers: []Handler{jwt},}, "Provider+default": &Chain{ ServiceType: "Provider", Name: "default", Handlers: []Handler{tracing-provider}},,}

初始化 server

63f005f1786be69c152f02807578b6d0.png

初始化的前提是服务已经加载,加载的步骤在init 之前就已经通过 init 方法载入了。

//Init initializesfunc Init() error { var err error for k, v := range config.GlobalDefinition.Cse.Protocols { if err = initialServer(config.GlobalDefinition.Cse.Handler.Chain.Provider, v, k); err != nil { log.Println(err) return err } } return nil}

这里初始化的是配置文件中 protocols 指定的服务。

//获取服务的方法func GetServerFunc(protocol string) (NewFunc, error) { f, ok := serverPlugins[protocol] if !ok { return nil, fmt.Errorf("unknown protocol server [%s]", protocol) } return f, nil}

这里会从 *var* serverPlugins = make(*map*[string]NewFunc) 读取server,所以在初始化时需要先安装server 对应的插件

chassis 会 默认安装rest 插件,对于grpc 需要首先指定

// p 对应 protocal 中的配置if p.Listen == "" { if p.Advertise != "" { p.Listen = p.Advertise } else { p.Listen = iputil.DefaultEndpoint4Protocol(name) } }

服务的Listen Advertise 优先级最高,如果 Advertise 和 Listen 都没有配置,使用默认配置。

初始化 server options,其中chainName 如果Provider 配置了对应 protocol name 的值,则使用protocol name。

chainName := common.DefaultChainName if _, ok := providerMap[name]; ok { chainName = name }o := Options{ Address: p.Listen, // 配置中监听的端口 ProtocolServerName: name, // protocal provider 中的名字,比如 rest grpc ChainName: chainName, // protocal provider 中的名字,比如 rest grpc TLSConfig: tlsConfig, BodyLimit: config.GlobalDefinition.Cse.Transport.MaxBodyBytes["rest"], }

其它

几个初始化外,init 还包括 register、configcenter、router、contorl、tracing、metric、reporter、熔断器、事件监听等就不再细说了。

为止,chassis 所需要的初始化步骤已经结束,接下来就是 服务运行的步骤。

chassis run

首先看一下 chassis.Run() 启动的整体流程

4dc4db4c90fcb7d669207b4de4e20dd5.png

公号内输入 【chassis_run】 获取原图

chassis 运行主要分为三个动作:

1.根据schema 找到服务,将对应的handle func 使用 handler chain 封装2.启动服务,将服务注册到服务中心3.监听退出信号

这里使用rest 服务作为例子看一下 chassis 启动服务的时候做了哪些操作。

服务注册

首先回顾一下hello world 代码:

//RestFulHello is a struct used for implementation of restfull hello programtype RestFulHello struct {}//Sayhi is a method used to reply user with hello world textfunc (r *RestFulHello) Sayhi(b *rf.Context) { b.Write([]byte( "hello world")) return}//URLPatterns helps to respond for corresponding API callsfunc (r *RestFulHello) URLPatterns() []rf.Route { return []rf.Route{ {Method: http.MethodGet, Path: "/sayhi", ResourceFunc: r.Sayhi, Returns: []*rf.Returns{{Code: 200}}}, }}chassis.RegisterSchema("rest", &RestFulHello{}) // 第一个参数即是服务名,第二个参数是 Router

RestFulHello ,其中有一个 URLPatterns() []Route 方法,实现了 Router 接口。

Router 定义

//Router is to define how route the requesttype Router interface { //URLPatterns returns route URLPatterns() []Route}

ff04e088ee2d512c8f8e4bb1bd89f4c7.png

// HTTPRequest2Invocation convert http request to uniform invocation data formatfunc HTTPRequest2Invocation(req *restful.Request, schema, operation string, resp *restful.Response) (*invocation.Invocation, error) { inv := &invocation.Invocation{ MicroServiceName: runtime.ServiceName, SourceMicroService: common.GetXCSEContext(common.HeaderSourceName, req.Request), Args: req, Reply: resp, Protocol: common.ProtocolRest, SchemaID: schema, OperationID: operation, URLPathFormat: req.Request.URL.Path, Metadata: map[string]interface{}{ common.RestMethod: req.Request.Method, }, } //set headers to Ctx, then user do not need to consider about protocol in handlers m := make(map[string]string) inv.Ctx = context.WithValue(context.Background(), common.ContextHeaderKey{}, m) for k := range req.Request.Header { m[k] = req.Request.Header.Get(k) } return inv, nil}

启动的服务注册流程中包含了将schemas 中所有Router 取出遍历,调用 WrapHandlerChain() 函数,这个函数主要做了以下工作:

1.取出 Route 中 ResourceFunc (即real handler func)2.将 HttpRequest 转换成 chassis Invocation,3.将Invocation 再添加回 request 中添加到 handler chain 中4.返回一个闭包函数。

最后会把使用 WrapHandlerChain 封装后的handler 注册到go-restful 框架中。

响应请求时,调用关系类似以下操作:

func handle(){ func handle1(){ func handle2(){ func handle3(){ real_handle_func() }() }() }()}

为什么需要转换成统一的invocation?

不同协议请求进入到对应的Server,Server将具体的协议请求转换为Invocation统一抽象模型,并传入Handler chain,由于handler根据统一模型Invocation进行处理,不必每个协议开发出来都自己开发一套治理。处理链可通过配置更新,再进入Transport handler,使用目标微服务的协议客户端传输到目标。

这种方式实际上真正提供业务处理的还是各个server 插件,chassis 只是中间商,可以对request 和 response 做它想要的处理,比如限流,熔断,路由更新等。

142565a3ed00582187561d82dc97137e.png

1.接收到协议请求后,由各协议Server转为统一的Invocation模型2.Invocation进入处理链处理3.处理结束后,进入具体的业务处理逻辑

信号监听

当服务需要关闭或重启时,应当处理完当前的请求或者设置为超时,而不是粗暴的断开链接,chassis 这里使用了信号监听的方式来处理关闭信号。

0a9acd32b794105d8063265c35d19185.png

func waitingSignal() { //Graceful shutdown c := make(chan os.Signal) // 创建一个os.Signal channel // 注册要接收的信号 signal.Notify(c, syscall.SIGINT, syscall.SIGHUP, syscall.SIGTERM, syscall.SIGQUIT, syscall.SIGILL, syscall.SIGTRAP, syscall.SIGABRT) select { case s := openlogging.Info("got os signal " + s.String()) case err := openlogging.Info("got server error " + err.Error()) } // 判断服务是否有注册 if !config.GetRegistratorDisable() { registry.HBService.Stop()// 停掉心跳服务 openlogging.Info("unregister servers ...") // 从server center 中退出 if err := server.UnRegistrySelfInstances(); err != nil { openlogging.GetLogger().Warnf("servers failed to unregister: %s", err) } } for name, s := range server.GetServers() { // 遍历服务,调用服务的 stop 方法 openlogging.Info("stopping server " + name + "...") err := s.Stop() if err != nil { openlogging.GetLogger().Warnf("servers failed to stop: %s", err) } openlogging.Info(name + " server stop success") } openlogging.Info("go chassis server gracefully shutdown")}

这里使用go信号通知机制通过往一个channel中发送os.Signal实现的。创建一个os.Signal channel,然后使用signal.Notify注册要接收的信号,chassis 关注以下信号:

信号动作说明
SIGHUP1Term终端控制进程结束(终端连接断开)
SIGINT2Term用户发送INTR字符(Ctrl+C)触发
SIGQUIT3Core用户发送QUIT字符(Ctrl+/)触发
SIGILL4Core非法指令(程序错误、试图执行数据段、栈溢出等)
SIGTRAP5CoreTrap指令触发(如断点,在调试器中使用)
SIGABRT6Core调用abort函数触发
SIGTERM15Term结束程序(可以被捕获、阻塞或忽略)

接收到信号后,首先判断是否注册到服务中心,如果注册,停掉心跳发送,退出注册,然后调用 server.Shutdown() 来优雅退出。

go http Server 从1.8 之后支持优雅退出。

具体实现可以参考此文章:http://xiaorui.cc/archives/5803

总结

这篇文章介绍了 chassis 服务启动的过程,主要介绍了init 中 配置 、插件、handler chain 、server 的初始化流程,然后分析了服务启动时做了哪些操作以及对服务退出的处理。

参考链接

1.使用ServiceComb Go-chassis构建微服务[1]2.Pattern: Microservice chassis[2]3.Linux Signal及Golang中的信号处理[3]4.源码分析golang http shutdown优雅退出的原理[4]5.Go语言微服务开发框架实践-go chassis[5]

References

[1] 使用ServiceComb Go-chassis构建微服务: https://www.infoq.cn/article/ServiceComb-Go-chassis-micro-service[2] Pattern: Microservice chassis: http://microservices.io/patterns/microservice-chassis.html[3] Linux Signal及Golang中的信号处理: https://colobu.com/2015/10/09/Linux-Signals/[4] 源码分析golang http shutdown优雅退出的原理: http://xiaorui.cc/archives/5803[5] Go语言微服务开发框架实践-go chassis: https://juejin.im/post/6844903682362834952

最后,感谢女朋友支持和包容,比❤️

也可以在公号输入以下关键字获取历史文章:公号&小程序 | 设计模式 | 并发&协程

f8c4bd92b77892c4492f5dafae71765a.png




推荐阅读
  • 在这分布式系统架构盛行的时代,很多互联网大佬公司开源出自己的分布式RPC系统框架,例如:阿里的dubbo,谷歌的gRPC,apache的Thrift。而在我们公司一直都在推荐使用d ... [详细]
  • 本文介绍了Python爬虫技术基础篇面向对象高级编程(中)中的多重继承概念。通过继承,子类可以扩展父类的功能。文章以动物类层次的设计为例,讨论了按照不同分类方式设计类层次的复杂性和多重继承的优势。最后给出了哺乳动物和鸟类的设计示例,以及能跑、能飞、宠物类和非宠物类的增加对类数量的影响。 ... [详细]
  • [翻译]微服务设计模式5. 服务发现服务端服务发现
    服务之间需要互相调用,在单体架构中,服务之间的互相调用直接通过编程语言层面的方法调用就搞定了。在传统的分布式应用的部署中,服务地 ... [详细]
  • 域名解析系统DNS
    文章目录前言一、域名系统概述二、因特网的域名结构三、域名服务器1.根域名服务器2.顶级域名服务器(TLD,top-leveldomain)3.权威(Authoritative)域名 ... [详细]
  • 什么是网关服务器初学linux服务器开发时,我们的服务器是很简单的,只需要一个程序完成与客户端的连接,接收客户端数据,数据处理,向客户端发送数据。但是在处理量很大的情况下,一 ... [详细]
  • Kubernetes(k8s)基础简介
    Kubernetes(k8s)基础简介目录一、Kubernetes概述(一)、Kubernetes是什么(二& ... [详细]
  • Istio是一个用来连接、管理和保护微服务的开放平台。Istio提供一种简单的方式来为已部署的服务建 ... [详细]
  • Zookeeper 总结与面试题汇总
    Zookeeper总结与面试题汇总,Go语言社区,Golang程序员人脉社 ... [详细]
  • 轻松实现Apache,Tomcat集群和负载均衡(http://hi.baidu.com/luodaijun/blog/item/5bbe4cfb5ffef864034f56a1.html)...
    0,环境说明Apache:apache_2.0.551个Tomcat:apache-tomcat-5.5.17(zip版)2个mod_jk::mod_jk-apach ... [详细]
  • Skywalking系列博客1安装单机版 Skywalking的快速安装方法
    本文介绍了如何快速安装单机版的Skywalking,包括下载、环境需求和端口检查等步骤。同时提供了百度盘下载地址和查询端口是否被占用的命令。 ... [详细]
  • 本文介绍了南邮ctf-web的writeup,包括签到题和md5 collision。在CTF比赛和渗透测试中,可以通过查看源代码、代码注释、页面隐藏元素、超链接和HTTP响应头部来寻找flag或提示信息。利用PHP弱类型,可以发现md5('QNKCDZO')='0e830400451993494058024219903391'和md5('240610708')='0e462097431906509019562988736854'。 ... [详细]
  • Nginxgaodaima.comnginx属于七层架构,支持的是http协议,本身对tcp协议没有支持。所以不能代理mysql等实现负载均衡。但是lvs这个东西不熟悉,主要是公司 ... [详细]
  • PartI:取经处: http:www.ramkitech.com201210tomcat-clustering ... [详细]
  • Nginx 中怎么实现动静分离与负载均衡
    本篇文章为大家展示了Nginx中怎么实现动静分离与负载均衡,内容简明扼要并且容易理解,绝对能使你眼前一亮,通过这篇文章的详细介绍希望你能有 ... [详细]
  • 1:SpringCloud、Dubbo等微服务主流开发框架盛行,SpringCloud也被开发者认为是最好的开发框架;2:Docker:容器和微服务相辅相成,两大技术成熟的时间点非常契合。 ... [详细]
author-avatar
龙马精神202502912663
这个家伙很懒,什么也没留下!
PHP1.CN | 中国最专业的PHP中文社区 | DevBox开发工具箱 | json解析格式化 |PHP资讯 | PHP教程 | 数据库技术 | 服务器技术 | 前端开发技术 | PHP框架 | 开发工具 | 在线工具
Copyright © 1998 - 2020 PHP1.CN. All Rights Reserved | 京公网安备 11010802041100号 | 京ICP备19059560号-4 | PHP1.CN 第一PHP社区 版权所有