V2EX = way to explore
V2EX 是一个关于分享和探索的地方
现在注册
已注册用户请  登录
V2EX  ›  kuanat  ›  全部回复第 5 页 / 共 12 页
回复总数  233
1  2  3  4  5  6  7  8  9  10 ... 12  
@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

就事论事,我不是很认可这位作者的说法。即使我和他得到了相同的结论,也不代表我们有一样的推理过程。
@whitedroa #31

我尝试用注释里带文件名的方式来区分是谁写的某个文件,看起来还是不够清晰表达意图。

现在假设我是一个包的作者,我只关心赶快完成我的功能。这时候 A.go 和 main.go 都是我自己写的,我的 use 方法入参就用我的 *A ,简单粗暴。

现在另一个人看到我写的包,他觉得你竟然把这么复杂的 A 业务给抽象出来了,那他就借你写的 A.go 里面的 get/put/... 方法一用吧,这样他就只需要写个 B 支持,就能同时支持 A/B 了。这里的重点是,他其实只想用我写的 get/put 方法,对其他的不感兴趣。

考虑到对他来说,他的 main 里面不希望为 A/B 写不同的调用方法,于是就写成了 func New(s Storage) *MyStorage 的形式,需要调用 get/put 就在 *MyStorage 上面调用。这个 type Storage interface 里面只包含 get/put 两个方法即可。

这样他的 B.go 就可以简单封装一下 sdk 满足接口就可以了。

这个事情还可以继续下去,第三个人看见了第二个人的代码,还可以添加 C 支持。甚至当他需要 get/put 之外第三个 delete 方法的时候,可以用到 embedding 机制:

```go
type C struct {
a A
}
func (c C) delete() { ... }
```

调用的时候接受一个三个方法 get/put/delete 的接口即可。

全程下游都不需要上游配合。
@yusheng88 #28

你有没有考虑过,为什么上游要提供接口呢?这是下游的事情。上游 preemptively 接口化是个违反工程实践的行为。

你的思路还停留在“Java 可以用 XXX 的方式来做同样的事情”,不好意思,就本文讨论的话题,Java 真做不到。
@sagaxu #19

你提到“我们”的时候,还是没有跳出固有的思维。

在 Go 的设计思想里,包或者说库的作者,应当( should )假定自己的包有一天可能成为别人的依赖。然而你并不需要假想别人要以何种方式使用你的包,你只管写你的实现完成功能就可以了。别人可能仅仅因为一个结构定义,或者一个导出方法就引用你的包作为依赖。

如果我自己要同时完成某个功能的 A/B/C 三个实现,我当然会提前把接口写好。但是如果我只有 A 的需求,那我完全可以不写接口,写成接口的形式是为了某一天我要添加 B/C 支持、或者我知道这个包可能被别人拿去用而做的预先设计。

无论如何,受限于 Java 的类型检查机制,如果最初没有写成接口,任何人除了包的作者都没有很简单的办法复用一个没有接口的包,因为把接口抽象出来这个操作只能由包的所有者完成。虽然你觉得原作者对于 A 功能的实现写得很好,但想拿过来用,才不得不选择复制粘贴,否则就要求助原包的作者将代码改成接口的形式。复制粘贴的问题是一旦上游更新,你就会面临是不是要手动跟随更新的问题。提 PR 是考虑到,减轻原作者的工作量,提高原作者接口化的意愿,避免你自己跟随更新的麻烦。

这两个都是在尽量减少对原包作者的依赖,但是在人的层面解耦不够彻底。
Reply

上面解释得可能还不是很清晰,我加一点代码来说明吧,还是以对象存储支持两个后端为例。由于回复不支持 markdown ,所以手动排版了一下。



1.
第一个非接口的版本:

```java
// A.java 实现功能的部分
class A {
____void get();
____void put();
}

// main.java 调用的部分
class main {
____void use(a A);
}
```

这个版本如果别人引用去,是很难添加 B 服务商支持的。除非是复制粘贴拿来用,但如果是复杂的项目,要么只能长期手动维护,要么向上游提 PR 。

所以包作者往往会以 preemptive 的形式,改成接口的版本,方便下游使用:

```java
// A.java
interface Storage {
____void get();
____void put();
}
class A implements Storage { ... }

// main.java
class main {
____void use(s Storage);
}
```

这样下游的人可以自己去适配另一个类 B 来实现 Storage 接口了。

```java
// B.java
import A.Storage;
class B implements Storage { ... }
```

这样做的问题是,如果 Storage 接口方法非常多(正常云服务 sdk 少说都有二三十个方法),那么 B 也要适配同样数量的方法。实际上下游适配 B 可能仅仅需要 get/put 两个方法而已,这对于开发和测试都是非常不利的。



2.
再来看看 Go 的初始非接口版本:

```go
// package A
type A struct { ... }
func (a *A) Get() {}
func (a *A) Put() {}

// package main
func Use(a *A)
```

然后上游作者就什么都不用管了。下游用户看到,想要增加 B 支持:

```go
// package B
type B struct { ... }
func (b *B) Get() {}
func (b *B) Put() {}

// package main
type Storage interface {
____Get()
____Put()
}
type MyStorage struct {}
func NewStorage(s Storage) *MyStorage { ... }
// 没有写成 func Use(s Storage) 是为了体现后半句 return structs ,这个不重要
```

其他不用管了。假如上游发布了更新,只要 Get/Put 接口签名不变(即上有发布的新版 API 向后兼容),那就可以直接升级使用。整个过程里,上游下游的工作是完全独立的。

即便考虑现实中 Storage 有几十个接口,下游用户也只需要实现他所用到的少量方法即可。



二者区别其实是在于:谁来定义接口。Java 的机制决定了只能是 producer 说了算,而 Go 则是 consumer 主导。这样看 preemptive 这个词的意义就很清晰了:还不知道会被怎么用的情况下就把接口定好了,当然是“抢占”了,实际上 Go 这里根本没必要预先定义,等用到的时候再写就是了,用多少写多少。
我刚好在写一模一样主题的文章,完成后会发上来。起因可以看我最近回复,当时我评价某个项目的代码“不能正确使用接口解耦”。

如果你在学习 Go 之前没有太多编程经验,这句话对你来说可能非常自然,自然到令人疑惑,因为你不知道反过来做是什么样子的。如果你的思维模型受 Java 的影响很深,那么理解这句话才能理解 Go 在解耦方面带来的巨大进步。(不抬杠,这里以 Go 和 Java 做对比纯粹因为最方便理解)

要说清楚这个问题需要比较大的篇幅,我这里简单概括一下。

1.
Go/Rust 这类现代语言都放弃了“继承”,这是设计思想的巨大进步,Java 这种旧时代的语言在设计的时候是没有意识到“组合优于继承”的。

理解组合优于继承对于中国人来说非常简单,汉语只需要几千个字就能描述整个宇宙,除此之外的其他语言的,那句台词怎么说的,不是针对谁,在座的诸位……

2.
基于组合的理念之上的 OO 抽象,才产生了 Accept Interfaces, return structs 这个 Go idiomatic 的范式。

我比较认同 Rob Pike 对这句话的评价,它不够准确,也不够精确。如果让我来表述,我会分成两句话:

- The bigger the interface, the weaker the abstraction. 这一句是纯引用作为铺垫,意在表达接口越小越好。

- Don't implement Interfaces preemptively. Preemptive 这个词一般翻译成“抢占式”,这里取其衍生含义,提前或者预先。Java 实现接口的代码范式就是 preemptive 的。

3.
Go 的隐式接口实现和 Java 显式接口实现,根本区别在于 Go 能够以接口为边界,从工程层面将开发工作解耦。

举个例子,你开发了一个库,功能是对象存储中间件,它支持以 A 厂商云存储作为后端,实现了 get/put 的读写方法。

如果有另外一个人需要增加对 B 厂商的支持:

- 用 Go 的话,他只需要引用你的包,然后定义一个包含 get/put 方法的接口,同时将 B 厂商的 sdk 做封装即可。调用的时候直接以接口为参数,而不需要关注具体实现接口的对象。

-用 Java 来实现的话,他要么把你的包以源码的形式复制一遍加入到项目里,要么就要向你提 PR ,来增加对 B 厂商的支持。这是因为 class B implements StorageInterface 只能写在你的包里。

这里就看出 Go 的先进之处了,你要做什么和原包的作者没有关系,原包的作者也不需要关心其他人是怎么用他的包的。而 Java 世界里,要么把上游的人拉进来,要么自己成为上游。代码上好像解耦了,但又没完全解耦,工程上是非常低效率的事情。

为了解决这个麻烦,Java 就有了 Preemptive 实现接口的惯例。考虑到需要增加适配,写类的时候有事没事先写个接口出来,不管用得到用不到。

但这样做的问题是,接口会变得巨大无比。一般的对象存储服务,少说也有二三十个方法。一个有二三十个接口的方法是什么概念?当你需要 mock 一下写测试的时候就有得受了,事实上你可能只会用到 get/put 两个接口而已。

然后 Java 提了一个叫 SOLID 的原则,其中 I 代表接口隔离,意思是要把接口拆分。问题是作为库的作者,你只有一次拆分机会,而包的使用者却有数不清的排列组合。


PS
补充几句题外话,我在 Go 语言主题里的回复经常会被人说是踩 Java 捧 Go ,但我依旧坚持有理有据地论证,而不是停留在嘴巴或者屁股上。

很容易看出来,如果设计思想落后了,想要模仿先进的东西是非常困难的。我不止一次重复过,考虑到 Java/Python 这些语言诞生的时间,从设计层面上评价它们落后是不公平的,毕竟这些语言作为先驱踩了坑,才会有后来现代语言的设计指导思想。

另外需要指出的是,Go 基于 duck typing 的隐士式接口的范式是少数不能通过语法糖等方式在 Java 中实现的机制。在一个静态类型语言上实现 weakly typed 的特性( duck typing ),Go 应该算是第一个。
205 天前
回复了 nmap 创建的主题 程序员 求一个文件 N 点同步的方案
TLDR

不知道你考虑过冲突解析的问题?同一时间两个节点上的同一文件产生了不同的变化,要以哪个副本为准?如果存在这种情况,那你要解决的实际上是分布式系统的同步问题,方案会非常复杂。比如一般的对象存储,都是传一个副本,然后节点之间完成副本同步。

如果不存在这种情况,可以降级成非分布式问题,方案也简单很多。比如你只是需要多个备份,同一时间只会在一个节点上操作。这个情况比较简单的做法是选一个节点作为权威副本,其他的节点都以这个节点为准,向权威副本推送更新的时候要先合并权威副本。类似于使用 github 之类的平台进行协作的模式。rsync 加上简单脚本就能实现。


----手动分割----

这个问题没有普适的答案,本质上它是受分布式系统 CAP 理论限制的。需要确认需求,进而在 CAP 三者当中选二,然后才能确认方案。由于绝大多数情况下,P 是不能放弃的,所以要么只能 AP 要么只能 CP 。

AP 方案放弃 C ,结果就是某一个时刻,各个节点之间的副本有的是最新版,有的是旧版。

CP 方案放弃 A ,在任意同步行为完成之前,不能进行其他操作。

实际应用里最先考虑的是降级,把分布式降级为星型,就是上面说的权威副本节点。如果无法降级,那就需要使用基于 paxos/raft 这类共识算法的同步机制。
207 天前
回复了 solywsh 创建的主题 Linux 有没能按照 ip 进行流量统计的
这个需求也算是 V2EX 上的常客了,我详细说一下思路和方向。

Linux 网络栈是在内核里实现的,流量统计就是 packet 计数(或者计量)这个计数有几个方式:

1. 内核态 pf 用户态 iptables

最简单的方式就是 iptables 对 chain 计数,只要把想统计的流量走一下自定义的 chain (这个 chain 可以什么都不做),就可以利用 chain 自身计数达到目的。

像 openwrt 之类的路由器统计内网流量基本都是这个方式,用 arp 配合 MAC 地址来区分客户端。

这个方式的优点是轻量,缺点是要预先设定规则。你的这个场景,如果几个朋友的 IP 有比较明显的区分度,可以根据 IP 段每个人走一个规则 chain 来统计。但是对于大量无规律来源 IP 就很难写这个规则,或者写出来几万条不实际。

2. conntrack

想要动态处理 ip 规则,就需要做基于状态的统计。conntrack 有自己维护一套映射表,这样就无需预先知道来源 ip 就可以按需统计。优点是灵活性比较高,但是性能影响也会比上一个方法高一些。

这两个方法的实现可以参考 https://openwrt.org/docs/guide-user/services/network_monitoring/bwmon 这个帖子 Available tools 章节,可以参考拿来用。

第一个方案其实很好写,第二个要想自己写可能需要比较多的背景知识。

3. 抓包自己算

就是计量的方式,不是很推荐的做法,为了做流量统计结果把所有数据包都过滤一遍,有点杀鸡用牛刀的意思了。
207 天前
回复了 pegasusz 创建的主题 程序员 戴尔内存泄漏漏洞?还是恶意攻击?
做个无责任推测,如果很多人同时发生类似的事情,可能是这个程序有个访问特定服务器的定时任务,然后服务器挂了导致程序无限高频重试,进而放大了内存泄漏、cpu 占用等问题。
209 天前
回复了 kuanat 创建的主题 Go 编程语言 分享一些 Go 在全栈开发中的经验
@AbrahamKindle #38

我不太确定你所说的性能指的是哪个方面,我就随便说一下。

原文里谈到 Go 不适合音频视频处理指的是生态问题。比如图片领域有 imagemagick 音频视频领域有 ffmpeg ,通常没有重新造轮子的。如果要用到 Go 项目里就需要通过 FFI 来调用,这就需要通过 CGO 的方式。如果没有人开发相应的 binding ,那就要通过命令行直接调用了。这两个都不是很理想的方式。

音视频处理领域谈性能,一般应该是实时领域吧,指标是延迟和 jitter 这种。粗略来说,不仅仅是 Go ,凡是带 GC 的语言都不适合干这个事情,因为没有完全控制 GC 介入行为的方法,GC 介入会短暂中断程序的运行,这对实时应用来说很难接受。当然这个事情不绝对,通过一些手动管理对象、内存或者 zero copy 流式处理的方式说不定也能满足性能需求,就是回到 C 那种思路上面。

另外音频视频处理涉及的范围很广,以我有限的经验来说:

- 涉及底层硬件的尽量还是用 C ,写 binding 一点都不好玩
- 调用个摄像头做 cv 识别这种是可以的,因为你对接的不是硬件而是 v4l2 之类的抽象,opencv 的 golang binding 也够用
- 走 opengl 写个 shader 什么的也还好,但是基本没法对接到一般游戏开发工作流里面

所以说还是尽量用对应领域的工具吧。
抱着解答问题的心态点进来,看见楼上的评论就笑了。名著你可以揣测一下作者的写作思路,地摊文学就算了……

代码层面的问题 @bv 在楼上已经说得比较清楚了,我补充一点我的理解。

这种代码水平在我之前的面试标准里面,按照初级/中级/高级三档水平,只能划分到初级那一档。我只通过看链接里那个文件就做出了判断,而且对此我非常有把握。

划分到初级的主要理由是:无法准确使用接口 Interface 来实现功能解耦。这个能力在我之前负责面试的时候是中级技能里的。相比之下,代码层面反倒是小问题了。

如果看不懂的话,我可以抽时间单独就 Go 通过接口来解耦专门做个解释。有隔壁那个代码氛围的帖子在,其实不想过多评价。但我还是要说,这段代码的作者的 Go 水平真就是初学者,出来卖课就是误人子弟了。
211 天前
回复了 zoeyx 创建的主题 程序员 前端 Coder 如何学习 Golang?
语法层面过一遍官方的 Tour 差不多够了。

练手的话我比较推荐尝试写个爬虫服务,不是让你真去爬什么,而是这个过程用到的东西学习曲线比较平滑。

解析页面元素这个过程,大概能熟悉强类型语言处理字符串的模式,了解结构体的应用。后期还会接触到反射等机制的应用。

之后是一般的网络编程,发送接收请求。这个过程可以熟悉标准库的风格和惯例,网络库算是 Go 比较精髓的部分了。

再之后是多线程处理,了解 chan/goroutine 的使用,以及常见的并发模型。领会一下用通信的方式来共享内存的核心思维。

最后把改造成服务,学习一下路由处理、中间件等等服务端常见的应用。之后有可能会慢慢接触到模板、泛型的应用。

前期不用考虑处理太复杂的情形,用标准库把功能实现出来就行。整个过程里可以慢慢熟悉后端工程化的实践。
211 天前
回复了 saranz 创建的主题 V2EX 好奇,至今还在 V2EX 活跃的 ID 是那些。
一般差 20 岁就是一代人,V2EX 从 10 算已经 15 年多,当年的种子用户可能都已经三十多岁了。

我估计有一批人应该和我一样,只是老账号各种原因不用了但是人还在。
211 天前
回复了 nobject 创建的主题 Go 编程语言 golang 日志记录
我不确定我的做法是不是 idiomatic 只是简单描述一下我的思路。毕竟 slog 进标准库也没多久,看 api 也是受 zap 影响很大。

大的逻辑方面我比较倾向于全局只有一个唯一 logger ,中间调用过程只传递。另外我习惯把业务层面的日志和功能实现层面的日志分开,然后两者用不同的方式来处理。

业务层面的日志我倾向于用格式化的字符串:一方面它是给运维用的,需要一眼看明白在哪个环节出了问题,但并不需要知道出问题的细节;另一方面这类日志要进日志服务器做后续统计等功能,需要方便处理。

对应的就是你的描述里 controller->service->repo 这个部分,我会用 errors 传递字符串,每个环节用 errorf 来 wrap 上一个环节的错误,起到简单的 stack trace 的功能。这里一般不建议用 runtime 来获取完整的 stack trace ,主要是性能开销的问题。

包或者实现层面的日志,一般是给开发者方便调试用的,比如看到底是什么入参触发了什么样的边界条件导致出错。这个信息在开发和测试环节比较有用,实际线上是相对冗余的信息。

日志记录这个信息我用的是自定义的 context ,因为标准库里的 context 实际上只有下文没有上文。可以看这个帖子 https://s.v2ex.com/t/1012453 我的回复。

用 context 的话可以自定义数据结构,能够比较好处理那些复杂的结构体,也就是你说的第二条里的麻烦。

对于 context 信息的记录就没有必要走业务层面的 logger 了。对于不太敏感又注重性能的业务,我的处理是加一个开关,当线上出现的问题无法复现的时候,开启开关再记录完整的 runtime stack trace 信息。对于相对敏感的业务,会单独用一个 logger 导出到日志服务器单独的表中,使用 traceId 对业务层面的日志和实现层面的日志做个关联。业务层面的日志要保留很久,但功能层面的日志一般三个月就可以清理了。
211 天前
回复了 afxcn 创建的主题 Go 编程语言 使用 go 遇到的一个奇怪问题,求教
对于 rand.NewSource 存在竞态导致 panic 这一点应该没有异议,而 panic 没有中断而是正常被调用,说明上层应该是存在 recover 逻辑的。结合 OP #23 指出本地正常而测试服务器异常,推测本地测试的生成逻辑,而服务器完成的是全部调用逻辑。

@R18 #16 指出 byte 空值转换为 string 之后是空字符串,说明在可见代码的部分,只可能产生空字符串。 @AceGo 和 @hopingtop 判断出是上层调用将空字符串变成了全 0 字符串。

我这里做一点补充,一般要保证字符串定长都会做 padding ,用字符串 0 做填充是最常见的。
1  2  3  4  5  6  7  8  9  10 ... 12  
关于   ·   帮助文档   ·   博客   ·   API   ·   FAQ   ·   实用小工具   ·   1113 人在线   最高记录 6679   ·     Select Language
创意工作者们的社区
World is powered by solitude
VERSION: 3.9.8.5 · 26ms · UTC 19:01 · PVG 03:01 · LAX 11:01 · JFK 14:01
Developed with CodeLauncher
♥ Do have faith in what you're doing.