Skip to content

Commit

Permalink
Merge pull request #3 from mazrean/doc/readme
Browse files Browse the repository at this point in the history
write README
  • Loading branch information
mazrean authored Mar 2, 2024
2 parents 3881e49 + f50a142 commit ebfac8c
Show file tree
Hide file tree
Showing 14 changed files with 730 additions and 19 deletions.
147 changes: 146 additions & 1 deletion README.md
Original file line number Diff line number Diff line change
@@ -1,6 +1,151 @@
# Formstream
# FormStream

[![GitHub release](https://img.shields.io/github/release/mazrean/formstream.svg)](https://github.com/mazrean/formstream/releases/)
![CI main](https://github.com/mazrean/formstream/actions/workflows/ci.yaml/badge.svg)
[![codecov](https://codecov.io/gh/mazrean/formstream/branch/master/graph/badge.svg)](https://codecov.io/gh/mazrean/formstream)
[![Go Reference](https://pkg.go.dev/badge/github.com/mazrean/formstream.svg)](https://pkg.go.dev/github.com/mazrean/formstream)

FormStream is a Golang streaming parser for multipart data, primarily used in web form submissions and file uploads.

## Features

- Provides a streaming parser, eliminating the need to store entire files in memory or on disk in most cases.
- Boasts extremely low memory usage.
- Delivers high performance, significantly faster than traditional methods.

## Benchmarks

Across all file sizes, FormStream outperforms the [`mime/multipart`](https://pkg.go.dev/mime/multipart) in both speed and memory efficiency.

<details>
<summary>Testing Environment</summary>

- OS:
- CPU:
- RAM:
- Disk:
- Go version:
</details>

![](./docs/images/memory.png)
![](./docs/images/time.png)

> [!NOTE]
> FormStream excels in speed by employing a stream for parsing multipart data that meets specific conditions (as shown in the `FastPath` on the graph).
> It remains significantly efficient even under less ideal conditions (`SlowPath` on the graph), marginally outperforming [`mime/multipart`](https://pkg.go.dev/mime/multipart).
> For more details, see [Technical Overview](./#technical-overview).
## Installation

```sh
go get github.com/mazrean/formstream@latest
```

## Usage

### Basic Usage

<details>
<summary>Example Data</summary>

```text
--boundary
Content-Disposition: form-data; name="name"
mazrean
--boundary
Content-Disposition: form-data; name="password"
password
--boundary
Content-Disposition: form-data; name="icon"; filename="icon.png"
Content-Type: image/png
icon contents
--boundary--
```
</details>

```go
parser, err := formstream.NewParser(r)
if err != nil {
return err
}

err = parser.Register("icon", func(r io.Reader, header formstream.Header) error {
name, _, _ := parser.Value("name")
password, _, _ := parser.Value("password")

return saveUser(r.Context(), name, password, r)
}, formstream.WithRequiredPart("name"), formstream.WithRequiredPart("password"))
if err != nil {
return err
}

err = parser.Parse()
if err != nil {
return err
}
```

### Integration with Web Frameworks

FormStream offers wrappers for popular web frameworks:

|Framework|Integration Package|
|-|-|
|[net/http](https://pkg.go.dev/net/http)|[httpform](./http)|
|[Echo](https://echo.labstack.com/)|[echoform](./echo)|
|[Gin](https://gin-gonic.com/)|[ginform](./gin)|

## Technical Overview

FormStream introduces a more efficient method for processing multipart data.

### Understanding Multipart Data

Multipart data is organized with defined boundaries separating each segment. Here's an example:

```text
--boundary
Content-Disposition: form-data; name="description"
file description
--boundary
Content-Disposition: form-data; name="file"; filename="large.png"
Content-Type: image/png
large png data...
--boundary--
```

For large files, streaming the data is vital for efficient memory usage. In the example above, streaming is made possible by sequentially processing each part from the beginning, which can be achieved using the [`(*Reader).NextPart`](https://pkg.go.dev/mime/multipart#Reader.NextPart) method in the [`mime/multipart`](https://pkg.go.dev/mime/multipart) package.

### Alternative Parsing Method

The [`mime/multipart`](https://pkg.go.dev/mime/multipart) package also includes the [`(*Reader).ReadForm`](https://pkg.go.dev/mime/multipart#Reader.ReadForm) method. Unlike streaming, this method stores data temporarily in memory or on a file, leading to slower processing. It's widely used in frameworks like `net/http`, `Echo`, and `Gin` due to its ability to handle parts in any order. For instance:

```text
--boundary
Content-Disposition: form-data; name="file"; filename="large.png"
Content-Type: image/png
large png data...
--boundary
Content-Disposition: form-data; name="description"
file description
--boundary--
```

With [`(*Reader).NextPart`](https://pkg.go.dev/mime/multipart#Reader.NextPart), processing strictly follows sequential order, making it challenging to handle such data where later parts contain information necessary for processing earlier ones.

### Efficient Processing Strategies

Optimal multipart handling strategies include:
- Stream processing with [`(*Reader).NextPart`](https://pkg.go.dev/mime/multipart#Reader.NextPart) when all necessary data is immediately available.
- Temporarily storing data on disk or memory, then processing it with [`(*Reader).ReadForm`](https://pkg.go.dev/mime/multipart#Reader.ReadForm) when needed.

### Advantages of FormStream

FormStream enhances this process. It outpaces the [`(*Reader).ReadForm`](https://pkg.go.dev/mime/multipart#Reader.ReadForm) method and, unlike [`(*Reader).NextPart`](https://pkg.go.dev/mime/multipart#Reader.NextPart), can handle multipart data in any order. This adaptability makes FormStream suitable for a range of multipart data scenarios.
Binary file added docs/images/memory.png
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
Binary file added docs/images/time.png
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
50 changes: 50 additions & 0 deletions echo/README.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,50 @@
# Wrapper for [echo](https://echo.labstack.com/)

## Usage

<details>
<summary>Example data</summary>

```text
--boundary
Content-Disposition: form-data; name="name"
mazrean
--boundary
Content-Disposition: form-data; name="password"
password
--boundary
Content-Disposition: form-data; name="icon"; filename="icon.png"
Content-Type: image/png
icon contents
--boundary--
```
</details>

```go
func createUserHandler(c echo.Context) error {
parser, err := echoform.NewParser(c)
if err != nil {
return c.NoContent(http.StatusBadRequest)
}

err = parser.Register("icon", func(r io.Reader, header formstream.Header) error {
name, _, _ := parser.Value("name")
password, _, _ := parser.Value("password")

return saveUser(c.Request().Context(), name, password, r)
}, formstream.WithRequiredPart("name"), formstream.WithRequiredPart("password"))
if err != nil {
return err
}

err = parser.Parse()
if err != nil {
return c.NoContent(http.StatusBadRequest)
}

return c.NoContent(http.StatusCreated)
}
```
2 changes: 1 addition & 1 deletion echo/parser.go
Original file line number Diff line number Diff line change
@@ -1,4 +1,4 @@
package echo
package echoform

import (
"io"
Expand Down
101 changes: 101 additions & 0 deletions echo/parser_test.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,101 @@
package echoform_test

import (
"context"
"fmt"
"io"
"net/http"
"net/http/httptest"
"strings"
"testing"

"github.com/labstack/echo/v4"
"github.com/mazrean/formstream"
echoform "github.com/mazrean/formstream/echo"
)

func TestExample(t *testing.T) {
e := echo.New()

req := httptest.NewRequest(http.MethodPost, "/user", strings.NewReader(`
--boundary
Content-Disposition: form-data; name="name"
mazrean
--boundary
Content-Disposition: form-data; name="password"
password
--boundary
Content-Disposition: form-data; name="icon"; filename="icon.png"
Content-Type: image/png
icon contents
--boundary--`))
req.Header.Set(echo.HeaderContentType, "multipart/form-data; boundary=boundary")

rec := httptest.NewRecorder()
c := e.NewContext(req, rec)

err := createUserHandler(c)
if err != nil {
t.Fatalf("failed to create user: %s\n", err)
return
}

if user.name != "mazrean" {
t.Errorf("user name is wrong: expected: mazrean, actual: %s\n", user.name)
}
if user.password != "password" {
t.Errorf("user password is wrong: expected: password, actual: %s\n", user.password)
}
if user.icon != "icon contents" {
t.Errorf("user icon is wrong: expected: icon contents, actual: %s\n", user.icon)
}
}

func createUserHandler(c echo.Context) error {
parser, err := echoform.NewParser(c)
if err != nil {
return c.NoContent(http.StatusBadRequest)
}

err = parser.Register("icon", func(r io.Reader, header formstream.Header) error {
name, _, _ := parser.Value("name")
password, _, _ := parser.Value("password")

return saveUser(c.Request().Context(), name, password, r)
}, formstream.WithRequiredPart("name"), formstream.WithRequiredPart("password"))
if err != nil {
return err
}

err = parser.Parse()
if err != nil {
return c.NoContent(http.StatusBadRequest)
}

return c.NoContent(http.StatusCreated)
}

var (
user = struct {
name string
password string
icon string
}{}
)

func saveUser(_ context.Context, name string, password string, iconReader io.Reader) error {
user.name = name
user.password = password

sb := strings.Builder{}
_, err := io.Copy(&sb, iconReader)
if err != nil {
return fmt.Errorf("failed to copy: %w", err)
}
user.icon = sb.String()

return nil
}
30 changes: 16 additions & 14 deletions formstream_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -109,35 +109,37 @@ func sampleForm(fileSize formstream.DataSize, boundary string, reverse bool) (io
return b, nil
}

func BenchmarkFormstream(b *testing.B) {
func BenchmarkFormStreamFastPath(b *testing.B) {
b.Run("1MB", func(b *testing.B) {
benchmarkFormstream(b, 1*formstream.MB, false)
benchmarkFormStream(b, 1*formstream.MB, false)
})
b.Run("10MB", func(b *testing.B) {
benchmarkFormstream(b, 10*formstream.MB, false)
benchmarkFormStream(b, 10*formstream.MB, false)
})
b.Run("100MB", func(b *testing.B) {
benchmarkFormstream(b, 100*formstream.MB, false)
benchmarkFormStream(b, 100*formstream.MB, false)
})
b.Run("1GB", func(b *testing.B) {
benchmarkFormstream(b, 1*formstream.GB, false)
benchmarkFormStream(b, 1*formstream.GB, false)
})
}

b.Run("1MB Reverse", func(b *testing.B) {
benchmarkFormstream(b, 1*formstream.MB, true)
func BenchmarkFormStreamSlowPath(b *testing.B) {
b.Run("1MB", func(b *testing.B) {
benchmarkFormStream(b, 1*formstream.MB, true)
})
b.Run("10MB Reverse", func(b *testing.B) {
benchmarkFormstream(b, 10*formstream.MB, true)
b.Run("10MB", func(b *testing.B) {
benchmarkFormStream(b, 10*formstream.MB, true)
})
b.Run("100MB Reverse", func(b *testing.B) {
benchmarkFormstream(b, 100*formstream.MB, true)
b.Run("100MB", func(b *testing.B) {
benchmarkFormStream(b, 100*formstream.MB, true)
})
b.Run("1GB Reverse", func(b *testing.B) {
benchmarkFormstream(b, 1*formstream.GB, true)
b.Run("1GB", func(b *testing.B) {
benchmarkFormStream(b, 1*formstream.GB, true)
})
}

func benchmarkFormstream(b *testing.B, fileSize formstream.DataSize, reverse bool) {
func benchmarkFormStream(b *testing.B, fileSize formstream.DataSize, reverse bool) {
for i := 0; i < b.N; i++ {
b.StopTimer()
r, err := sampleForm(fileSize, boundary, reverse)
Expand Down
Loading

0 comments on commit ebfac8c

Please sign in to comment.