首页 文章 精选 留言 我的

精选列表

搜索[源码阅读],共10000篇文章
优秀的个人博客,低调大师

KubeSphere 后端源码深度解析

这篇文章我们将学习在 vscode 上的 ssh remote 插件基础上,尝试 debug 和学习 KubeSphere 后端模块架构。 前提 安装好 vscode 以及 ssh remote container 插件; 在远程主机上安装好 kubenertes 容器 " 操作系统 " 和 KubeSphere >= v3.1.0 云“控制面板”; 安装 go >=1.16; 在 KubeSphere 上安装了需要 debug 的 ks 组件,如 devops、kubeedge 或者 whatever, 如果是默认激活的组件,像 monitoring,不需要去激活。 配置 launch 文件 $ cat .vscode/launch.json { // 使用 IntelliSense 了解相关属性。 // 悬停以查看现有属性的描述。 // 欲了解更多信息,请访问: https://go.microsoft.com/fwlink/?linkid=830387 "version": "0.2.0", "configurations": [ { "name": "ks-apiserver", "type": "go", "request": "launch", "mode": "auto", "program": "${workspaceFolder}/cmd/ks-apiserver/apiserver.go" } ] } ks-apiserver 调试依赖文件 在相对路径 cmd/ks-apiserver/ 下配置 kubesphere.yaml。 首先,查看集群之中的 cm 配置文件 : $ kubectl -n kubesphere-system get cm kubesphere-config -oyaml 因为上述 configmap 中差少 kubeconfig 相关配置,所以需要将上述 yaml 文件拷贝出来整合以下。 为啥要用添加 kubeconfig 文件? 主要是因为 k8s 在创建 client 时需要这么一个文件 , 而容器中会用到 inclusterconfig 就不需要添加了。 感兴趣可以看下 client-go 的例子: https://github.com/kubernetes/client-go/blob/master/examples/in-cluster-client-configuration/main.go#L41 https://github.com/kubernetes/client-go/blob/master/examples/out-of-cluster-client-configuration/main.go#L53 所以完整的配置启动文件如下: $ cat ./cmd/ks-apiserver/kubesphere.yaml kubernetes: kubeconfig: "/root/.kube/config" master: https://192.168.88.6:6443 $qps: 1e+06 burst: 1000000 authentication: authenticateRateLimiterMaxTries: 10 authenticateRateLimiterDuration: 10m0s loginHistoryRetentionPeriod: 168h maximumClockSkew: 10s multipleLogin: True kubectlImage: kubesphere/kubectl:v1.20.0 jwtSecret: "Xtc8ZWUf9f3cJN89bglrTJhfUPMZR87d" oauthOptions: clients: - name: kubesphere secret: kubesphere redirectURIs: - '*' network: ippoolType: none monitoring: endpoint: http://prometheus-operated.kubesphere-monitoring-system.svc:9090 enableGPUMonitoring: false gpu: kinds: - resourceName: nvidia.com/gpu resourceType: GPU default: True notification: endpoint: http://notification-manager-svc.kubesphere-monitoring-system.svc:19093 kubeedge: endpoint: http://edge-watcher.kubeedge.svc/api/ gateway: watchesPath: /var/helm-charts/watches.yaml namespace: kubesphere-controls-system 除了 kubernetes, 第一层的 key 表示我们集群中已经按照或者默认激活的 ks 组件,现在就可以通过 F5 来启动 debug 了。 在 debug 之前,你可能会问,这个配置文件为啥要放在 /cmd/ks-apiserver/kubesphere.yaml? 我们先来探索一波 ks-apiserver 的运行逻辑。 启动 ks-apiserver 查看 cmd/ks-apiserver/app/server.go 的逻辑 : // Load configuration from file conf, err := apiserverconfig.TryLoadFromDisk() TryLoadFromDisk 的逻辑如下: viper.SetConfigName(defaultConfigurationName) // kubesphere viper.AddConfigPath(defaultConfigurationPath) // /etc/kubesphere // Load from current working directory, only used for debugging viper.AddConfigPath(".") // Load from Environment variables viper.SetEnvPrefix("kubesphere") viper.AutomaticEnv() viper.SetEnvKeyReplacer(strings.NewReplacer(".", "_")) // 上面一顿配置之后,单步调试,ReadInConfig这一步读取的文件路径是 // v.configPaths:["/etc/kubesphere","/root/go/src/kubesphere.io/kubesphere/cmd/ks-apiserver"] if err := viper.ReadInConfig(); err != nil { if _, ok := err.(viper.ConfigFileNotFoundError); ok { return nil, err } else { return nil, fmt.Errorf("error parsing configuration file %s", err) } } conf := New() // 初始化各组件配置 // 从读取的实际路径配置文件来反序列化到conf这个struct if err := viper.Unmarshal(conf); err != nil { return nil, err } return conf, n 上面的注释,解释了需要在指定路径下添加 kubesphere.yaml 启动 ks-apiserver 命令行。 我们接着往下撸,这里使用 cobra.Command 这个 package 来做命令行的集成: func Run(s *options.ServerRunOptions, ctx context.Context) error { // NewAPIServer 通过给定的配置启动apiserver实例,绑定实例化的各组件的client // 这一步还通过AddToScheme来注册一些自定义的GVK到k8s,最终暴露为apis API // 借助rest.Config和scheme 初始化runtimecache和runtimeClient apiserver, err := s.NewAPIServer(ctx.Done()) if err != nil { return err } // PrepareRun 主要是使用resful-go集成kapis API // 上一步绑定了各组件的client,这一步就可以调用各组件的client来访问对应组件的server端了 // 猜猜4.0后端可插拔架构会是什么样子的? err = apiserver.PrepareRun(ctx.Done()) if err != nil { return nil } // 运行各种informers同步资源,并开始ks-apiserver监听请求 return apiserver.Run(ctx) } s.NewAPIServer(ctx.Done()) 主要是创建一个 apiserver 实例。创建 apiserver 实例这一步,还通过 scheme 注册 ks 自定义的 GVK 到 k8s, 暴露为 apis 请求路径的 API。 PrepareRun 主要是使用 resful-go 框架集成了各子模块代理请求或集成服务, 暴露为 kapis 请求路径的 API 功能 。 apiserver.Run(ctx) 则是做了资源同步,并启动 server 监听。 下面分开阐述说明。 NewAPIServer 首先是绑定各种 client 和 informers: // 调用各组件的NewForConfig方法整合clientset kubernetesClient, err := k8s.NewKubernetesClient(s.KubernetesOptions) if err != nil { return nil, err } apiServer.KubernetesClient = kubernetesClient informerFactory := informers.NewInformerFactories(kubernetesClient.Kubernetes(), kubernetesClient.KubeSphere(),kubernetesClient.Istio(), kubernetesClient.Snapshot(), kubernetesClient.ApiExtensions(), kubernetesClient.Prometheus()) apiServer.InformerFactory = informerFactory ... // 根据kubesphere.yaml或者kubesphere-config configmap的配置来绑定ks组件的client ... 初始化绑定完毕后 , 会启动一个 server 来响应请求 , 所以这里会做一个 addr 绑定 : ... server := &http.Server{ Addr: fmt.Sprintf(":%d", s.GenericServerRunOptions.InsecurePort), } if s.GenericServerRunOptions.SecurePort != 0 { certificate, err := tls.LoadX509KeyPair(s.GenericServerRunOptions.TlsCertFile, s.GenericServerRunOptions.TlsPrivateKey) if err != nil { return nil, err } server.TLSConfig = &tls.Config{ Certificates: []tls.Certificate{certificate}, } server.Addr = fmt.Sprintf(":%d", s.GenericServerRunOptions.SecurePort) } sch := scheme.Scheme if err := apis.AddToScheme(sch); err != nil { klog.Fatalf("unable add APIs to scheme: %v", err) } ... 注意这一步 apis.AddToScheme(sch), 将我们定义的 GVK 注册到 k8s 中。 顺带一提,GVK 指的是 Group,Version, Kind, 举个栗子: {Group: "", Version: "v1", Resource: "namespaces"} {Group: "", Version: "v1", Resource: "nodes"} {Group: "", Version: "v1", Resource: "resourcequotas"} ... {Group: "tenant.kubesphere.io", Version: "v1alpha1", Resource: "workspaces"} {Group: "cluster.kubesphere.io", Version: "v1alpha1", Resource: "clusters"} ... Scheme 管理 GVK 和 Type 的关系 , 一个 GVK 只能对应一个 reflect.Type, 一个 reflect.Type 可能对应多个 GVK;此外,Scheme 还聚合了 converter 及 cloner, 用来转换不同版本的结构体和获取结构体值的拷贝;限于篇幅有限,感兴趣的童鞋可以深入探索下。 回归正文,下面我们看下怎么注入 scheme 的: // AddToSchemes may be used to add all resources defined in the project to a Schemevar AddToSchemes runtime.SchemeBuilder // AddToScheme adds all Resources to the Schemefunc AddToScheme(s *runtime.Scheme) error { return AddToSchemes.AddToScheme(s)} 而 AddToSchemes 这个类型的是[]func(*Scheme) error 的别名,只需要在 package apis 下的接口文件中实现相应的 init() 方法来导入实现的版本 API,就可以注入 Scheme 中。 举个例子: $ cat pkg/apis/addtoscheme_dashboard_v1alpha2.go package apis import monitoringdashboardv1alpha2 "kubesphere.io/monitoring-dashboard/api/v1alpha2" func init() { AddToSchemes = append(AddToSchemes, monitoringdashboardv1alpha2.SchemeBuilder.AddToScheme) } 也就是,我们开发的插件集成的版本化资源,必须实现 xxx.SchemeBuilder.AddToScheme 功能,才能注册到 scheme 中,最终暴露为 apis 访问 API 服务。 至此,所有子模块对应的 client 已经与这个 apiserver 绑定。 PrepareRun 下面,我们探讨下 PrepareRun 是怎么注册 kapis 以及绑定 handler 的。 主要是通过 restful-go 框架来实现的。 restful-go 框架使用 container 来 hold 住拥有特定 GVR 的 webservice, 一个 webserver 可以绑定多个 router,允许 container 或者 webserver 添加自定义拦截器,也就是调用 filter 方法。 func (s *APIServer) PrepareRun(stopCh <-chan struct{}) error { // container来hold住拥有特定GVR的webservice s.container = restful.NewContainer() // 添加请求Request日志拦截器 s.container.Filter(logRequestAndResponse) s.container.Router(restful.CurlyRouter{}) // 发生Recover时,绑定一个日志handler s.container.RecoverHandler(func(panicReason interface{}, httpWriter http.ResponseWriter) { logStackOnRecover(panicReason, httpWriter) }) // 每个API组都构建一个webservice,然后根据路由规则来并绑定回调函数 // 通过AddToContainer来完成绑定 s.installKubeSphereAPIs() // 注册metrics指标: ks_server_request_total、ks_server_request_duration_seconds // 绑定metrics handler s.installMetricsAPI() // 为有效请求增加监控计数 s.container.Filter(monitorRequest) for _, ws := range s.container.RegisteredWebServices() { klog.V(2).Infof("%s", ws.RootPath()) } s.Server.Handler = s.container // 添加各个调用链的拦截器, 用于验证和路由分发 s.buildHandlerChain(stopCh) return nil } 上面主要使用 restful-go 框架给 s.Server.handler 绑定了一个 container, 添加了各种拦截器。 在 s.installKubeSphereAPIS() 这一步安装 GVR 绑定了 kapis 代理,具体是这样实现的: // 调用各api组的AddToContainer方法来向container注册kapi: urlruntime.Must(monitoringv1alpha3.AddToContainer(s.container, s.KubernetesClient.Kubernetes(), s.MonitoringClient, s.MetricsClient, s.InformerFactory, s.KubernetesClient.KubeSphere(), s.Config.OpenPitrixOptions)) // 详细来说,各个组件实现的AddToContainer方法 // 为带有GroupVersion信息的webserver添加route,不同路由路径绑定不同的handler ws := runtime.NewWebService(GroupVersion) // 给子路由绑定回调函数 ws.Route(ws.GET("/kubesphere"). To(h.handleKubeSphereMetricsQuery). Doc("Get platform-level metric data."). Metadata(restfulspec.KeyOpenAPITags, []string{constants.KubeSphereMetricsTag}). Writes(model.Metrics{}). Returns(http.StatusOK, respOK, model.Metrics{})). Produces(restful.MIME_JSON) 我们知道 apis 对应 k8s 的请求,而在 ks 中 kapis 对应子组件的代理请求,由 ks-apiserver 自身或者转发目标组件 server 来提供响应,那么 ks-apiserver 是怎么区分这些请求的? 答案是通过 buildHandlerChain 来进行分发的。 buildHandlerChain 上面说到 buildHandlerChain 构建了各种服务的拦截器,按序排列如下。 handler = filters.WithKubeAPIServer(handler, s.KubernetesClient.Config(), &errorResponder{}) if s.Config.AuditingOptions.Enable { handler = filters.WithAuditing(handler, audit.NewAuditing(s.InformerFactory, s.Config.AuditingOptions, stopCh)) } handler = filters.WithAuthorization(handler, authorizers) if s.Config.MultiClusterOptions.Enable { clusterDispatcher := dispatch.NewClusterDispatch(s.InformerFactory.KubeSphereSharedInformerFactory().Cluster().V1alpha1().Clusters()) handler = filters.WithMultipleClusterDispatcher(handler, clusterDispatcher) } handler = filters.WithAuthentication(handler, authn) handler = filters.WithRequestInfo(handler, requestInfoResolver) WithRequestInfo 这个 filter 定义了如下逻辑: info, err := resolver.NewRequestInfo(req) --- func (r *RequestInfoFactory) NewRequestInfo(req *http.Request) (*RequestInfo, error) { ... defer func() { prefix := requestInfo.APIPrefix if prefix == "" { currentParts := splitPath(requestInfo.Path) //Proxy discovery API if len(currentParts) > 0 && len(currentParts) < 3 { prefix = currentParts[0] } } // 通过api路由路径中的携带apis还是kapis就可以区分 if kubernetesAPIPrefixes.Has(prefix) { requestInfo.IsKubernetesRequest = true } }() ... // URL forms: /clusters/{cluster}/* if currentParts[0] == "clusters" { if len(currentParts) > 1 { requestInfo.Cluster = currentParts[1] } if len(currentParts) > 2 { currentParts = currentParts[2:] } } ... } 代码很多,我就不一一截图了,大概意思可以从注释看到: // NewRequestInfo returns the information from the http request. If error is not nil, RequestInfo holds the information as best it is known before the failure // It handles both resource and non-resource requests and fills in all the pertinent information for each. // Valid Inputs: // // /apis/{api-group}/{version}/namespaces // /api/{version}/namespaces // /api/{version}/namespaces/{namespace} // /api/{version}/namespaces/{namespace}/{resource} // /api/{version}/namespaces/{namespace}/{resource}/{resourceName} // /api/{version}/{resource} // /api/{version}/{resource}/{resourceName} // // Special verbs without subresources: // /api/{version}/proxy/{resource}/{resourceName} // /api/{version}/proxy/namespaces/{namespace}/{resource}/{resourceName} // // Special verbs with subresources: // /api/{version}/watch/{resource} // /api/{version}/watch/namespaces/{namespace}/{resource} // // /kapis/{api-group}/{version}/workspaces/{workspace}/{resource}/{resourceName} // / // /kapis/{api-group}/{version}/namespaces/{namespace}/{resource} // /kapis/{api-group}/{version}/namespaces/{namespace}/{resource}/{resourceName} // With workspaces: // /kapis/clusters/{cluster}/{api-group}/{version}/namespaces/{namespace}/{resource} // /kapis/clusters/{cluster}/{api-group}/{version}/namespaces/{namespace}/{resource}/{resourceName} 通过路由定义的信息,就可以区分这个请求是什么级别的,以及这个请求要分发到哪个 server 了。 我们给各个 filter 的回调函数加上断点, 然后做个小实验看下拦截器的拦截顺序是怎样的。 假设远程云主机的服务已经启动,服务端口在 9090,以及你为 anonymous 这个 globalrole 设定了 monitoring.kubesphere.io 这个组下资源类型为 ClusterDashboard 的访问权限。当然了,你也可以用有访问权限的账号来直接测试。 接下来,我们来发送一个 kapis 请求,看这个链路怎么跳跃的: curl -d '{"grafanaDashboardUrl":"https://grafana.com/api/dashboards/7362/revisions/5/download", "description":"this is a test dashboard."}' -H "Content-Type: application/json" localhost:9090/kapis/monitoring.kubesphere.io/v1alpha3/clusterdashboards/test1/template 测试结果如下: WithRequestInfo -> WithAuthentication -> WithAuthorization -> WithKubeAPIServer Run 这个方法主要干了两件事,一是启动 informers 同步资源 , 二是启动 ks apiserver。 func (s *APIServer) Run(ctx context.Context) (err error) { // 启动informer工厂,包括k8s和ks的informers // 同步资源,包括k8s和ks的GVR // 检查GVR是否存在,不存在报错警告,存在就同步 err = s.waitForResourceSync(ctx) if err != nil { return err } shutdownCtx, cancel := context.WithCancel(context.Background()) defer cancel() go func() { <-ctx.Done() _ = s.Server.Shutdown(shutdownCtx) }() // 启动server klog.V(0).Infof("Start listening on %s", s.Server.Addr) if s.Server.TLSConfig != nil { err = s.Server.ListenAndServeTLS("", "") } else { err = s.Server.ListenAndServe() } return err } 至此,调用完 Run 方法后,ks-apiserver 就启动了。 现在我们做一下简单总结: 根据配置文件创建 ks-apiserver 实例 , 该实例调用了三个关键方法,分别是 NewAPIServer、PrepareRun 以及 Run 方法; NewAPIServer 通过给定的配置,绑定各个模块的 client,将自定义的 GVK 注册到 Scheme,暴露 apis 路由服务; PrepareRun 通过 restful-go 框架来注册、绑定 kapi 路由和回调函数,用来自身响应或者下发组件 server 查询合并数据返回给客户端 ; 最后 , 调用 Run 方法,同步资源并启动 ks-apiserver 服务; GVK 探索实战 显然,我们只需要关注各模块的 AddToContainer 方法就行了。 iam.kubesphere.io pkg/kapis/iam/v1alpha2/register.go 从代码注释来看,这个模块管理着 users、clustermembers、globalroles、clusterroles、workspaceroles、roles、workspaces groups 、workspace members、devops members 等账号角色的 CRUD。 现在我们可以在 handler 中打上断点,去请求这些 api。 $ curl "localhost:9090/kapis/iam.kubesphere.io/v1alpha2/users" $ curl "localhost:9090/kapis/iam.kubesphere.io/v1alpha2/clustermembers" $ curl "localhost:9090/kapis/iam.kubesphere.io/v1alpha2/users/admin/globalroles" ... kubeedge.kubesphere.io pkg/kapis/kubeedge/v1alpha1/register.go 代码里面使用的代理转发请求: func AddToContainer(container *restful.Container, endpoint string) error { proxy, err := generic.NewGenericProxy(endpoint, GroupVersion.Group, GroupVersion.Version) if err != nil { return nil } return proxy.AddToContainer(container) } 也就是 kapis/kubeedge.kubesphere.io 的请求会转发到 http://edge-watcher.kubeedge.svc/api/,也就是 kubeedge 这个 namespace 下的 service,相关的接口集成在那里。 关于整合边缘计算平台的集成,除了需要做一个主流边缘框架的快速安装和集成外,还可以集成一个类似 edge-shim 的适配器,大概需要从一下几个方面考虑: 代理 endpoint: 现在的 kubeedge 就是使用代理模式转发; 健康检查接口:至少要确保云端的组件已经成功部署; 事件、长期日志、审计等可观测组件的支持; 其他边缘辅助功能,如文件或者配置下发等; notification.kubesphere.io pkg/kapis/notification/v2beta1/register.go 这个组下的 api 主要实现了 notification 的全局或租户级别的 config 和 receivers 资源的 CRUD。 config 资源 用于配置对接通知渠道相关参数的一些配置,分为全局的和租户级别的 config 资源; reciever 资源 用于配置接收者的一些配置信息,区分全局的和租户级别的接收者; 我们挑选一个回调函数进行剖析: ws.Route(ws.GET("/{resources}"). To(h.ListResource). Doc("list the notification configs or receivers"). Metadata(KeyOpenAPITags, []string{constants.NotificationTag}). Param(ws.PathParameter("resources", "known values include configs, receivers, secrets")). Param(ws.QueryParameter(query.ParameterName, "name used for filtering").Required(false)). Param(ws.QueryParameter(query.ParameterLabelSelector, "label selector used for filtering").Required(false)). Param(ws.QueryParameter("type", "config or receiver type, known values include dingtalk, email, slack, webhook, wechat").Required(false)). Param(ws.QueryParameter(query.ParameterPage, "page").Required(false).DataFormat("page=%d").DefaultValue("page=1")). Param(ws.QueryParameter(query.ParameterLimit, "limit").Required(false)). Param(ws.QueryParameter(query.ParameterAscending, "sort parameters, e.g. ascending=false").Required(false).DefaultValue("ascending=false")). Param(ws.QueryParameter(query.ParameterOrderBy, "sort parameters, e.g. orderBy=createTime")). Returns(http.StatusOK, api.StatusOK, api.ListResult{Items: []interface{}{}})) func (h *handler) ListResource(req *restful.Request, resp *restful.Response) { // 租户或用户的名称 user := req.PathParameter("user") // 资源类型,configs/recievers/secrets resource := req.PathParameter("resources") // 通知渠道 dingtalk/slack/email/webhook/wechat subresource := req.QueryParameter("type") q := query.ParseQueryParameter(req) if !h.operator.IsKnownResource(resource, subresource) { api.HandleBadRequest(resp, req, servererr.New("unknown resource type %s/%s", resource, subresource)) return } objs, err := h.operator.List(user, resource, subresource, q) handleResponse(req, resp, objs, err) } 我们看下 list object 的逻辑: // List objects. func (o *operator) List(user, resource, subresource string, q *query.Query) (*api.ListResult, error) { if len(q.LabelSelector) > 0 { q.LabelSelector = q.LabelSelector + "," } filter := "" // 如果没有给定租户的名称,则获取全局的对象 if user == "" { if isConfig(o.GetObject(resource)) { // type=default对config资源来说是全局的 filter = "type=default" } else { // type=global对receiever资源来说是全局的 filter = "type=global" } } else { // 否则就给过滤器绑定租户名称 filter = "type=tenant,user=" + user } // 组装过滤标签 q.LabelSelector = q.LabelSelector + filter ... // 通过过滤标签获取cluster或者namespace下的指定资源 res, err := o.resourceGetter.List(resource, ns, q) if err != nil { return nil, err } if subresource == "" || resource == Secret { return res, nil } results := &api.ListResult{} ... } 这样一来,就实现了租户级别的通知告警 CR 配置的 CRUD,这些 CR 是这么分类的: config 分为全局 type = default, 租户 type = tenant 两种级别; reciever 分为全局 type = global, 租户 type = tenant 两种级别; 那么 config 和 reciever 怎么相互绑定、告警是如何通过渠道给租户发消息的? https://github.com/kubesphere/notification-manager/blob/master/pkg/webhook/v1/handler.go#L45 https://github.com/kubesphere/notification-manager/blob/master/pkg/notify/notify.go#L66 notification-manager 简称 nm,我这里断章取义地简要回答一下。 功能方面: 全局配置 reciever 通过配置的渠道将所有的 alerts 发送给其定义好的接收者名单, 配置了租户信息的 reciever 只能通过渠道发送当前 ns 下的 alerts; reciever 中可以通过配置 alertSelector 参数来进一步过滤告警消息; 通过修改名为 notification-manager-template 的 confimap 来定制发送消息模板; 告警到通知的流程: nm 使用端口 19093 和 API 路径 /api/v2/alerts 接收从 Alertmanager 发送的告警 ; 回调函数接受 alerts 转换为 notification 模板数据,按照 namespace 区分告警数据; 遍历所有 Recievers,每个 ns 下启动一个协程来发送消息, 而这里每个 ns 对应着多个通知渠道,因此也使用 waitgroup 来并发编排完成任务; monitoring.kubesphere.io pkg/kapis/monitoring/v1alpha3/register.go 将监控指标分为平台级、节点级、workspaces、namespaces、pods 等级别,不仅可以获取总的统计,还能获取 nodes/namespaces/workspaces 下的所有 pods/containers 等监控指标。 我们查看回调函数,以 handleNamedMetricsQuery 为例分析: 遍历给定指标级别下的合法 metric 指标,根据请求参数中 metricFilter 的来过滤指标名; 判断为范围查询还是实时查询,来调取 monitoring 包中相关方法,通过对应的 client 请求后端获取结果返回; 代码如下: func (h handler) handleNamedMetricsQuery(resp *restful.Response, q queryOptions) { var res model.Metrics var metrics []string // q.namedMetrics 是一组按照监控指标级别分类好的拥有promsql expr定义的完整指标名数组 // 监控指标级别分类是根据 monitoring.Levelxxx在上一个栈里细分的,i.e: monitoring.LevelPod for _, metric := range q.namedMetrics { if strings.HasPrefix(metric, model.MetricMeterPrefix) { // skip meter metric continue } // 根据请求参数中的指标名来过滤 ok, _ := regexp.MatchString(q.metricFilter, metric) if ok { metrics = append(metrics, metric) } } if len(metrics) == 0 { resp.WriteAsJson(res) return } // 判断是否是范围查询还是实时查询,继续调用相关函数 // 主要还是用prometheus client去查询promsql, 边缘节点的指标目前通过metrics server来查询 if q.isRangeQuery() { res = h.mo.GetNamedMetricsOverTime(metrics, q.start, q.end, q.step, q.option) } else { res = h.mo.GetNamedMetrics(metrics, q.time, q.option) if q.shouldSort() { res = *res.Sort(q.target, q.order, q.identifier).Page(q.page, q.limit) } } resp.WriteAsJson(res) } 现在,我们将视角移植到 : pkg/models/monitoring/monitoring.go:156 以 GetNamedMetricsOverTime 为例,这里阐述了会合并 prometheus 和 metrics-server 的查询结果进行返回: func (mo monitoringOperator) GetNamedMetricsOverTime(metrics []string, start, end time.Time, step time.Duration, opt monitoring.QueryOption) Metrics { // 获取prometheus client查询结果,主要使用sync.WaitGroup并发查询,每个指标启动一个goroutine,最后将结果和并返回 ress := mo.prometheus.GetNamedMetricsOverTime(metrics, start, end, step, opt) // 如果metrics-server激活了 if mo.metricsserver != nil { //合并边缘节点数据 edgeMetrics := make(map[string]monitoring.MetricData) for i, ressMetric := range ress { metricName := ressMetric.MetricName ressMetricValues := ressMetric.MetricData.MetricValues if len(ressMetricValues) == 0 { // this metric has no prometheus metrics data if len(edgeMetrics) == 0 { // start to request monintoring metricsApi data mr := mo.metricsserver.GetNamedMetricsOverTime(metrics, start, end, step, opt) for _, mrMetric := range mr { edgeMetrics[mrMetric.MetricName] = mrMetric.MetricData } } if val, ok := edgeMetrics[metricName]; ok { ress[i].MetricData.MetricValues = append(ress[i].MetricData.MetricValues, val.MetricValues...) } } } } return Metrics{Results: ress} } 此外,monitoring 包还定义了各监控查询 client 的接口方法,可以按需探索: GetMetric(expr string, time time.Time) Metric GetMetricOverTime(expr string, start, end time.Time, step time.Duration) Metric GetNamedMetrics(metrics []string, time time.Time, opt QueryOption) []Metric GetNamedMetricsOverTime(metrics []string, start, end time.Time, step time.Duration, opt QueryOption) []Metric GetMetadata(namespace string) []Metadata GetMetricLabelSet(expr string, start, end time.Time) []map[string]string tenant.kubesphere.io 再聊 api 之前,顺带一提多租户在隔离的安全程度上,我们可以将其分为软隔离 (Soft Multi-tenancy) 和硬隔离 (Hard Multi-tenancy) 两种。 软隔离更多的是面向企业内部的多租需求; 硬隔离面向的更多是对外提供服务的服务供应商,需要更严格的隔离作为安全保障。 这个 group 下比较重要的部分是实现租户查询 logs/audits/events: 以查询日志为例: func (h *tenantHandler) QueryLogs(req *restful.Request, resp *restful.Response) { // 查询上下文中携带的租户信息 user, ok := request.UserFrom(req.Request.Context()) if !ok { err := fmt.Errorf("cannot obtain user info") klog.Errorln(err) api.HandleForbidden(resp, req, err) return } // 解析查询的参数,比如确定属于哪个ns/workload/pod/container的查询、时间段,是否为柱状查询等 queryParam, err := loggingv1alpha2.ParseQueryParameter(req) if err != nil { klog.Errorln(err) api.HandleInternalError(resp, req, err) return } // 导出数据 if queryParam.Operation == loggingv1alpha2.OperationExport { resp.Header().Set(restful.HEADER_ContentType, "text/plain") resp.Header().Set("Content-Disposition", "attachment") // 验证账号是否有权限 // admin账号可以导出所有ns的日志,租户只能导出本ns的日志 // 组装loggingclient进行日志导出 err := h.tenant.ExportLogs(user, queryParam, resp) if err != nil { klog.Errorln(err) api.HandleInternalError(resp, req, err) return } } else { // 验证账号是否有权限 // admin账号可以查看所有ns的日志,租户只能查看本ns的日志 // 组装loggingclient进行日志返回 result, err := h.tenant.QueryLogs(user, queryParam) if err != nil { klog.Errorln(err) api.HandleInternalError(resp, req, err) return } resp.WriteAsJson(result) } } 由于篇幅有限,只对以上 GVR 进行了调试,感兴趣可以深入了解~ 本文由博客一文多发平台 OpenWrite 发布!

优秀的个人博客,低调大师

从ReentrantLock分析AbstractQueuedSynchronized源码

1.示例代码 ReentrantLock lock = new ReentrantLock(); lock.lock(); lock.unlock(); 2.ReentrantLock构造方法 private final Sync sync; ​ //空构造的情况创建一个非公平锁 public ReentrantLock() { sync = new NonfairSync(); } ​ //传boolean值,true的情况创建一个公平锁,false创建一个非公平锁 public ReentrantLock(boolean fair) { sync = fair ? new FairSync() : new NonfairSync(); } 3.Sync,FairSync,AbstractQueuedSynchronizer继承关系 AbstractQueuedSynchronizer提供了通用方法,但其tryAcquire方法需要子类FairSync自己实现 4.Node节点 AbstractQueuedSynchronized内部维护了一个双向链表,通过waitStatus表示当前线程等待状态 具体结构如下图: 阻塞队列不包含 head 节点,这里要先有一个概念,为什么呢?因为首节点是一个虚节点的概念,不存储数据,从addWaiter方法中的enq方法内可以看出来。在添加一个节点进队列的时候,第一次添加的时候会构建一个空节点。 private Node enq(final Node node) { for (;;) { //获取当前尾节点 Node t = tail; //如果当前尾节点为空 if (t == null) { //构造一个空节点并赋值给head节点 if (compareAndSetHead(new Node())) //将尾节点指向当前空的首节点 tail = head; } else { //循环第二次进来,尾节点一点不为空 //设置当前节点的前置节点为之前的尾节点 node.prev = t; //设置当前节点为尾节点 if (compareAndSetTail(t, node)) { //设置初始化为空的尾节点的后继节点为当前节点 t.next = node; //返回空节点 return t; } } } } static final class Node { //代表节点当前在共享模式下 static final Node SHARED = new Node(); //代表当前节点在独占模式下 static final Node EXCLUSIVE = null; ​ //当前节点线程取消争抢锁 static final int CANCELLED = 1; //当前node节点的后继节点的线程需要被唤醒 static final int SIGNAL = -1; //codition队列节点属性 static final int CONDITION = -2; //这里不讨论 static final int PROPAGATE = -3; ​ //取值为上面的1,-1,-2,-3或者默认值0 volatile int waitStatus; ​ //前驱节点 volatile Node prev; //后继节点 volatile Node next; //当前node节点对应的线程 volatile Thread thread; // Node nextWaiter; /** * Returns true if node is waiting in shared mode. */ final boolean isShared() { return nextWaiter == SHARED; } //获取队列的首节点 final Node predecessor() throws NullPointerException { Node p = prev; if (p == null) throw new NullPointerException(); else return p; } Node() { // Used to establish initial head or SHARED marker } Node(Thread thread, Node mode) { // Used by addWaiter this.nextWaiter = mode; this.thread = thread; } Node(Thread thread, int waitStatus) { // Used by Condition this.waitStatus = waitStatus; this.thread = thread; } ​ } 5.state属性 state值代表当前线程是否获取锁 //AbstractQueuedSynchronizer的核心属性,大于0说明线程持有锁,每重入一次该值+1 //所谓的重入可以理解为lock.lock()调用多次 private volatile int state; ​ //父类AbstractQueuedSynchronizer的方法 //通过UNSAFE类设置内存地址中state的属性值,当state>=1的时候,说明当前线程持有锁 //线程每进行一次重入,state值会+1 protected final boolean compareAndSetState(int expect, int update) { // See below for intrinsics setup to support this return unsafe.compareAndSwapInt(this, stateOffset, expect, update); } 6.从ReentrantLock公平锁分析AbstractQueuedSynchronized 公平锁和非公平锁的区别在于,非公平锁在入队之前,进行了2次CAS操作进行state值的设置,而公平锁只在tryAcquire中首先进行了hasQueuedPredecessors的判断之后,才进行了CAS操作,之后两者的逻辑一样,竞争激烈的时候线程都会被构造成Node节点进入阻塞队列。 加锁过程方法调用图,方便分析代码调用过程 6.1.加锁过程分析 6.1.1.AbstractQueuedSynchronized.acquire方法分析 //加锁核心方法入口 public final void acquire(int arg) { //条件1:FairSync.tryAcquire尝试获取锁,获取成功返回true,失败返回false //条件2:addWaiter将当前线程封装成Node加入到双向队列中 //acquireQueued为核心方法,包含阻塞当前线程,清除队列中CANCELED状态线程 //返回true表示 if (!tryAcquire(arg) && acquireQueued(addWaiter(Node.EXCLUSIVE), arg)) selfInterrupt();//设置当前线程的中断状态,这里涉及interrupt()和interrupted()方法的概念 } interrupt()只设置线程的中断状态为true,并不能中断线程; interrupted()测试当前线程是否已经被中断,并清除线程的中断状态; 6.1.2.FairSync.tryAcquire方法分析 aqs将tryAcquire交给子类去实现,这里示例为FairSync //尝试获取锁 protected final boolean tryAcquire(int acquires) { //获取当前线程 final Thread current = Thread.currentThread(); //获取当前线程状态 int c = getState(); //c==0说明当前线程还未获取锁 if (c == 0) { //条件1:判断当前队列中是否有其他线程在等待,没有则返回false //条件2:条件1中队列中没有其他线程在等待,尝试修改state值,修改成功则代表加锁成功 if (!hasQueuedPredecessors() && compareAndSetState(0, acquires)) { //设置当前线程为独占线程 setExclusiveOwnerThread(current); return true; } } //c>0说当前线程已经获取锁,判断当前线程和独占线程是否相等 else if (current == getExclusiveOwnerThread()) { //将state的值递增 int nextc = c + acquires; if (nextc < 0) throw new Error("Maximum lock count exceeded"); //更新state的值 setState(nextc); return true; } return false; } 6.1.3.AbstractQueuedSynchronized.hasQueuedPredecessors方法分析 FairSync.tryAcquire里面面有一个hasQueuedPredecessors方法,这是和非公平锁的区别,公平锁这里多了这个方法。用于判断队列中是否已经有其他线程在等待,如果没有,则返回false。因为前面也已经说过head节点不存储数据,只是一个虚节点,所以判断队列中是否有处于等待的节点有以下判断: 1.h != t,如果head和tail相同,也就不用判断了,肯定没有处于等待的节点。 2.(s = h.next) == null说明只有首节点,肯定没有处于等待的节点。 3.s.thread != Thread.currentThread(),首节点的next节点的线程不和当前线程一样。如果相同,说明当前首节点只有一个next节点,也就是只有一个线程竞争锁资源,可以直接通过CAS竞争锁资源。 这里有个细节,tail的声明在head之前,因为根据tail你一定可以获取head,但是反过来有head就不一定有tail了。因为head肯定是在tail之前初始化的。这样在多线程竞争情况下,如果head先声明,tail后声明,就会出现head初始化了但tail还未初始化的过程,使得h!=t等式成立。 public final boolean hasQueuedPredecessors() { Node t = tail; Node h = head; Node s; return h != t && ((s = h.next) == null || s.thread != Thread.currentThread()); } 6.1.4.AbstractQueuedSynchronized.addWaiter方法分析 //将当前线程封装成Node节点并返回,指定为独占模式,则节点的nextWaiter为NULL //如果当前节点存在尾节点,则将当前节点加入到队列中,并设置当前节点为尾节点 private Node addWaiter(Node mode) { Node node = new Node(Thread.currentThread(), mode); //将节点加入到队尾当中 Node pred = tail; //如果尾节点不为空 if (pred != null) { //设置当前节点的前驱节点为尾节点 node.prev = pred; //通过cas方式设置当前节点为尾节点 if (compareAndSetTail(pred, node)) { //之前尾节点的后续节点为当前节点 pred.next = node; return node; } } //两种情况走到这里:1.尾节点为空,则队列为空;2.通过CAS设置尾节点失败,存在竞争的时候可能出现 //自旋方式设置当前节点为尾节点,如果尾节点为空,则初始化一个空节点作为首节点,并将当前节点和空的首 //节点构成双向队列,返回该空节点 enq(node); return node; } 6.1.5.AbstractQueuedSynchronized.enq方法分析 自旋方式设置当前节点为尾节点,如果尾节点为空,则初始化一个空节点作为首节点,并将当前节点和空的首节点构成双向队列,返回该空节点 private Node enq(final Node node) { for (;;) { //获取当前尾节点 Node t = tail; //如果当前尾节点为空 if (t == null) { //构造一个空节点并赋值给head节点 if (compareAndSetHead(new Node())) //将尾节点指向当前空的首节点 tail = head; } else { //循环第二次进来,尾节点一点不为空 //设置当前节点的前置节点为之前的尾节点 node.prev = t; //设置当前节点为尾节点 if (compareAndSetTail(t, node)) { //设置初始化为空的尾节点的后继节点为当前节点 t.next = node; //返回空节点 return t; } } } } 6.1.6.AbstractQueuedSynchronized.acquireQueued方法分析 ​ final boolean acquireQueued(final Node node, int arg) { //失败标识 boolean failed = true; try { //中断标识 boolean interrupted = false; //自旋 for (;;) { //获取当前节点前驱节点 final Node p = node.predecessor(); //如果当前节点为前驱节点为首节点,尝试加锁 if (p == head && tryAcquire(arg)) { //加锁成功则设置当前节点为首节点 setHead(node); //之前头节点的后续节点设置为空,帮助gc p.next = null; // help GC //失败标识设置为false failed = false; return interrupted; } //如果节点的前驱节点不是头节点或者尝试加锁失败 //shouldParkAfterFailedAcquire判断当前节点线程是否需要阻塞 //当能阻塞当前线程时,调用parkAndCheckInterrupt方法阻塞线程 //如果被阻塞,当前自旋操作也走不下去了 //线程被阻塞在自旋的这里等待唤醒,这里是非常需要注意的,释放锁后,唤醒的下一个节点的 //代码会立即运行到这一段,然后尝试加锁并把自己设置为首节点,这样就完成了首节点的变更 if (shouldParkAfterFailedAcquire(p, node) && parkAndCheckInterrupt()) interrupted = true; } } finally { //如果阻塞过程发生异常或其他原因导致失败,将当前线程状态设置为CANCELED,同时从队列中剔除 if (failed) cancelAcquire(node); } } 6.1.7.AbstractQueuedSynchronized.shouldParkAfterFailedAcquire方法 private static boolean shouldParkAfterFailedAcquire(Node pred, Node node) { //获取当前节点前驱节点的状态 int ws = pred.waitStatus; //如果前驱节点状态为SIGNAL,当前节点线程可以直接阻塞 if (ws == Node.SIGNAL) return true; //如果前驱节点的状态为CANCELLED,从当前节点向前循环获取第一个waitStatus不为CANCELLED的节点, //并将找到的前驱节点和当前节点构成双向队列,剔除CANCELLED状态的节点 if (ws > 0) { do { node.prev = pred = pred.prev; } while (pred.waitStatus > 0); pred.next = node; } else {//如果ws=0,设置当前节点的前驱节点的状态为SIGNAL //通过cas设置前驱节点的状态为SIGNAL compareAndSetWaitStatus(pred, ws, Node.SIGNAL); } return false; } 6.1.8.AbstractQueuedSynchronized.parkAndCheckInterrupt private final boolean parkAndCheckInterrupt() { //阻塞当前线程,当前线程已被中断 LockSupport.park(this); //清除当前线程的中断状态并测试线程是否已被中断,这里返回true表示线程已经被中断 //虽然这里清除了线程的中断状态,但acquire方法里面调用了selfInterrupt()设置了线程的中断状态 return Thread.interrupted(); } 6.1.9.AbstractQueuedSynchronized.cancelAcquire方法 取消加锁操作,将当前Node从队列中移除,构建新的队列,移除构建新的队列过程中遍历到的CANCELED状态的节点,根据当前节点所处位置分析是否需要唤醒当前节点的后续节点。 这段代码首先干了这几件事: 首先将当前节点的线程设置为空,状态设置为CANCELED,从后向前找到当前节点第一个状态不为CANCELED的前驱节点,并把当前节点的前驱节点设置为找到的这个节点。 再来就是构建新的双向队列,根据当前节点所处的位置有三种情况: (1).当前节点是尾节点。这种情况我们要做什么呢?前面我们已经从后向前获取到了当前节点的前驱节点中第一个不为CANCELED状态的节点,那么是否就需要将所有的CANCELED状态的节点和当前节点剔除呢?没错,这里面就做了这件事。把找到的前驱节点设置为尾节点,把找到的前驱节点的后续节点设置为空,这样就形成了新的双向队列。 (2).当前节点不是尾节点,也不是head的后继节点,对应的就是这个条件判断pred != head。这种情况节点就处于中间了。 类似结构:head<->node1<->node2<->...<->pred(searchNode)<->...(canceled状态节点)<->currentNode<->next<->...<->tail,...(canceled状态节点)<->currentNode这之间的节点都需要剔除,然后将pred(searchNode)<->next构成新的链表。 那么这里是否还需要将找到的pred节点的状态设置为SIGNAL呢,代表后续节点需要被唤醒。 (3).当前节点是head节点的后继节点,需要唤醒当前节点的后继节点,具体方法在unparkSuccessor中 private void cancelAcquire(Node node) { if (node == null) return; //将当前节点线程设置为空 node.thread = null; //获取当前节点的前驱节点 Node pred = node.prev; //从后向前循环,找到当前节点的前驱节点中第一个不为CANCELED状态的节点 //并设置当前节点的前驱节点为找到的节点 while (pred.waitStatus > 0) //node.prev = pred->设置当前节点的前驱节点 //pred = pred.prev node.prev = pred = pred.prev; ​ //获取找到的前驱节点的后续节点 //可能是CANCELLED状态的节点,也可能是当前节点自己 Node predNext = pred.next; ​ //设置当前节点的状态为CANCELLED node.waitStatus = Node.CANCELLED; ​ //(1) //1.如果当前节点是尾节点 //则设置找到的当前节点不为CANCELLED状态的节点为尾节点,为CANCELLED状态的节点可以从队列中剔除了 if (node == tail && compareAndSetTail(node, pred)) { //同时设置找到的当前节点不为CANCELLED状态的前驱节点的尾节点为空,剔除队列中CANCELLED状态的节点 compareAndSetNext(pred, predNext, null); } else { //(2) int ws; //2.当前节点不是head的next节点也不是尾节点 //条件1:当前节点不是head的next节点也不是尾节点 //条件2.1:(ws = pred.waitStatus) == Node.SIGNAL,说明当前节点找到的前驱节点状态是SIGNAL //如果不是SIGNAL,也可能为0 //条件2.2:ws <= 0 && compareAndSetWaitStatus(pred, ws, Node.SIGNAL) //如果前驱节点的状态<=0,则设置找到的前驱节点的状态为SIGNAL,表示需要唤醒后继节点 if (pred != head && ((ws = pred.waitStatus) == Node.SIGNAL || (ws <= 0 && compareAndSetWaitStatus(pred, ws, Node.SIGNAL))) && pred.thread != null) { //获取当前节点的后续节点 Node next = node.next; //如果当前节点后续节点不为空且状态<=0 if (next != null && next.waitStatus <= 0) //设置当前节点找到的前驱节点的后继节点为当前节点的next节点,剔除CANCELLED状态的节点 compareAndSetNext(pred, predNext, next); } else { //(3) //当前节点是head的next节点,需要唤醒当前节点的后继节点,具体方法在unparkSuccessor中 unparkSuccessor(node); } node.next = node; // help GC } } 6.1.10.AbstractQueuedSynchronized.unparkSuccessor方法 private void unparkSuccessor(Node node) { //获取当前节点状态 int ws = node.waitStatus; //如果状态是<0,则设置当前节点状态为0 if (ws < 0) compareAndSetWaitStatus(node, ws, 0); //如果ws > 0 //获取当前节点的后续节点 Node s = node.next; //如果后续节点空或者状态为CANCELED if (s == null || s.waitStatus > 0) { s = null; //从后向前找到最靠近当前节点的状态<1的节点 for (Node t = tail; t != null && t != node; t = t.prev) if (t.waitStatus <= 0) s = t; } //如果找到了这个节点,解除线程的阻塞 if (s != null) LockSupport.unpark(s.thread); } 6.2.释放锁过程分析 lock.unlock(); 这段代码主要调了AbstractQueuedSynchronized的release方法。 6.2.1.分析AbstractQueuedSynchronized的release方法 public final boolean release(int arg) { //尝试释放锁,当state=0的时候返回true if (tryRelease(arg)) { Node h = head; //当首节点不为空且首节点状态不为初始化的状态的情况,唤醒后续节点 if (h != null && h.waitStatus != 0) unparkSuccessor(h); return true; } return false; } //递减c的值,该方法也是aqs交给子类实现的,这里的具体实现是在Sync当中 protected final boolean tryRelease(int releases) { int c = getState() - releases; if (Thread.currentThread() != getExclusiveOwnerThread()) throw new IllegalMonitorStateException(); boolean free = false; //当c的值递减为0的时候 if (c == 0) { free = true; //设置独占线程为空 setExclusiveOwnerThread(null); } //设置state的值为0 setState(c); return free; } 6.2.2.释放锁变更首节点的逻辑和next节点加锁逻辑 acquireQueued方法里面,所有处于阻塞过程中的线程的代码都卡在了自旋的这里,如果当前首节点释放锁,当前首节点的next节点立即从自旋的代码这里恢复,并尝试加锁并把自己变成首节点,这样就完成了已执行完的节点的出队操作。而且自己成为首节点执行完成释放锁时,也把自己的线程设置为空了,然后继续唤醒下一个节点,下一个节点的线程卡在自旋代码块中,然后解除阻塞继续执行。 //acquireQueued方法的自旋逻辑,所有被阻塞的节点的线程都会被卡在这里等待唤醒 for (;;) { //获取当前节点前驱节点 final Node p = node.predecessor(); //如果当前节点为前驱节点为首节点,尝试加锁 if (p == head && tryAcquire(arg)) { //加锁成功则设置当前节点为首节点 setHead(node); //之前头节点的后续节点设置为空,帮助gc p.next = null; // help GC //失败标识设置为false failed = false; return interrupted; } //如果节点的前驱节点不是头节点或者尝试加锁失败 //shouldParkAfterFailedAcquire判断当前节点线程是否需要阻塞 //当能阻塞当前线程时,调用parkAndCheckInterrupt方法阻塞线程 //如果被阻塞,当前自旋操作也走不下去了 //线程被阻塞在自旋的这里等待唤醒,这里是非常需要注意的,释放锁后,唤醒的下一个节点的 //代码会立即运行到这一段,然后尝试加锁并把自己设置为首节点,这样就完成了首节点的变更 if (shouldParkAfterFailedAcquire(p, node) && parkAndCheckInterrupt()) interrupted = true; }

资源下载

更多资源
腾讯云软件源

腾讯云软件源

为解决软件依赖安装时官方源访问速度慢的问题,腾讯云为一些软件搭建了缓存服务。您可以通过使用腾讯云软件源站来提升依赖包的安装速度。为了方便用户自由搭建服务架构,目前腾讯云软件源站支持公网访问和内网访问。

Spring

Spring

Spring框架(Spring Framework)是由Rod Johnson于2002年提出的开源Java企业级应用框架,旨在通过使用JavaBean替代传统EJB实现方式降低企业级编程开发的复杂性。该框架基于简单性、可测试性和松耦合性设计理念,提供核心容器、应用上下文、数据访问集成等模块,支持整合Hibernate、Struts等第三方框架,其适用范围不仅限于服务器端开发,绝大多数Java应用均可从中受益。

Rocky Linux

Rocky Linux

Rocky Linux(中文名:洛基)是由Gregory Kurtzer于2020年12月发起的企业级Linux发行版,作为CentOS稳定版停止维护后与RHEL(Red Hat Enterprise Linux)完全兼容的开源替代方案,由社区拥有并管理,支持x86_64、aarch64等架构。其通过重新编译RHEL源代码提供长期稳定性,采用模块化包装和SELinux安全架构,默认包含GNOME桌面环境及XFS文件系统,支持十年生命周期更新。

WebStorm

WebStorm

WebStorm 是jetbrains公司旗下一款JavaScript 开发工具。目前已经被广大中国JS开发者誉为“Web前端开发神器”、“最强大的HTML5编辑器”、“最智能的JavaScript IDE”等。与IntelliJ IDEA同源,继承了IntelliJ IDEA强大的JS部分的功能。

用户登录
用户注册