V2EX = way to explore
V2EX 是一个关于分享和探索的地方
现在注册
已注册用户请  登录
V2EX  ›  kuanat  ›  全部回复第 1 页 / 共 9 页
回复总数  168
1  2  3  4  5  6  7  8  9  
@easychen #45

我之前的思路局限在脚本和工作流都是人在写之上,如果把视野展开,让 AI 来做胶水类型的工作(这本来也是语言模型的强项),现在就具备可用性了。

我也有尝试用 AI 来辅助生成功能性脚本,目前遇到的主要问题是没有很好的验证机制,很多时候需要人参与代码审计。另外目前基于提示词的运作方式接近于声明式编程,生成代码的实现方式比较不可控,更增加了人工判断的成本。
@meshell #24

继续往下查吧,wh.srv 可能做了些什么操作。随便猜一下,可能是某个 context 有 30s 的设置,超时之后直接在 http 层面触发了 defer Close() 之类的操作,这个操作完成了 tcp 层面 FIN/ACK 的关闭,结果导致 ws 层面是没有 opClose 消息的。

我看了一下 gobwas/ws 的代码,UpgradeHTTP 这里就把 net.Conn 的 deadline 给清除了(设置了 time.Time 的零值)。(既然是无超时,理论上每次读 message 的时候 err := conn.SetReadDeadline(time.Time{}) 这个重置就没有必要了,不过与你的问题无关)
@meshell #17

你给的截图里,最后一次客户端 ACK 确认服务端 Pong 之后,服务端主动发送 FIN ,说明断开是服务端的行为。

这个断开没有 opClose ,说明不是你的程序、也不是 ws 库的行为。

因为你是本地测试,也不会涉及防火墙。

由于 Ping/Pong 的间隔是 2s ,有双向通信,说明不是 Idle 相关的超时。也就是说,并不是 KeepAlive 等机制触发的先断开底层,再断开上层。

整个协议层面,在 ws 之下,还有(大概率)标准库 net/http ,再下层就是系统的 tcp socket 了。

我记忆中标准库 DefaultTransport 有个 30s 超时,查了一下 https://go.dev/src/net/http/transport.go 确实有,但是应该与你的问题无关。

正好你说你用的不是 http.client ,可以贴一下最小可复现的完整代码。因为之前的代码看不到 conn 的来源,可能是有哪个地方设置了超时参数。
我没有用过这个库,随便猜测一下。

理论上 ws 这种应用层协议,没有主动关闭行为,是不会在自己层面关闭连接的。底层的 tcp 在没有 keepalive 介入的情况下,连接建立后能够无限保持。ws 库在收到关闭信号之后,会向更底层传递这个信号,于是 http 到 tcp 都会关闭相应 socket 。

上面的意思是,这个行为不是 ws 库和你的程序主动行为造成的。

我看到你说有定时发送 ping ,那么另一端是否有回应 pong 呢?如果没有回应的话会出现一种情况,接收方会保持正常,而发送方连续 30s 只有发送而没有接收,触发了更底层协议的某个断开机制。

正好 golang net/http 默认 transport 超时就是 30s 。如果上面的库是基于标准库实现的话,可能就是 http 层先断开了。
之前的话题让我给带跑偏了,前面解释了 accept interfaces ,这里回归到 return structs 上面总结一下。

这句话的应用场景应该是 API 兼容性方面的,即返回结构体的代码写法可以避免很多不兼容的改动。

我之前在 Python 包管理的一个帖子里 https://hk.v2ex.com/t/1007645 简单提到过,像 Go 这样设计先于实现的语言,都会将包管理作为工具链的一部分。但这里的大前提是广大开发者合作,所有开源项目的包都尽量支持 semantic versioning 的版本号原则。包的提供者通过版本号主动声明 API 兼容性,包管理可以以很低的成本(非 NP 算法)解决依赖计算问题。

另一方面,Go 在 OO 抽象层面选择了组合机制而不是继承,从客观事实上也鼓励了包的复用。作为 Go 的开发者需要一个思维转变,就是任何一个包都可能依赖别的包,也可能被别的包依赖。后者这个情况就需要开发者清楚了解,什么情况下会造成 API 无法向后兼容。

在之前的讨论里已经明确过,Go 的接口是由使用方定义的,当这个使用方 X 的包变成其他包的依赖之后,X 就很难对这个导出接口做改动了,因为给一个接口增加新的方法一定是个非兼容的改动。

所以对于一般的应用场景来说,既然接口是调用方来定义的,那么这个定义只对调用方有意义,它完全可以是非导出的形式。这样 X 对于接口的改动都不会影响到下游的使用者。

这句话隐含的意思是 return structs (not interfaces),针对的是从传统基于继承的语言转过来,习惯使用工厂方法而言的。

在 Java 中需要在多个实现中选择一个实例化的时候, 受到接口必须和实现在一个包里的限制,使用工厂方法实际上是暴露接口,隐藏对象(结构体)。在 Go 当中没有这个限制,实际是鼓励暴露结构体,隐藏接口。(当然技术上说一个非导出的接口只是形式上不可见,下游依旧可以根据源码隐式实现,这里不展开说了。)



Java 部分就不举例了,这里用 Go 模仿工厂方法模式来展示这样做的缺陷:

```go
type Storage interface {
____Get()
}
func NewStore(provider string) Storage {
____switch provider {
____case "A": ...
____case "B": ...
____default: ...
____}
}
```

项目使用过程中发现,还需要批量下载接口,于是想修改接口为以下形式:

```go
type Storage interface {
____Get()
____GetBatch()
}
```

无论在 Go 还是 Java 中,这个非兼容改动会导致大量的修改工作。

回到 Go idiomatic 的实现方式上:

```go
type storage interface {
____Get()
____GetBatch()
}
type MyStore struct {}
func NewStore(s storage) *MyStore {}
```

下游只依赖 MyStore ,上有对于 storage 的改动是 API 兼容的。

对于接口改动,需要对 A/B 的实现进行封装,改动也比工厂方法模式简单。比如可以独立另一个接口:

```go
type storageExt interface {
____GetBatch()
}
```

也可以用在结构体中嵌入( embedding )一个接口,其他部分封装一下:

```go
type MyStore interface {
____storage
}
```

这里有个技术层面的大前提,扩展结构体在绝大多数时候都是向后兼容的,而扩展接口永远都不是向后兼容的。所以暴露一个未来可能扩展的结构体,远比暴露一个接口更合理。关于这一点可以看我在另一个帖子 https://v2ex.com/t/1007845 当中的回复,中间提到两个讲座就是对这个问题的解释。

由于 V2EX 回复里面插代码太难读了,我这里就不举例展开了,顺着这个场景想象一下大概就知道增加功能这个需求所需要的工作量。就这个扩展接口的场景,Java 无论如何都要 X 主导这个修改,而 Go 里面 X 有需要就 X 来改; Y 如果有需要,把 X 的包引入进来,Y 也可以做这个修改。还是那句话,Go 的接口模式实现了工程层面(不仅仅是 API 层面)的解耦。


做个简单总结:

Java 工厂方法模式是为了解决只有 Java 才有的问题而形成的一般设计方法,而 Go 天然是不存在这个问题的。所以在 Go 中使用接口的原则和 Java 中是完全不一样的。

站在上游的角度,主动暴露接口一般是两个目的:一是规范使用,比如标准库把 Error 定义为接口;二是为文档服务,因为非导出接口是不会体现在 godoc 里面的。

站在下游的角度,只有在第一种情况才会主动使用上游接口,比如所有人都用 slog 的日志接口;使用上游接口等于主动为自己增加一个硬编码的依赖,正确的做法是使用上游暴露的结构体,然后封装并实现自己的接口。

顺便一提,由于太多下游错误使用上游接口的情况存在,很多上游开发者会在导出接口中包含一个非导出方法,这样下游就无法实现这个接口,上游就可以主动控制下游的使用方式,避免后期改动影响太多用户。

Accept interfaces, return structs 虽然只有四个字,但它代表的是思维模型的转变,想要说清楚实在是太困难了。这句话的核心思想我觉得 Rob Pike 的总结更恰当:Don't design with interfaces, discover them.

用我的话来总结就是,不要沿用 Java 面向接口编程的思路,先设计再实现,而是先实现,当发现有重复实现的需要时,再用接口来重构。在 Java 里面,没有设计到的功能重构起来代价非常大,所以变相要求预先做大量设计,而 Go 里面重构代价非常小,用到了再改。这是组合优于继承的体现。
这种通信场景一般没有路由的说法吧,都是用协议这个词。

如楼上说得都挺好了。我有个建议,你可以看看用 unix domain socket 做 IPC 通信一般是怎么做的。ws 就是把本地变远程,protobuf 就是 socket 的具体实现(协议/路由)。

在 web 编程里是匹配路由然后把请求交给对应的 handler ,在 socket 编程里硬要说路由或者协议的话就是某个字节代表特定的类型,然后每个类型有一个专门的 handler 来响应。
2 天前
回复了 wzhings 创建的主题 信息安全 [求助] 如何有效地保存用户名和密码?
Pass, the standard unix password manager.
https://www.passwordstore.org/

git+gpg ,把楼主想做的自动化了。
一开始看帖子没看明白是做什么用的,看了手册才反应过来这是做了一套类似 IPC 的规范,外加一个注册中心。

有一点我觉得 OP 做得非常好,schema 自动生成。但是就如 #5 @w568w 所说的,这套规范化的做法是和写脚本这个行为的初衷天然互斥的,一个追求复用,一个追求敏捷。而且楼主在文章里也说,为了这个工具重写了大量脚本,着实有一种为了这碟醋包了一顿饺子的意味。

正好借这个帖子我想探讨一下 IPC 和工作流的话题,不知楼主是否了解过 dmenu ?



鉴于很多人可能从来没有听说过 dmenu ,我就简单介绍一下。它原本是为 dwm 窗口管理器设计的动态菜单( dynamic menu )应用,常常用作 Linux 环境的启动器。

和其他所谓效率工具最大的不同在于,dmenu 的哲学是它仅仅只负责从 stdin 读取输入生成菜单,然后向 stdout 输出用户的选择。整个过程的交互全部是纯文本。如果有必要上一个 dmenu 的输出也可以作为下一个 dmenu 的输入。

我个人认为 dmenu 设计思想的优秀之处在于:将写脚本和写工作流恰当地分离开,写脚本依然是那个能跑就行的随手工作,当需要将脚本集成进工作流的时候,适配所需的代价非常小,关键是并不需要刻意改动脚本。



单纯这样说可能依旧看不出 dmenu 设计思路的巧妙之处,我举几个例子说明。

比如我希望命令行将 wifi 切换至手机热点。需要准备两个脚本,一个是调用 nmcli 扫描当前可用网络,然后字符串处理一下生成 wifi 列表;另一个是从命令行读入一个字符串,调用 nmcli 连接该字符串所代表的 wifi 网络。

这个过程里 dmenu 的作用是,调用第一个脚本得到输出结果,以列表的形式供用户选择,并将用户的选择输出,传递给第二个脚本使用。写成命令行就是 `script1 | dmenu | script2`,通过管道的形式传递。

从这个例子可以看出,两个脚本本身就是实现功能用的,原本怎么写就怎么写,不需要预先考虑去适配某种输出。即便是别人写的脚本,只要它是文本输出,完全可以套一层 wrapper 使用 awk/sed 等工具转换成需要的输出。脚本不支持命令行输入,也可以使用 xargs 等方式将 stdin 转换为命令行参数。

至于工作流,无非就是扩展之前的命令行 `script1 | dmenu | script2 | xargs ... script3 | dmenu | script4` 这样,在有需要用户输入或者选择的地方加入 dmenu 即可。更进一步的话,将脚本都放在 ~/.local/bin 然后设计一个“入口”工作流,让用户首先选择所需要调用的脚本,那就变成了万能入口的效率工具了。



我不记得是什么时候接触到 dmenu 了,15 年应该是有了,现在我早已不用 dmenu ,但是依旧在用基于 dmenu 思路的替代品。写这个回复的时候突然想起了很早之前比较流行的一篇文章《开发人员为何应该使用 Mac OS X 兼 OS X 小史》,链接在 https://blog.youxu.info/2010/02/28/why-mac-os-x-for-programmers/

从时代的发展来看,即便是 macOS 这么理想化的 IPC 设计(图形化界面基于 Mach 的 Service 服务概念),依旧是推广不开的。抛开技术层面统一 runtime 不靠谱这一点,更重要的应该是人性,想要别人主动去适配实在过于困难。

万能入口、启动器这种工具的生态其实挺有意思的:Linux 用户似乎从来不关心,估计是都有自己手搓的方案; Windows/macOS 平台上的实现几乎看不到基于 dmenu 思想的实现,虽然可以解释为这些工具面向的都是没有编程能力的群体,那适配脚本的工作就显得更加难以实现了。
@shinelamla #74

回复比较晚……单就引文那个代码来看,我觉得没有必要写接口,因为还没用到。直接写成 func (c *Conn) ListPosts() []*Post { ... } 就行,Conn 可以 embed 一个 grpc.ClientConn 这样。

等我写完文章吧,这个话题确实不太容易说清楚。
@xywanghb #72

我也是到了 70 楼的回复才意识到关键所在,你说的就是我想表达的。

Java 的接口和 Go 的接口只是有一样的名字,实际上作用完全不一样,根本不能拿来类比的。Java 的接口是用来解决多重继承问题的,而 Go 天然基于组合而非继承,接口的能力和责任范围都更大。

Java 的思维模型里,抽象(动词)设计这个行为越早越好,而且机制上鼓励你尽可能考虑易用性和扩展性,原因是后期做调整很麻烦。这让我想起了上学的时候,万物皆对象,想把整个宇宙都用对象和类描述出来。这个思路导致了 Java 在工程方面是有过度设计和复杂化倾向的,现实里 java 团队往往也比较大。

Go 的思维模型里,越简单越好,不需要考虑额外的东西。责任划分非常清晰,抽象这个行为局限在非常小的业务层面。

这中间的区别我认为可以上升到哲学层面,就是我开头提到的汉语和其他语言的区别。汉语是建立在组合的哲学上的,把全宇宙所有具象、抽象的概念都解构归纳成最基础的元素,大概只有几千个汉字。任何人学会这几千个字,就可以尝试自行描述整个世界。

换到其他语言,简单举例几个,化学、医学和植物学,每个都有自己无限衍生的词汇表,在一个领域的词汇积累是无法平移到另一个领域的(多继承失败)。

从这个意义上说,我认为以 Go/Rust 等等现代语言就是先进生产力的代表,减轻了开发者的心智负担,也就解放了生产力。
@aababc #64

没办法确定“实现”了接口。

在 Java 这种 strongly typed 语言中,这个判定过程发生在编译时,implements 就是告诉编译器做这个验证工作的。在 Go 这种 weakly typed 语言中,这个判定被推迟到运行时,如果没能真正实现,调用的那一刻会产生运行时错误。

于是 Java 的思维模型就是要先说清楚,即库和包的作者主动声明并接口化。而 Go 的思维模型是用到的时候再说,即调用方来定义到底需要什么接口(我定义的我自己当然知道谁实现了谁没实现)。

我前面举的例子可能不是特别恰当,但是由于 Go 的接口声明在调用方,而实现在上游的包和库,这个隔离或者独立已经是非常大的进步了。从各种开源项目看,引用上游依赖几乎是毫无副作用的事情。
@sagaxu #66

我在这个帖子反复讨论中突然意识到一个问题,就是 Go 的接口其实并不是等价于 Java 中的接口的。在 Go 实现泛型之前,Go 的接口承担了很大的抽象作用,而这个问题在 Java 中并不存在。

我在构思文章的时候一直很纠结,总感觉说不到重点上。现在看我更应该回答的问题是,Go 这样的设计到底带来了哪些实质的好处,而不是执着于辩论这个设计是否先进。
@leonshaw #62

经过这么一整个帖子的讨论,我越来越意识到之前的举例不恰当。

不论是 Go 还是 Java 都需要适配,区别更多是在难易程度上。

我设想了一个新例子,比如我一个已经存在的项目,实现了批量上传功能,调用方法入参是个包含 batchUpload() 方法的接口。

如果需要增加 A 作为云服务后端,而 A 的 sdk 只有单文件 put 功能,那么我适配的时候可以直接 func (a *A) batchUpload() { ... } 然后调用 a.put() 完成实现。

也就是说 Go 支持给我并没有所有权的代码里的结构体添加新的方法。换到 Java 里不能修改 A 的实现,就需要子类实现接口过渡一下。

在 Go 里 A 永远是那个 A ,而 Java 里子类和父类就要额外考虑类型兼容的问题。如果再有下游项目引用了我的包,或者需要 mock 一下做测试,Go 都是肉眼可见比 Java 简单很多。
@shinelamla #44

你说的不啰嗦就是我也感同身受,随便举几个例子。

从读的方面说,项目选型的时候有多个开源库备选,选哪个总要花很久调研。Go 在做同样的事情的时候,再复杂的项目,很快就能梳理清楚架构,了解代码质量。

我也是 Java 过来的人,写 Java 的时候我很讨厌写测试。原因是项目依赖很多都是非接口化的,真正用的时候要自己再封装一层。没有接口化的代码是很难做 mock 测试的,所以有很多测试框架使用了运行时动态生成 mock 代码的方式来解决这个问题,但是我内心还是不情愿写。

接触 Go 之后我反倒非常习惯写测试,不论依赖质量高低如何,mock 就是接口套一下的事情,代码很少。很早之前标准库想要提供 mock 的,后来废弃了,原因就是 mock 这个事情其实用不到再搞个库。还有个意外的副作用是甩锅的时候很有底气,接口内侧是我负责,外侧该找谁找谁。

所以我体会到最重要的事情是,只有机制上足够简洁便利,大家才会愿意用主动用,人性使然。大多数时间我并不想辩论“XXX 也可以”这种能不能的问题,大家都是图灵完备的换个表达方式而已,但是好不好用愿不愿意用才更重要。
@whitedroa #41

10 楼是走在路上手机回复的,感觉没说清楚,所以补了 11 楼的内容,后面的内容比较好理解一点。原意是 A/B 都是封装了对应厂商的 sdk 的实现,调用的时候是不关心具体是 A/B 哪个实例化的。
@lxdlam #45

之前走在路上手机回复了第一条,然后觉得不妥又举了代码的例子。经过反复讨论之后我觉得确实不合适,和我想表达的意思差得比较远了。

我这样重新总结一下,就用“推卸责任”这个说法,我觉得很恰当。

Duck typing 通过把类型检查推迟到运行时,达到了解耦接口与实现的目的。

在 Java 这类语言中,接口的定义和实现总是绑定在一起的。要么库的作者提前声明接口,然后给一个示例实现。要么调用方封装适配,把别人的代码封装到自己的接口里。

Go 里面把这个责任拆分了,写实现的就写实现,写接口的就写接口。都不用向对方负责。
@lxdlam #46

这个是我孤陋寡闻了。

我有个问题,可能严格来说 OCaml 更接近于 TS 那一类 structural typing 类型的语言?
@sagaxu #40

如果你认可要由使用者定义接口,那我们的立场是一致的。用到接口的时候再定义比预先设想就定义要好。

你举的例子正好就是 Go 风格接口的用法。区别在于如果你对 A/B 包的代码没有所有权的话(引入的第三方),并不能直接写 class A implements Storage 这样,所以一般要写一个子类 StorageA 然后你要手动完成 class StorageA implements Storage 内部的代码再封装一下。习惯上一般叫适配器模式吧。

编程语言在图灵完备层面是一样的,只是写法不同。这里的区别在于,Java 里面接口的实现和定义总是在一起的,或者说总是由同一个代码所有者完成的。我上面举的例子,接口定义和实现都是 A 的作者写的,你这个例子里实现和定义都是 main 的作者维护的。

在 Go 的例子里,接口和定义是分属不同的包,由不同的人实现的。
@aababc #38

楼上有个链接,也是提到了原文那个说法 accept interfaces, return structs 含义是很模糊的。

现在 reddit 上有个帖子,里面提到了这句话最原始的出处:
https://medium.com/@cep21/preemptive-interface-anti-pattern-in-go-54c18ac0668a

我上面的回答其实是个简化的版本,并没有非常正面回答 accept interfaces, return structs 的意义,因为这句话根本体现不出来接口对于 Go 的意义(况且很多场合并不适用)。

上面的解释是回归到本质,即它真正想解决的问题什么。我对这个问题的解释是,这样的写法不仅在代码层面把功能进行了解耦,也在工程层面对人的责任边界完成了切分。

就像你所说的,标准库里的接口是个指导作用,如果没有标准库的影响,下层写成任何形式都是有可能的。现在的写法是在当前语言表达能力下,最 idiomatic 那一个。
@gowk #23

就事论事,我不是很认可这位作者的说法。即使我和他得到了相同的结论,也不代表我们有一样的推理过程。
1  2  3  4  5  6  7  8  9  
关于   ·   帮助文档   ·   博客   ·   API   ·   FAQ   ·   实用小工具   ·   2916 人在线   最高记录 6679   ·     Select Language
创意工作者们的社区
World is powered by solitude
VERSION: 3.9.8.5 · 20ms · UTC 11:38 · PVG 19:38 · LAX 04:38 · JFK 07:38
Developed with CodeLauncher
♥ Do have faith in what you're doing.