首页 文章 精选 留言 我的

精选列表

搜索[记忆机制],共10000篇文章
优秀的个人博客,低调大师

回顾 Android 11 中的存储机制更新

Android 10 引入了对 外部存储权限的更改,旨在更好地保护用户数据以及降低应用的存储空间。在 Android 11 开发者预览版 的时候也加入了很多改进,以帮助开发者更好地适应这些权限修改。 在 Google Play 上发布的大部分应用都会 请求 (READ_EXTERNAL_STORAGE) 存储权限,来做一些诸如在 SD 卡中存储文件或者读取多媒体文件等常规操作。这些应用可能会在磁盘中存储大量文件,即使应用被卸载了还会依然存在。另外,这些应用还可能会读取其他应用的一些敏感文件数据。 在 Android 10 中,我们调整了存储权限的工作方式,仅为应用提供其所需的访问权限。这也是在鼓励应用在指定目录下进行文件存储以限制文件混乱。当应用被卸载后,这些相关的目录也会被删除。 Android 10 所带来的关于存储上的变更遵循了以下三个基本原则 更好的从属性: 系统知道哪些文件属于哪些应用,这可以让用户更方便地管理他们的文件。当应用被卸载后,除非用户需要,否则应用之前所创建的文件也不应该保留在设备上; 保护应用数据: 当一个应用将 它所属的文件 写入外部存储时,这些文件是不应该被其他应用所访问的; 保护用户数据: 当用户下载了一些文件,比如带有敏感信息的邮件附件,这些文件应该对其他应用不可见。 目标 API 级别 (Target SDK Level) 设定为 Android 10 的应用无需请求 Storage 权限,就可以使用自己的外部存储目录并管理媒体集合 (音频、视频、图片和下载数据)。Storage 权限仅允许读取其他应用共享的音频、视频和图片集合,但并不允许访问非本应用创建的下载数据。在 Android 10 里唯一一种访问其他应用创建的非媒体文件的途径是使用 存储访问框架 (Storage Access Framework) 提供的文档选择器。 在 Android 11 中,通过下述的几点来继续优化分区存储 (Scoped Storage) 的开发者体验。 改进媒体存储 Android 10 中要求所有应用都使用 MediaStore API 来访问照片、视频和音乐文件,我们也将继续秉承这个原则。但是我们也知道,很多深度依赖基于原始文件路径 API 的应用和第三方库是很难切换到使用文件描述符 (File Descriptor) 的。因此在 Android 11 里,依赖原始文件路径的 API 和库 可以再次使用 了。您需要在应用的 Manifest 文件里添加 requestLegacyExternalStorage 属性,以保证 Android 10 的用户也可以使用该特性。 在实际的运行中,依赖原始文件路径的 I/O 请求会被重定向到使用 MediaStore API,当使用这种方式访问本应用存储空间之外的文件时,这次重定向会造成性能影响。而且直接使用原始文件路径,并不会比使用 MediaStore API 有更多优势,因此我们强烈建议直接使用 MediaStore API。 在 Android 10 中,应用在对每一个文件请求编辑或删除时都必须得到用户的确认。而在 Android 11 中,应用可以一次请求 修改或者删除多个媒体文件。系统的默认图库应用 (Gallery) 将不再展示这些对话框。我们希望这项改进能够使用户体验更加顺畅。 对 Storage Access Framework 的更新 当我们对广泛的存储访问进行限制后,一些开发者试图使用 Storage Access Framework (SAF) 遍历整个文件系统。但是,SAF 并不适用于广泛地访问共享存储内容。因此,我们 对其进行了更新,限制了它对某些路径的可见性。 在 Android 11 中,将不再允许用户授权访问 Downloads 的根目录、每个可用 SD 卡的根目录以及其它应用的目录。应用仍然可以通过 Storage Access Framework API 或者文件选择器来帮助用户从共享存储中选取个别文件。 针对文件管理应用的特殊权限 针对文件管理器以及一些备份类的应用,它们需要获得共享存储的更广泛的访问权限。Android 11 里引入了一个特别的权限叫做 MANAGE _EXTERNAL_STORAGE,该权限将授权读写所有共享存储内容,这也将同时包含非媒体类型的文件。但是获得这个权限的应用还是无法访问其他应用的应用专属目录 (app-specific directory),无论是外部存储还是内部存储。 我们希望继续允许一些确实有广泛访问外部存储文件需求的应用。在 Android 11 中,已获得 MANAGE_EXTERNAL_STORAGE 权限的应用,可以将用户引导至系统设置页面,让用户选择是否允许该应用 "访问所有文件" (All Files Access)。下面的两种应用示例是可以使用该权限的: 文件管理器 —— 该类应用的主要功能是管理文件; 备份和恢复 —— 该类应用需要访问大批量的文件 (比如切换设备的时候进行数据迁移,或者将数据备份到云端)。 如果您的应用需要访问单个文件,比如文字处理应用,则应该使用 Storage Access Framework (SAF)。 如果您的应用需要 MANAGE_EXTERNAL_STORAGE 权限或者调用了依赖原始文件路径的 API,那么您必须在 AndroidManifest 文件中添加 requestLegacyExternalStorage=true,这样您的应用才能够在搭载 Android 10 的设备上正常运行。 更多相关信息请查看我们在去年 Android 开发者峰会的分享视频《准备好使用分区存储》 或查看 Android 开发者文档: 《Android 11 中的存储更新》 我们非常重视您的反馈,您可以通过 issues tracker 向我们反馈 issue 或新特性需求。

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

分析kubernetes中的事件机制-源码解析

我们通过 kubectl describe [资源] 命令,可以在看到Event输出,并且经常依赖event进行问题定位,从event中可以分析整个POD的运行轨迹,为服务的客观测性提供数据来源,由此可见,event在Kubernetes中起着举足轻重的作用。 event并不只是kubelet中都有的,关于event的操作被封装在client-go/tools/record包,我们完全可以在写入自定义的event。 现在让我们来一步步揭开event的面纱。 Event定义 其实event也是一个资源对象,并且通过apiserver将event存储在etcd中,所以我们也可以通过 kubectl get event 命令查看对应的event对象。 以下是一个event的yaml文件: apiVersion: v1 count: 1 eventTime: null firstTimestamp: "2020-03-02T13:08:22Z" involvedObject: apiVersion: v1 kind: Pod name: example-foo-d75d8587c-xsf64 namespace: default resourceVersion: "429837" uid: ce611c62-6c1a-4bd8-9029-136a1adf7de4 kind: Event lastTimestamp: "2020-03-02T13:08:22Z" message: Pod sandbox changed, it will be killed and re-created. metadata: creationTimestamp: "2020-03-02T13:08:30Z" name: example-foo-d75d8587c-xsf64.15f87ea1df862b64 namespace: default resourceVersion: "479466" selfLink: /api/v1/namespaces/default/events/example-foo-d75d8587c-xsf64.15f87ea1df862b64 uid: 9fe6f72a-341d-4c49-960b-e185982d331a reason: SandboxChanged reportingComponent: "" reportingInstance: "" source: component: kubelet host: minikube type: Normal 主要字段说明:** involvedObject: 触发event的资源类型 lastTimestamp:最后一次触发的时间 message:事件说明 metadata :event的元信息,name,namespace等 reason:event的原因 source:上报事件的来源,比如kubelet中的某个节点 type:事件类型,Normal或Warning event字段定义可以看这里:types.go#L5078 接下来我们来看看,整个event是如何下入的。 写入事件 1、这里以kubelet为例,看看是如何进行事件写入的 2、文中代码以Kubernetes 1.17.3为例进行分析 先以一幅图来看下整个的处理流程 创建操作事件的客户端:kubelet/app/server.go#L461 // makeEventRecorder sets up kubeDeps.Recorder if it's nil. It's a no-op otherwise. func makeEventRecorder(kubeDeps *kubelet.Dependencies, nodeName types.NodeName) { if kubeDeps.Recorder != nil { return } //事件广播 eventBroadcaster := record.NewBroadcaster() //创建EventRecorder kubeDeps.Recorder = eventBroadcaster.NewRecorder(legacyscheme.Scheme, v1.EventSource{Component: componentKubelet, Host: string(nodeName)}) //发送event至log输出 eventBroadcaster.StartLogging(klog.V(3).Infof) if kubeDeps.EventClient != nil { klog.V(4).Infof("Sending events to api server.") //发送event至apiserver eventBroadcaster.StartRecordingToSink(&v1core.EventSinkImpl{Interface: kubeDeps.EventClient.Events("")}) } else { klog.Warning("No api server defined - no events will be sent to API server.") } } 通过 makeEventRecorder 创建了 EventRecorder 实例,这是一个事件广播器,通过它提供了StartLogging和StartRecordingToSink两个事件处理函数,分别将event发送给log和apiserver。NewRecorder创建了 EventRecorder 的实例,它提供了 Event ,Eventf 等方法供事件记录。 EventBroadcaster 我们来看下EventBroadcaster接口定义:event.go#L113 // EventBroadcaster knows how to receive events and send them to any EventSink, watcher, or log. type EventBroadcaster interface { // StartEventWatcher(eventHandler func(*v1.Event)) watch.Interface StartRecordingToSink(sink EventSink) watch.Interface StartLogging(logf func(format string, args ...interface{})) watch.Interface NewRecorder(scheme *runtime.Scheme, source v1.EventSource) EventRecorder Shutdown() } 具体实现是通过 eventBroadcasterImpl struct来实现了各个方法。 其中StartLogging 和 StartRecordingToSink 其实就是完成了对事件的消费,EventRecorder实现对事件的写入,中间通过channel实现了生产者消费者模型。 EventRecorder 我们先来看下EventRecorder 接口定义:event.go#L88,提供了一下4个方法 // EventRecorder knows how to record events on behalf of an EventSource. type EventRecorder interface { // Event constructs an event from the given information and puts it in the queue for sending. // 'object' is the object this event is about. Event will make a reference-- or you may also // pass a reference to the object directly. // 'type' of this event, and can be one of Normal, Warning. New types could be added in future // 'reason' is the reason this event is generated. 'reason' should be short and unique; it // should be in UpperCamelCase format (starting with a capital letter). "reason" will be used // to automate handling of events, so imagine people writing switch statements to handle them. // You want to make that easy. // 'message' is intended to be human readable. // // The resulting event will be created in the same namespace as the reference object. Event(object runtime.Object, eventtype, reason, message string) // Eventf is just like Event, but with Sprintf for the message field. Eventf(object runtime.Object, eventtype, reason, messageFmt string, args ...interface{}) // PastEventf is just like Eventf, but with an option to specify the event's 'timestamp' field. PastEventf(object runtime.Object, timestamp metav1.Time, eventtype, reason, messageFmt string, args ...interface{}) // AnnotatedEventf is just like eventf, but with annotations attached AnnotatedEventf(object runtime.Object, annotations map[string]string, eventtype, reason, messageFmt string, args ...interface{}) } 主要参数说明: object 对应event资源定义中的 involvedObject eventtype 对应event资源定义中的type,可选Normal,Warning. reason :事件原因 message :事件消息 我们来看下当我们调用 Event(object runtime.Object, eventtype, reason, message string) 的整个过程。发现最终都调用到了 generateEvent 方法:event.go#L316 func (recorder *recorderImpl) generateEvent(object runtime.Object, annotations map[string]string, timestamp metav1.Time, eventtype, reason, message string) { ..... event := recorder.makeEvent(ref, annotations, eventtype, reason, message) event.Source = recorder.source go func() { // NOTE: events should be a non-blocking operation defer utilruntime.HandleCrash() recorder.Action(watch.Added, event) }() } 最终事件在一个 goroutine 中通过调用 recorder.Action 进入处理,这里保证了每次调用event方法都是非阻塞的。其中 makeEvent 的作用主要是构造了一个event对象,事件name根据InvolvedObject中的name加上时间戳生成: 注意看:对于一些非namespace资源产生的event,event的namespace是default func (recorder *recorderImpl) makeEvent(ref *v1.ObjectReference, annotations map[string]string, eventtype, reason, message string) *v1.Event { t := metav1.Time{Time: recorder.clock.Now()} namespace := ref.Namespace if namespace == "" { namespace = metav1.NamespaceDefault } return &v1.Event{ ObjectMeta: metav1.ObjectMeta{ Name: fmt.Sprintf("%v.%x", ref.Name, t.UnixNano()), Namespace: namespace, Annotations: annotations, }, InvolvedObject: *ref, Reason: reason, Message: message, FirstTimestamp: t, LastTimestamp: t, Count: 1, Type: eventtype, } } 进一步跟踪Action方法,apimachinery/blob/master/pkg/watch/mux.go#L188:23 // Action distributes the given event among all watchers. func (m *Broadcaster) Action(action EventType, obj runtime.Object) { m.incoming <- Event{action, obj} } 将event写入到了一个channel里面。注意:这个Action方式是apimachinery包中的方法,因为实现的sturt recorderImpl将 *watch.Broadcaster 作为一个匿名struct,并且在 NewRecorder 进行 Broadcaster 赋值,这个Broadcaster其实就是 eventBroadcasterImpl 中的Broadcaster。 到此,基本清楚了event最终被写入到了 Broadcaster 中的 incoming channel中,下面看下是怎么进行消费的。 消费事件 在 makeEventRecorder 调用的 StartLogging 和 StartRecordingToSink 其实就是完成了对事件的消费。 StartLogging直接将event输出到日志 StartRecordingToSink将事件写入到apiserver 两个方法内部都调用了 StartEventWatcher 方法,并且传入一个 eventHandler 方法对event进行处理 func (e *eventBroadcasterImpl) StartEventWatcher(eventHandler func(*v1.Event)) watch.Interface { watcher := e.Watch() go func() { defer utilruntime.HandleCrash() for watchEvent := range watcher.ResultChan() { event, ok := watchEvent.Object.(*v1.Event) if !ok { // This is all local, so there's no reason this should // ever happen. continue } eventHandler(event) } }() return watcher } 其中 watcher.ResultChan 方法就拿到了事件,这里是在一个goroutine中通过func (m *Broadcaster) loop() ==>func (m *Broadcaster) distribute(event Event) 方法调用将event又写入了broadcasterWatcher.result 主要看下 StartRecordingToSink 提供的的eventHandler, recordToSink 方法: func recordToSink(sink EventSink, event *v1.Event, eventCorrelator *EventCorrelator, sleepDuration time.Duration) { // Make a copy before modification, because there could be multiple listeners. // Events are safe to copy like this. eventCopy := *event event = &eventCopy result, err := eventCorrelator.EventCorrelate(event) if err != nil { utilruntime.HandleError(err) } if result.Skip { return } tries := 0 for { if recordEvent(sink, result.Event, result.Patch, result.Event.Count > 1, eventCorrelator) { break } tries++ if tries >= maxTriesPerEvent { klog.Errorf("Unable to write event '%#v' (retry limit exceeded!)", event) break } // Randomize the first sleep so that various clients won't all be // synced up if the master goes down. // 第一次重试增加随机性,防止 apiserver 重启的时候所有的事件都在同一时间发送事件 if tries == 1 { time.Sleep(time.Duration(float64(sleepDuration) * rand.Float64())) } else { time.Sleep(sleepDuration) } } } 其中event被经过了一个 eventCorrelator.EventCorrelate(event) 方法做预处理,主要是聚合相同的事件(避免产生的事件过多,增加 etcd 和 apiserver 的压力,也会导致查看 pod 事件很不清晰) 下面一个for循环就是在进行重试,最大重试次数是12次,调用 recordEvent 方法才真正将event写入到了apiserver。 事件处理 我们来看下EventCorrelate方法: // EventCorrelate filters, aggregates, counts, and de-duplicates all incoming events func (c *EventCorrelator) EventCorrelate(newEvent *v1.Event) (*EventCorrelateResult, error) { if newEvent == nil { return nil, fmt.Errorf("event is nil") } aggregateEvent, ckey := c.aggregator.EventAggregate(newEvent) observedEvent, patch, err := c.logger.eventObserve(aggregateEvent, ckey) if c.filterFunc(observedEvent) { return &EventCorrelateResult{Skip: true}, nil } return &EventCorrelateResult{Event: observedEvent, Patch: patch}, err } 分别调用了 aggregator.EventAggregate , logger.eventObserve , filterFunc 三个方法,分别作用是: aggregator.EventAggregate:聚合event,如果在最近 10 分钟出现过 10 个相似的事件(除了 message 和时间戳之外其他关键字段都相同的事件),aggregator 会把它们的 message 设置为 (combined from similar events)+event.Message logger.eventObserve:它会把相同的事件以及包含 aggregator 被聚合了的相似的事件,通过增加 Count 字段来记录事件发生了多少次。 filterFunc: 这里实现了一个基于令牌桶的限流算法,如果超过设定的速率则丢弃,保证了apiserver的安全。 我们主要来看下aggregator.EventAggregate方法: func (e *EventAggregator) EventAggregate(newEvent *v1.Event) (*v1.Event, string) { now := metav1.NewTime(e.clock.Now()) var record aggregateRecord // eventKey is the full cache key for this event //eventKey 是将除了时间戳外所有字段结合在一起 eventKey := getEventKey(newEvent) // aggregateKey is for the aggregate event, if one is needed. //aggregateKey 是除了message和时间戳外的字段结合在一起,localKey 是message aggregateKey, localKey := e.keyFunc(newEvent) // Do we have a record of similar events in our cache? e.Lock() defer e.Unlock() //从cache中根据aggregateKey查询是否存在,如果是相同或者相类似的事件会被放入cache中 value, found := e.cache.Get(aggregateKey) if found { record = value.(aggregateRecord) } //判断上次事件产生的时间是否超过10分钟,如何操作则重新生成一个localKeys集合(集合中存放message) maxInterval := time.Duration(e.maxIntervalInSeconds) * time.Second interval := now.Time.Sub(record.lastTimestamp.Time) if interval > maxInterval { record = aggregateRecord{localKeys: sets.NewString()} } // Write the new event into the aggregation record and put it on the cache //将locakKey也就是message放入集合中,如果message相同就是覆盖了 record.localKeys.Insert(localKey) record.lastTimestamp = now e.cache.Add(aggregateKey, record) // If we are not yet over the threshold for unique events, don't correlate them //判断localKeys集合中存放的类似事件是否超过10个, if uint(record.localKeys.Len()) < e.maxEvents { return newEvent, eventKey } // do not grow our local key set any larger than max record.localKeys.PopAny() // create a new aggregate event, and return the aggregateKey as the cache key // (so that it can be overwritten.) eventCopy := &v1.Event{ ObjectMeta: metav1.ObjectMeta{ Name: fmt.Sprintf("%v.%x", newEvent.InvolvedObject.Name, now.UnixNano()), Namespace: newEvent.Namespace, }, Count: 1, FirstTimestamp: now, InvolvedObject: newEvent.InvolvedObject, LastTimestamp: now, //这里会对message加个前缀:(combined from similar events): Message: e.messageFunc(newEvent), Type: newEvent.Type, Reason: newEvent.Reason, Source: newEvent.Source, } return eventCopy, aggregateKey } aggregator.EventAggregate方法中其实就是判断了通过cache和localKeys判断事件是否相似,如果最近 10 分钟出现过 10 个相似的事件就合并并加上前缀,后续通过logger.eventObserve方法进行count累加,如果message也相同,肯定就是直接count++。 总结 好了,event处理的整个流程基本就是这样,我们可以概括一下,可以结合文中的图对比一起看下: 创建 EventRecorder 对象,通过其提供的 Event 等方法,创建好event对象 将创建出来的对象发送给 EventBroadcaster 中的channel中 EventBroadcaster 通过后台运行的goroutine,从管道中取出事件,并广播给提前注册好的handler处理 当输出log的handler收到事件就直接打印事件 当 EventSink handler收到处理事件就通过预处理之后将事件发送给apiserver 其中预处理包含三个动作,1、限流 2、聚合 3、计数 apiserver收到事件处理之后就存储在etcd中 回顾event的整个流程,可以看到event并不是保证100%事件写入(从预处理的过程来看),这样做是为了后端服务etcd的可用性,因为event事件在整个集群中产生是非常频繁的,尤其在服务不稳定的时候,而相比Deployment,Pod等其他资源,又没那么的重要。所以这里做了个取舍。 参考文档: https://cizixs.com/2017/06/22/kubelet-source-code-analysis-part4-event/ 关注公众号,获取最新文章推送

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

深入理解 Go-Defer的机制

defer 的作用和执行时机go 的 defer 语句是用来延迟执行函数的,而且延迟发生在调用函数 return 之后,比如 func a() int { defer b() return 0}b 的执行是发生在return 0之后,注意defer 的语法,关键字defer之后是函数的调用。 defer 的重要用途一:清理释放资源由于defer 的延迟特性,defer常用在函数调用结束之后清理相关的资源,比如 f, _ := os.Open(filename)defer f.Close()文件资源的释放会在函数调用结束之后借助 defer 自动执行,不需要时刻记住哪里的资源需要释放,打开和释放必须相对应。 用一个例子深刻诠释一下defer带来的便利和简洁。 代码的主要目的是打开一个文件,然后复制内容到另一个新的文件中,没有defer时这样写: func CopyFile(dstName, srcName string) (written int64, err error) { src, err := os.Open(srcName) if err != nil { return } dst, err := os.Create(dstName) if err != nil { //1 return } written, err = io.Copy(dst, src) dst.Close() src.Close() return }代码在 #1处返回之后,src文件没有执行关闭操作,可能会导致资源不能正确释放,改用 defer实现: func CopyFile(dstName, srcName string) (written int64, err error) { src, err := os.Open(srcName) if err != nil { return } defer src.Close() dst, err := os.Create(dstName) if err != nil { return } defer dst.Close() return io.Copy(dst, src) }src 和dst都能及时清理和释放,无论return在什么地方执行。 鉴于defer 的这种作用,defer常用来释放数据库连接,文件打开句柄等释放资源的操作。 defer的重要用途二:执行recover被 defer的函数在return之后执行,这个时机点正好可以捕获函数抛出的 panic,因而 defer 的另一个重要用途就是执行 recover。 recover 只有在defer中使用才更有意义,如果在其他地方使用,由于 program已经调用结束而提前返回而无法有效捕捉错误。 package main import ( "fmt" ) func main() { defer func() { if ok := recover(); ok != nil { fmt.Println("recover") } }() panic("error") }记住defer要放在panic执行之前。 多个 defer 的执行顺序defer 的作用就是把关键字之后的函数执行压入一个栈中延迟执行,多个defer的执行顺序是后进先出LIFO: defer func() { fmt.Println("1") }()defer func() { fmt.Println("2") }()defer func() { fmt.Println("3") }()输出顺序是 321。 这个特性可以对一个 array实现逆序操作。 被 deferred 函数的参数在 defer 时确定这是 defer的特点,一个函数被 defer时,它的参数在defer 时进行计算确定,即使defer之后参数发生修改,对已经defer的函数没有影响,什么意思?看例子: func a() { i := 0 defer fmt.Println(i) i++ return }a 执行输出的是 0 而不是 1,因为defer 时,i 的值是 0,此时被 defer的函数参数已经进行执行计算并确定了。 再看一个例子: func calc(index string, a, b int) int { ret := a + b fmt.Println(index, a, b, ret) return ret } func main() { a := 1 b := 2 defer calc("1", a, calc("10", a, b)) a = 0 return }执行代码输出 10 1 2 3 1 1 3 4defer函数的参数 第三个参数在 defer时就已经计算完成并确定,第二个参数 a 也是如此,无论之后 a 变量是否修改都不影响。 被 defer的函数可以读取和修改带名称的返回值func c() (i int) { defer func() { i++ }() return 1 }被 defer的函数是在return 之后执行,可以修改带名称的返回值,上面的函数 c 返回的是 2。 参考资料https://blog.golang.org/defer-panic-and-recover

资源下载

更多资源
Mario

Mario

马里奥是站在游戏界顶峰的超人气多面角色。马里奥靠吃蘑菇成长,特征是大鼻子、头戴帽子、身穿背带裤,还留着胡子。与他的双胞胎兄弟路易基一起,长年担任任天堂的招牌角色。

Spring

Spring

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

Sublime Text

Sublime Text

Sublime Text具有漂亮的用户界面和强大的功能,例如代码缩略图,Python的插件,代码段等。还可自定义键绑定,菜单和工具栏。Sublime Text 的主要功能包括:拼写检查,书签,完整的 Python API , Goto 功能,即时项目切换,多选择,多窗口等等。Sublime Text 是一个跨平台的编辑器,同时支持Windows、Linux、Mac OS X等操作系统。

WebStorm

WebStorm

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

用户登录
用户注册