Skip to content

Commit

Permalink
feat: Major refactor and renaming of functions
Browse files Browse the repository at this point in the history
This is major refactor of gonanoid for version 2.
More tests were added and also some bugs fixed on
the way, mainly related to non-ascii alphabets
for IDs.

BREAKING CHANGES: Nanoid() and ID() functions were removed,
use New() instead. MustID() was removed, use Must() instead.
  • Loading branch information
matoous committed Nov 19, 2020
1 parent 554e6f8 commit ae3f94a
Show file tree
Hide file tree
Showing 10 changed files with 172 additions and 278 deletions.
21 changes: 6 additions & 15 deletions .github/workflows/test.yml
Original file line number Diff line number Diff line change
Expand Up @@ -5,30 +5,21 @@ jobs:
name: Test
runs-on: ubuntu-latest
steps:
- name: Set up Go 1.12
uses: actions/setup-go@v1
- uses: actions/setup-go@v1
with:
go-version: 1.12.3
id: go

- name: Check out code
uses: actions/checkout@v1

go-version: 1.15.2
- uses: actions/checkout@v1
- name: Test
run: make test

benchmark:
name: Benchmark
runs-on: ubuntu-latest
steps:
- name: Set up Go 1.12
uses: actions/setup-go@v1
- uses: actions/setup-go@v1
with:
go-version: 1.12.3
go-version: 1.15.2
id: go

- name: Check out code
uses: actions/checkout@v1

- uses: actions/checkout@v1
- name: Benchmark
run: make bench
28 changes: 0 additions & 28 deletions .goreleaser.yml

This file was deleted.

2 changes: 1 addition & 1 deletion Makefile
Original file line number Diff line number Diff line change
Expand Up @@ -3,7 +3,7 @@
help: ## Show help/documentation for the Makefile
@grep -Eh '^[0-9a-zA-Z_-]+:.*?## .*$$' $(MAKEFILE_LIST) | sort | awk 'BEGIN {FS = ":.*?## "}; {printf "\033[36m%-30s\033[0m %s\n", $$1, $$2}'

configure:
configure: ## Download dependencies
go mod download

lint: configure ## Lint the repository with golang-ci lint
Expand Down
10 changes: 2 additions & 8 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -30,21 +30,15 @@ $ go get github.com/matoous/go-nanoid
Generate ID

``` go
id, err := gonanoid.Nanoid()
id, err := gonanoid.New()
```

Generate ID with custom alphabet and length
Generate ID with a custom alphabet and length

``` go
id, err := gonanoid.Generate("abcde", 54)
```

## Testing

``` bash
$ go test
```

## Notice

If you use Go Nanoid in your project, please let me know!
Expand Down
9 changes: 6 additions & 3 deletions examples/simple_example.go
Original file line number Diff line number Diff line change
Expand Up @@ -3,19 +3,19 @@ package main
import (
"fmt"

"github.com/matoous/go-nanoid"
gonanoid "github.com/matoous/go-nanoid/v2"
)

func main() {
// Simple usage
id, err := gonanoid.Nanoid()
id, err := gonanoid.New()
if err != nil {
panic(err)
}
fmt.Printf("Generated id: %s\n", id)

// Custom length
id, err = gonanoid.ID(5)
id, err = gonanoid.New(5)
if err != nil {
panic(err)
}
Expand All @@ -34,4 +34,7 @@ func main() {
panic(err)
}
fmt.Printf("Generated id: %s\n", id)

fmt.Printf("Generated id: %s\n", gonanoid.Must())
fmt.Printf("Generated id: %s\n", gonanoid.MustGenerate("🚀💩🦄🤖", 4))
}
7 changes: 6 additions & 1 deletion go.mod
Original file line number Diff line number Diff line change
@@ -1,3 +1,8 @@
go 1.15

module github.com/matoous/go-nanoid
module github.com/matoous/go-nanoid/v2

require (
github.com/matoous/go-nanoid v1.5.0
github.com/stretchr/testify v1.6.1
)
14 changes: 14 additions & 0 deletions go.sum
Original file line number Diff line number Diff line change
@@ -0,0 +1,14 @@
github.com/davecgh/go-spew v1.1.0 h1:ZDRjVQ15GmhC3fiQ8ni8+OwkZQO4DARzQgrnXU1Liz8=
github.com/davecgh/go-spew v1.1.0/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38=
github.com/matoous/go-nanoid v1.5.0 h1:VRorl6uCngneC4oUQqOYtO3S0H5QKFtKuKycFG3euek=
github.com/matoous/go-nanoid v1.5.0/go.mod h1:zyD2a71IubI24efhpvkJz+ZwfwagzgSO6UNiFsZKN7U=
github.com/pmezard/go-difflib v1.0.0 h1:4DBwDE0NGyQoBHbLQYPwSUPoCMWR5BEzIk/f1lZbAQM=
github.com/pmezard/go-difflib v1.0.0/go.mod h1:iKH77koFhYxTK1pcRnkKkqfTogsbg7gZNVY4sRDYZ/4=
github.com/stretchr/objx v0.1.0 h1:4G4v2dO3VZwixGIRoQ5Lfboy6nUhCyYzaqnIAPPhYs4=
github.com/stretchr/objx v0.1.0/go.mod h1:HFkY916IF+rwdDfMAkV7OtwuqBVzrE8GR6GFx+wExME=
github.com/stretchr/testify v1.6.1 h1:hDPOHmpOpP40lSULcqw7IrRb/u7w6RpDC9399XyoNd0=
github.com/stretchr/testify v1.6.1/go.mod h1:6Fq8oRcR53rry900zMqJjRRixrwX3KX962/h/Wwjteg=
gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405 h1:yhCVgyC4o1eVCa2tZl7eS0r+SDo693bJlVdllGtEeKM=
gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0=
gopkg.in/yaml.v3 v3.0.0-20200313102051-9f266ea9e77c h1:dUUwHk2QECo/6vqA44rthZ8ie2QXMNeKRTHCNY2nXvo=
gopkg.in/yaml.v3 v3.0.0-20200313102051-9f266ea9e77c/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM=
101 changes: 39 additions & 62 deletions gonanoid.go
Original file line number Diff line number Diff line change
Expand Up @@ -3,74 +3,56 @@ package gonanoid
import (
"crypto/rand"
"errors"
"fmt"
"math"
)

// defaultAlphabet is the alphabet used for ID characters by default.
var defaultAlphabet = []rune("_-0123456789abcdefghijklmnopqrstuvwxyzABCDEFGHIJKLMNOPQRSTUVWXYZ")

const (
defaultSize = 21
defaultMaskSize = 5
defaultSize = 21
)

// Generator function
type Generator func([]byte) (int, error)

// BytesGenerator is the default bytes generator
var BytesGenerator Generator = rand.Read

func initMasks(params ...int) []uint {
var size int
if len(params) == 0 {
size = defaultMaskSize
} else {
size = params[0]
}
masks := make([]uint, size)
for i := 0; i < size; i++ {
shift := 3 + i
masks[i] = (2 << uint(shift)) - 1
}
return masks
}

func getMask(alphabet []rune, masks []uint) int {
for i := 0; i < len(masks); i++ {
curr := int(masks[i])
if curr >= len(alphabet)-1 {
return curr
// getMask generates bit mask used to obtain bits from the random bytes that are used to get index of random character
// from the alphabet. Example: if the alphabet has 6 = (110)_2 characters it is sufficient to use mask 7 = (111)_2
func getMask(alphabetSize int) int {
for i := 1; i <= 8; i++ {
mask := (2 << uint(i)) - 1
if mask >= alphabetSize-1 {
return mask
}
}
return 0
}

// Generate is a low-level function to change alphabet and ID size.
func Generate(rawAlphabet string, size int) (string, error) {
alphabet := []rune(rawAlphabet)
func Generate(alphabet string, size int) (string, error) {
chars := []rune(alphabet)

if len(alphabet) == 0 || len(alphabet) > 255 {
return "", fmt.Errorf("alphabet must not empty and contain no more than 255 chars. Current len is %d", len(alphabet))
return "", errors.New("alphabet must not be empty and contain no more than 255 chars")
}
if size <= 0 {
return "", fmt.Errorf("size must be positive integer")
return "", errors.New("size must be positive integer")
}

masks := initMasks(size)
mask := getMask(alphabet, masks)
mask := getMask(len(chars))
// estimate how many random bytes we will need for the ID, we might actually need more but this is tradeoff
// between average case and worst case
ceilArg := 1.6 * float64(mask*size) / float64(len(alphabet))
step := int(math.Ceil(ceilArg))

id := make([]rune, size)
bytes := make([]byte, step)
for j := 0; ; {
_, err := BytesGenerator(bytes)
_, err := rand.Read(bytes)
if err != nil {
return "", err
}
for i := 0; i < step; i++ {
currByte := bytes[i] & byte(mask)
if currByte < byte(len(alphabet)) {
id[j] = alphabet[currByte]
if currByte < byte(len(chars)) {
id[j] = chars[currByte]
j++
if j == size {
return string(id[:size]), nil
Expand All @@ -80,22 +62,32 @@ func Generate(rawAlphabet string, size int) (string, error) {
}
}

// Nanoid generates secure URL-friendly unique ID.
func Nanoid(param ...int) (string, error) {
// MustGenerate is the same as Generate but panics on error.
func MustGenerate(alphabet string, size int) string {
id, err := Generate(alphabet, size)
if err != nil {
panic(err)
}
return id
}

// New generates secure URL-friendly unique ID.
// Accepts optional parameter - length of the ID to be generated (21 by default).
func New(l ...int) (string, error) {
var size int
switch {
case len(param) == 0:
case len(l) == 0:
size = defaultSize
case len(param) == 1:
size = param[0]
case len(l) == 1:
size = l[0]
if size < 0 {
return "", errors.New("negative id length")
}
default:
return "", errors.New("unexpected parameter")
}
bytes := make([]byte, size)
_, err := BytesGenerator(bytes)
_, err := rand.Read(bytes)
if err != nil {
return "", err
}
Expand All @@ -106,24 +98,9 @@ func Nanoid(param ...int) (string, error) {
return string(id[:size]), nil
}

// ID provides more golang idiomatic interface for generating IDs.
// Calling ID is shorter yet still clear `gonanoid.ID(20)` and it requires the lengths parameter by default.
func ID(l int) (string, error) {
return Nanoid(l)
}

// MustID is the same as ID but panics on error.
func MustID(l int) string {
id, err := Nanoid(l)
if err != nil {
panic(err)
}
return id
}

// MustGenerate is the same as Generate but panics on error.
func MustGenerate(rawAlphabet string, size int) string {
id, err := Generate(rawAlphabet, size)
// Must is the same as New but panics on error.
func Must(l ...int) string {
id, err := New(l...)
if err != nil {
panic(err)
}
Expand Down
41 changes: 41 additions & 0 deletions gonanoid_internal_test.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,41 @@
package gonanoid

import (
"testing"

"github.com/stretchr/testify/require"
)

func TestHasNoCollisions(t *testing.T) {
tries := 100_000
used := make(map[string]bool)
for i := 0; i < tries; i++ {
id := Must()
require.False(t, used[id], "shouldn't return colliding IDs")
used[id] = true
}
}

func TestFlatDistribution(t *testing.T) {
tries := 100_000
alphabet := "abcdefghij"
size := 10
chars := make(map[rune]int)
for i := 0; i < tries; i++ {
id := MustGenerate(alphabet, size)
for _, r := range id {
chars[r]++
}
}

for _, count := range chars {
require.InEpsilon(t, size*tries/len(alphabet), count, .01, "should have flat distribution")
}
}

// Benchmark nanoid generator
func BenchmarkNanoid(b *testing.B) {
for n := 0; n < b.N; n++ {
_, _ = New()
}
}

0 comments on commit ae3f94a

Please sign in to comment.