How to tidy up repeated if err := x(); err != nil { return err }?

agolangf · · 448 次点击    
这是一个分享于 的资源,其中的信息可能已经有所发展或是发生改变。
<p>Is there a better way to write this, something similar to <a href="https://www.postgresql.org/docs/current/static/functions-conditional.html#FUNCTIONS-COALESCE-NVL-IFNULL">PostgreSQL COALESCE</a>?</p> <pre><code>if err := a(); err != nil { return err } if err := b(); err != nil { return err } return c() </code></pre> <hr/>**评论:**<br/><br/>JackOhBlades: <pre><p>I think there&#39;s some psychology behind why this comes up a lot. </p> <p>As a Go newb you return errors without touching them and you start to wonder, why do I need to repetitively check and return? Couldn&#39;t this be like, implicit, or something? It&#39;s just really non-DRY code! I&#39;m just doing the same thing over again and Go is forcing me to code this way!</p> <p>And then after digesting some Dave Chaney posts and other Go materials you start to realise that maybe you <em>shouldn&#39;t</em> be just returning the errors. Maybe the problem isn&#39;t with Go, it&#39;s with the fact that you&#39;re <em>choosing</em>, over and over again, to do nothing with the error. </p> <p>Then you start wrapping errors with context and you realise there&#39;s actually nothing to DRY up, you were simply choosing not to handle the error correctly before.</p> <p>Finally you either accept or disagree with one thing: Go forces you to <em>handle</em> the error. Even if that means simply returning it. It puts that choice in your face, making it hard for programmers to ignore inconvenient errors. </p> <p>This is important for scaling code bases, especially when on-boarding new programmers who might be tempted to not handle errors.</p> <p>I don&#39;t know where I was going with this. </p></pre>shovelpost: <pre><blockquote> <p>Go forces you to handle the error.</p> </blockquote> <p>I agree with everything you said except that technically Go doesn&#39;t force you to handle errors (though I understand what you mean).</p> <p>For example this is the signature of <a href="https://golang.org/pkg/fmt/#Println">fmt.Println</a>: </p> <pre><code>func Println(a ...interface{}) (n int, err error) </code></pre> <p>I don&#39;t see much error checking on that! :P</p></pre>YEPHENAS: <pre><blockquote> <p>I agree with everything you said except that technically Go doesn&#39;t force you to handle errors (though I understand what you mean).</p> </blockquote> <p>psychological force, not technical force</p></pre>shovelpost: <pre><p><a href="https://www.youtube.com/watch?v=hou0lU8WMgo" rel="nofollow">You&#39;re technically correct.</a></p></pre>JackOhBlades: <pre><p>I foresaw this response! ;)</p> <p>I&#39;d argue that my comments generally hold true <em>in practice</em>, errors that should be handled are hard to not handle.</p> <p>I think a strong counter point would be an anecdote describing how it was Go semantics, not the programmer, that was mostly responsible for why a serious error was not handled correctly, or rather, was hard to handle correctly. </p></pre>FierceDeity_: <pre><p>Basically I&#39;d say the &#34;psychological forcing&#34; is in that you have to purposefully ignore go errors by typing comma, underscore every time. </p> <p>In a language like C# ignoring them is much easier than in go. In go you have to <em>explicitly</em> not take that error</p></pre>SeerUD: <pre><p>I <em>still</em> have a love/hate relationship with this part of Go, even a couple of years in now. I like how simple errors are, but they&#39;re not easy. Go is the first language that I&#39;ve used where I&#39;ve had to add the context myself manually, and I think that&#39;s where the problem lies in it for me.</p> <p>I don&#39;t know why people seem to hate stack traces so much. Sure, they can go out of date, but provided your logging is sufficient (e.g. log app version too?) then you don&#39;t have a problem there either. They can be long, but at the end of it all, they tell you exactly where to look for a problem. This is part of what <code>pkg/errors</code> provides (if you manually add it on each error), but I feel like it could be part of the language really.</p></pre>JackOhBlades: <pre><p>Stack traces will tell you the nitty gritty details you didn&#39;t ask for, this can at least, slow you down a lot, and at most, completely bury the actual problem. Stack traces can&#39;t capture the semantics of the error.</p> <p>Good error handling encapsulates domain specific details, the semantics of the error (in my opinion). </p> <p>I don&#39;t think they are mutually exclusive: you can have good errors and a stack trace included for good measure.</p> <p>If you want some interesting material on error handling and Go I really enjoyed these articles:</p> <ul> <li><a href="https://commandcenter.blogspot.com.au/2017/12/error-handling-in-upspin.html" rel="nofollow">error handling in upspin</a></li> <li><a href="https://hackmd.io/s/B1NrW6_dM" rel="nofollow">the two sides of an error</a></li> </ul></pre>SeerUD: <pre><p>I agree; context is very important. Like you&#39;ve said though, they&#39;re not mutually exclusive. If stack traces were a built-in piece of functionality for errors then it&#39;d just make the process easier.</p></pre>JackOhBlades: <pre><p>I think one must also consider that stack traces aren&#39;t free.</p> <p>There&#39;s a performance penalty associated with them, and so making them a default would be questionable given Go&#39;s philosophy of making costly operations explicit.</p> <p>I&#39;m not 100% sure what you mean by &#39;built-in&#39;, though you can add this functionality quite easily with <code>github.com/pkg/errors.Wrap</code> which adds a stack trace to any given error (thanks <a href="/u/qu33ksilver" rel="nofollow">/u/qu33ksilver</a>!).</p> <p>I do empathise with your concerns. At the end of the day, I think I agree with Go on this; the ergonomic cost is preferable to the performance cost... But this is purely my opinion.</p></pre>SeerUD: <pre><p>By built-in, I made automatic really. Or at least, maybe you should be able to ask for the stack for an error at a given point? Sort of like how panics are able to give you a stack trace too, at the time of the panic.</p></pre>skidooer: <pre><p>Interestingly, the core Go team found that <a href="https://commandcenter.blogspot.com/2017/12/error-handling-in-upspin.html" rel="nofollow">stack traces weren&#39;t all that useful in the presence of meaningful errors</a>.</p> <blockquote> <p>For those cases where stack traces would be helpful, we allow the errors package to be built with the &#34;debug&#34; tag, which enables them. This works fine, but it&#39;s worth noting that we have almost never used this feature. </p> </blockquote></pre>therealfakemoot: <pre><blockquote> <p>Then you start wrapping errors with context and you realise there&#39;s actually nothing to DRY up, you were simply choosing not to handle the error correctly before.</p> </blockquote> <p>Could you elaborate on that point a little more? I&#39;d say I&#39;m somewhere between novice and intermediate Go development skill, and at an abstract level I understand the &#34;add context to errors&#34; bit, but I&#34;m not 100% sure how that looks in actual Go code.</p> <p>I&#39;ve seen loads of libraries/toolkits that let you wrap errors or have fancy error value chains ( for building custom stacktraces, effectively, I guess ) but I&#39;ve yet to fully realize a meaningful implementation of this behavior/pattern.</p></pre>skidooer: <pre><p>There is a <a href="https://commandcenter.blogspot.com/2017/12/error-handling-in-upspin.html" rel="nofollow">good article</a> from Rob Pike and Andrew Gerrand about error handling and wrapping context that you might find useful.</p></pre>therealfakemoot: <pre><p>Nice, looks like a good read. Thanks for sharing.</p></pre>JackOhBlades: <pre><p>Along with the upspin article as <a href="/u/skidooer" rel="nofollow">/u/skidooer</a> suggested, I&#39;d recommend <a href="https://hackmd.io/s/B1NrW6_dM" rel="nofollow">the two sides of an error</a>.</p> <p>Here&#39;s some real code that I wrote to demonstrate adding context to errors: <a href="https://play.golang.org/p/84sdS4KopCG" rel="nofollow">https://play.golang.org/p/84sdS4KopCG</a></p> <p>The program downloads any URL links you feed to it (concurrently, in true Go fashion).</p> <p>This is not advanced or sophisticated error handling, but when an error occurs you&#39;ll be able to instantly pinpoint what the error is and where it occurs instantly. </p> <p>Furthermore, there&#39;s no instance in this code that can be made DRYer. The errors are just handled. </p></pre>anaerobic_lifeform: <pre><blockquote> <p>Then you start wrapping errors with context and you realise there&#39;s actually nothing to DRY up, you were simply choosing not to handle the error correctly before.</p> </blockquote> <p>The claim that in Go you <em>really</em> handle errors as opposed to other languages, is a fallacy that must die. A kind of No True Scotsman: &#34;errors are not properly handled if you don&#39;t write all steps yourself&#34;.</p> <p>I understand you may prefer writing all the error handling plumbing by hand, and in some cases I can agree that the result might be preferable to exceptions in terms of readability or performance. But I see no reason to believe the claim that Go code has necessarily better error handling by default, or scales better.</p> <p>Note that with exceptions, all errors are checked, propagated and handled automatically (even errors you did not think about beforehand). The default handler either enters the debugger or aborts the program with a stacktrace, because it has no way of doing better; but at least all errors are guaranteed to be &#34;in your face&#34;, sooner or later. </p> <p>In some way, it is true that Go <em>forces</em> to handle all errors, to the extent tat the compiler complains if you don&#39;t use intermediate results (except for single-return functions). However, in practice, this does not change much. My experience with checked exceptions or &#34;monadic&#34; error handling is that attempts to make compilation fails until all errors are checked tend to make developers take shortcuts and ignore errors (underscores or empty try/catch blocks) just to get rid of compilation errors during first drafts. And in that case, I tend to side with those developers: better flesh out the logic first; once you commit to a specific solution, then the error path is worth being finalized. </p> <blockquote> <p>I don&#39;t know where I was going with this.</p> </blockquote> <p>Same here :-)</p></pre>JackOhBlades: <pre><blockquote> <p>The claim that in Go you really handle errors as opposed to other languages, is a fallacy that must die.</p> </blockquote> <p>I never claimed this. I don&#39;t think anyone has ever claimed that you don&#39;t or can&#39;t handle errors in other languages.</p> <p>My argument is about how the error value check is not actually simple language boilerplate, and why that is a good thing. The main hypothesis is that when people complain about <code>if err != nil { return err }</code> boilerplate it&#39;s often because they&#39;re not handling the errors, not because it is actually boilerplate. </p> <p>Following that, when people do handle the errors &#34;properly&#34; in Go they tend to (and this is anecdote) not consider the error handling conventions as boilerplate any longer.</p> <blockquote> <p>I understand you may prefer writing all the error handling plumbing by hand</p> </blockquote> <p>This is where we disagree. It&#39;s the belief that the error check is simply &#34;plumbing&#34; that I believe causes one to say Go error handling is &#34;verbose boilerplate&#34;, to quote many a person. </p> <p>I don&#39;t see how checking an error is, in principle, anymore &#34;plumbing&#34; than checking for an exception. You should treat errors as values and handle them like any other value. Do you consider your business logic to be &#34;plumbing&#34;? If so, then I concede. </p> <p>Nevertheless, there are many reasons why the exception model is poor, and I&#39;d rather not repeat those arguments here. I&#39;m not here to claim that Go does errors better than other languages. I&#39;m here to claim that Go&#39;s idioms around error handling are not considered verbose when you &#34;do it properly&#34;, with respect to Go&#39;s semantics. </p></pre>MonkeeSage: <pre><blockquote> <p>you were simply choosing not to handle the error correctly before</p> </blockquote> <p>Kind of, but kind of not, but mostly.</p> <p>In a lot of other languages with error/exception types there&#39;s a syntactic way of handling them with special keywords like try/catch, which allows them to be handled at specific points in the control flow.</p> <p>Think about a method for opening and reading a file into a buffer, which might raise an error if the file doesn&#39;t exist, if there are not sufficient permissions to open it, during the reading of the file into the buffer, or when closing the file. But you can bundle all of those up in a try/catch block when you call the method rather than checking at each step inside the method.</p> <p>This seems good when you just want to catch a generic error from a group of linked operations that should succeed or fail as a unit, and log the failure message from the exception (whichever one it might be). But once you start having to care about different kinds of exceptions and handle them differently, you have to then write a bunch of different exception handlers for the different cases. And now you are handling all of them anyway, they are just far away from where the code that threw them is.</p> <p>And even worse, if code below yours doesn&#39;t properly wrap code they are calling in try/catch, an exception might bubble up to your code! And this isn&#39;t just theoretical, I&#39;ve seen this happen in real world code. The openstack network orchestration layer is neutron and is written in python, and I remember a bug from a few versions ago where some low level code, calling out to the OS to bind a port to a bridge, threw an exception, but wasn&#39;t caught when the method that threw it was called, so it bubbled all the way up to an exception handler in the quota code (i.e., &#34;your tenant/user can only have 10 ports but you asked for 11&#34;). Took quite a a bit of reading through code and debugging to figure out the actual issue was several layers down, and the over quota log message was just masking the real issue.</p> <p>Java of course has checked exceptions but we&#39;re trying to be less verbose and tedious so moving on.</p> <p>Go also does have a <code>recover</code> keyword that can unwind a <code>panic</code> (and can only be used inside a <code>defer</code> block), but panics are highly discouraged from being used as flow control and treated as errors, making a distinction between error conditions and exceptional (panic) conditions.</p> <p>Course there&#39;s nothing stopping you from assigning all errors in go to <code>_</code> and ignoring them either.</p> <p>But the convention of handling all errors immediately and explicitly in go does seem to be a really good idea overall, and does force you to actually think about what the errors mean at the time rather than just passing the buck downstream.</p></pre>JackOhBlades: <pre><p>I lol&#39;d at the Java comment.</p> <blockquote> <p>But once you start having to care about different kinds of exceptions and handle them differently, you have to then write a bunch of different exception handlers for the different cases. And now you are handling all of them anyway, they are just far away from where the code that threw them is.</p> </blockquote> <p>Exactly. I suppose there&#39;s two working hypothesis at play here. </p> <ul> <li>If errors <strong>can</strong> be handled generally in most cases, then handling them individually is going to be &#34;verbose&#34;.</li> <li>If errors <strong>can&#39;t</strong> be handled generally in most cases, then handling them individually is &#34;necessary&#34;. </li> </ul> <p>Given that <em>both</em> of these cases occur in software, it&#39;s really a question of which case is more common, or harder to deal with. In practice I&#39;ve found that in most cases you actually want to handle each error uniquely. This is probably what biases me towards Go vs exception based languages. </p> <p>One might question why you can&#39;t have both a generic mechanism like a try-catch <em>and</em> and the classic value check in the same language. I&#39;d argue that while there is no technical reason why you can&#39;t, in practice many programmers would just treat all errors generally when they shouldn&#39;t, giving you all the issues associated with try-catch. Humans are lazy. We do what we know, we stick the familiar. No one would embrace the value check if try-catch was available in the language. </p> <p>In many ways it&#39;s really about competing philosophies, not competing syntax. </p></pre>shovelpost: <pre><blockquote> <p>Is there a better way to write this</p> </blockquote> <p>Yes there is:</p> <pre><code>if err := a(); err != nil { return fmt.Errorf(&#34;doing a stuff: %v&#34;, err) } if err := b(); err != nil { return fmt.Errorf(&#34;doing b stuff: %v&#34;, err) } if err := c(); err != nil { return fmt.Errorf(&#34;doing c stuff: %v&#34;, err) } return nil </code></pre> <p>Also <a href="https://blog.golang.org/errors-are-values">errors are values</a>. You can program with them to make the API more concise:</p> <pre><code>type abc struct { err error } func (t *abc) a() { if t.err != nil { return } t.err = a() } func (t *abc) b() { if t.err != nil { return } t.err = b() } func (t *abc) c() { if t.err != nil { return } t.err = c() } func main() { t := abc{} t.a() t.b() t.c() if t.err != nil { log.Fatal(t.err) } } </code></pre> <p>Check out <a href="https://www.youtube.com/watch?v=1B71SL6Y0kA&amp;feature=youtu.be&amp;t=15s">this talk</a>.</p></pre>qu33ksilver: <pre><p>Even better -</p> <pre><code>if err := a(); err != nil { return errors.WithMessage(err, &#34;doing a stuff&#34;) } if err := b(); err != nil { return errors.WithMessage(err, &#34;doing b stuff&#34;) } if err := c(); err != nil { return errors.WithMessage(err, &#34;doing c stuff&#34;) } return nil </code></pre> <p>With the indispensable <code>github.com/pkg/errors</code> package</p></pre>theephie: <pre><p>Link for the lazy: <a href="https://github.com/pkg/errors" rel="nofollow">https://github.com/pkg/errors</a></p> <p>Do you mean <code>errors.Wrap</code>?</p></pre>qu33ksilver: <pre><p>Nah, I meant <code>WithMessage</code>. <code>Wrap</code> captures the stack trace too, which is a costly operation. <code>WithMessage</code> is much lighter. Since the OP used <code>fmt.Errorf</code>, I used its closest alternative.</p></pre>JackOhBlades: <pre><p>I had no idea about this, thanks for the tip!</p></pre>shovelpost: <pre><p>I don&#39;t understand why you have to use a dependency of 1.2k+ lines of code just to send a simple message up to the user. If you really care about the cause of the error then instead of a simple message you can send a custom type that includes the cause and which is trivial to do.</p></pre>SeerUD: <pre><p>Mainly because a lot of the time there&#39;s a <code>fmt.Errorf</code> in the middle somewhere. <code>pkg/errors</code> exists to remove the boilerplate of adding the custom types every time you want to wrap an error, and provide utilities to work with the wrapped errors too. I don&#39;t think <code>pkg/errors</code> is perfect though, there are a couple of bits it&#39;s missing IMO.</p></pre>shovelpost: <pre><blockquote> <p>pkg/errors exists to remove the boilerplate of adding the custom types every time you want to wrap an error</p> </blockquote> <p>The way I see it, pkg/errors exist to gather the stack trace. That&#39;s the only useful feature. Everything else can be done with 5-6 lines of code vs importing 1.2k+ lines. But who cares because it&#39;s just a few extra lines in Gopkg.toml right?</p> <blockquote> <p>and provide utilities to work with the wrapped errors too.</p> </blockquote> <p>That forces the client to use the dependency as well.</p> <p>To be fair, I think pkg/errors is good when it&#39;s used in large, complex applications but small applications have no need for it. The absolute worst case is when it&#39;s used by libraries. That&#39;s simply horrifying.</p></pre>titpetric: <pre><p>Actually, depends on how you can handle an error. Generally if you can&#39;t handle an error you just return it down the execution stack, until it hits the outer most code which does something like logging. Since by default errors don&#39;t have stack traces, it makes is <em>really hard</em>, really <em>impossibly hard</em> to figure out where some error is coming from, especially if you didn&#39;t wrap it along it&#39;s path, for which you&#39;d probably convert it to string and lose some underlying context which is returned by some packages (encoding/json SyntaxError, database/sql driver dependant error objects,...). At least pkg/errors allows you to Wrap an error without losing it&#39;s underlying cause (<code>Cause() error</code>), which you can still type cast or print with <code>%#v</code> with all it&#39;s details.</p> <p>The most obvious issue I have with pkg/errors is that <code>errors.New</code> is used to define errors that may be returned in the package, and it does this by using a var. See the <code>os</code> package for <a href="https://godoc.org/os#pkg-variables" rel="nofollow">the ultimate example</a> in the stdlib. The stack trace for such declarations of errors is useless, as it doesn&#39;t give you information about where the error was returned from, but only where the instance was made. While I concede that it&#39;s overkill having stack traces everywhere, it feels somehow limiting that you can&#39;t have them with compiling with some debug flags or something. If we&#39;re into this kind of territory, then it&#39;s better to end it here.</p> <p>As with all things, if you know how it works, you can use it to your advantage.</p></pre>shovelpost: <pre><p>I don&#39;t disagree on the usefulness of pkg/errors. But in my opinion it is way overused without strong reason. You do not need stack traces for good error handling.</p></pre>SeerUD: <pre><p>I&#39;m not sure I agree really. I think it&#39;s functionality could be useful for almost anything unless you only have one layer in your app that returns errors (i.e. an error from a library is always only one error away from being handled for the last time), because you might want to treat different errors differently.</p> <p>Aside from stack traces, how would you provide the rest of the functionality of <code>pkg/errors</code> in 5-6 lines?</p> <p>edit: I actually made an error wrapping library myself for internal use, it clocks in at 1308 lines in 12 files. It handles the error wrapping, stack traces, and a bunch of utilities for extracting errors that happened or checking if a certain type of error is in the stack, and others for printing out the information is different ways (e.g. stack as array, stack as string, etc.). It&#39;s not a small library, but it&#39;s drastically improved our ability to do things like appropriate logging around errors, and has allowed us to provide much more useful and meaningful context as a result.</p></pre>shovelpost: <pre><blockquote> <p>Aside from stack traces, how would you provide the rest of the functionality of pkg/errors in 5-6 lines?</p> </blockquote> <p>I was thinking about the aforementioned example. To create a custom error that holds the cause, you need around 5-6 lines. I am not sure what else you need. If we are to also count a function that checks the error maybe we go up to 10 lines or something.</p></pre>chownplus: <pre><p>The vast majority of that &#34;1.2k+ lines&#34; of code in the dependency are tests. Once you disregard that, the dependency is a very manageable 416 lines of which over 20% amount to comments. </p></pre>shovelpost: <pre><p>Even if we omit the loc, in my opinion it is yet another dependency that the majority is blindingly importing in their projects without good reason. The worst offenders are libraries.</p></pre>weberc2: <pre><p>Why does the package loc matter? Only the lines that are used will be compiled into the final binary. Anyway, it&#39;s often better than &#34;error doing foo stuff: error doing bar stuff: error doing baz stuff: ...&#34;. This is especially true if you log as JSON and use a tool that understands JSON to query your logs. I get the desire to omit the dependency, but it&#39;s truly useful and the std lib offers no analog.</p></pre>shovelpost: <pre><blockquote> <p>but it&#39;s truly useful and the std lib offers no analog.</p> </blockquote> <p>If we are talking about the stack traces feature then yes I agree. But without that the standard library easily delivers. I am don&#39;t arguing about the usefulness of pkg/errors.</p></pre>theephie: <pre><p>Interesting take on solving the issue.</p> <p>I don&#39;t like the readability of this though, it&#39;s not obvious there are conditions inside:</p> <blockquote> <pre><code>t.a() t.b() t.c() </code></pre> </blockquote></pre>anaerobic_lifeform: <pre><p><code>errOR</code> is an OR operation on errors. The name should be changed, probably.</p> <pre><code>func errOR(errors ...error) (error) { for _, err := range errors { if err != nil { return err } } return nil } </code></pre> <p>Call site:</p> <pre><code>return errOR(a(), b(), c()); </code></pre></pre>theephie: <pre><p>I find this hilarious yet readable!</p></pre>mcouturier: <pre><p>The problem is that a, b and c often returns values along with the error</p></pre>SeerUD: <pre><p>You could wrap the functions and store the results in other variables defined outside of the call... something like:</p> <pre><code>var ar, br, cr int return errOR( func() { ar, err := a() return err }, func() { br, err := b() return err }, func() { cr, err := c() return err }, ) </code></pre> <p>...please don&#39;t do this.</p></pre>gargamelus: <pre><p>b and c may also have side effects that are not wanted if a errs out.</p></pre>weberc2: <pre><p>This could be solved by making the function accept func() error and then calling it with errOR(a, b, c). Of course, supporting functions with arguments or return values means jumping through closure hoops, which defeats the purpose.</p></pre>mcouturier: <pre><p>Stop trying to workaround, this is really bad from the start!</p></pre>weberc2: <pre><p>Calm down, I agree. I was musing for amusement. :)</p></pre>asian_driver_wee: <pre><p>Unpopular opinion, but I find just panicing whenever I don&#39;t plan to handle an error makes the app code much cleaner and easier to deal with. I don&#39;t do this when I write libs though.</p></pre>titpetric: <pre><p>I wrote about some useful patterns before <a href="https://scene-si.org/2017/11/13/error-handling-in-go/" rel="nofollow">in this article</a>. You could ctrl+f &#34;flow&#34; in there. Alternatively, you could just look at <a href="https://play.golang.org/p/AStZiZ_-Ml" rel="nofollow">this playground sample</a>, or perhaps the <a href="https://godoc.org/golang.org/x/sync/errgroup" rel="nofollow">x/sync/errgroup package</a>.</p></pre>skidooer: <pre><pre><code>for _, fn := range []func() error{a, b, c} { if err := fn(); err != nil { return err } } return nil </code></pre></pre>

入群交流(和以上内容无关):加入Go大咖交流群,或添加微信:liuxiaoyan-s 备注:入群;或加QQ群:692541889

448 次点击  
加入收藏 微博
暂无回复
添加一条新回复 (您需要 登录 后才能回复 没有账号 ?)
  • 请尽量让自己的回复能够对别人有帮助
  • 支持 Markdown 格式, **粗体**、~~删除线~~、`单行代码`
  • 支持 @ 本站用户;支持表情(输入 : 提示),见 Emoji cheat sheet
  • 图片支持拖拽、截图粘贴等方式上传