├── .gitattributes
├── .github
├── ISSUE_TEMPLATE
│ ├── bug_report.md
│ └── feature_request.md
├── dependabot.yml
├── pull_request_template.md
└── workflows
│ ├── codeql-analysis.yml
│ ├── linters.yml
│ └── linux.yml
├── .gitignore
├── .golangci.yml
├── .rr.yaml
├── CHANGELOG.md
├── CODE_OF_CONDUCT.md
├── LICENSE
├── Makefile
├── README.md
├── build.sh
├── cmd
├── protoc-gen-php-grpc
│ ├── main.go
│ ├── php
│ │ ├── generate.go
│ │ ├── keywords.go
│ │ ├── ns.go
│ │ └── template.go
│ ├── plugin_test.go
│ └── testdata
│ │ ├── import
│ │ ├── Import
│ │ │ └── ServiceInterface.php
│ │ ├── service.proto
│ │ └── sub
│ │ │ └── message.proto
│ │ ├── import_custom
│ │ ├── Test
│ │ │ └── CustomImport
│ │ │ │ └── ServiceInterface.php
│ │ ├── service.proto
│ │ └── sub
│ │ │ └── message.proto
│ │ ├── php_namespace
│ │ ├── Test
│ │ │ └── CustomNamespace
│ │ │ │ └── ServiceInterface.php
│ │ └── service.proto
│ │ ├── simple
│ │ ├── TestSimple
│ │ │ └── SimpleServiceInterface.php
│ │ └── simple.proto
│ │ └── use_empty
│ │ ├── Test
│ │ └── ServiceInterface.php
│ │ └── service.proto
└── rr-grpc
│ ├── grpc
│ ├── debug.go
│ ├── metrics.go
│ ├── reset.go
│ └── workers.go
│ └── main.go
├── codec.go
├── codec_test.go
├── codecov.yml
├── composer.json
├── config.go
├── config_test.go
├── event.go
├── go.mod
├── parser
├── message.proto
├── parse.go
├── parse_test.go
├── pong.proto
├── test.proto
├── test_import.proto
└── test_nested
│ ├── message.proto
│ ├── pong.proto
│ └── test_import.proto
├── phpunit.xml
├── proxy.go
├── proxy_test.go
├── psalm.xml
├── rpc.go
├── rpc_test.go
├── service.go
├── service_test.go
└── src
├── Context.php
├── ContextInterface.php
├── Exception
├── GRPCException.php
├── GRPCExceptionInterface.php
├── InvokeException.php
├── MutableGRPCExceptionInterface.php
├── NotFoundException.php
├── ServiceException.php
├── UnauthenticatedException.php
└── UnimplementedException.php
├── Internal
└── Json.php
├── Invoker.php
├── InvokerInterface.php
├── Method.php
├── ResponseHeaders.php
├── Server.php
├── ServiceInterface.php
├── ServiceWrapper.php
└── StatusCode.php
/.gitattributes:
--------------------------------------------------------------------------------
1 | /tests export-ignore
2 | /example export-ignore
3 |
--------------------------------------------------------------------------------
/.github/ISSUE_TEMPLATE/bug_report.md:
--------------------------------------------------------------------------------
1 | ---
2 | name: Bug report
3 | about: Create a report to help us improve
4 | title: "[BUG]"
5 | labels: B-bug, F-need-verification
6 | assignees: rustatian
7 |
8 | ---
9 |
10 | ---
11 | name: Bug Report
12 | about: Issue in HTTP module
13 | labels: A-network
14 | ---
15 |
19 |
20 | I tried this code:
21 |
22 | ```go
23 |
24 | ```
25 |
26 | I expected to see this happen: *explanation*
27 |
28 | Instead, this happened: *explanation*
29 |
30 | The version of RR used: *explanation*
31 |
32 | My `.rr.yaml` configuration is: *config*
33 |
34 | Errortrace, Backtrace or Panictrace
35 | ```
36 |
37 | ```
38 |
39 |
40 |
41 |
--------------------------------------------------------------------------------
/.github/ISSUE_TEMPLATE/feature_request.md:
--------------------------------------------------------------------------------
1 | ---
2 | name: Feature request
3 | about: Suggest an idea for this project
4 | title: "[FEATURE REQUEST]"
5 | labels: C-feature-request
6 | assignees: rustatian
7 |
8 | ---
9 |
10 | **Is your feature request related to a problem? Please describe.**
11 | A clear and concise description of what the problem is. Ex. I'm always frustrated when [...]
12 |
13 | **Describe the solution you'd like**
14 | A clear and concise description of what you want to happen.
15 |
16 | **Describe alternatives you've considered**
17 | A clear and concise description of any alternative solutions or features you've considered.
18 |
19 | **Additional context**
20 | Add any other context or screenshots about the feature request here.
21 |
--------------------------------------------------------------------------------
/.github/dependabot.yml:
--------------------------------------------------------------------------------
1 | # To get started with Dependabot version updates, you'll need to specify which
2 | # package ecosystems to update and where the package manifests are located.
3 | # Please see the documentation for all configuration options:
4 | # https://help.github.com/github/administering-a-repository/configuration-options-for-dependency-updates
5 |
6 | version: 2
7 | updates:
8 | - package-ecosystem: gomod # See documentation for possible values
9 | directory: "/" # Location of package manifests
10 | schedule:
11 | interval: daily
12 |
13 | - package-ecosystem: "github-actions"
14 | directory: "/"
15 | schedule:
16 | interval: daily
17 |
--------------------------------------------------------------------------------
/.github/pull_request_template.md:
--------------------------------------------------------------------------------
1 | # Reason for This PR
2 |
3 | `[Author TODO: add issue # or explain reasoning.]`
4 |
5 | ## Description of Changes
6 |
7 | `[Author TODO: add description of changes.]`
8 |
9 | ## License Acceptance
10 |
11 | By submitting this pull request, I confirm that my contribution is made under
12 | the terms of the MIT license.
13 |
14 | ## PR Checklist
15 |
16 | `[Author TODO: Meet these criteria.]`
17 | `[Reviewer TODO: Verify that these criteria are met. Request changes if not]`
18 |
19 | - [ ] All commits in this PR are signed (`git commit -s`).
20 | - [ ] The reason for this PR is clearly provided (issue no. or explanation).
21 | - [ ] The description of changes is clear and encompassing.
22 | - [ ] Any required documentation changes (code and docs) are included in this PR.
23 | - [ ] Any user-facing changes are mentioned in `CHANGELOG.md`.
24 | - [ ] All added/changed functionality is tested.
25 |
--------------------------------------------------------------------------------
/.github/workflows/codeql-analysis.yml:
--------------------------------------------------------------------------------
1 | # For most projects, this workflow file will not need changing; you simply need
2 | # to commit it to your repository.
3 | #
4 | # You may wish to alter this file to override the set of languages analyzed,
5 | # or to provide custom queries or build logic.
6 | name: "CodeQL"
7 |
8 | on:
9 | push:
10 | branches: [ master ]
11 | pull_request:
12 | # The branches below must be a subset of the branches above
13 | branches: [ master ]
14 | schedule:
15 | - cron: '0 15 * * 6'
16 |
17 | jobs:
18 | analyze:
19 | name: Analyze
20 | runs-on: ubuntu-latest
21 |
22 | strategy:
23 | fail-fast: false
24 | matrix:
25 | # Override automatic language detection by changing the below list
26 | # Supported options are ['csharp', 'cpp', 'go', 'java', 'javascript', 'python']
27 | language: [ 'go' ]
28 | # Learn more...
29 | # https://docs.github.com/en/github/finding-security-vulnerabilities-and-errors-in-your-code/configuring-code-scanning#overriding-automatic-language-detection
30 |
31 | steps:
32 | - name: Checkout repository
33 | uses: actions/checkout@v2
34 | with:
35 | # We must fetch at least the immediate parents so that if this is
36 | # a pull request then we can checkout the head.
37 | fetch-depth: 2
38 |
39 | # Initializes the CodeQL tools for scanning.
40 | - name: Initialize CodeQL
41 | uses: github/codeql-action/init@v1
42 | with:
43 | languages: ${{ matrix.language }}
44 | # If you wish to specify custom queries, you can do so here or in a config file.
45 | # By default, queries listed here will override any specified in a config file.
46 | # Prefix the list here with "+" to use these queries and those in the config file.
47 | # queries: ./path/to/local/query, your-org/your-repo/queries@main
48 |
49 | # Autobuild attempts to build any compiled languages (C/C++, C#, or Java).
50 | # If this step fails, then you should remove it and run the build manually (see below)
51 | - name: Autobuild
52 | uses: github/codeql-action/autobuild@v1
53 |
54 | # ℹ️ Command-line programs to run using the OS shell.
55 | # 📚 https://git.io/JvXDl
56 |
57 | # ✏️ If the Autobuild fails above, remove it and uncomment the following three lines
58 | # and modify them (or add more) to build your code if your project
59 | # uses a compiled language
60 |
61 | #- run: |
62 | # make bootstrap
63 | # make release
64 |
65 | - name: Perform CodeQL Analysis
66 | uses: github/codeql-action/analyze@v1
67 |
--------------------------------------------------------------------------------
/.github/workflows/linters.yml:
--------------------------------------------------------------------------------
1 | name: Linters
2 |
3 | on: [push, pull_request]
4 |
5 | jobs:
6 | golangci-lint:
7 | name: Golang-CI (lint)
8 | runs-on: ubuntu-latest
9 | steps:
10 | - name: Check out code
11 | uses: actions/checkout@v2
12 |
13 | - name: Run linter
14 | uses: golangci/golangci-lint-action@v2 # Action page:
15 | with:
16 | version: v1.43 # without patch version
17 | only-new-issues: false # show only new issues if it's a pull request
18 | args: --timeout=10m
19 |
--------------------------------------------------------------------------------
/.github/workflows/linux.yml:
--------------------------------------------------------------------------------
1 | name: Linux
2 |
3 | on:
4 | push:
5 | branches:
6 | - master
7 | - beta
8 | - stable
9 | tags-ignore:
10 | - "**"
11 | paths-ignore:
12 | - "**.md"
13 | - "**.yaml"
14 | - "**.yml"
15 | pull_request:
16 | paths-ignore:
17 | - "**.md"
18 | - "**.yaml"
19 | - "**.yml"
20 |
21 | jobs:
22 | golang:
23 | name: Build (Go ${{ matrix.go }}, PHP ${{ matrix.php }}, OS ${{matrix.os}})
24 | runs-on: ${{ matrix.os }}
25 | timeout-minutes: 60
26 | strategy:
27 | fail-fast: true
28 | matrix:
29 | php: ["7.4", "8.0", "8.1"]
30 | go: ["1.17.4"]
31 | os: ["ubuntu-latest"]
32 | steps:
33 | - name: Set up Go ${{ matrix.go }}
34 | uses: actions/setup-go@v2 # action page:
35 | with:
36 | go-version: ${{ matrix.go }}
37 |
38 | - name: Set up PHP ${{ matrix.php }}
39 | uses: shivammathur/setup-php@v2 # action page:
40 | with:
41 | php-version: ${{ matrix.php }}
42 | extensions: sockets, grpc
43 |
44 | - name: Check out code
45 | uses: actions/checkout@v2
46 |
47 | - name: Get Composer Cache Directory
48 | id: composer-cache
49 | run: echo "::set-output name=dir::$(composer config cache-files-dir)"
50 |
51 | - name: Init Composer Cache # Docs:
52 | uses: actions/cache@v2
53 | with:
54 | path: ${{ steps.composer-cache.outputs.dir }}
55 | key: ${{ runner.os }}-composer-${{ matrix.php }}-${{ hashFiles('**/composer.json') }}
56 | restore-keys: ${{ runner.os }}-composer-
57 |
58 | - name: Install Composer dependencies
59 | run: composer update --prefer-dist --no-progress --ansi
60 |
61 | - name: Init Go modules Cache # Docs:
62 | uses: actions/cache@v2
63 | with:
64 | path: ~/go/pkg/mod
65 | key: ${{ runner.os }}-go-${{ hashFiles('**/go.sum') }}
66 | restore-keys: ${{ runner.os }}-go-
67 |
68 | - name: Install Go dependencies
69 | run: go mod download
70 |
71 | - name: Go mod tidy
72 | run: go mod tidy
73 |
74 | - name: Install protoc
75 | uses: arduino/setup-protoc@v1
76 | with:
77 | version: '3.x'
78 |
79 | - name: Run golang tests with coverage
80 | run: make test
81 |
82 | - uses: codecov/codecov-action@v2 # Docs:
83 | with:
84 | file: ./coverage-ci/summary.txt
85 | fail_ci_if_error: false
86 |
--------------------------------------------------------------------------------
/.gitignore:
--------------------------------------------------------------------------------
1 | .idea/
2 | .env
3 | composer.lock
4 | vendor/
5 | *.db
6 | clover.xml
7 | example/vendor/
8 | go.sum
9 | builds/
10 | .phpunit.result.cache
11 | vendor_php
12 | coverage-ci
13 |
--------------------------------------------------------------------------------
/.golangci.yml:
--------------------------------------------------------------------------------
1 | # Documentation:
2 |
3 | run:
4 | timeout: 1m
5 | skip-dirs:
6 | - .github
7 | - .git
8 | - tests
9 | - src
10 | - vendor_php
11 | skip-files:
12 | - condec_test.go
13 | - config_test.go
14 | - proxy_test.go
15 | - rpc_test.go
16 | - service_test.go
17 | modules-download-mode: readonly
18 | allow-parallel-runners: true
19 |
20 | output:
21 | format: colored-line-number # colored-line-number|line-number|json|tab|checkstyle|code-climate
22 |
23 | linters-settings:
24 | govet:
25 | check-shadowing: true
26 | revive:
27 | confidence: 0.8
28 | errorCode: 0
29 | warningCode: 0
30 | gocyclo:
31 | min-complexity: 15
32 | godot:
33 | scope: declarations
34 | capital: true
35 | dupl:
36 | threshold: 100
37 | goconst:
38 | min-len: 2
39 | min-occurrences: 3
40 | misspell:
41 | locale: US
42 | lll:
43 | line-length: 120
44 | prealloc:
45 | simple: true
46 | range-loops: true
47 | for-loops: true
48 | nolintlint:
49 | allow-leading-space: false
50 | require-specific: true
51 |
52 | linters: # All available linters list:
53 | disable-all: true
54 | enable:
55 | - asciicheck # Simple linter to check that your code does not contain non-ASCII identifiers
56 | - bodyclose # Checks whether HTTP response body is closed successfully
57 | - deadcode # Finds unused code
58 | - depguard # Go linter that checks if package imports are in a list of acceptable packages
59 | - dogsled # Checks assignments with too many blank identifiers (e.g. x, _, _, _, := f())
60 | - errcheck # Errcheck is a program for checking for unchecked errors in go programs. These unchecked errors can be critical bugs in some cases
61 | - exhaustive # check exhaustiveness of enum switch statements
62 | - exportloopref # checks for pointers to enclosing loop variables
63 | - goconst # Finds repeated strings that could be replaced by a constant
64 | - gocritic # The most opinionated Go source code linter
65 | - gofmt # Gofmt checks whether code was gofmt-ed. By default this tool runs with -s option to check for code simplification
66 | - goimports # Goimports does everything that gofmt does. Additionally it checks unused imports
67 | - revive # Golint differs from gofmt. Gofmt reformats Go source code, whereas golint prints out style mistakes
68 | - goprintffuncname # Checks that printf-like functions are named with `f` at the end
69 | - gosec # Inspects source code for security problems
70 | - gosimple # Linter for Go source code that specializes in simplifying a code
71 | - govet # Vet examines Go source code and reports suspicious constructs, such as Printf calls whose arguments do not align with the format string
72 | - ineffassign # Detects when assignments to existing variables are not used
73 | - misspell # Finds commonly misspelled English words in comments
74 | - nakedret # Finds naked returns in functions greater than a specified function length
75 | - noctx # finds sending http request without context.Context
76 | - nolintlint # Reports ill-formed or insufficient nolint directives
77 | - prealloc # Finds slice declarations that could potentially be preallocated
78 | - rowserrcheck # Checks whether Err of rows is checked successfully
79 | - staticcheck # Staticcheck is a go vet on steroids, applying a ton of static analysis checks
80 | - structcheck # Finds unused struct fields
81 | - stylecheck # Stylecheck is a replacement for golint
82 | - tparallel # detects inappropriate usage of t.Parallel() method in your Go test codes
83 | - unconvert # Remove unnecessary type conversions
84 | - unused # Checks Go code for unused constants, variables, functions and types
85 | - varcheck # Finds unused global variables and constants
86 | - gochecknoglobals
87 | - whitespace # Tool for detection of leading and trailing whitespace
88 |
--------------------------------------------------------------------------------
/.rr.yaml:
--------------------------------------------------------------------------------
1 | # GRPC service configuration
2 | grpc:
3 | # socket to listen
4 | listen: "tcp://localhost:9001"
5 |
6 | # proto root file
7 | proto: "test.proto"
8 |
9 | # max send limit (MB)
10 | MaxSendMsgSize: 50
11 | # max receive limit (MB)
12 | MaxRecvMsgSize: 50
13 | # MaxConnectionIdle is a duration for the amount of time after which an
14 | # idle connection would be closed by sending a GoAway. Idleness duration is
15 | # defined since the most recent time the number of outstanding RPCs became
16 | # zero or the connection establishment.
17 | MaxConnectionIdle: 0s
18 | # MaxConnectionAge is a duration for the maximum amount of time a
19 | # connection may exist before it will be closed by sending a GoAway. A
20 | # random jitter of +/-10% will be added to MaxConnectionAge to spread out
21 | # connection storms.
22 | MaxConnectionAge: 0s
23 | # MaxConnectionAgeGrace is an additive period after MaxConnectionAge after
24 | # which the connection will be forcibly closed.
25 | MaxConnectionAgeGrace: 0s
26 | # MaxConnectionAgeGrace is an additive period after MaxConnectionAge after
27 | # which the connection will be forcibly closed.
28 | MaxConcurrentStreams: 10
29 | # After a duration of this time if the server doesn't see any activity it
30 | # pings the client to see if the transport is still alive.
31 | # If set below 1s, a minimum value of 1s will be used instead.
32 | PingTime: 1s
33 | # After having pinged for keepalive check, the server waits for a duration
34 | # of Timeout and if no activity is seen even after that the connection is
35 | # closed.
36 | Timeout: 200s
37 |
38 | # worker and pool configuration
39 | workers:
40 | command: "php worker.php"
41 | pool:
42 | numWorkers: 1
43 | maxJobs: 1
44 |
45 | metrics:
46 | # prometheus client address (path /metrics added automatically)
47 | address: localhost:2112
48 |
49 | # list of metrics to collect from application
50 | collect:
51 | # metric name
52 | app_metric:
53 | # type [gauge, counter, histogram, symnmary]
54 | type: histogram
55 |
56 | # short description
57 | help: "Custom application metric"
58 |
59 | # metric groups/tags
60 | labels: ["type"]
61 |
62 | # for histogram only
63 | buckets: [0.1, 0.2, 0.3, 1.0]
64 |
65 | # monitors rr server(s)
66 | limit:
67 | # check worker state each second
68 | interval: 1
69 |
70 | # custom watch configuration for each service
71 | services:
72 | # monitor http workers
73 | grpc:
74 | # maximum allowed memory consumption per worker (soft)
75 | maxMemory: 100
76 |
77 | # maximum time to live for the worker (soft)
78 | TTL: 0
79 |
80 | # maximum allowed amount of time worker can spend in idle before being removed (for weak db connections, soft)
81 | idleTTL: 0
82 |
83 | # max_execution_time (brutal)
84 | execTTL: 60
--------------------------------------------------------------------------------
/CHANGELOG.md:
--------------------------------------------------------------------------------
1 | CHANGELOG
2 | =========
3 |
4 | v1.4.1 (13.10.2020)
5 | -------------------
6 | - RoadRunner version update to 1.8.3
7 | - Golang version in go.mod bump to 1.15
8 | - Add server configuration options (debug) (@aldump)
9 |
10 | v1.4.0 (1.08.2020)
11 | -------------------
12 | - Add all major gRPC configuration options. [Docs](https://github.com/spiral/docs/blob/master/grpc/configuration.md#application-server)
13 |
14 | v1.3.1 (20.07.2020)
15 | -------------------
16 | - RoadRunner version updated to 1.8.2
17 |
18 | v1.3.0 (25.05.2020)
19 | -------------------
20 | - Add the ability to append the certificate authority in the config under the `tls: rootCA` key
21 | - RoadRunner version updated to 1.8.1
22 |
23 | v1.2.2 (05.05.2020)
24 | -------------------
25 | - RoadRunner version updated to 1.8.0
26 |
27 | v1.2.1 (22.04.2020)
28 | -------------------
29 | - Replaced deprecated github.com/golang/protobuf/proto with new google.golang.org/protobuf/proto
30 | - RoadRunner version updated to 1.7.1
31 |
32 | v1.2.0 (27.01.2020)
33 | -------------------
34 | - Add the ability to work on Golang level only (without roadrunner worker and proto file)
35 |
36 | v1.1.1 (27.01.2020)
37 | -------------------
38 | - [bugfix] invalid constructor parameters in ServiceException by @everflux
39 |
40 | v1.1.0 (30.11.2019)
41 | -------------------
42 | - Add automatic CS fixing
43 | - The minimum PHP version set to 7.2
44 | - Add ResponseHeaders and metadata generation by server by @wolfgang-braun
45 |
46 | v1.0.8 (06.09.2019)
47 | -------------------
48 | - Include `limit` and `metrics` service
49 | - Ability to expose GRPC stats to Prometheus
50 |
51 | v1.0.7 (22.05.2019)
52 | -------------------
53 | - Server and Invoker are final
54 | - Add support for pool controller (roadrunner 1.4.0)
55 | - Add strict_types=1
56 |
57 | v1.0.4-1.0.6 (26.04.2019)
58 | -------------------
59 | - bugfix, support for imported services in proto annotation by @oneslash
60 |
61 | v1.0.2 (18.03.2019)
62 | -------------------
63 | - Add support for `php_namespace` option
64 | - Add support for nested namespace resolution in generated code
65 | (thanks to @zarianec)
66 | - protobuf version bump to 1.3.1
67 |
68 | v1.0.1 (30.01.2019)
69 | -------------------
70 | - Fix bug causing server not working with empty payloads
71 | - Fix bug with disabled RPC service
72 | - Add elapsed time to the debug log
73 |
74 | v1.0.0 (20.10.2018)
75 | -------------------
76 | - initial application release
77 |
--------------------------------------------------------------------------------
/CODE_OF_CONDUCT.md:
--------------------------------------------------------------------------------
1 | # Contributor Covenant Code of Conduct
2 |
3 | ## Our Pledge
4 |
5 | In the interest of fostering an open and welcoming environment, we as contributors and maintainers pledge to making participation in our project and our community a harassment-free experience for everyone, regardless of age, body size, disability, ethnicity, gender identity and expression, level of experience, nationality, personal appearance, race, religion, or sexual identity and orientation.
6 |
7 | ## Our Standards
8 |
9 | Examples of behavior that contributes to creating a positive environment include:
10 |
11 | * Using welcoming and inclusive language
12 | * Being respectful of differing viewpoints and experiences
13 | * Gracefully accepting constructive criticism
14 | * Focusing on what is best for the community
15 | * Showing empathy towards other community members
16 |
17 | Examples of unacceptable behavior by participants include:
18 |
19 | * The use of sexualized language or imagery and unwelcome sexual attention or advances
20 | * Trolling, insulting/derogatory comments, and personal or political attacks
21 | * Public or private harassment
22 | * Publishing others' private information, such as a physical or electronic address, without explicit permission
23 | * Other conduct which could reasonably be considered inappropriate in a professional setting
24 |
25 | ## Our Responsibilities
26 |
27 | Project maintainers are responsible for clarifying the standards of acceptable behavior and are expected to take appropriate and fair corrective action in response to any instances of unacceptable behavior.
28 |
29 | Project maintainers have the right and responsibility to remove, edit, or reject comments, commits, code, wiki edits, issues, and other contributions that are not aligned to this Code of Conduct, or to ban temporarily or permanently any contributor for other behaviors that they deem inappropriate, threatening, offensive, or harmful.
30 |
31 | ## Scope
32 |
33 | This Code of Conduct applies both within project spaces and in public spaces when an individual is representing the project or its community. Examples of representing a project or community include using an official project e-mail address, posting via an official social media account, or acting as an appointed representative at an online or offline event. Representation of a project may be further defined and clarified by project maintainers.
34 |
35 | ## Enforcement
36 |
37 | Instances of abusive, harassing, or otherwise unacceptable behavior may be reported by contacting the project team at wolfy-j@spiralscout.com. The project team will review and investigate all complaints, and will respond in a way that it deems appropriate to the circumstances. The project team is obligated to maintain confidentiality with regard to the reporter of an incident. Further details of specific enforcement policies may be posted separately.
38 |
39 | Project maintainers who do not follow or enforce the Code of Conduct in good faith may face temporary or permanent repercussions as determined by other members of the project's leadership.
40 |
41 | ## Attribution
42 |
43 | This Code of Conduct is adapted from the [Contributor Covenant][homepage], version 1.4, available at [http://contributor-covenant.org/version/1/4][version]
44 |
45 | [homepage]: http://contributor-covenant.org
46 | [version]: http://contributor-covenant.org/version/1/4/
47 |
--------------------------------------------------------------------------------
/LICENSE:
--------------------------------------------------------------------------------
1 | MIT License
2 |
3 | Copyright (c) 2020 Spiral Scout
4 |
5 | Permission is hereby granted, free of charge, to any person obtaining a copy
6 | of this software and associated documentation files (the "Software"), to deal
7 | in the Software without restriction, including without limitation the rights
8 | to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
9 | copies of the Software, and to permit persons to whom the Software is
10 | furnished to do so, subject to the following conditions:
11 |
12 | The above copyright notice and this permission notice shall be included in all
13 | copies or substantial portions of the Software.
14 |
15 | THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
16 | IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
17 | FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
18 | AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
19 | LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
20 | OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
21 | SOFTWARE.
22 |
--------------------------------------------------------------------------------
/Makefile:
--------------------------------------------------------------------------------
1 | all:
2 | @./build.sh all
3 | build:
4 | @./build.sh
5 | clean:
6 | rm -rf protoc-gen-php-grpc
7 | rm -rf rr-grpc
8 | install: all
9 | cp protoc-gen-php-grpc /usr/local/bin/protoc-gen-php-grpc
10 | cp rr-grpc /usr/local/bin/rr-grpc
11 | uninstall:
12 | rm -f /usr/local/bin/protoc-gen-php-grpc
13 | rm -f /usr/local/bin/rr-grpc
14 | test:
15 | rm -rf coverage-ci
16 | mkdir ./coverage-ci
17 |
18 | go test -v -race -cover -tags=debug -coverpkg=./... -failfast -coverprofile=./coverage-ci/root.out -covermode=atomic .
19 | go test -v -race -cover -tags=debug -coverpkg=./... -failfast -coverprofile=./coverage-ci/parser.out -covermode=atomic ./parser
20 | go test -v -race -cover -tags=debug -coverpkg=./... -failfast -coverprofile=./coverage-ci/gen.out -covermode=atomic ./cmd/protoc-gen-php-grpc
21 | echo 'mode: atomic' > ./coverage-ci/summary.txt
22 | tail -q -n +2 ./coverage-ci/*.out >> ./coverage-ci/summary.txt
23 |
24 | vendor_php/bin/phpunit
25 |
26 | lint:
27 | go fmt ./...
28 | golint ./...
--------------------------------------------------------------------------------
/README.md:
--------------------------------------------------------------------------------
1 | ⚠️ RRv2 `protoc-gen-php-grpc` releases are here: https://github.com/roadrunner-server/roadrunner/releases ⚠️
2 | ⚠️ `PHP Client` for the RRv2 is here: https://github.com/spiral/roadrunner-grpc ⚠️
3 |
4 | PHP-GRPC
5 | =================================
6 | [](https://packagist.org/packages/spiral/php-grpc)
7 | [](https://godoc.org/github.com/spiral/php-grpc)
8 | [](https://github.com/spiral/roadrunner-plugins/actions)
9 | [](https://github.com/spiral/roadrunner-plugins/actions)
10 | [](https://goreportcard.com/report/github.com/spiral/php-grpc)
11 | [](https://lgtm.com/projects/g/spiral/php-grpc/alerts/)
12 | [](https://codecov.io/gh/spiral/php-grpc/)
13 |
14 | PHP-GRPC is an open-source (MIT) high-performance PHP [GRPC](https://grpc.io/) server build at top of [RoadRunner](https://github.com/spiral/roadrunner).
15 | Server support both PHP and Golang services running within one application.
16 |
17 | Note:
18 | -------
19 | For the RoadRunner v2, please use the [RR-GRPC](https://github.com/spiral/roadrunner-grpc) library.
20 |
21 | Documentation:
22 | --------
23 | * [Installation and Configuration](https://spiral.dev/docs/grpc-configuration)
24 | * [Service Code](https://spiral.dev/docs/grpc-service)
25 | * [Client SDK](https://spiral.dev/docs/grpc-client)
26 | * [Golang Services](https://spiral.dev/docs/grpc-golang)
27 | * [Data Streaming](https://spiral.dev/docs/grpc-streaming)
28 |
29 | Features:
30 | --------
31 | - native Golang GRPC implementation compliant
32 | - minimal configuration, plug-and-play model
33 | - very fast, low footprint proxy
34 | - simple TLS configuration
35 | - debug tools included
36 | - Prometheus metrics
37 | - middleware and server customization support
38 | - code generation using `protoc` plugin (`go get github.com/spiral/php-grpc/cmd/protoc-gen-php-grpc`)
39 | - transport, message, worker error management
40 | - response error codes over php exceptions
41 | - works on Windows
42 |
43 | Usage:
44 | --------
45 | Install `rr-grpc` and `protoc-gen-php-grpc` by building it or use [pre-build binaries](https://github.com/spiral/php-grpc/releases).
46 |
47 | Define your service schema using proto file. You can scaffold protobuf classes and GRPC [service interfaces](https://github.com/spiral/php-grpc/blob/master/example/server/src/Service/EchoInterface.php) using:
48 |
49 | ```
50 | $ protoc --php_out=target-dir/ --php-grpc_out=target-dir/ sample.proto
51 | ```
52 |
53 | > Make sure to install [protoc compiler](https://github.com/protocolbuffers/protobuf) and run `composer require spiral/php-grpc` first
54 |
55 | [Implement](https://github.com/spiral/php-grpc/blob/master/example/server/src/EchoService.php) needed classes and create [worker.php](https://github.com/spiral/php-grpc/blob/master/example/server/worker.php) to invoke your services.
56 |
57 | Place [.rr.yaml](https://github.com/spiral/php-grpc/blob/master/example/server/.rr.yaml) (or any other format supported by viper configurator) into the root of your project. You can run your application now:
58 |
59 | ```
60 | $ rr-grpc serve -v -d
61 | ```
62 |
63 | To reset workers state:
64 |
65 | ```
66 | $ rr-grpc grpc:reset
67 | ```
68 |
69 | To show workers statistics:
70 |
71 | ```
72 | $ rr-grpc grpc:workers -i
73 | ```
74 |
75 | > See [example](https://github.com/spiral/php-grpc/tree/master/example).
76 |
77 | You can find more details regarding server configuration at [RoadRunner Wiki](https://roadrunner.dev/docs).
78 |
79 | License:
80 | --------
81 | MIT License (MIT). Please see [`LICENSE`](./LICENSE) for more information. Maintained by [SpiralScout](https://spiralscout.com).
82 |
--------------------------------------------------------------------------------
/build.sh:
--------------------------------------------------------------------------------
1 | #!/bin/bash
2 | cd $(dirname "${BASH_SOURCE[0]}")
3 | OD="$(pwd)"
4 | # Pushes application version into the build information.
5 | RR_VERSION=1.6.0
6 |
7 | # Hardcode some values to the core package
8 | LDFLAGS="$LDFLAGS -X github.com/spiral/roadrunner/cmd/rr/cmd.Version=${RR_VERSION}"
9 | LDFLAGS="$LDFLAGS -X github.com/spiral/roadrunner/cmd/rr/cmd.BuildTime=$(date +%FT%T%z)"
10 |
11 | build(){
12 | echo Packaging $1 Build
13 | bdir=rr-grpc-${RR_VERSION}-$2-$3
14 | rm -rf builds/$bdir && mkdir -p builds/$bdir
15 | GOOS=$2 GOARCH=$3 ./build.sh
16 |
17 | if [ "$2" == "windows" ]; then
18 | mv rr-grpc builds/$bdir/rr-grpc.exe
19 | else
20 | mv rr-grpc builds/$bdir
21 | fi
22 |
23 | cp README.md builds/$bdir
24 | cp CHANGELOG.md builds/$bdir
25 | cp LICENSE builds/$bdir
26 | cd builds
27 |
28 | if [ "$2" == "linux" ]; then
29 | tar -zcf $bdir.tar.gz $bdir
30 | else
31 | zip -r -q $bdir.zip $bdir
32 | fi
33 |
34 | rm -rf $bdir
35 | cd ..
36 | }
37 |
38 | build_protoc(){
39 | echo Packaging Protoc $1 Build
40 | bdir=protoc-gen-php-grpc-${RR_VERSION}-$2-$3
41 | rm -rf builds/$bdir && mkdir -p builds/$bdir
42 | GOOS=$2 GOARCH=$3 ./build.sh
43 |
44 | if [ "$2" == "windows" ]; then
45 | mv protoc-gen-php-grpc builds/$bdir/protoc-gen-php-grpc.exe
46 | else
47 | mv protoc-gen-php-grpc builds/$bdir
48 | fi
49 |
50 | cp README.md builds/$bdir
51 | cp CHANGELOG.md builds/$bdir
52 | cp LICENSE builds/$bdir
53 | cd builds
54 |
55 | if [ "$2" == "linux" ]; then
56 | tar -zcf $bdir.tar.gz $bdir
57 | else
58 | zip -r -q $bdir.zip $bdir
59 | fi
60 |
61 | rm -rf $bdir
62 | cd ..
63 | }
64 |
65 | if [ "$1" == "all" ]; then
66 | rm -rf builds/
67 | build "Windows" "windows" "amd64"
68 | build "Mac" "darwin" "amd64"
69 | build "Linux" "linux" "amd64"
70 | build "FreeBSD" "freebsd" "amd64"
71 | build_protoc "Windows" "windows" "amd64"
72 | build_protoc "Mac" "darwin" "amd64"
73 | build_protoc "Linux" "linux" "amd64"
74 | build_protoc "FreeBSD" "freebsd" "amd64"
75 | exit
76 | fi
77 |
78 | CGO_ENABLED=0 go build -ldflags "$LDFLAGS -extldflags '-static'" -o "$OD/protoc-gen-php-grpc" cmd/protoc-gen-php-grpc/main.go
79 | CGO_ENABLED=0 go build -ldflags "$LDFLAGS -extldflags '-static'" -o "$OD/rr-grpc" cmd/rr-grpc/main.go
80 |
--------------------------------------------------------------------------------
/cmd/protoc-gen-php-grpc/main.go:
--------------------------------------------------------------------------------
1 | // MIT License
2 | //
3 | // Copyright (c) 2018 SpiralScout
4 | //
5 | // Permission is hereby granted, free of charge, to any person obtaining a copy
6 | // of this software and associated documentation files (the "Software"), to deal
7 | // in the Software without restriction, including without limitation the rights
8 | // to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
9 | // copies of the Software, and to permit persons to whom the Software is
10 | // furnished to do so, subject to the following conditions:
11 | //
12 | // The above copyright notice and this permission notice shall be included in all
13 | // copies or substantial portions of the Software.
14 | //
15 | // THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
16 | // IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
17 | // FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
18 | // AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
19 | // LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
20 | // OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
21 | // SOFTWARE.
22 |
23 | package main
24 |
25 | import (
26 | "io"
27 | "io/ioutil"
28 | "os"
29 |
30 | "github.com/spiral/php-grpc/cmd/protoc-gen-php-grpc/php"
31 | "google.golang.org/protobuf/proto"
32 | plugin "google.golang.org/protobuf/types/pluginpb"
33 | )
34 |
35 | func main() {
36 | req, err := readRequest(os.Stdin)
37 | if err != nil {
38 | panic(err)
39 | }
40 |
41 | if err = writeResponse(os.Stdout, php.Generate(req)); err != nil {
42 | panic(err)
43 | }
44 | }
45 |
46 | func readRequest(in io.Reader) (*plugin.CodeGeneratorRequest, error) {
47 | data, err := ioutil.ReadAll(in)
48 | if err != nil {
49 | return nil, err
50 | }
51 |
52 | req := new(plugin.CodeGeneratorRequest)
53 | if err = proto.Unmarshal(data, req); err != nil {
54 | return nil, err
55 | }
56 |
57 | return req, nil
58 | }
59 |
60 | func writeResponse(out io.Writer, resp *plugin.CodeGeneratorResponse) error {
61 | data, err := proto.Marshal(resp)
62 | if err != nil {
63 | return err
64 | }
65 |
66 | _, err = out.Write(data)
67 | return err
68 | }
69 |
--------------------------------------------------------------------------------
/cmd/protoc-gen-php-grpc/php/generate.go:
--------------------------------------------------------------------------------
1 | // MIT License
2 | //
3 | // Copyright (c) 2018 SpiralScout
4 | //
5 | // Permission is hereby granted, free of charge, to any person obtaining a copy
6 | // of this software and associated documentation files (the "Software"), to deal
7 | // in the Software without restriction, including without limitation the rights
8 | // to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
9 | // copies of the Software, and to permit persons to whom the Software is
10 | // furnished to do so, subject to the following conditions:
11 | //
12 | // The above copyright notice and this permission notice shall be included in all
13 | // copies or substantial portions of the Software.
14 | //
15 | // THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
16 | // IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
17 | // FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
18 | // AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
19 | // LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
20 | // OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
21 | // SOFTWARE.
22 |
23 | package php
24 |
25 | import (
26 | desc "google.golang.org/protobuf/types/descriptorpb"
27 | plugin "google.golang.org/protobuf/types/pluginpb"
28 | )
29 |
30 | // Generate generates needed service classes
31 | func Generate(req *plugin.CodeGeneratorRequest) *plugin.CodeGeneratorResponse {
32 | resp := &plugin.CodeGeneratorResponse{}
33 |
34 | for _, file := range req.ProtoFile {
35 | for _, service := range file.Service {
36 | resp.File = append(resp.File, generate(req, file, service))
37 | }
38 | }
39 |
40 | return resp
41 | }
42 |
43 | func generate(
44 | req *plugin.CodeGeneratorRequest,
45 | file *desc.FileDescriptorProto,
46 | service *desc.ServiceDescriptorProto,
47 | ) *plugin.CodeGeneratorResponse_File {
48 | return &plugin.CodeGeneratorResponse_File{
49 | Name: str(filename(file, service.Name)),
50 | Content: str(body(req, file, service)),
51 | }
52 | }
53 |
54 | // helper to convert string into string pointer
55 | func str(str string) *string {
56 | return &str
57 | }
58 |
--------------------------------------------------------------------------------
/cmd/protoc-gen-php-grpc/php/keywords.go:
--------------------------------------------------------------------------------
1 | // MIT License
2 | //
3 | // Copyright (c) 2018 SpiralScout
4 | //
5 | // Permission is hereby granted, free of charge, to any person obtaining a copy
6 | // of this software and associated documentation files (the "Software"), to deal
7 | // in the Software without restriction, including without limitation the rights
8 | // to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
9 | // copies of the Software, and to permit persons to whom the Software is
10 | // furnished to do so, subject to the following conditions:
11 | //
12 | // The above copyright notice and this permission notice shall be included in all
13 | // copies or substantial portions of the Software.
14 | //
15 | // THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
16 | // IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
17 | // FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
18 | // AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
19 | // LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
20 | // OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
21 | // SOFTWARE.
22 |
23 | package php
24 |
25 | import (
26 | "bytes"
27 | "strings"
28 | "unicode"
29 | )
30 |
31 | // @see https://github.com/protocolbuffers/protobuf/blob/master/php/ext/google/protobuf/protobuf.c#L168
32 | // immutable
33 | var reservedKeywords = []string{ //nolint:gochecknoglobals
34 | "abstract", "and", "array", "as", "break",
35 | "callable", "case", "catch", "class", "clone",
36 | "const", "continue", "declare", "default", "die",
37 | "do", "echo", "else", "elseif", "empty",
38 | "enddeclare", "endfor", "endforeach", "endif", "endswitch",
39 | "endwhile", "eval", "exit", "extends", "final",
40 | "for", "foreach", "function", "global", "goto",
41 | "if", "implements", "include", "include_once", "instanceof",
42 | "insteadof", "interface", "isset", "list", "namespace",
43 | "new", "or", "print", "private", "protected",
44 | "public", "require", "require_once", "return", "static",
45 | "switch", "throw", "trait", "try", "unset",
46 | "use", "var", "while", "xor", "int",
47 | "float", "bool", "string", "true", "false",
48 | "null", "void", "iterable",
49 | }
50 |
51 | // Check if given name/keyword is reserved by php.
52 | func isReserved(name string) bool {
53 | name = strings.ToLower(name)
54 | for _, k := range reservedKeywords {
55 | if name == k {
56 | return true
57 | }
58 | }
59 |
60 | return false
61 | }
62 |
63 | // generate php namespace or path
64 | func namespace(pkg *string, sep string) string {
65 | if pkg == nil {
66 | return ""
67 | }
68 |
69 | result := bytes.NewBuffer(nil)
70 | for _, p := range strings.Split(*pkg, ".") {
71 | result.WriteString(identifier(p, ""))
72 | result.WriteString(sep)
73 | }
74 |
75 | return strings.Trim(result.String(), sep)
76 | }
77 |
78 | // create php identifier for class or message
79 | func identifier(name string, suffix string) string {
80 | name = Camelize(name)
81 | if suffix != "" {
82 | return name + Camelize(suffix)
83 | }
84 |
85 | return name
86 | }
87 |
88 | func resolveReserved(identifier string, pkg string) string {
89 | if isReserved(strings.ToLower(identifier)) {
90 | if pkg == ".google.protobuf" {
91 | return "GPB" + identifier
92 | }
93 | return "PB" + identifier
94 | }
95 |
96 | return identifier
97 | }
98 |
99 | // Camelize "dino_party" -> "DinoParty"
100 | func Camelize(word string) string {
101 | words := splitAtCaseChangeWithTitlecase(word)
102 | return strings.Join(words, "")
103 | }
104 |
105 | func splitAtCaseChangeWithTitlecase(s string) []string {
106 | words := make([]string, 0)
107 | word := make([]rune, 0)
108 | for _, c := range s {
109 | spacer := isSpacerChar(c)
110 | if len(word) > 0 {
111 | if unicode.IsUpper(c) || spacer {
112 | words = append(words, string(word))
113 | word = make([]rune, 0)
114 | }
115 | }
116 | if !spacer {
117 | if len(word) > 0 {
118 | word = append(word, unicode.ToLower(c))
119 | } else {
120 | word = append(word, unicode.ToUpper(c))
121 | }
122 | }
123 | }
124 | words = append(words, string(word))
125 | return words
126 | }
127 |
128 | func isSpacerChar(c rune) bool {
129 | switch {
130 | case c == rune("_"[0]):
131 | return true
132 | case c == rune(" "[0]):
133 | return true
134 | case c == rune(":"[0]):
135 | return true
136 | case c == rune("-"[0]):
137 | return true
138 | }
139 | return false
140 | }
141 |
--------------------------------------------------------------------------------
/cmd/protoc-gen-php-grpc/php/ns.go:
--------------------------------------------------------------------------------
1 | package php
2 |
3 | import (
4 | "bytes"
5 | "fmt"
6 | "strings"
7 |
8 | desc "google.golang.org/protobuf/types/descriptorpb"
9 | plugin "google.golang.org/protobuf/types/pluginpb"
10 | )
11 |
12 | // manages internal name representation of the package
13 | type ns struct {
14 | // Package defines file package.
15 | Package string
16 |
17 | // Root namespace of the package
18 | Namespace string
19 |
20 | // Import declares what namespaces to be imported
21 | Import map[string]string
22 | }
23 |
24 | // newNamespace creates new work namespace.
25 | func newNamespace(req *plugin.CodeGeneratorRequest, file *desc.FileDescriptorProto, service *desc.ServiceDescriptorProto) *ns {
26 | ns := &ns{
27 | Package: *file.Package,
28 | Namespace: namespace(file.Package, "\\"),
29 | Import: make(map[string]string),
30 | }
31 |
32 | if file.Options != nil && file.Options.PhpNamespace != nil {
33 | ns.Namespace = *file.Options.PhpNamespace
34 | }
35 |
36 | for k := range service.Method {
37 | ns.importMessage(req, service.Method[k].InputType)
38 | ns.importMessage(req, service.Method[k].OutputType)
39 | }
40 |
41 | return ns
42 | }
43 |
44 | // importMessage registers new import message namespace (only the namespace).
45 | func (ns *ns) importMessage(req *plugin.CodeGeneratorRequest, msg *string) {
46 | if msg == nil {
47 | return
48 | }
49 |
50 | chunks := strings.Split(*msg, ".")
51 | pkg := strings.Join(chunks[:len(chunks)-1], ".")
52 |
53 | result := bytes.NewBuffer(nil)
54 | for _, p := range chunks[:len(chunks)-1] {
55 | result.WriteString(identifier(p, ""))
56 | result.WriteString(`\`)
57 | }
58 |
59 | if pkg == "."+ns.Package {
60 | // root package
61 | return
62 | }
63 |
64 | for _, f := range req.ProtoFile {
65 | if pkg == "."+*f.Package {
66 | if f.Options != nil && f.Options.PhpNamespace != nil {
67 | // custom imported namespace
68 | ns.Import[pkg] = *f.Options.PhpNamespace
69 | return
70 | }
71 | }
72 | }
73 |
74 | ns.Import[pkg] = strings.Trim(result.String(), `\`)
75 | }
76 |
77 | // resolve message alias
78 | func (ns *ns) resolve(msg *string) string {
79 | chunks := strings.Split(*msg, ".")
80 | pkg := strings.Join(chunks[:len(chunks)-1], ".")
81 |
82 | if pkg == "."+ns.Package {
83 | // root message
84 | return identifier(chunks[len(chunks)-1], "")
85 | }
86 |
87 | for iPkg, ns := range ns.Import {
88 | if pkg == iPkg {
89 | // use last namespace chunk
90 | nsChunks := strings.Split(ns, `\`)
91 | identifier := identifier(chunks[len(chunks)-1], "")
92 |
93 | return fmt.Sprintf(
94 | `%s\%s`,
95 | nsChunks[len(nsChunks)-1],
96 | resolveReserved(identifier, pkg),
97 | )
98 | }
99 | }
100 |
101 | // fully clarified name (fallback)
102 | return "\\" + namespace(msg, "\\")
103 | }
104 |
--------------------------------------------------------------------------------
/cmd/protoc-gen-php-grpc/php/template.go:
--------------------------------------------------------------------------------
1 | // MIT License
2 | //
3 | // Copyright (c) 2018 SpiralScout
4 | //
5 | // Permission is hereby granted, free of charge, to any person obtaining a copy
6 | // of this software and associated documentation files (the "Software"), to deal
7 | // in the Software without restriction, including without limitation the rights
8 | // to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
9 | // copies of the Software, and to permit persons to whom the Software is
10 | // furnished to do so, subject to the following conditions:
11 | //
12 | // The above copyright notice and this permission notice shall be included in all
13 | // copies or substantial portions of the Software.
14 | //
15 | // THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
16 | // IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
17 | // FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
18 | // AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
19 | // LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
20 | // OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
21 | // SOFTWARE.
22 |
23 | package php
24 |
25 | import (
26 | "bytes"
27 | "fmt"
28 | "strings"
29 | "text/template"
30 |
31 | desc "google.golang.org/protobuf/types/descriptorpb"
32 | plugin "google.golang.org/protobuf/types/pluginpb"
33 | )
34 |
35 | const phpBody = ` 0 || err != nil {
30 | t.Log("RUNNING: ", strings.Join(cmd.Args, " "))
31 | }
32 |
33 | if len(out) > 0 {
34 | t.Log(string(out))
35 | }
36 |
37 | if err != nil {
38 | t.Fatalf("protoc: %v", err)
39 | }
40 | }
41 |
42 | func Test_Simple(t *testing.T) {
43 | workdir, _ := os.Getwd()
44 | tmpdir, err := ioutil.TempDir("", "proto-test")
45 | if err != nil {
46 | t.Fatal(err)
47 | }
48 | defer os.RemoveAll(tmpdir)
49 |
50 | args := []string{
51 | "-Itestdata",
52 | "--php-grpc_out=" + tmpdir,
53 | "simple/simple.proto",
54 | }
55 | protoc(t, args)
56 |
57 | assertEqualFiles(
58 | t,
59 | workdir+"/testdata/simple/TestSimple/SimpleServiceInterface.php",
60 | tmpdir+"/TestSimple/SimpleServiceInterface.php",
61 | )
62 | }
63 |
64 | func Test_PhpNamespaceOption(t *testing.T) {
65 | workdir, _ := os.Getwd()
66 | tmpdir, err := ioutil.TempDir("", "proto-test")
67 | if err != nil {
68 | t.Fatal(err)
69 | }
70 | defer os.RemoveAll(tmpdir)
71 |
72 | args := []string{
73 | "-Itestdata",
74 | "--php-grpc_out=" + tmpdir,
75 | "php_namespace/service.proto",
76 | }
77 | protoc(t, args)
78 |
79 | assertEqualFiles(
80 | t,
81 | workdir+"/testdata/php_namespace/Test/CustomNamespace/ServiceInterface.php",
82 | tmpdir+"/Test/CustomNamespace/ServiceInterface.php",
83 | )
84 | }
85 |
86 | func Test_UseImportedMessage(t *testing.T) {
87 | workdir, _ := os.Getwd()
88 | tmpdir, err := ioutil.TempDir("", "proto-test")
89 | if err != nil {
90 | t.Fatal(err)
91 | }
92 | defer os.RemoveAll(tmpdir)
93 |
94 | args := []string{
95 | "-Itestdata",
96 | "--php-grpc_out=" + tmpdir,
97 | "import/service.proto",
98 | }
99 | protoc(t, args)
100 |
101 | assertEqualFiles(
102 | t,
103 | workdir+"/testdata/import/Import/ServiceInterface.php",
104 | tmpdir+"/Import/ServiceInterface.php",
105 | )
106 | }
107 |
108 | func Test_PhpNamespaceOptionInUse(t *testing.T) {
109 | workdir, _ := os.Getwd()
110 | tmpdir, err := ioutil.TempDir("", "proto-test")
111 | if err != nil {
112 | t.Fatal(err)
113 | }
114 | defer os.RemoveAll(tmpdir)
115 | args := []string{
116 | "-Itestdata",
117 | "--php-grpc_out=" + tmpdir,
118 | "import_custom/service.proto",
119 | }
120 | protoc(t, args)
121 |
122 | assertEqualFiles(
123 | t,
124 | workdir+"/testdata/import_custom/Test/CustomImport/ServiceInterface.php",
125 | tmpdir+"/Test/CustomImport/ServiceInterface.php",
126 | )
127 | }
128 |
129 | func Test_UseOfGoogleEmptyMessage(t *testing.T) {
130 | workdir, _ := os.Getwd()
131 | tmpdir, err := ioutil.TempDir("", "proto-test")
132 | if err != nil {
133 | t.Fatal(err)
134 | }
135 | defer os.RemoveAll(tmpdir)
136 | args := []string{
137 | "-Itestdata",
138 | "--php-grpc_out=" + tmpdir,
139 | "use_empty/service.proto",
140 | }
141 | protoc(t, args)
142 |
143 | assertEqualFiles(
144 | t,
145 | workdir+"/testdata/use_empty/Test/ServiceInterface.php",
146 | tmpdir+"/Test/ServiceInterface.php",
147 | )
148 | }
149 |
150 | func assertEqualFiles(t *testing.T, original, generated string) {
151 | assert.FileExists(t, generated)
152 |
153 | originalData, err := ioutil.ReadFile(original)
154 | if err != nil {
155 | t.Fatal("Can't find original file for comparison")
156 | }
157 |
158 | generatedData, err := ioutil.ReadFile(generated)
159 | if err != nil {
160 | t.Fatal("Can't find generated file for comparison")
161 | }
162 |
163 | // every OS has a special boy
164 | r := strings.NewReplacer("\r\n", "", "\n", "")
165 | assert.Equal(t, r.Replace(string(originalData)), r.Replace(string(generatedData)))
166 | }
167 |
--------------------------------------------------------------------------------
/cmd/protoc-gen-php-grpc/testdata/import/Import/ServiceInterface.php:
--------------------------------------------------------------------------------
1 | %s Ok %s %s",
61 | getPeer(uc.Context),
62 | elapsed(uc.Elapsed()),
63 | uc.Info.FullMethod,
64 | ))
65 | return
66 | }
67 | if st, ok := status.FromError(uc.Error); ok {
68 | d.logger.Error(util.Sprintf(
69 | "%s %s %s %s %s",
70 | getPeer(uc.Context),
71 | wrapStatus(st),
72 | elapsed(uc.Elapsed()),
73 | uc.Info.FullMethod,
74 | st.Message(),
75 | ))
76 | } else {
77 | d.logger.Error(util.Sprintf(
78 | "%s %s %s %s",
79 | getPeer(uc.Context),
80 | elapsed(uc.Elapsed()),
81 | uc.Info.FullMethod,
82 | uc.Error.Error(),
83 | ))
84 | }
85 | }
86 |
87 | if util.LogEvent(d.logger, event, ctx) {
88 | // handler by default debug package
89 | return
90 | }
91 | }
92 |
93 | func wrapStatus(st *status.Status) string {
94 | switch st.Code() { //nolint:exhaustive
95 | case codes.NotFound, codes.Canceled, codes.Unavailable:
96 | return util.Sprintf("%s", st.Code().String())
97 | default:
98 | return util.Sprintf("%s", st.Code().String())
99 | }
100 | }
101 |
102 | func getPeer(ctx context.Context) string {
103 | pr, ok := peer.FromContext(ctx)
104 | if ok {
105 | return pr.Addr.String()
106 | }
107 |
108 | return "unknown"
109 | }
110 |
111 | // fits duration into 5 characters
112 | func elapsed(d time.Duration) string {
113 | var v string
114 | switch {
115 | case d > 100*time.Second:
116 | v = fmt.Sprintf("%.1fs", d.Seconds())
117 | case d > 10*time.Second:
118 | v = fmt.Sprintf("%.2fs", d.Seconds())
119 | case d > time.Second:
120 | v = fmt.Sprintf("%.3fs", d.Seconds())
121 | case d > 100*time.Millisecond:
122 | v = fmt.Sprintf("%.0fms", d.Seconds()*1000)
123 | case d > 10*time.Millisecond:
124 | v = fmt.Sprintf("%.1fms", d.Seconds()*1000)
125 | default:
126 | v = fmt.Sprintf("%.2fms", d.Seconds()*1000)
127 | }
128 |
129 | if d > time.Second {
130 | return util.Sprintf("{%v}", v)
131 | }
132 |
133 | if d > time.Millisecond*50 {
134 | return util.Sprintf("{%v}", v)
135 | }
136 |
137 | return util.Sprintf("{%v}", v)
138 | }
139 |
--------------------------------------------------------------------------------
/cmd/rr-grpc/grpc/metrics.go:
--------------------------------------------------------------------------------
1 | // Copyright (c) 2018 SpiralScout
2 | //
3 | // Permission is hereby granted, free of charge, to any person obtaining a copy
4 | // of this software and associated documentation files (the "Software"), to deal
5 | // in the Software without restriction, including without limitation the rights
6 | // to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
7 | // copies of the Software, and to permit persons to whom the Software is
8 | // furnished to do so, subject to the following conditions:
9 | //
10 | // The above copyright notice and this permission notice shall be included in all
11 | // copies or substantial portions of the Software.
12 | //
13 | // THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
14 | // IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
15 | // FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
16 | // AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
17 | // LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
18 | // OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
19 | // SOFTWARE.
20 |
21 | package grpc
22 |
23 | import (
24 | "time"
25 |
26 | "github.com/prometheus/client_golang/prometheus"
27 | "github.com/spf13/cobra"
28 | rrpc "github.com/spiral/php-grpc"
29 | "github.com/spiral/roadrunner"
30 | rr "github.com/spiral/roadrunner/cmd/rr/cmd"
31 | "github.com/spiral/roadrunner/service/metrics"
32 | "github.com/spiral/roadrunner/util"
33 | "google.golang.org/grpc/status"
34 | )
35 |
36 | func init() {
37 | cobra.OnInitialize(func() {
38 | svc, _ := rr.Container.Get(metrics.ID)
39 | mtr, ok := svc.(*metrics.Service)
40 | if !ok || !mtr.Enabled() {
41 | return
42 | }
43 |
44 | ht, _ := rr.Container.Get(rrpc.ID)
45 | if gp, ok := ht.(*rrpc.Service); ok {
46 | collector := newCollector()
47 |
48 | // register metrics
49 | mtr.MustRegister(collector.callCounter)
50 | mtr.MustRegister(collector.callDuration)
51 | mtr.MustRegister(collector.workersMemory)
52 |
53 | // collect events
54 | gp.AddListener(collector.listener)
55 |
56 | // update memory usage every 10 seconds
57 | go collector.collectMemory(gp, time.Second*10)
58 | }
59 | })
60 | }
61 |
62 | // listener provide debug callback for system events. With colors!
63 | type metricCollector struct {
64 | callCounter *prometheus.CounterVec
65 | callDuration *prometheus.HistogramVec
66 | workersMemory prometheus.Gauge
67 | }
68 |
69 | func newCollector() *metricCollector {
70 | return &metricCollector{
71 | callCounter: prometheus.NewCounterVec(
72 | prometheus.CounterOpts{
73 | Name: "rr_grpc_call_total",
74 | Help: "Total number of handled grpc requests after server restart.",
75 | },
76 | []string{"code", "method"},
77 | ),
78 | callDuration: prometheus.NewHistogramVec(
79 | prometheus.HistogramOpts{
80 | Name: "rr_grpc_call_duration_seconds",
81 | Help: "GRPC call duration.",
82 | },
83 | []string{"code", "method"},
84 | ),
85 | workersMemory: prometheus.NewGauge(
86 | prometheus.GaugeOpts{
87 | Name: "rr_grpc_workers_memory_bytes",
88 | Help: "Memory usage by GRPC workers.",
89 | },
90 | ),
91 | }
92 | }
93 |
94 | // listener listens to http events and generates nice looking output.
95 | func (c *metricCollector) listener(event int, ctx interface{}) {
96 | if event == rrpc.EventUnaryCall {
97 | uc := ctx.(*rrpc.UnaryCallEvent)
98 | code := "Unknown"
99 |
100 | if uc.Error == nil {
101 | code = "Ok"
102 | } else if st, ok := status.FromError(uc.Error); ok && st.Code() < 17 {
103 | code = st.Code().String()
104 | }
105 |
106 | method := "Unknown"
107 | if uc.Info != nil {
108 | method = uc.Info.FullMethod
109 | }
110 | c.callDuration.With(prometheus.Labels{"code": code, "method": method}).Observe(uc.Elapsed().Seconds())
111 | c.callCounter.With(prometheus.Labels{"code": code, "method": method}).Inc()
112 | }
113 | }
114 |
115 | // collect memory usage by server workers
116 | func (c *metricCollector) collectMemory(service roadrunner.Controllable, tick time.Duration) {
117 | started := false
118 | for {
119 | server := service.Server()
120 | if server == nil && started {
121 | // stopped
122 | return
123 | }
124 |
125 | started = true
126 |
127 | if workers, err := util.ServerState(server); err == nil {
128 | sum := 0.0
129 | for _, w := range workers {
130 | sum += float64(w.MemoryUsage)
131 | }
132 |
133 | c.workersMemory.Set(sum)
134 | }
135 |
136 | time.Sleep(tick)
137 | }
138 | }
139 |
--------------------------------------------------------------------------------
/cmd/rr-grpc/grpc/reset.go:
--------------------------------------------------------------------------------
1 | // Copyright (c) 2018 SpiralScout
2 | //
3 | // Permission is hereby granted, free of charge, to any person obtaining a copy
4 | // of this software and associated documentation files (the "Software"), to deal
5 | // in the Software without restriction, including without limitation the rights
6 | // to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
7 | // copies of the Software, and to permit persons to whom the Software is
8 | // furnished to do so, subject to the following conditions:
9 | //
10 | // The above copyright notice and this permission notice shall be included in all
11 | // copies or substantial portions of the Software.
12 | //
13 | // THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
14 | // IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
15 | // FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
16 | // AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
17 | // LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
18 | // OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
19 | // SOFTWARE.
20 |
21 | package grpc
22 |
23 | import (
24 | "github.com/spf13/cobra"
25 | rr "github.com/spiral/roadrunner/cmd/rr/cmd"
26 | "github.com/spiral/roadrunner/cmd/util"
27 | )
28 |
29 | func init() {
30 | rr.CLI.AddCommand(&cobra.Command{
31 | Use: "grpc:reset",
32 | Short: "Reload RoadRunner worker pool for the GRPC service",
33 | RunE: reloadHandler,
34 | })
35 | }
36 |
37 | func reloadHandler(cmd *cobra.Command, args []string) error {
38 | client, err := util.RPCClient(rr.Container)
39 | if err != nil {
40 | return err
41 | }
42 | defer client.Close()
43 |
44 | util.Printf("restarting http worker pool: ")
45 |
46 | var r string
47 | if err := client.Call("grpc.Reset", true, &r); err != nil {
48 | return err
49 | }
50 |
51 | util.Printf("done\n")
52 | return nil
53 | }
54 |
--------------------------------------------------------------------------------
/cmd/rr-grpc/grpc/workers.go:
--------------------------------------------------------------------------------
1 | // Copyright (c) 2018 SpiralScout
2 | //
3 | // Permission is hereby granted, free of charge, to any person obtaining a copy
4 | // of this software and associated documentation files (the "Software"), to deal
5 | // in the Software without restriction, including without limitation the rights
6 | // to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
7 | // copies of the Software, and to permit persons to whom the Software is
8 | // furnished to do so, subject to the following conditions:
9 | //
10 | // The above copyright notice and this permission notice shall be included in all
11 | // copies or substantial portions of the Software.
12 | //
13 | // THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
14 | // IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
15 | // FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
16 | // AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
17 | // LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
18 | // OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
19 | // SOFTWARE.
20 |
21 | package grpc
22 |
23 | import (
24 | "net/rpc"
25 | "os"
26 | "os/signal"
27 | "syscall"
28 | "time"
29 |
30 | tm "github.com/buger/goterm"
31 | "github.com/spf13/cobra"
32 | rrpc "github.com/spiral/php-grpc"
33 | rr "github.com/spiral/roadrunner/cmd/rr/cmd"
34 | "github.com/spiral/roadrunner/cmd/util"
35 | )
36 |
37 | var (
38 | interactive bool //nolint:gochecknoglobals
39 | stopSignal = make(chan os.Signal, 1) //nolint:gochecknoglobals
40 | )
41 |
42 | func init() {
43 | workersCommand := &cobra.Command{
44 | Use: "grpc:workers",
45 | Short: "List workers associated with RoadRunner GRPC service",
46 | RunE: workersHandler,
47 | }
48 |
49 | workersCommand.Flags().BoolVarP(
50 | &interactive,
51 | "interactive",
52 | "i",
53 | false,
54 | "render interactive workers table",
55 | )
56 |
57 | rr.CLI.AddCommand(workersCommand)
58 |
59 | signal.Notify(stopSignal, syscall.SIGTERM)
60 | signal.Notify(stopSignal, syscall.SIGINT)
61 | }
62 |
63 | func workersHandler(cmd *cobra.Command, args []string) (err error) {
64 | defer func() {
65 | if r, ok := recover().(error); ok {
66 | err = r
67 | }
68 | }()
69 |
70 | client, err := util.RPCClient(rr.Container)
71 | if err != nil {
72 | return err
73 | }
74 | defer client.Close()
75 |
76 | if !interactive {
77 | showWorkers(client)
78 | return nil
79 | }
80 |
81 | tm.Clear()
82 | for {
83 | select {
84 | case <-stopSignal:
85 | return nil
86 | case <-time.NewTicker(time.Millisecond * 500).C:
87 | tm.MoveCursor(1, 1)
88 | showWorkers(client)
89 | tm.Flush()
90 | }
91 | }
92 | }
93 |
94 | func showWorkers(client *rpc.Client) {
95 | var r rrpc.WorkerList
96 | if err := client.Call("grpc.Workers", true, &r); err != nil {
97 | panic(err)
98 | }
99 |
100 | util.WorkerTable(r.Workers).Render()
101 | }
102 |
--------------------------------------------------------------------------------
/cmd/rr-grpc/main.go:
--------------------------------------------------------------------------------
1 | package main
2 |
3 | import (
4 | rr "github.com/spiral/roadrunner/cmd/rr/cmd"
5 | "github.com/spiral/roadrunner/service/limit"
6 | "github.com/spiral/roadrunner/service/metrics"
7 | "github.com/spiral/roadrunner/service/rpc"
8 |
9 | grpc "github.com/spiral/php-grpc"
10 |
11 | // grpc specific commands
12 | _ "github.com/spiral/php-grpc/cmd/rr-grpc/grpc"
13 | )
14 |
15 | func main() {
16 | rr.Container.Register(rpc.ID, &rpc.Service{})
17 | rr.Container.Register(grpc.ID, &grpc.Service{})
18 |
19 | rr.Container.Register(metrics.ID, &metrics.Service{})
20 | rr.Container.Register(limit.ID, &limit.Service{})
21 |
22 | rr.Execute()
23 | }
24 |
--------------------------------------------------------------------------------
/codec.go:
--------------------------------------------------------------------------------
1 | package grpc
2 |
3 | import (
4 | "google.golang.org/grpc/encoding"
5 | )
6 |
7 | type rawMessage []byte
8 |
9 | const CodecName string = "proto"
10 |
11 | func (r rawMessage) Reset() {}
12 | func (rawMessage) ProtoMessage() {}
13 | func (rawMessage) String() string { return "rawMessage" }
14 |
15 | type Codec struct{ Base encoding.Codec }
16 |
17 | func (c *Codec) Name() string {
18 | return CodecName
19 | }
20 |
21 | // Marshal returns the wire format of v. rawMessages would be returned without encoding.
22 | func (c *Codec) Marshal(v interface{}) ([]byte, error) {
23 | if raw, ok := v.(rawMessage); ok {
24 | return raw, nil
25 | }
26 |
27 | return c.Base.Marshal(v)
28 | }
29 |
30 | // Unmarshal parses the wire format into v. rawMessages would not be unmarshalled.
31 | func (c *Codec) Unmarshal(data []byte, v interface{}) error {
32 | if raw, ok := v.(*rawMessage); ok {
33 | *raw = data
34 | return nil
35 | }
36 |
37 | return c.Base.Unmarshal(data, v)
38 | }
39 |
40 | // String return Codec name.
41 | func (c *Codec) String() string {
42 | return "raw:" + c.Base.Name()
43 | }
44 |
--------------------------------------------------------------------------------
/codec_test.go:
--------------------------------------------------------------------------------
1 | package grpc
2 |
3 | import (
4 | "encoding/json"
5 | "testing"
6 |
7 | "github.com/stretchr/testify/assert"
8 | )
9 |
10 | type jsonCodec struct{}
11 |
12 | func (jsonCodec) Marshal(v interface{}) ([]byte, error) {
13 | return json.Marshal(v)
14 | }
15 |
16 | func (jsonCodec) Unmarshal(data []byte, v interface{}) error {
17 | return json.Unmarshal(data, v)
18 | }
19 |
20 | func (jsonCodec) Name() string {
21 | return "json"
22 | }
23 |
24 | func TestCodec_String(t *testing.T) {
25 | c := Codec{jsonCodec{}}
26 |
27 | assert.Equal(t, "raw:json", c.String())
28 |
29 | r := rawMessage{}
30 | r.Reset()
31 | r.ProtoMessage()
32 | assert.Equal(t, "rawMessage", r.String())
33 | }
34 |
35 | func TestCodec_Unmarshal_ByPass(t *testing.T) {
36 | c := Codec{jsonCodec{}}
37 |
38 | s := struct {
39 | Name string
40 | }{}
41 |
42 | assert.NoError(t, c.Unmarshal([]byte(`{"name":"name"}`), &s))
43 | assert.Equal(t, "name", s.Name)
44 | }
45 |
46 | func TestCodec_Marshal_ByPass(t *testing.T) {
47 | c := Codec{jsonCodec{}}
48 |
49 | s := struct {
50 | Name string
51 | }{
52 | Name: "name",
53 | }
54 |
55 | d, err := c.Marshal(s)
56 | assert.NoError(t, err)
57 |
58 | assert.Equal(t, `{"Name":"name"}`, string(d))
59 | }
60 |
61 | func TestCodec_Unmarshal_Raw(t *testing.T) {
62 | c := Codec{jsonCodec{}}
63 |
64 | s := rawMessage{}
65 |
66 | assert.NoError(t, c.Unmarshal([]byte(`{"name":"name"}`), &s))
67 | assert.Equal(t, `{"name":"name"}`, string(s))
68 | }
69 |
70 | func TestCodec_Marshal_Raw(t *testing.T) {
71 | c := Codec{jsonCodec{}}
72 |
73 | s := rawMessage(`{"Name":"name"}`)
74 |
75 | d, err := c.Marshal(s)
76 | assert.NoError(t, err)
77 |
78 | assert.Equal(t, `{"Name":"name"}`, string(d))
79 | }
80 |
--------------------------------------------------------------------------------
/codecov.yml:
--------------------------------------------------------------------------------
1 | coverage:
2 | status:
3 | project:
4 | default:
5 | target: auto
6 | threshold: 50%
7 | informational: true
8 | patch:
9 | default:
10 | target: auto
11 | threshold: 50%
12 | informational: true
13 |
14 | # do not include tests folders
15 | ignore:
16 | - ".github"
17 | - "tests"
18 | - "codec_test.go"
19 | - "config_test.go"
20 | - "proxy_test.go"
21 | - "rpc_test.go"
22 | - "service_test.go"
23 | - "vendor_php"
24 | - "src"
25 | - "parser/parse_test.go"
26 | - "example"
27 | - "cmd/protoc-gen-php-grpc/plugin_test.go"
28 |
--------------------------------------------------------------------------------
/composer.json:
--------------------------------------------------------------------------------
1 | {
2 | "name": "spiral/php-grpc",
3 | "type": "library",
4 | "description": "High-Performance GRPC server for PHP applications",
5 | "license": "MIT",
6 | "authors": [
7 | {
8 | "name": "Anton Titov / Wolfy-J",
9 | "email": "wolfy.jd@gmail.com"
10 | }
11 | ],
12 | "require": {
13 | "php": ">=7.2",
14 | "ext-json": "*",
15 | "symfony/polyfill-php80": "^1.22",
16 | "symfony/polyfill-php73": "^1.22",
17 | "google/protobuf": "^3.7",
18 | "spiral/roadrunner": "^1.8"
19 | },
20 | "require-dev": {
21 | "phpunit/phpunit": "^8.5|^9.0",
22 | "spiral/code-style": "^1.0",
23 | "jetbrains/phpstorm-attributes": "^1.0",
24 | "symfony/var-dumper": ">=4.4",
25 | "vimeo/psalm": "^4.6"
26 | },
27 | "autoload": {
28 | "psr-4": {
29 | "Spiral\\GRPC\\": "src"
30 | }
31 | },
32 | "autoload-dev": {
33 | "psr-4": {
34 | "": "tests/src",
35 | "Spiral\\GRPC\\Tests\\": "tests/GRPC"
36 | }
37 | },
38 | "scripts": {
39 | "test": [
40 | "spiral-cs check src tests/GRPC",
41 | "psalm --no-cache",
42 | "phpunit"
43 | ]
44 | },
45 | "extra": {
46 | "branch-alias": {
47 | "dev-master": "2.0.x-dev"
48 | }
49 | },
50 | "config": {
51 | "sort-packages": true,
52 | "vendor-dir": "vendor_php"
53 | },
54 | "minimum-stability": "dev",
55 | "prefer-stable": true
56 | }
57 |
--------------------------------------------------------------------------------
/config.go:
--------------------------------------------------------------------------------
1 | package grpc
2 |
3 | import (
4 | "errors"
5 | "fmt"
6 | "math"
7 | "net"
8 | "os"
9 | "strings"
10 | "syscall"
11 | "time"
12 |
13 | "github.com/spiral/roadrunner"
14 | "github.com/spiral/roadrunner/service"
15 | )
16 |
17 | // Config describes GRPC service configuration.
18 | type Config struct {
19 | // Address to listen.
20 | Listen string
21 |
22 | // Proto files associated with the service.
23 | Proto []string
24 |
25 | // TLS defined authentication method (TLS for now).
26 | TLS TLS
27 |
28 | // Workers configures roadrunner grpc and worker pool.
29 | Workers *roadrunner.ServerConfig
30 |
31 | // see .rr.yaml for the explanations
32 | MaxSendMsgSize int64
33 | MaxRecvMsgSize int64
34 | MaxConnectionIdle time.Duration
35 | MaxConnectionAge time.Duration
36 | MaxConnectionAgeGrace time.Duration
37 | MaxConcurrentStreams int64
38 | PingTime time.Duration
39 | Timeout time.Duration
40 | }
41 |
42 | // TLS defines auth credentials.
43 | type TLS struct {
44 | // Key defined private server key.
45 | Key string
46 |
47 | // Cert is https certificate.
48 | Cert string
49 |
50 | // Root CA
51 | RootCA string
52 | }
53 |
54 | // Hydrate the config and validate it's values.
55 | func (c *Config) Hydrate(cfg service.Config) error {
56 | c.Workers = &roadrunner.ServerConfig{}
57 | err := c.Workers.InitDefaults()
58 | if err != nil {
59 | return err
60 | }
61 |
62 | if err := cfg.Unmarshal(c); err != nil {
63 | return err
64 | }
65 | c.Workers.UpscaleDurations()
66 |
67 | return c.Valid()
68 | }
69 |
70 | // Valid validates the configuration.
71 | func (c *Config) Valid() error {
72 | if len(c.Proto) == 0 && c.Workers.Command != "" {
73 | // only when rr server is set
74 | return errors.New("at least one proto file is required")
75 | }
76 |
77 | for _, proto := range c.Proto {
78 | if _, err := os.Stat(proto); err != nil {
79 | if os.IsNotExist(err) {
80 | return fmt.Errorf("proto file '%s' does not exists", proto)
81 | }
82 |
83 | return err
84 | }
85 | }
86 |
87 | if c.Workers.Command != "" {
88 | if err := c.Workers.Pool.Valid(); err != nil {
89 | return err
90 | }
91 | }
92 |
93 | if !strings.Contains(c.Listen, ":") {
94 | return errors.New("mailformed grpc grpc address")
95 | }
96 |
97 | if c.EnableTLS() {
98 | if _, err := os.Stat(c.TLS.Key); err != nil {
99 | if os.IsNotExist(err) {
100 | return fmt.Errorf("key file '%s' does not exists", c.TLS.Key)
101 | }
102 |
103 | return err
104 | }
105 |
106 | if _, err := os.Stat(c.TLS.Cert); err != nil {
107 | if os.IsNotExist(err) {
108 | return fmt.Errorf("cert file '%s' does not exists", c.TLS.Cert)
109 | }
110 |
111 | return err
112 | }
113 |
114 | // RootCA is optional, but if provided - check it
115 | if c.TLS.RootCA != "" {
116 | if _, err := os.Stat(c.TLS.RootCA); err != nil {
117 | if os.IsNotExist(err) {
118 | return fmt.Errorf("root ca path provided, but key file '%s' does not exists", c.TLS.RootCA)
119 | }
120 | return err
121 | }
122 | }
123 | }
124 |
125 | // used to set max time
126 | infinity := time.Duration(math.MaxInt64)
127 |
128 | if c.PingTime == 0 {
129 | c.PingTime = time.Hour * 2
130 | }
131 |
132 | if c.Timeout == 0 {
133 | c.Timeout = time.Second * 20
134 | }
135 |
136 | if c.MaxConcurrentStreams == 0 {
137 | c.MaxConcurrentStreams = 10
138 | }
139 | // set default
140 | if c.MaxConnectionAge == 0 {
141 | c.MaxConnectionAge = infinity
142 | }
143 |
144 | // set default
145 | if c.MaxConnectionIdle == 0 {
146 | c.MaxConnectionIdle = infinity
147 | }
148 |
149 | if c.MaxConnectionAgeGrace == 0 {
150 | c.MaxConnectionAgeGrace = infinity
151 | }
152 |
153 | if c.MaxRecvMsgSize == 0 {
154 | c.MaxRecvMsgSize = 1024 * 1024 * 50
155 | } else {
156 | c.MaxRecvMsgSize = 1024 * 1024 * c.MaxRecvMsgSize
157 | }
158 |
159 | if c.MaxSendMsgSize == 0 {
160 | c.MaxSendMsgSize = 1024 * 1024 * 50
161 | } else {
162 | c.MaxSendMsgSize = 1024 * 1024 * c.MaxSendMsgSize
163 | }
164 |
165 | return nil
166 | }
167 |
168 | // Listener creates new rpc socket Listener.
169 | func (c *Config) Listener() (net.Listener, error) {
170 | dsn := strings.Split(c.Listen, "://")
171 | if len(dsn) != 2 {
172 | return nil, errors.New("invalid socket DSN (tcp://:6001, unix://rpc.sock)")
173 | }
174 |
175 | if dsn[0] == "unix" {
176 | _ = syscall.Unlink(dsn[1])
177 | }
178 |
179 | return net.Listen(dsn[0], dsn[1])
180 | }
181 |
182 | // EnableTLS returns true if rr must listen TLS connections.
183 | func (c *Config) EnableTLS() bool {
184 | // Key and Cert OR Key and Cert and RootCA
185 | return (c.TLS.RootCA != "" && c.TLS.Key != "" && c.TLS.Cert != "") || (c.TLS.Key != "" && c.TLS.Cert != "")
186 | }
187 |
--------------------------------------------------------------------------------
/config_test.go:
--------------------------------------------------------------------------------
1 | package grpc
2 |
3 | import (
4 | "encoding/json"
5 | "runtime"
6 | "testing"
7 | "time"
8 |
9 | "github.com/spiral/roadrunner"
10 | "github.com/spiral/roadrunner/service"
11 | "github.com/stretchr/testify/assert"
12 | )
13 |
14 | type mockCfg struct{ cfg string }
15 |
16 | func (cfg *mockCfg) Get(name string) service.Config { return nil }
17 | func (cfg *mockCfg) Unmarshal(out interface{}) error { return json.Unmarshal([]byte(cfg.cfg), out) }
18 |
19 | func Test_Config_Hydrate_Error2(t *testing.T) {
20 | cfg := &mockCfg{`{"`}
21 | c := &Config{}
22 |
23 | assert.Error(t, c.Hydrate(cfg))
24 | }
25 |
26 | func Test_Config_Valid_TLS(t *testing.T) {
27 | cfg := &Config{
28 | Listen: "tcp://:8080",
29 | TLS: TLS{
30 | Key: "tests/server.key",
31 | Cert: "tests/server.crt",
32 | RootCA: "tests/server.crt",
33 | },
34 | Proto: []string{"tests/test.proto"},
35 | Workers: &roadrunner.ServerConfig{
36 | Command: "php tests/worker.php",
37 | Relay: "pipes",
38 | Pool: &roadrunner.Config{
39 | NumWorkers: 1,
40 | AllocateTimeout: time.Second,
41 | DestroyTimeout: time.Second,
42 | },
43 | },
44 | }
45 |
46 | assert.NoError(t, cfg.Valid())
47 | assert.True(t, cfg.EnableTLS())
48 | }
49 |
50 | func Test_Config_No_Proto(t *testing.T) {
51 | cfg := &Config{
52 | Listen: "tcp://:8080",
53 | TLS: TLS{
54 | Key: "tests/server.key",
55 | Cert: "tests/server.crt",
56 | },
57 | Proto: []string{},
58 | Workers: &roadrunner.ServerConfig{
59 | Command: "php tests/worker.php",
60 | Relay: "pipes",
61 | Pool: &roadrunner.Config{
62 | NumWorkers: 1,
63 | AllocateTimeout: time.Second,
64 | DestroyTimeout: time.Second,
65 | },
66 | },
67 | }
68 |
69 | assert.Error(t, cfg.Valid())
70 | }
71 |
72 | func Test_Config_Nil_Proto(t *testing.T) {
73 | cfg := &Config{
74 | Listen: "tcp://:8080",
75 | TLS: TLS{
76 | Key: "tests/server.key",
77 | Cert: "tests/server.crt",
78 | },
79 | Proto: nil,
80 | Workers: &roadrunner.ServerConfig{
81 | Command: "php tests/worker.php",
82 | Relay: "pipes",
83 | Pool: &roadrunner.Config{
84 | NumWorkers: 1,
85 | AllocateTimeout: time.Second,
86 | DestroyTimeout: time.Second,
87 | },
88 | },
89 | }
90 |
91 | assert.Error(t, cfg.Valid())
92 | }
93 |
94 | func Test_Config_Missing_Proto(t *testing.T) {
95 | cfg := &Config{
96 | Listen: "tcp://:8080",
97 | TLS: TLS{
98 | Key: "tests/server.key",
99 | Cert: "tests/server.crt",
100 | },
101 | Proto: []string{"tests/missing.proto"},
102 | Workers: &roadrunner.ServerConfig{
103 | Command: "php tests/worker.php",
104 | Relay: "pipes",
105 | Pool: &roadrunner.Config{
106 | NumWorkers: 1,
107 | AllocateTimeout: time.Second,
108 | DestroyTimeout: time.Second,
109 | },
110 | },
111 | }
112 |
113 | assert.Error(t, cfg.Valid())
114 | }
115 |
116 | func Test_Config_Multiple_Missing_Proto(t *testing.T) {
117 | cfg := &Config{
118 | Listen: "tcp://:8080",
119 | TLS: TLS{
120 | Key: "tests/server.key",
121 | Cert: "tests/server.crt",
122 | },
123 | Proto: []string{"tests/test.proto", "tests/missing.proto"},
124 | Workers: &roadrunner.ServerConfig{
125 | Command: "php tests/worker.php",
126 | Relay: "pipes",
127 | Pool: &roadrunner.Config{
128 | NumWorkers: 1,
129 | AllocateTimeout: time.Second,
130 | DestroyTimeout: time.Second,
131 | },
132 | },
133 | }
134 |
135 | assert.Error(t, cfg.Valid())
136 | }
137 |
138 | func Test_Config_BadAddress(t *testing.T) {
139 | cfg := &Config{
140 | Listen: "tcp//8080",
141 | TLS: TLS{
142 | Key: "tests/server.key",
143 | Cert: "tests/server.crt",
144 | },
145 | Proto: []string{"tests/test.proto"},
146 | Workers: &roadrunner.ServerConfig{
147 | Command: "php tests/worker.php",
148 | Relay: "pipes",
149 | Pool: &roadrunner.Config{
150 | NumWorkers: 1,
151 | AllocateTimeout: time.Second,
152 | DestroyTimeout: time.Second,
153 | },
154 | },
155 | }
156 |
157 | assert.Error(t, cfg.Valid())
158 | }
159 |
160 | func Test_Config_BadListener(t *testing.T) {
161 | cfg := &Config{
162 | Listen: "unix//8080",
163 | TLS: TLS{
164 | Key: "tests/server.key",
165 | Cert: "tests/server.crt",
166 | },
167 | Proto: []string{"tests/test.proto"},
168 | Workers: &roadrunner.ServerConfig{
169 | Command: "php tests/worker.php",
170 | Relay: "pipes",
171 | Pool: &roadrunner.Config{
172 | NumWorkers: 1,
173 | AllocateTimeout: time.Second,
174 | DestroyTimeout: time.Second,
175 | },
176 | },
177 | }
178 |
179 | _, err := cfg.Listener()
180 | assert.Error(t, err)
181 | }
182 |
183 | func Test_Config_UnixListener(t *testing.T) {
184 | if runtime.GOOS == "windows" {
185 | t.Skip("not supported on " + runtime.GOOS)
186 | }
187 |
188 | cfg := &Config{
189 | Listen: "unix://rr.sock",
190 | TLS: TLS{
191 | Key: "tests/server.key",
192 | Cert: "tests/server.crt",
193 | },
194 | Proto: []string{"tests/test.proto"},
195 | Workers: &roadrunner.ServerConfig{
196 | Command: "php tests/worker.php",
197 | Relay: "pipes",
198 | Pool: &roadrunner.Config{
199 | NumWorkers: 1,
200 | AllocateTimeout: time.Second,
201 | DestroyTimeout: time.Second,
202 | },
203 | },
204 | }
205 |
206 | ln, err := cfg.Listener()
207 | assert.NoError(t, err)
208 | assert.NotNil(t, ln)
209 | ln.Close()
210 | }
211 |
212 | func Test_Config_InvalidWorkerPool(t *testing.T) {
213 | cfg := &Config{
214 | Listen: "unix://rr.sock",
215 | TLS: TLS{
216 | Key: "tests/server.key",
217 | Cert: "tests/server.crt",
218 | },
219 | Proto: []string{"tests/test.proto"},
220 | Workers: &roadrunner.ServerConfig{
221 | Command: "php tests/worker.php",
222 | Relay: "pipes",
223 | Pool: &roadrunner.Config{
224 | AllocateTimeout: 0,
225 | },
226 | },
227 | }
228 |
229 | assert.Error(t, cfg.Valid())
230 | }
231 |
232 | func Test_Config_TLS_No_key(t *testing.T) {
233 | cfg := &Config{
234 | Listen: "tcp://:8080",
235 | TLS: TLS{
236 | Cert: "tests/server.crt",
237 | },
238 | Proto: []string{"tests/test.proto"},
239 | Workers: &roadrunner.ServerConfig{
240 | Command: "php tests/worker.php",
241 | Relay: "pipes",
242 | Pool: &roadrunner.Config{
243 | NumWorkers: 1,
244 | AllocateTimeout: time.Second,
245 | DestroyTimeout: time.Second,
246 | },
247 | },
248 | }
249 |
250 | // should return nil, because c.EnableTLS will be false in case of missed certs
251 | assert.Nil(t, cfg.Valid())
252 | }
253 |
254 | func Test_Config_TLS_WrongKeyPath(t *testing.T) {
255 | cfg := &Config{
256 | Listen: "tcp://:8080",
257 | TLS: TLS{
258 | Cert: "testss/server.crt",
259 | Key: "testss/server.key",
260 | },
261 | Proto: []string{"tests/test.proto"},
262 | Workers: &roadrunner.ServerConfig{
263 | Command: "php tests/worker.php",
264 | Relay: "pipes",
265 | Pool: &roadrunner.Config{
266 | NumWorkers: 1,
267 | AllocateTimeout: time.Second,
268 | DestroyTimeout: time.Second,
269 | },
270 | },
271 | }
272 |
273 | assert.Error(t, cfg.Valid())
274 | }
275 |
276 | func Test_Config_TLS_WrongRootCAPath(t *testing.T) {
277 | cfg := &Config{
278 | Listen: "tcp://:8080",
279 | TLS: TLS{
280 | Cert: "tests/server.crt",
281 | Key: "tests/server.key",
282 | RootCA: "testss/server.crt",
283 | },
284 | Proto: []string{"tests/test.proto"},
285 | Workers: &roadrunner.ServerConfig{
286 | Command: "php tests/worker.php",
287 | Relay: "pipes",
288 | Pool: &roadrunner.Config{
289 | NumWorkers: 1,
290 | AllocateTimeout: time.Second,
291 | DestroyTimeout: time.Second,
292 | },
293 | },
294 | }
295 |
296 | assert.Error(t, cfg.Valid())
297 | }
298 |
299 | func Test_Config_TLS_No_Cert(t *testing.T) {
300 | cfg := &Config{
301 | Listen: "tcp://:8080",
302 | TLS: TLS{
303 | Key: "tests/server.key",
304 | },
305 | Proto: []string{"tests/test.proto"},
306 | Workers: &roadrunner.ServerConfig{
307 | Command: "php tests/worker.php",
308 | Relay: "pipes",
309 | Pool: &roadrunner.Config{
310 | NumWorkers: 1,
311 | AllocateTimeout: time.Second,
312 | DestroyTimeout: time.Second,
313 | },
314 | },
315 | }
316 |
317 | // should return nil, because c.EnableTLS will be false in case of missed certs
318 | assert.Nil(t, cfg.Valid())
319 | }
320 |
--------------------------------------------------------------------------------
/event.go:
--------------------------------------------------------------------------------
1 | package grpc
2 |
3 | import (
4 | "context"
5 | "time"
6 |
7 | "google.golang.org/grpc"
8 | )
9 |
10 | // EventUnaryCall thrown after every unary call.
11 | const EventUnaryCall = 8001
12 |
13 | // UnaryCallEvent contains information about invoked method.
14 | type UnaryCallEvent struct {
15 | // Info contains unary call info.
16 | Info *grpc.UnaryServerInfo
17 |
18 | // Context associated with the call.
19 | Context context.Context
20 |
21 | // Error associated with event.
22 | Error error
23 |
24 | // event timings
25 | start time.Time
26 | elapsed time.Duration
27 | }
28 |
29 | // Elapsed returns duration of the invocation.
30 | func (e *UnaryCallEvent) Elapsed() time.Duration {
31 | return e.elapsed
32 | }
33 |
--------------------------------------------------------------------------------
/go.mod:
--------------------------------------------------------------------------------
1 | module github.com/spiral/php-grpc
2 |
3 | go 1.17
4 |
5 | require (
6 | github.com/buger/goterm v1.0.1
7 | github.com/emicklei/proto v1.9.1
8 | github.com/golang/protobuf v1.5.2
9 | github.com/prometheus/client_golang v1.11.0
10 | github.com/sirupsen/logrus v1.8.1
11 | github.com/spf13/cobra v1.2.1
12 | github.com/spiral/roadrunner v1.9.2
13 | github.com/stretchr/testify v1.7.0
14 | golang.org/x/net v0.0.0-20211209124913-491a49abca63
15 | google.golang.org/grpc v1.42.0
16 | google.golang.org/protobuf v1.27.1
17 | )
18 |
19 | require (
20 | github.com/beorn7/perks v1.0.1 // indirect
21 | github.com/cespare/xxhash/v2 v2.1.2 // indirect
22 | github.com/davecgh/go-spew v1.1.1 // indirect
23 | github.com/dustin/go-humanize v1.0.0 // indirect
24 | github.com/fsnotify/fsnotify v1.5.1 // indirect
25 | github.com/go-ole/go-ole v1.2.6 // indirect
26 | github.com/hashicorp/hcl v1.0.0 // indirect
27 | github.com/inconshreveable/mousetrap v1.0.0 // indirect
28 | github.com/json-iterator/go v1.1.12 // indirect
29 | github.com/kr/pretty v0.2.0 // indirect
30 | github.com/magiconair/properties v1.8.5 // indirect
31 | github.com/mattn/go-colorable v0.1.12 // indirect
32 | github.com/mattn/go-isatty v0.0.14 // indirect
33 | github.com/mattn/go-runewidth v0.0.13 // indirect
34 | github.com/matttproud/golang_protobuf_extensions v1.0.1 // indirect
35 | github.com/mgutz/ansi v0.0.0-20200706080929-d51e80ef957d // indirect
36 | github.com/mitchellh/mapstructure v1.4.3 // indirect
37 | github.com/modern-go/concurrent v0.0.0-20180306012644-bacd9c7ef1dd // indirect
38 | github.com/modern-go/reflect2 v1.0.2 // indirect
39 | github.com/olekukonko/tablewriter v0.0.5 // indirect
40 | github.com/pelletier/go-toml v1.9.4 // indirect
41 | github.com/pkg/errors v0.9.1 // indirect
42 | github.com/pmezard/go-difflib v1.0.0 // indirect
43 | github.com/prometheus/client_model v0.2.0 // indirect
44 | github.com/prometheus/common v0.32.1 // indirect
45 | github.com/prometheus/procfs v0.7.3 // indirect
46 | github.com/rivo/uniseg v0.2.0 // indirect
47 | github.com/shirou/gopsutil v3.21.11+incompatible // indirect
48 | github.com/spf13/afero v1.6.0 // indirect
49 | github.com/spf13/cast v1.4.1 // indirect
50 | github.com/spf13/jwalterweatherman v1.1.0 // indirect
51 | github.com/spf13/pflag v1.0.5 // indirect
52 | github.com/spf13/viper v1.10.0 // indirect
53 | github.com/spiral/goridge/v2 v2.4.6 // indirect
54 | github.com/subosito/gotenv v1.2.0 // indirect
55 | github.com/tklauser/go-sysconf v0.3.9 // indirect
56 | github.com/tklauser/numcpus v0.3.0 // indirect
57 | github.com/valyala/tcplisten v1.0.0 // indirect
58 | github.com/yusufpapurcu/wmi v1.2.2 // indirect
59 | golang.org/x/sys v0.0.0-20211210111614-af8b64212486 // indirect
60 | golang.org/x/text v0.3.7 // indirect
61 | google.golang.org/genproto v0.0.0-20211208223120-3a66f561d7aa // indirect
62 | gopkg.in/ini.v1 v1.66.2 // indirect
63 | gopkg.in/yaml.v2 v2.4.0 // indirect
64 | gopkg.in/yaml.v3 v3.0.0-20210107192922-496545a6307b // indirect
65 | )
66 |
--------------------------------------------------------------------------------
/parser/message.proto:
--------------------------------------------------------------------------------
1 | syntax = "proto3";
2 | package app.namespace;
3 |
4 | message Message {
5 | string msg = 1;
6 | int64 value = 2;
7 | }
--------------------------------------------------------------------------------
/parser/parse.go:
--------------------------------------------------------------------------------
1 | package parser
2 |
3 | import (
4 | "bytes"
5 | "io"
6 | "os"
7 |
8 | pp "github.com/emicklei/proto"
9 | )
10 |
11 | // Service contains information about singular GRPC service.
12 | type Service struct {
13 | // Package defines service namespace.
14 | Package string
15 |
16 | // Name defines service name.
17 | Name string
18 |
19 | // Methods list.
20 | Methods []Method
21 | }
22 |
23 | // Method describes singular RPC method.
24 | type Method struct {
25 | // Name is method name.
26 | Name string
27 |
28 | // StreamsRequest defines if method accept stream input.
29 | StreamsRequest bool
30 |
31 | // RequestType defines message name (from the same package) of method input.
32 | RequestType string
33 |
34 | // StreamsReturns defines if method streams result.
35 | StreamsReturns bool
36 |
37 | // ReturnsType defines message name (from the same package) of method return value.
38 | ReturnsType string
39 | }
40 |
41 | // File parses given proto file or returns error.
42 | func File(file string, importPath string) ([]Service, error) {
43 | reader, _ := os.Open(file)
44 | defer func() {
45 | _ = reader.Close()
46 | }()
47 |
48 | return parse(reader, importPath)
49 | }
50 |
51 | // Bytes parses string into proto definition.
52 | func Bytes(data []byte) ([]Service, error) {
53 | return parse(bytes.NewBuffer(data), "")
54 | }
55 |
56 | func parse(reader io.Reader, importPath string) ([]Service, error) {
57 | proto, err := pp.NewParser(reader).Parse()
58 | if err != nil {
59 | return nil, err
60 | }
61 |
62 | return parseServices(
63 | proto,
64 | parsePackage(proto),
65 | importPath,
66 | )
67 | }
68 |
69 | func parsePackage(proto *pp.Proto) string {
70 | for _, e := range proto.Elements {
71 | if p, ok := e.(*pp.Package); ok {
72 | return p.Name
73 | }
74 | }
75 |
76 | return ""
77 | }
78 |
79 | func parseServices(proto *pp.Proto, pkg string, importPath string) ([]Service, error) {
80 | services := make([]Service, 0)
81 |
82 | pp.Walk(proto, pp.WithService(func(service *pp.Service) {
83 | services = append(services, Service{
84 | Package: pkg,
85 | Name: service.Name,
86 | Methods: parseMethods(service),
87 | })
88 | }))
89 |
90 | pp.Walk(proto, func(v pp.Visitee) {
91 | if i, ok := v.(*pp.Import); ok {
92 | if im, err := File(importPath+"/"+i.Filename, importPath); err == nil {
93 | services = append(services, im...)
94 | }
95 | }
96 | })
97 |
98 | return services, nil
99 | }
100 |
101 | func parseMethods(s *pp.Service) []Method {
102 | methods := make([]Method, 0)
103 | for _, e := range s.Elements {
104 | if m, ok := e.(*pp.RPC); ok {
105 | methods = append(methods, Method{
106 | Name: m.Name,
107 | StreamsRequest: m.StreamsRequest,
108 | RequestType: m.RequestType,
109 | StreamsReturns: m.StreamsReturns,
110 | ReturnsType: m.ReturnsType,
111 | })
112 | }
113 | }
114 |
115 | return methods
116 | }
117 |
--------------------------------------------------------------------------------
/parser/parse_test.go:
--------------------------------------------------------------------------------
1 | package parser
2 |
3 | import (
4 | "testing"
5 |
6 | "github.com/stretchr/testify/assert"
7 | )
8 |
9 | func TestParseFile(t *testing.T) {
10 | services, err := File("test.proto", "")
11 | assert.NoError(t, err)
12 | assert.Len(t, services, 2)
13 |
14 | assert.Equal(t, "app.namespace", services[0].Package)
15 | }
16 |
17 | func TestParseFileWithImportsNestedFolder(t *testing.T) {
18 | services, err := File("./test_nested/test_import.proto", "./test_nested")
19 | assert.NoError(t, err)
20 | assert.Len(t, services, 2)
21 |
22 | assert.Equal(t, "app.namespace", services[0].Package)
23 | }
24 |
25 | func TestParseFileWithImports(t *testing.T) {
26 | services, err := File("test_import.proto", ".")
27 | assert.NoError(t, err)
28 | assert.Len(t, services, 2)
29 |
30 | assert.Equal(t, "app.namespace", services[0].Package)
31 | }
32 |
33 | func TestParseNotFound(t *testing.T) {
34 | _, err := File("test2.proto", "")
35 | assert.Error(t, err)
36 | }
37 |
38 | func TestParseBytes(t *testing.T) {
39 | services, err := Bytes([]byte{})
40 | assert.NoError(t, err)
41 | assert.Len(t, services, 0)
42 | }
43 |
44 | func TestParseString(t *testing.T) {
45 | services, err := Bytes([]byte(`
46 | syntax = "proto3";
47 | package app.namespace;
48 |
49 | // Ping Service.
50 | service PingService {
51 | // Ping Method.
52 | rpc Ping (Message) returns (Message) {
53 | }
54 | }
55 |
56 | // Pong service.
57 | service PongService {
58 | rpc Pong (stream Message) returns (stream Message) {
59 | }
60 | }
61 |
62 | message Message {
63 | string msg = 1;
64 | int64 value = 2;
65 | }
66 | `))
67 | assert.NoError(t, err)
68 | assert.Len(t, services, 2)
69 |
70 | assert.Equal(t, "app.namespace", services[0].Package)
71 | }
72 |
--------------------------------------------------------------------------------
/parser/pong.proto:
--------------------------------------------------------------------------------
1 | syntax = "proto3";
2 | package app.namespace;
3 |
4 | import "message.proto";
5 |
6 | // Pong service.
7 | service PongService {
8 | rpc Pong (stream Message) returns (stream Message) {
9 | }
10 | }
--------------------------------------------------------------------------------
/parser/test.proto:
--------------------------------------------------------------------------------
1 | syntax = "proto3";
2 | package app.namespace;
3 |
4 | // Ping Service.
5 | service PingService {
6 | // Ping Method.
7 | rpc Ping (Message) returns (Message) {
8 | }
9 | }
10 |
11 | // Pong service.
12 | service PongService {
13 | rpc Pong (stream Message) returns (stream Message) {
14 | }
15 | }
16 |
17 | message Message {
18 | string msg = 1;
19 | int64 value = 2;
20 | }
--------------------------------------------------------------------------------
/parser/test_import.proto:
--------------------------------------------------------------------------------
1 | syntax = "proto3";
2 | package app.namespace;
3 |
4 | import "message.proto";
5 | import "pong.proto";
6 |
7 | // Ping Service.
8 | service PingService {
9 | // Ping Method.
10 | rpc Ping (Message) returns (Message) {
11 | }
12 | }
--------------------------------------------------------------------------------
/parser/test_nested/message.proto:
--------------------------------------------------------------------------------
1 | syntax = "proto3";
2 | package app.namespace;
3 |
4 | message Message {
5 | string msg = 1;
6 | int64 value = 2;
7 | }
--------------------------------------------------------------------------------
/parser/test_nested/pong.proto:
--------------------------------------------------------------------------------
1 | syntax = "proto3";
2 | package app.namespace;
3 |
4 | import "message.proto";
5 |
6 | // Pong service.
7 | service PongService {
8 | rpc Pong (stream Message) returns (stream Message) {
9 | }
10 | }
--------------------------------------------------------------------------------
/parser/test_nested/test_import.proto:
--------------------------------------------------------------------------------
1 | syntax = "proto3";
2 | package app.namespace;
3 |
4 | import "message.proto";
5 | import "pong.proto";
6 |
7 | // Ping Service.
8 | service PingService {
9 | // Ping Method.
10 | rpc Ping (Message) returns (Message) {
11 | }
12 | }
13 |
--------------------------------------------------------------------------------
/phpunit.xml:
--------------------------------------------------------------------------------
1 |
2 |
14 |
15 |
16 | tests
17 |
18 |
19 |
20 |
21 | src
22 |
23 |
24 |
25 |
26 |
27 |
28 |
29 |
--------------------------------------------------------------------------------
/proxy.go:
--------------------------------------------------------------------------------
1 | package grpc
2 |
3 | import (
4 | "encoding/json"
5 | "fmt"
6 | "math"
7 | "strconv"
8 | "strings"
9 |
10 | "github.com/golang/protobuf/ptypes/any"
11 | "github.com/spiral/roadrunner"
12 | "golang.org/x/net/context"
13 | "google.golang.org/grpc"
14 | "google.golang.org/grpc/codes"
15 | "google.golang.org/grpc/metadata"
16 | "google.golang.org/grpc/peer"
17 | "google.golang.org/grpc/status"
18 | "google.golang.org/protobuf/proto"
19 | )
20 |
21 | // base interface for Proxy class
22 | type proxyService interface {
23 | // RegisterMethod registers new RPC method.
24 | RegisterMethod(method string)
25 |
26 | // ServiceDesc returns service description for the proxy.
27 | ServiceDesc() *grpc.ServiceDesc
28 | }
29 |
30 | // carry details about service, method and RPC context to PHP process
31 | type rpcContext struct {
32 | Service string `json:"service"`
33 | Method string `json:"method"`
34 | Context map[string][]string `json:"context"`
35 | }
36 |
37 | // Proxy manages GRPC/RoadRunner bridge.
38 | type Proxy struct {
39 | rr *roadrunner.Server
40 | name string
41 | metadata string
42 | methods []string
43 | }
44 |
45 | // NewProxy creates new service proxy object.
46 | func NewProxy(name string, metadata string, rr *roadrunner.Server) *Proxy {
47 | return &Proxy{
48 | rr: rr,
49 | name: name,
50 | metadata: metadata,
51 | methods: make([]string, 0),
52 | }
53 | }
54 |
55 | // RegisterMethod registers new RPC method.
56 | func (p *Proxy) RegisterMethod(method string) {
57 | p.methods = append(p.methods, method)
58 | }
59 |
60 | // ServiceDesc returns service description for the proxy.
61 | func (p *Proxy) ServiceDesc() *grpc.ServiceDesc {
62 | desc := &grpc.ServiceDesc{
63 | ServiceName: p.name,
64 | Metadata: p.metadata,
65 | HandlerType: (*proxyService)(nil),
66 | Methods: []grpc.MethodDesc{},
67 | Streams: []grpc.StreamDesc{},
68 | }
69 |
70 | // Registering methods
71 | for _, m := range p.methods {
72 | desc.Methods = append(desc.Methods, grpc.MethodDesc{
73 | MethodName: m,
74 | Handler: p.methodHandler(m),
75 | })
76 | }
77 |
78 | return desc
79 | }
80 |
81 | // Generate method handler proxy.
82 | func (p *Proxy) methodHandler(method string) func(srv interface{}, ctx context.Context, dec func(interface{}) error, interceptor grpc.UnaryServerInterceptor) (interface{}, error) {
83 | return func(
84 | srv interface{},
85 | ctx context.Context,
86 | dec func(interface{}) error,
87 | interceptor grpc.UnaryServerInterceptor,
88 | ) (interface{}, error) {
89 | in := rawMessage{}
90 | if err := dec(&in); err != nil {
91 | return nil, wrapError(err)
92 | }
93 |
94 | if interceptor == nil {
95 | return p.invoke(ctx, method, in)
96 | }
97 |
98 | info := &grpc.UnaryServerInfo{
99 | Server: srv,
100 | FullMethod: fmt.Sprintf("/%s/%s", p.name, method),
101 | }
102 |
103 | handler := func(ctx context.Context, req interface{}) (interface{}, error) {
104 | return p.invoke(ctx, method, req.(rawMessage))
105 | }
106 |
107 | return interceptor(ctx, in, info, handler)
108 | }
109 | }
110 |
111 | func (p *Proxy) invoke(ctx context.Context, method string, in rawMessage) (interface{}, error) {
112 | payload, err := p.makePayload(ctx, method, in)
113 | if err != nil {
114 | return nil, err
115 | }
116 |
117 | resp, err := p.rr.Exec(payload)
118 |
119 | if err != nil {
120 | return nil, wrapError(err)
121 | }
122 |
123 | md, err := p.responseMetadata(resp)
124 | if err != nil {
125 | return nil, err
126 | }
127 | ctx = metadata.NewIncomingContext(ctx, md)
128 | err = grpc.SetHeader(ctx, md)
129 | if err != nil {
130 | return nil, err
131 | }
132 |
133 | return rawMessage(resp.Body), nil
134 | }
135 |
136 | // responseMetadata extracts metadata from roadrunner response Payload.Context and converts it to metadata.MD
137 | func (p *Proxy) responseMetadata(resp *roadrunner.Payload) (metadata.MD, error) {
138 | var md metadata.MD
139 | if resp == nil || len(resp.Context) == 0 {
140 | return md, nil
141 | }
142 |
143 | var rpcMetadata map[string]string
144 | err := json.Unmarshal(resp.Context, &rpcMetadata)
145 | if err != nil {
146 | return md, err
147 | }
148 |
149 | if len(rpcMetadata) > 0 {
150 | md = metadata.New(rpcMetadata)
151 | }
152 |
153 | return md, nil
154 | }
155 |
156 | // makePayload generates RoadRunner compatible payload based on GRPC message. todo: return error
157 | func (p *Proxy) makePayload(ctx context.Context, method string, body rawMessage) (*roadrunner.Payload, error) {
158 | ctxMD := make(map[string][]string)
159 |
160 | if md, ok := metadata.FromIncomingContext(ctx); ok {
161 | for k, v := range md {
162 | ctxMD[k] = v
163 | }
164 | }
165 |
166 | if pr, ok := peer.FromContext(ctx); ok {
167 | ctxMD[":peer.address"] = []string{pr.Addr.String()}
168 | if pr.AuthInfo != nil {
169 | ctxMD[":peer.auth-type"] = []string{pr.AuthInfo.AuthType()}
170 | }
171 | }
172 |
173 | ctxData, err := json.Marshal(rpcContext{Service: p.name, Method: method, Context: ctxMD})
174 |
175 | if err != nil {
176 | return nil, err
177 | }
178 |
179 | return &roadrunner.Payload{Context: ctxData, Body: body}, nil
180 | }
181 |
182 | // mounts proper error code for the error
183 | func wrapError(err error) error {
184 | // internal agreement
185 | if strings.Contains(err.Error(), "|:|") {
186 | chunks := strings.Split(err.Error(), "|:|")
187 | code := codes.Internal
188 |
189 | // protect the slice access
190 | if len(chunks) < 2 {
191 | return err
192 | }
193 |
194 | phpCode, errConv := strconv.ParseUint(chunks[0], 10, 32)
195 | if errConv != nil {
196 | return err
197 | }
198 |
199 | if phpCode > 0 && phpCode < math.MaxUint32 {
200 | code = codes.Code(phpCode)
201 | }
202 |
203 | st := status.New(code, chunks[1]).Proto()
204 |
205 | for _, detailsMessage := range chunks[2:] {
206 | anyDetailsMessage := any.Any{}
207 | err = proto.Unmarshal([]byte(detailsMessage), &anyDetailsMessage)
208 | if err == nil {
209 | st.Details = append(st.Details, &anyDetailsMessage)
210 | }
211 | }
212 |
213 | return status.ErrorProto(st)
214 | }
215 |
216 | return status.Error(codes.Internal, err.Error())
217 | }
218 |
--------------------------------------------------------------------------------
/proxy_test.go:
--------------------------------------------------------------------------------
1 | package grpc
2 |
3 | import (
4 | "testing"
5 | "time"
6 |
7 | "github.com/sirupsen/logrus"
8 | "github.com/sirupsen/logrus/hooks/test"
9 | "github.com/spiral/php-grpc/tests"
10 | "github.com/spiral/roadrunner/service"
11 | "github.com/stretchr/testify/assert"
12 | "golang.org/x/net/context"
13 | "google.golang.org/grpc"
14 | "google.golang.org/grpc/codes"
15 | "google.golang.org/grpc/metadata"
16 | "google.golang.org/grpc/status"
17 | )
18 |
19 | func Test_Proxy_Error(t *testing.T) {
20 | logger, _ := test.NewNullLogger()
21 | logger.SetLevel(logrus.DebugLevel)
22 |
23 | c := service.NewContainer(logger)
24 | c.Register(ID, &Service{})
25 |
26 | assert.NoError(t, c.Init(&testCfg{
27 | grpcCfg: `{
28 | "listen": "tcp://:9093",
29 | "tls": {
30 | "key": "tests/server.key",
31 | "cert": "tests/server.crt"
32 | },
33 | "proto": ["tests/test.proto"],
34 | "workers":{
35 | "command": "php tests/worker.php",
36 | "relay": "pipes",
37 | "pool": {
38 | "numWorkers": 1,
39 | "allocateTimeout": 10,
40 | "destroyTimeout": 10
41 | }
42 | }
43 | }`,
44 | }))
45 |
46 | s, st := c.Get(ID)
47 | assert.NotNil(t, s)
48 | assert.Equal(t, service.StatusOK, st)
49 |
50 | // should do nothing
51 | s.(*Service).Stop()
52 |
53 | go func() { assert.NoError(t, c.Serve()) }()
54 | time.Sleep(time.Millisecond * 100)
55 | defer c.Stop()
56 |
57 | cl, cn := getClient("127.0.0.1:9093")
58 | defer cn.Close()
59 |
60 | _, err := cl.Throw(context.Background(), &tests.Message{Msg: "notFound"})
61 |
62 | assert.Error(t, err)
63 | se, _ := status.FromError(err)
64 | assert.Equal(t, "nothing here", se.Message())
65 | assert.Equal(t, codes.NotFound, se.Code())
66 |
67 | _, errWithDetails := cl.Throw(context.Background(), &tests.Message{Msg: "withDetails"})
68 |
69 | assert.Error(t, errWithDetails)
70 | statusWithDetails, _ := status.FromError(errWithDetails)
71 | assert.Equal(t, "main exception message", statusWithDetails.Message())
72 | assert.Equal(t, codes.InvalidArgument, statusWithDetails.Code())
73 |
74 | details := statusWithDetails.Details()
75 |
76 | detailsMessageForException := details[0].(*tests.DetailsMessageForException)
77 |
78 | assert.Equal(t, detailsMessageForException.Code, uint64(1))
79 | assert.Equal(t, detailsMessageForException.Message, "details message")
80 | }
81 |
82 | func Test_Proxy_Metadata(t *testing.T) {
83 | logger, _ := test.NewNullLogger()
84 | logger.SetLevel(logrus.DebugLevel)
85 |
86 | c := service.NewContainer(logger)
87 | c.Register(ID, &Service{})
88 |
89 | assert.NoError(t, c.Init(&testCfg{
90 | grpcCfg: `{
91 | "listen": "tcp://:9094",
92 | "tls": {
93 | "key": "tests/server.key",
94 | "cert": "tests/server.crt"
95 | },
96 | "proto": ["tests/test.proto"],
97 | "workers":{
98 | "command": "php tests/worker.php",
99 | "relay": "pipes",
100 | "pool": {
101 | "numWorkers": 1,
102 | "allocateTimeout": 10,
103 | "destroyTimeout": 10
104 | }
105 | }
106 | }`,
107 | }))
108 |
109 | s, st := c.Get(ID)
110 | assert.NotNil(t, s)
111 | assert.Equal(t, service.StatusOK, st)
112 |
113 | // should do nothing
114 | s.(*Service).Stop()
115 |
116 | go func() { assert.NoError(t, c.Serve()) }()
117 | time.Sleep(time.Millisecond * 100)
118 | defer c.Stop()
119 |
120 | cl, cn := getClient("127.0.0.1:9094")
121 | defer cn.Close()
122 |
123 | ctx := metadata.AppendToOutgoingContext(context.Background(), "key", "proxy-value")
124 | var header metadata.MD
125 | out, err := cl.Info(
126 | ctx,
127 | &tests.Message{Msg: "MD"},
128 | grpc.Header(&header),
129 | grpc.WaitForReady(true),
130 | )
131 | assert.Equal(t, []string{"bar"}, header.Get("foo"))
132 | assert.NoError(t, err)
133 | assert.Equal(t, `["proxy-value"]`, out.Msg)
134 | }
135 |
--------------------------------------------------------------------------------
/psalm.xml:
--------------------------------------------------------------------------------
1 |
2 |
16 |
17 |
18 |
19 |
20 |
21 |
22 |
23 |
24 |
25 |
26 |
27 |
28 |
--------------------------------------------------------------------------------
/rpc.go:
--------------------------------------------------------------------------------
1 | package grpc
2 |
3 | import (
4 | "errors"
5 |
6 | "github.com/spiral/roadrunner/util"
7 | )
8 |
9 | type rpcServer struct {
10 | svc *Service
11 | }
12 |
13 | // WorkerList contains list of workers.
14 | type WorkerList struct {
15 | // Workers is list of workers.
16 | Workers []*util.State `json:"workers"`
17 | }
18 |
19 | // Reset resets underlying RR worker pool and restarts all of it's workers.
20 | func (rpc *rpcServer) Reset(reset bool, r *string) error {
21 | if rpc.svc == nil || rpc.svc.grpc == nil {
22 | return errors.New("grpc server is not running")
23 | }
24 |
25 | *r = "OK"
26 | return rpc.svc.rr.Reset()
27 | }
28 |
29 | // Workers returns list of active workers and their stats.
30 | func (rpc *rpcServer) Workers(list bool, r *WorkerList) (err error) {
31 | if rpc.svc == nil || rpc.svc.grpc == nil {
32 | return errors.New("grpc server is not running")
33 | }
34 |
35 | r.Workers, err = util.ServerState(rpc.svc.rr)
36 | return err
37 | }
38 |
--------------------------------------------------------------------------------
/rpc_test.go:
--------------------------------------------------------------------------------
1 | package grpc
2 |
3 | import (
4 | "strconv"
5 | "testing"
6 | "time"
7 |
8 | "github.com/sirupsen/logrus"
9 | "github.com/sirupsen/logrus/hooks/test"
10 | "github.com/spiral/php-grpc/tests"
11 | "github.com/spiral/roadrunner/service"
12 | "github.com/spiral/roadrunner/service/rpc"
13 | "github.com/stretchr/testify/assert"
14 | "golang.org/x/net/context"
15 | )
16 |
17 | func Test_RPC(t *testing.T) {
18 | logger, _ := test.NewNullLogger()
19 | logger.SetLevel(logrus.DebugLevel)
20 |
21 | c := service.NewContainer(logger)
22 | c.Register(rpc.ID, &rpc.Service{})
23 | c.Register(ID, &Service{})
24 |
25 | assert.NoError(t, c.Init(&testCfg{
26 | rpcCfg: `{"enable":true, "listen":"tcp://:5004"}`,
27 | grpcCfg: `{
28 | "listen": "tcp://:9095",
29 | "tls": {
30 | "key": "tests/server.key",
31 | "cert": "tests/server.crt"
32 | },
33 | "proto": ["tests/test.proto"],
34 | "workers":{
35 | "command": "php tests/worker.php",
36 | "relay": "pipes",
37 | "pool": {
38 | "numWorkers": 1,
39 | "allocateTimeout": 10,
40 | "destroyTimeout": 10
41 | }
42 | }
43 | }`,
44 | }))
45 |
46 | s, _ := c.Get(ID)
47 | ss := s.(*Service)
48 |
49 | s2, _ := c.Get(rpc.ID)
50 | rs := s2.(*rpc.Service)
51 |
52 | go func() { assert.NoError(t, c.Serve()) }()
53 | time.Sleep(time.Millisecond * 100)
54 | defer c.Stop()
55 |
56 | cl, cn := getClient("127.0.0.1:9095")
57 | defer cn.Close()
58 |
59 | rcl, err := rs.Client()
60 | assert.NoError(t, err)
61 |
62 | out, err := cl.Info(context.Background(), &tests.Message{Msg: "PID"})
63 |
64 | assert.NoError(t, err)
65 | assert.Equal(t, strconv.Itoa(*ss.rr.Workers()[0].Pid), out.Msg)
66 |
67 | r := ""
68 | assert.NoError(t, rcl.Call("grpc.Reset", true, &r))
69 | assert.Equal(t, "OK", r)
70 |
71 | out2, err := cl.Info(context.Background(), &tests.Message{Msg: "PID"})
72 |
73 | assert.NoError(t, err)
74 | assert.Equal(t, strconv.Itoa(*ss.rr.Workers()[0].Pid), out2.Msg)
75 |
76 | assert.NotEqual(t, out.Msg, out2.Msg)
77 | }
78 |
79 | func Test_Workers(t *testing.T) {
80 | logger, _ := test.NewNullLogger()
81 | logger.SetLevel(logrus.DebugLevel)
82 |
83 | c := service.NewContainer(logger)
84 | c.Register(rpc.ID, &rpc.Service{})
85 | c.Register(ID, &Service{})
86 |
87 | assert.NoError(t, c.Init(&testCfg{
88 | rpcCfg: `{"enable":true, "listen":"tcp://:5004"}`,
89 | grpcCfg: `{
90 | "listen": "tcp://:9096",
91 | "tls": {
92 | "key": "tests/server.key",
93 | "cert": "tests/server.crt"
94 | },
95 | "proto": ["tests/test.proto"],
96 | "workers":{
97 | "command": "php tests/worker.php",
98 | "relay": "pipes",
99 | "pool": {
100 | "numWorkers": 1,
101 | "allocateTimeout": 10,
102 | "destroyTimeout": 10
103 | }
104 | }
105 | }`,
106 | }))
107 |
108 | s, _ := c.Get(ID)
109 | ss := s.(*Service)
110 |
111 | s2, _ := c.Get(rpc.ID)
112 | rs := s2.(*rpc.Service)
113 |
114 | go func() { assert.NoError(t, c.Serve()) }()
115 | time.Sleep(time.Millisecond * 100)
116 | defer c.Stop()
117 |
118 | cl, cn := getClient("127.0.0.1:9096")
119 | defer cn.Close()
120 |
121 | rcl, err := rs.Client()
122 | assert.NoError(t, err)
123 |
124 | out, err := cl.Info(context.Background(), &tests.Message{Msg: "PID"})
125 |
126 | assert.NoError(t, err)
127 | assert.Equal(t, strconv.Itoa(*ss.rr.Workers()[0].Pid), out.Msg)
128 |
129 | r := &WorkerList{}
130 | assert.NoError(t, rcl.Call("grpc.Workers", true, &r))
131 | assert.Len(t, r.Workers, 1)
132 | }
133 |
134 | func Test_Errors(t *testing.T) {
135 | r := &rpcServer{nil}
136 |
137 | assert.Error(t, r.Reset(true, nil))
138 | assert.Error(t, r.Workers(true, nil))
139 | }
140 |
--------------------------------------------------------------------------------
/service.go:
--------------------------------------------------------------------------------
1 | package grpc
2 |
3 | import (
4 | "context"
5 | "crypto/tls"
6 | "crypto/x509"
7 | "errors"
8 | "fmt"
9 | "io/ioutil"
10 | "path"
11 | "sync"
12 | "time"
13 |
14 | "github.com/spiral/php-grpc/parser"
15 | "github.com/spiral/roadrunner"
16 | "github.com/spiral/roadrunner/service/env"
17 | "github.com/spiral/roadrunner/service/rpc"
18 | "google.golang.org/grpc"
19 | "google.golang.org/grpc/credentials"
20 | "google.golang.org/grpc/encoding"
21 | "google.golang.org/grpc/keepalive"
22 | )
23 |
24 | // ID sets public GRPC service ID for roadrunner.Container.
25 | const ID = "grpc"
26 |
27 | var errCouldNotAppendPemError = errors.New("could not append Certs from PEM")
28 |
29 | // Service manages set of GPRC services, options and connections.
30 | type Service struct {
31 | cfg *Config
32 | env env.Environment
33 | list []func(event int, ctx interface{})
34 | opts []grpc.ServerOption
35 | services []func(server *grpc.Server)
36 | mu sync.Mutex
37 | rr *roadrunner.Server
38 | cr roadrunner.Controller
39 | grpc *grpc.Server
40 | }
41 |
42 | // Attach attaches cr. Currently only one cr is supported.
43 | func (svc *Service) Attach(ctr roadrunner.Controller) {
44 | svc.cr = ctr
45 | }
46 |
47 | // AddListener attaches grpc event watcher.
48 | func (svc *Service) AddListener(l func(event int, ctx interface{})) {
49 | svc.list = append(svc.list, l)
50 | }
51 |
52 | // AddService would be invoked after GRPC service creation.
53 | func (svc *Service) AddService(r func(server *grpc.Server)) error {
54 | svc.services = append(svc.services, r)
55 | return nil
56 | }
57 |
58 | // AddOption adds new GRPC server option. Codec and TLS options are controlled by service internally.
59 | func (svc *Service) AddOption(opt grpc.ServerOption) {
60 | svc.opts = append(svc.opts, opt)
61 | }
62 |
63 | // Init service.
64 | func (svc *Service) Init(cfg *Config, r *rpc.Service, e env.Environment) (ok bool, err error) {
65 | svc.cfg = cfg
66 | svc.env = e
67 |
68 | if r != nil {
69 | if err := r.Register(ID, &rpcServer{svc}); err != nil {
70 | return false, err
71 | }
72 | }
73 |
74 | if svc.cfg.Workers.Command != "" {
75 | svc.rr = roadrunner.NewServer(svc.cfg.Workers)
76 | }
77 |
78 | // register the Codec
79 | encoding.RegisterCodec(&Codec{
80 | Base: encoding.GetCodec(CodecName),
81 | })
82 |
83 | return true, nil
84 | }
85 |
86 | // Serve GRPC grpc.
87 | func (svc *Service) Serve() (err error) {
88 | svc.mu.Lock()
89 |
90 | if svc.grpc, err = svc.createGPRCServer(); err != nil {
91 | svc.mu.Unlock()
92 | return err
93 | }
94 |
95 | ls, err := svc.cfg.Listener()
96 | if err != nil {
97 | svc.mu.Unlock()
98 | return err
99 | }
100 | defer ls.Close()
101 |
102 | if svc.rr != nil {
103 | if svc.env != nil {
104 | if err := svc.env.Copy(svc.cfg.Workers); err != nil {
105 | svc.mu.Unlock()
106 | return err
107 | }
108 | }
109 |
110 | svc.cfg.Workers.SetEnv("RR_GRPC", "true")
111 |
112 | svc.rr.Listen(svc.throw)
113 |
114 | if svc.cr != nil {
115 | svc.rr.Attach(svc.cr)
116 | }
117 |
118 | if err := svc.rr.Start(); err != nil {
119 | svc.mu.Unlock()
120 | return err
121 | }
122 | defer svc.rr.Stop()
123 | }
124 |
125 | svc.mu.Unlock()
126 |
127 | return svc.grpc.Serve(ls)
128 | }
129 |
130 | // Stop the service.
131 | func (svc *Service) Stop() {
132 | svc.mu.Lock()
133 | defer svc.mu.Unlock()
134 | if svc.grpc == nil {
135 | return
136 | }
137 |
138 | go svc.grpc.GracefulStop()
139 | }
140 |
141 | // Server returns associated rr server (if any).
142 | func (svc *Service) Server() *roadrunner.Server {
143 | svc.mu.Lock()
144 | defer svc.mu.Unlock()
145 |
146 | return svc.rr
147 | }
148 |
149 | // call info
150 | func (svc *Service) interceptor(
151 | ctx context.Context,
152 | req interface{},
153 | info *grpc.UnaryServerInfo,
154 | handler grpc.UnaryHandler,
155 | ) (resp interface{}, err error) {
156 | start := time.Now()
157 | resp, err = handler(ctx, req)
158 |
159 | svc.throw(EventUnaryCall, &UnaryCallEvent{
160 | Info: info,
161 | Context: ctx,
162 | Error: err,
163 | start: start,
164 | elapsed: time.Since(start),
165 | })
166 |
167 | return resp, err
168 | }
169 |
170 | // throw handles service, grpc and pool events.
171 | func (svc *Service) throw(event int, ctx interface{}) {
172 | for _, l := range svc.list {
173 | l(event, ctx)
174 | }
175 |
176 | if event == roadrunner.EventServerFailure {
177 | // underlying rr grpc is dead
178 | svc.Stop()
179 | }
180 | }
181 |
182 | // new configured GRPC server
183 | func (svc *Service) createGPRCServer() (*grpc.Server, error) {
184 | opts, err := svc.serverOptions()
185 | if err != nil {
186 | return nil, err
187 | }
188 |
189 | server := grpc.NewServer(opts...)
190 |
191 | if len(svc.cfg.Proto) > 0 && svc.rr != nil {
192 | for _, proto := range svc.cfg.Proto {
193 | // php proxy services
194 | services, err := parser.File(proto, path.Dir(proto))
195 | if err != nil {
196 | return nil, err
197 | }
198 |
199 | for _, service := range services {
200 | p := NewProxy(fmt.Sprintf("%s.%s", service.Package, service.Name), proto, svc.rr)
201 | for _, m := range service.Methods {
202 | p.RegisterMethod(m.Name)
203 | }
204 |
205 | server.RegisterService(p.ServiceDesc(), p)
206 | }
207 | }
208 | }
209 |
210 | // external and native services
211 | for _, r := range svc.services {
212 | r(server)
213 | }
214 |
215 | return server, nil
216 | }
217 |
218 | // server options
219 | func (svc *Service) serverOptions() ([]grpc.ServerOption, error) {
220 | var tcreds credentials.TransportCredentials
221 | var opts []grpc.ServerOption
222 | var cert tls.Certificate
223 | var certPool *x509.CertPool
224 | var rca []byte
225 | var err error
226 | if svc.cfg.EnableTLS() {
227 | // if client CA is not empty we combine it with Cert and Key
228 | if svc.cfg.TLS.RootCA != "" {
229 | cert, err = tls.LoadX509KeyPair(svc.cfg.TLS.Cert, svc.cfg.TLS.Key)
230 | if err != nil {
231 | return nil, err
232 | }
233 |
234 | certPool, err = x509.SystemCertPool()
235 | if err != nil {
236 | return nil, err
237 | }
238 | if certPool == nil {
239 | certPool = x509.NewCertPool()
240 | }
241 | rca, err = ioutil.ReadFile(svc.cfg.TLS.RootCA)
242 | if err != nil {
243 | return nil, err
244 | }
245 |
246 | if ok := certPool.AppendCertsFromPEM(rca); !ok {
247 | return nil, errCouldNotAppendPemError
248 | }
249 |
250 | tcreds = credentials.NewTLS(&tls.Config{
251 | MinVersion: tls.VersionTLS12,
252 | ClientAuth: tls.RequireAndVerifyClientCert,
253 | Certificates: []tls.Certificate{cert},
254 | ClientCAs: certPool,
255 | RootCAs: certPool,
256 | })
257 | } else {
258 | var err error
259 | tcreds, err = credentials.NewServerTLSFromFile(svc.cfg.TLS.Cert, svc.cfg.TLS.Key)
260 | if err != nil {
261 | return nil, err
262 | }
263 | }
264 |
265 | serverOptions := []grpc.ServerOption{
266 | grpc.MaxSendMsgSize(int(svc.cfg.MaxSendMsgSize)),
267 | grpc.MaxRecvMsgSize(int(svc.cfg.MaxRecvMsgSize)),
268 | grpc.KeepaliveParams(keepalive.ServerParameters{
269 | MaxConnectionIdle: svc.cfg.MaxConnectionIdle,
270 | MaxConnectionAge: svc.cfg.MaxConnectionAge,
271 | MaxConnectionAgeGrace: svc.cfg.MaxConnectionAge,
272 | Time: svc.cfg.PingTime,
273 | Timeout: svc.cfg.Timeout,
274 | }),
275 | grpc.MaxConcurrentStreams(uint32(svc.cfg.MaxConcurrentStreams)),
276 | }
277 |
278 | opts = append(opts, grpc.Creds(tcreds))
279 | opts = append(opts, serverOptions...)
280 | }
281 |
282 | opts = append(opts, svc.opts...)
283 |
284 | // custom Codec is required to bypass protobuf, common interceptor used for debug and stats
285 | // custom Codec is required to bypass protobuf, common interceptor used for debug and stats
286 | return append(
287 | opts,
288 | grpc.UnaryInterceptor(svc.interceptor),
289 | ), nil
290 | }
291 |
--------------------------------------------------------------------------------
/service_test.go:
--------------------------------------------------------------------------------
1 | package grpc
2 |
3 | import (
4 | "encoding/json"
5 | "testing"
6 | "time"
7 |
8 | "github.com/sirupsen/logrus"
9 | "github.com/sirupsen/logrus/hooks/test"
10 | "github.com/spiral/php-grpc/tests"
11 | "github.com/spiral/php-grpc/tests/ext"
12 | "github.com/spiral/roadrunner"
13 | "github.com/spiral/roadrunner/service"
14 | "github.com/spiral/roadrunner/service/env"
15 | "github.com/spiral/roadrunner/service/rpc"
16 | "github.com/stretchr/testify/assert"
17 | "golang.org/x/net/context"
18 | ngrpc "google.golang.org/grpc"
19 | "google.golang.org/grpc/credentials"
20 | )
21 |
22 | type testCfg struct {
23 | grpcCfg string
24 | rpcCfg string
25 | envCfg string
26 | target string
27 | }
28 |
29 | func (cfg *testCfg) Get(service string) service.Config {
30 | if service == ID {
31 | if cfg.grpcCfg == "" {
32 | return nil
33 | }
34 |
35 | return &testCfg{target: cfg.grpcCfg}
36 | }
37 |
38 | if service == rpc.ID {
39 | return &testCfg{target: cfg.rpcCfg}
40 | }
41 |
42 | if service == env.ID {
43 | return &testCfg{target: cfg.envCfg}
44 | }
45 |
46 | return nil
47 | }
48 |
49 | func (cfg *testCfg) Unmarshal(out interface{}) error {
50 | return json.Unmarshal([]byte(cfg.target), out)
51 | }
52 |
53 | func Test_Service_NoConfig(t *testing.T) {
54 | logger, _ := test.NewNullLogger()
55 | logger.SetLevel(logrus.DebugLevel)
56 |
57 | c := service.NewContainer(logger)
58 | c.Register(ID, &Service{})
59 |
60 | assert.Error(t, c.Init(&testCfg{grpcCfg: `{}`}))
61 |
62 | s, st := c.Get(ID)
63 | assert.NotNil(t, s)
64 | assert.Equal(t, service.StatusInactive, st)
65 | }
66 |
67 | func Test_Service_Configure_Enable(t *testing.T) {
68 | logger, _ := test.NewNullLogger()
69 | logger.SetLevel(logrus.DebugLevel)
70 |
71 | c := service.NewContainer(logger)
72 | c.Register(ID, &Service{})
73 |
74 | assert.NoError(t, c.Init(&testCfg{
75 | grpcCfg: `{
76 | "listen": "tcp://:9081",
77 | "tls": {
78 | "key": "tests/server.key",
79 | "cert": "tests/server.crt"
80 | },
81 | "proto": ["tests/test.proto"],
82 | "workers":{
83 | "command": "php tests/worker.php",
84 | "relay": "pipes",
85 | "pool": {
86 | "numWorkers": 1,
87 | "allocateTimeout": 1,
88 | "destroyTimeout": 1
89 | }
90 | }
91 | }`,
92 | }))
93 |
94 | s, st := c.Get(ID)
95 | assert.NotNil(t, s)
96 | assert.Equal(t, service.StatusOK, st)
97 | }
98 |
99 | func Test_Service_Dead(t *testing.T) {
100 | logger, _ := test.NewNullLogger()
101 | logger.SetLevel(logrus.DebugLevel)
102 |
103 | c := service.NewContainer(logger)
104 | c.Register(ID, &Service{})
105 |
106 | assert.NoError(t, c.Init(&testCfg{
107 | grpcCfg: `{
108 | "listen": "tcp://:9082",
109 | "tls": {
110 | "key": "tests/server.key",
111 | "cert": "tests/server.crt"
112 | },
113 | "proto": ["tests/test.proto"],
114 | "workers":{
115 | "command": "php tests/worker-bad.php",
116 | "relay": "pipes",
117 | "pool": {
118 | "numWorkers": 1,
119 | "allocateTimeout": 10,
120 | "destroyTimeout": 10
121 | }
122 | }
123 | }`,
124 | }))
125 |
126 | s, st := c.Get(ID)
127 | assert.NotNil(t, s)
128 | assert.Equal(t, service.StatusOK, st)
129 |
130 | // should do nothing
131 | s.(*Service).Stop()
132 |
133 | assert.Error(t, c.Serve())
134 | }
135 |
136 | func Test_Service_Invalid_TLS(t *testing.T) {
137 | logger, _ := test.NewNullLogger()
138 | logger.SetLevel(logrus.DebugLevel)
139 |
140 | c := service.NewContainer(logger)
141 | c.Register(ID, &Service{})
142 |
143 | assert.NoError(t, c.Init(&testCfg{
144 | grpcCfg: `{
145 | "listen": "tcp://:9083",
146 | "tls": {
147 | "key": "tests/server.key",
148 | "cert": "tests/test.proto"
149 | },
150 | "proto": ["tests/test.proto"],
151 | "workers":{
152 | "command": "php tests/worker.php",
153 | "relay": "pipes",
154 | "pool": {
155 | "numWorkers": 1,
156 | "allocateTimeout": 10,
157 | "destroyTimeout": 10
158 | }
159 | }
160 | }`,
161 | }))
162 |
163 | s, st := c.Get(ID)
164 | assert.NotNil(t, s)
165 | assert.Equal(t, service.StatusOK, st)
166 |
167 | // should do nothing
168 | s.(*Service).Stop()
169 |
170 | assert.Error(t, c.Serve())
171 | }
172 |
173 | func Test_Service_Invalid_Proto(t *testing.T) {
174 | logger, _ := test.NewNullLogger()
175 | logger.SetLevel(logrus.DebugLevel)
176 |
177 | c := service.NewContainer(logger)
178 | c.Register(ID, &Service{})
179 |
180 | assert.NoError(t, c.Init(&testCfg{
181 | grpcCfg: `{
182 | "listen": "tcp://:9084",
183 | "tls": {
184 | "key": "tests/server.key",
185 | "cert": "tests/server.crt"
186 | },
187 | "proto": ["tests/server.key"],
188 | "workers":{
189 | "command": "php tests/worker.php",
190 | "relay": "pipes",
191 | "pool": {
192 | "numWorkers": 1,
193 | "allocateTimeout": 10,
194 | "destroyTimeout": 10
195 | }
196 | }
197 | }`,
198 | }))
199 |
200 | s, st := c.Get(ID)
201 | assert.NotNil(t, s)
202 | assert.Equal(t, service.StatusOK, st)
203 |
204 | // should do nothing
205 | s.(*Service).Stop()
206 |
207 | assert.Error(t, c.Serve())
208 | }
209 |
210 | func Test_Service_Multiple_Invalid_Proto(t *testing.T) {
211 | logger, _ := test.NewNullLogger()
212 | logger.SetLevel(logrus.DebugLevel)
213 |
214 | c := service.NewContainer(logger)
215 | c.Register(ID, &Service{})
216 |
217 | assert.NoError(t, c.Init(&testCfg{
218 | grpcCfg: `{
219 | "listen": "tcp://:9085",
220 | "tls": {
221 | "key": "tests/server.key",
222 | "cert": "tests/server.crt"
223 | },
224 | "proto": ["tests/health.proto", "tests/server.key"],
225 | "workers":{
226 | "command": "php tests/worker.php",
227 | "relay": "pipes",
228 | "pool": {
229 | "numWorkers": 1,
230 | "allocateTimeout": 10,
231 | "destroyTimeout": 10
232 | }
233 | }
234 | }`,
235 | }))
236 |
237 | s, st := c.Get(ID)
238 | assert.NotNil(t, s)
239 | assert.Equal(t, service.StatusOK, st)
240 |
241 | // should do nothing
242 | s.(*Service).Stop()
243 |
244 | assert.Error(t, c.Serve())
245 | }
246 |
247 | func Test_Service_Echo(t *testing.T) {
248 | logger, _ := test.NewNullLogger()
249 | logger.SetLevel(logrus.DebugLevel)
250 |
251 | c := service.NewContainer(logger)
252 | c.Register(ID, &Service{})
253 |
254 | assert.NoError(t, c.Init(&testCfg{
255 | grpcCfg: `{
256 | "listen": "tcp://:9086",
257 | "tls": {
258 | "key": "tests/server.key",
259 | "cert": "tests/server.crt"
260 | },
261 | "proto": ["tests/test.proto"],
262 | "workers":{
263 | "command": "php tests/worker.php",
264 | "relay": "pipes",
265 | "pool": {
266 | "numWorkers": 1,
267 | "allocateTimeout": 10,
268 | "destroyTimeout": 10
269 | }
270 | }
271 | }`,
272 | }))
273 |
274 | s, st := c.Get(ID)
275 | assert.NotNil(t, s)
276 | assert.Equal(t, service.StatusOK, st)
277 |
278 | // should do nothing
279 | s.(*Service).Stop()
280 |
281 | go func() { assert.NoError(t, c.Serve()) }()
282 | time.Sleep(time.Millisecond * 100)
283 | defer c.Stop()
284 |
285 | cl, cn := getClient("localhost:9086")
286 | defer cn.Close()
287 |
288 | out, err := cl.Echo(context.Background(), &tests.Message{Msg: "ping"})
289 |
290 | assert.NoError(t, err)
291 | assert.Equal(t, "ping", out.Msg)
292 | }
293 |
294 | func Test_Service_Multiple_Echo(t *testing.T) {
295 | logger, _ := test.NewNullLogger()
296 | logger.SetLevel(logrus.DebugLevel)
297 |
298 | c := service.NewContainer(logger)
299 | c.Register(ID, &Service{})
300 |
301 | assert.NoError(t, c.Init(&testCfg{
302 | grpcCfg: `{
303 | "listen": "tcp://:9087",
304 | "tls": {
305 | "key": "tests/server.key",
306 | "cert": "tests/server.crt"
307 | },
308 | "proto": ["tests/test.proto", "tests/health.proto"],
309 | "workers":{
310 | "command": "php tests/worker.php",
311 | "relay": "pipes",
312 | "pool": {
313 | "numWorkers": 1,
314 | "allocateTimeout": 10,
315 | "destroyTimeout": 10
316 | }
317 | }
318 | }`,
319 | }))
320 |
321 | s, st := c.Get(ID)
322 | assert.NotNil(t, s)
323 | assert.Equal(t, service.StatusOK, st)
324 |
325 | // should do nothing
326 | s.(*Service).Stop()
327 |
328 | go func() { assert.NoError(t, c.Serve()) }()
329 | time.Sleep(time.Millisecond * 100)
330 | defer c.Stop()
331 |
332 | cl, cn := getClient("127.0.0.1:9087")
333 | defer cn.Close()
334 |
335 | out, err := cl.Echo(context.Background(), &tests.Message{Msg: "ping"})
336 |
337 | assert.NoError(t, err)
338 | assert.Equal(t, "ping", out.Msg)
339 | }
340 |
341 | func Test_Service_Empty(t *testing.T) {
342 | logger, _ := test.NewNullLogger()
343 | logger.SetLevel(logrus.DebugLevel)
344 |
345 | c := service.NewContainer(logger)
346 | c.Register(ID, &Service{})
347 |
348 | assert.NoError(t, c.Init(&testCfg{
349 | grpcCfg: `{
350 | "listen": "tcp://:9088",
351 | "tls": {
352 | "key": "tests/server.key",
353 | "cert": "tests/server.crt"
354 | },
355 | "proto": ["tests/test.proto"],
356 | "workers":{
357 | "command": "php tests/worker.php",
358 | "relay": "pipes",
359 | "pool": {
360 | "numWorkers": 1,
361 | "allocateTimeout": 10,
362 | "destroyTimeout": 10
363 | }
364 | }
365 | }`,
366 | }))
367 |
368 | s, st := c.Get(ID)
369 | assert.NotNil(t, s)
370 | assert.Equal(t, service.StatusOK, st)
371 |
372 | // should do nothing
373 | s.(*Service).Stop()
374 |
375 | go func() { assert.NoError(t, c.Serve()) }()
376 | time.Sleep(time.Millisecond * 100)
377 | defer c.Stop()
378 |
379 | cl, cn := getClient("127.0.0.1:9088")
380 | defer cn.Close()
381 |
382 | _, err := cl.Ping(context.Background(), &tests.EmptyMessage{})
383 |
384 | assert.NoError(t, err)
385 | }
386 |
387 | func Test_Service_ErrorBuffer(t *testing.T) {
388 | logger, _ := test.NewNullLogger()
389 | logger.SetLevel(logrus.DebugLevel)
390 |
391 | c := service.NewContainer(logger)
392 | c.Register(ID, &Service{})
393 |
394 | assert.NoError(t, c.Init(&testCfg{
395 | grpcCfg: `{
396 | "listen": "tcp://:9089",
397 | "tls": {
398 | "key": "tests/server.key",
399 | "cert": "tests/server.crt"
400 | },
401 | "proto": ["tests/test.proto"],
402 | "workers":{
403 | "command": "php tests/worker.php",
404 | "relay": "pipes",
405 | "pool": {
406 | "numWorkers": 1,
407 | "allocateTimeout": 10,
408 | "destroyTimeout": 10
409 | }
410 | }
411 | }`,
412 | }))
413 |
414 | s, st := c.Get(ID)
415 | assert.NotNil(t, s)
416 | assert.Equal(t, service.StatusOK, st)
417 |
418 | // should do nothing
419 | s.(*Service).Stop()
420 |
421 | goterr := make(chan interface{})
422 | s.(*Service).AddListener(func(event int, ctx interface{}) {
423 | if event == roadrunner.EventStderrOutput {
424 | if string(ctx.([]byte)) == "WORLD\n" {
425 | goterr <- nil
426 | }
427 | }
428 | })
429 |
430 | go func() { assert.NoError(t, c.Serve()) }()
431 | time.Sleep(time.Millisecond * 100)
432 | defer c.Stop()
433 |
434 | cl, cn := getClient("127.0.0.1:9089")
435 | defer cn.Close()
436 |
437 | out, err := cl.Die(context.Background(), &tests.Message{Msg: "WORLD"})
438 |
439 | <-goterr
440 | assert.NoError(t, err)
441 | assert.Equal(t, "WORLD", out.Msg)
442 | }
443 |
444 | func Test_Service_Env(t *testing.T) {
445 | logger, _ := test.NewNullLogger()
446 | logger.SetLevel(logrus.DebugLevel)
447 |
448 | c := service.NewContainer(logger)
449 | c.Register(env.ID, &env.Service{})
450 | c.Register(ID, &Service{})
451 |
452 | assert.NoError(t, c.Init(&testCfg{
453 | grpcCfg: `{
454 | "listen": "tcp://:9090",
455 | "tls": {
456 | "key": "tests/server.key",
457 | "cert": "tests/server.crt"
458 | },
459 | "proto": ["tests/test.proto"],
460 | "workers":{
461 | "command": "php tests/worker.php",
462 | "relay": "pipes",
463 | "pool": {
464 | "numWorkers": 1,
465 | "allocateTimeout": 10,
466 | "destroyTimeout": 10
467 | }
468 | }
469 | }`,
470 | envCfg: `
471 | {
472 | "env_key": "value"
473 | }`,
474 | }))
475 |
476 | s, st := c.Get(ID)
477 | assert.NotNil(t, s)
478 | assert.Equal(t, service.StatusOK, st)
479 |
480 | // should do nothing
481 | s.(*Service).Stop()
482 |
483 | go func() { assert.NoError(t, c.Serve()) }()
484 | time.Sleep(time.Millisecond * 100)
485 | defer c.Stop()
486 |
487 | cl, cn := getClient("127.0.0.1:9090")
488 | defer cn.Close()
489 |
490 | out, err := cl.Info(context.Background(), &tests.Message{Msg: "RR_GRPC"})
491 |
492 | assert.NoError(t, err)
493 | assert.Equal(t, "true", out.Msg)
494 |
495 | out, err = cl.Info(context.Background(), &tests.Message{Msg: "ENV_KEY"})
496 |
497 | assert.NoError(t, err)
498 | assert.Equal(t, "value", out.Msg)
499 | }
500 |
501 | func Test_Service_External_Service_Test(t *testing.T) {
502 | logger, _ := test.NewNullLogger()
503 | logger.SetLevel(logrus.DebugLevel)
504 |
505 | c := service.NewContainer(logger)
506 | c.Register(ID, &Service{})
507 |
508 | assert.NoError(t, c.Init(&testCfg{grpcCfg: `{
509 | "listen": "tcp://:9091",
510 | "tls": {
511 | "key": "tests/server.key",
512 | "cert": "tests/server.crt"
513 | },
514 | "proto": ["tests/test.proto"],
515 | "workers":{
516 | "command": "php tests/worker.php",
517 | "relay": "pipes",
518 | "pool": {
519 | "numWorkers": 1,
520 | "allocateTimeout": 10,
521 | "destroyTimeout": 10
522 | }
523 | }
524 | }`}))
525 |
526 | s, st := c.Get(ID)
527 | assert.NotNil(t, s)
528 | assert.Equal(t, service.StatusOK, st)
529 |
530 | // should do nothing
531 | s.(*Service).AddService(func(server *ngrpc.Server) {
532 | ext.RegisterExternalServer(server, &externalService{})
533 | })
534 |
535 | go func() { assert.NoError(t, c.Serve()) }()
536 | time.Sleep(time.Millisecond * 100)
537 | defer c.Stop()
538 |
539 | cl, cn := getExternalClient("localhost:9091")
540 | defer cn.Close()
541 |
542 | out, err := cl.Echo(context.Background(), &ext.Ping{Value: 9})
543 |
544 | assert.NoError(t, err)
545 | assert.Equal(t, int64(90), out.Value)
546 | }
547 |
548 | func Test_Service_Kill(t *testing.T) {
549 | logger, _ := test.NewNullLogger()
550 | logger.SetLevel(logrus.DebugLevel)
551 |
552 | c := service.NewContainer(logger)
553 | c.Register(ID, &Service{})
554 |
555 | assert.NoError(t, c.Init(&testCfg{grpcCfg: `{
556 | "listen": "tcp://:9092",
557 | "tls": {
558 | "key": "tests/server.key",
559 | "cert": "tests/server.crt"
560 | },
561 | "proto": ["tests/test.proto"],
562 | "workers":{
563 | "command": "php tests/worker.php",
564 | "relay": "pipes",
565 | "pool": {
566 | "numWorkers": 1,
567 | "allocateTimeout": 10,
568 | "destroyTimeout": 10
569 | }
570 | }
571 | }`}))
572 |
573 | s, st := c.Get(ID)
574 | assert.NotNil(t, s)
575 | assert.Equal(t, service.StatusOK, st)
576 |
577 | go func() { c.Serve() }()
578 | time.Sleep(time.Millisecond * 100)
579 |
580 | s.(*Service).throw(roadrunner.EventServerFailure, nil)
581 | }
582 |
583 | func getClient(addr string) (client tests.TestClient, conn *ngrpc.ClientConn) {
584 | creds, err := credentials.NewClientTLSFromFile("tests/server.crt", "")
585 | if err != nil {
586 | panic(err)
587 | }
588 |
589 | conn, err = ngrpc.Dial(addr, ngrpc.WithTransportCredentials(creds))
590 | if err != nil {
591 | panic(err)
592 | }
593 |
594 | return tests.NewTestClient(conn), conn
595 | }
596 |
597 | func getExternalClient(addr string) (client ext.ExternalClient, conn *ngrpc.ClientConn) {
598 | creds, err := credentials.NewClientTLSFromFile("tests/server.crt", "")
599 | if err != nil {
600 | panic(err)
601 | }
602 |
603 | conn, err = ngrpc.Dial(addr, ngrpc.WithTransportCredentials(creds))
604 | if err != nil {
605 | panic(err)
606 | }
607 |
608 | return ext.NewExternalClient(conn), conn
609 | }
610 |
611 | // externalService service.
612 | type externalService struct{}
613 |
614 | // Echo for external service.
615 | func (s *externalService) Echo(ctx context.Context, ping *ext.Ping) (*ext.Pong, error) {
616 | return &ext.Pong{Value: ping.Value * 10}, nil
617 | }
618 |
--------------------------------------------------------------------------------
/src/Context.php:
--------------------------------------------------------------------------------
1 |
16 | * @template-implements \ArrayAccess
17 | */
18 | final class Context implements ContextInterface, \IteratorAggregate, \Countable, \ArrayAccess
19 | {
20 | /**
21 | * @var array
22 | */
23 | private $values;
24 |
25 | /**
26 | * @param array $values
27 | */
28 | public function __construct(array $values)
29 | {
30 | $this->values = $values;
31 | }
32 |
33 | /**
34 | * {@inheritDoc}
35 | */
36 | public function withValue(string $key, $value): ContextInterface
37 | {
38 | $ctx = clone $this;
39 | $ctx->values[$key] = $value;
40 |
41 | return $ctx;
42 | }
43 |
44 | /**
45 | * {@inheritDoc}
46 | * @param mixed|null $default
47 | */
48 | public function getValue(string $key, $default = null)
49 | {
50 | return $this->values[$key] ?? $default;
51 | }
52 |
53 | /**
54 | * {@inheritDoc}
55 | */
56 | public function getValues(): array
57 | {
58 | return $this->values;
59 | }
60 |
61 | /**
62 | * {@inheritDoc}
63 | */
64 | public function offsetExists($offset): bool
65 | {
66 | assert(\is_string($offset), 'Offset argument must be a type of string');
67 |
68 | /**
69 | * Note: PHP Opcode optimisation
70 | * @see https://www.php.net/manual/pt_BR/internals2.opcodes.isset-isempty-var.php
71 | *
72 | * Priority use `ZEND_ISSET_ISEMPTY_VAR !0` opcode instead of `DO_FCALL 'array_key_exists'`.
73 | */
74 | return isset($this->values[$offset]) || \array_key_exists($offset, $this->values);
75 | }
76 |
77 | /**
78 | * {@inheritDoc}
79 | */
80 | public function offsetGet($offset)
81 | {
82 | assert(\is_string($offset), 'Offset argument must be a type of string');
83 |
84 | return $this->values[$offset] ?? null;
85 | }
86 |
87 | /**
88 | * {@inheritDoc}
89 | */
90 | public function offsetSet($offset, $value): void
91 | {
92 | assert(\is_string($offset), 'Offset argument must be a type of string');
93 |
94 | $this->values[$offset] = $value;
95 | }
96 |
97 | /**
98 | * {@inheritDoc}
99 | */
100 | public function offsetUnset($offset): void
101 | {
102 | assert(\is_string($offset), 'Offset argument must be a type of string');
103 |
104 | unset($this->values[$offset]);
105 | }
106 |
107 |
108 | /**
109 | * {@inheritDoc}
110 | */
111 | public function getIterator(): \Traversable
112 | {
113 | return new \ArrayIterator($this->values);
114 | }
115 |
116 | /**
117 | * {@inheritDoc}
118 | */
119 | public function count(): int
120 | {
121 | return \count($this->values);
122 | }
123 | }
124 |
--------------------------------------------------------------------------------
/src/ContextInterface.php:
--------------------------------------------------------------------------------
1 |
40 | */
41 | public function getValues(): array;
42 | }
43 |
--------------------------------------------------------------------------------
/src/Exception/GRPCException.php:
--------------------------------------------------------------------------------
1 |
39 | */
40 | private $details;
41 |
42 | /**
43 | * @param string $message
44 | * @param StatusCodeType|null $code
45 | * @param array $details
46 | * @param \Throwable|null $previous
47 | */
48 | final public function __construct(
49 | string $message = '',
50 | #[ExpectedValues(valuesFromClass: StatusCode::class)]
51 | int $code = null,
52 | array $details = [],
53 | \Throwable $previous = null
54 | ) {
55 | parent::__construct($message, (int)($code ?? static::CODE), $previous);
56 |
57 | $this->details = $details;
58 | }
59 |
60 | /**
61 | * @param string $message
62 | * @param StatusCodeType|null $code
63 | * @param array $details
64 | * @param \Throwable|null $previous
65 | * @return static
66 | */
67 | public static function create(
68 | string $message,
69 | #[ExpectedValues(valuesFromClass: StatusCode::class)]
70 | int $code = null,
71 | \Throwable $previous = null,
72 | array $details = []
73 | ): self {
74 | return new static($message, $code, $details, $previous);
75 | }
76 |
77 | /**
78 | * {@inheritDoc}
79 | */
80 | public function getDetails(): array
81 | {
82 | return $this->details;
83 | }
84 |
85 | /**
86 | * {@inheritDoc}
87 | */
88 | public function setDetails(array $details): void
89 | {
90 | $this->details = $details;
91 | }
92 |
93 | /**
94 | * {@inheritDoc}
95 | */
96 | public function addDetails(Message $message): void
97 | {
98 | $this->details[] = $message;
99 | }
100 |
101 | /**
102 | * Push details message to the exception.
103 | *
104 | * @param Message $details
105 | * @return $this
106 | * @deprecated Please use {@see GRPCException::addDetails()} method instead.
107 | */
108 | #[Deprecated('Please use GRPCException::addDetails() instead', '%class%::addDetails(%parameter0%)')]
109 | public function withDetails(
110 | $details
111 | ): self {
112 | $this->details[] = $details;
113 |
114 | return $this;
115 | }
116 | }
117 |
--------------------------------------------------------------------------------
/src/Exception/GRPCExceptionInterface.php:
--------------------------------------------------------------------------------
1 |
35 | */
36 | public function getDetails(): array;
37 | }
38 |
--------------------------------------------------------------------------------
/src/Exception/InvokeException.php:
--------------------------------------------------------------------------------
1 | $details
22 | */
23 | public function setDetails(array $details): void;
24 |
25 | /**
26 | * Appends details message to the GRPC Exception.
27 | *
28 | * @param Message $message
29 | */
30 | public function addDetails(Message $message): void;
31 | }
32 |
--------------------------------------------------------------------------------
/src/Exception/NotFoundException.php:
--------------------------------------------------------------------------------
1 | getName()];
42 |
43 | /** @var Message $message */
44 | $message = $callable($ctx, $this->makeInput($method, $input));
45 |
46 | // Note: This validation will only work if the
47 | // assertions option ("zend.assertions") is enabled.
48 | assert($this->assertResultType($method, $message));
49 |
50 | try {
51 | return $message->serializeToString();
52 | } catch (\Throwable $e) {
53 | throw InvokeException::create($e->getMessage(), StatusCode::INTERNAL, $e);
54 | }
55 | }
56 |
57 | /**
58 | * Checks that the result from the GRPC service method returns the
59 | * Message object.
60 | *
61 | * @param Method $method
62 | * @param mixed $result
63 | * @return bool
64 | * @throws \BadFunctionCallException
65 | */
66 | private function assertResultType(Method $method, $result): bool
67 | {
68 | if (! $result instanceof Message) {
69 | $type = \is_object($result) ? \get_class($result) : \get_debug_type($result);
70 |
71 | throw new \BadFunctionCallException(
72 | \sprintf(self::ERROR_METHOD_RETURN, $method->getName(), Message::class, $type)
73 | );
74 | }
75 |
76 | return true;
77 | }
78 |
79 | /**
80 | * @param Method $method
81 | * @param string|null $body
82 | * @return Message
83 | * @throws InvokeException
84 | */
85 | private function makeInput(Method $method, ?string $body): Message
86 | {
87 | try {
88 | $class = $method->getInputType();
89 |
90 | // Note: This validation will only work if the
91 | // assertions option ("zend.assertions") is enabled.
92 | assert($this->assertInputType($method, $class));
93 |
94 | /** @psalm-suppress UnsafeInstantiation */
95 | $in = new $class();
96 |
97 | if ($body !== null) {
98 | $in->mergeFromString($body);
99 | }
100 |
101 | return $in;
102 | } catch (\Throwable $e) {
103 | throw InvokeException::create($e->getMessage(), StatusCode::INTERNAL, $e);
104 | }
105 | }
106 |
107 | /**
108 | * Checks that the input of the GRPC service method contains the
109 | * Message object.
110 | *
111 | * @param Method $method
112 | * @param string $class
113 | * @return bool
114 | * @throws \InvalidArgumentException
115 | */
116 | private function assertInputType(Method $method, string $class): bool
117 | {
118 | if (! \is_subclass_of($class, Message::class)) {
119 | throw new \InvalidArgumentException(
120 | \sprintf(self::ERROR_METHOD_IN_TYPE, $method->getName(), Message::class, $class)
121 | );
122 | }
123 |
124 | return true;
125 | }
126 | }
127 |
--------------------------------------------------------------------------------
/src/InvokerInterface.php:
--------------------------------------------------------------------------------
1 |
75 | */
76 | private $input;
77 |
78 | /**
79 | * @var class-string
80 | */
81 | private $output;
82 |
83 | /**
84 | * @param string $name
85 | * @param class-string $input
86 | * @param class-string $output
87 | */
88 | private function __construct(string $name, string $input, string $output)
89 | {
90 | $this->name = $name;
91 | $this->input = $input;
92 | $this->output = $output;
93 | }
94 |
95 | /**
96 | * @return string
97 | */
98 | public function getName(): string
99 | {
100 | return $this->name;
101 | }
102 |
103 | /**
104 | * @return class-string
105 | */
106 | public function getInputType(): string
107 | {
108 | return $this->input;
109 | }
110 |
111 | /**
112 | * @return class-string
113 | */
114 | public function getOutputType(): string
115 | {
116 | return $this->output;
117 | }
118 |
119 | /**
120 | * @param \ReflectionType|null $type
121 | * @return \ReflectionClass|null
122 | * @throws \ReflectionException
123 | */
124 | private static function getReflectionClassByType(?\ReflectionType $type): ?\ReflectionClass
125 | {
126 | if ($type instanceof \ReflectionNamedType && ! $type->isBuiltin()) {
127 | /** @psalm-suppress ArgumentTypeCoercion */
128 | return new \ReflectionClass($type->getName());
129 | }
130 |
131 | return null;
132 | }
133 |
134 | /**
135 | * Returns true if method signature matches.
136 | *
137 | * @param \ReflectionMethod $method
138 | * @return bool
139 | */
140 | public static function match(\ReflectionMethod $method): bool
141 | {
142 | try {
143 | self::assertMethodSignature($method);
144 | } catch (\Throwable $e) {
145 | return false;
146 | }
147 |
148 | return true;
149 | }
150 |
151 | /**
152 | * @param \ReflectionMethod $method
153 | * @param \ReflectionParameter $context
154 | * @throws \ReflectionException
155 | */
156 | private static function assertContextParameter(\ReflectionMethod $method, \ReflectionParameter $context): void
157 | {
158 | $type = $context->getType();
159 |
160 | // When the type is not specified, it means that it is declared as
161 | // a "mixed" type, which is a valid case
162 | if ($type !== null) {
163 | if (! $type instanceof \ReflectionNamedType) {
164 | $message = \sprintf(self::ERROR_PARAM_UNION_TYPE, $context->getName(), $method->getName());
165 | throw new \DomainException($message, 0x02);
166 | }
167 |
168 | // If the type is not declared as a generic "mixed" or "object",
169 | // then it can only be a type that implements ContextInterface.
170 | if (! \in_array($type->getName(), ['mixed', 'object'], true)) {
171 | /** @psalm-suppress ArgumentTypeCoercion */
172 | $isContextImplementedType = ! $type->isBuiltin()
173 | && (new \ReflectionClass($type->getName()))
174 | ->implementsInterface(ContextInterface::class)
175 | ;
176 |
177 | // Checking that the signature can accept the context.
178 | //
179 | // TODO If the type is any other implementation of the Spiral\GRPC\ContextInterface other than
180 | // class Spiral\GRPC\Context, it may cause an error.
181 | // It might make sense to check for such cases?
182 | if (! $isContextImplementedType) {
183 | $message = \vsprintf(self::ERROR_PARAM_CONTEXT_TYPE, [
184 | $context->getName(),
185 | $method->getName(),
186 | ContextInterface::class
187 | ]);
188 |
189 | throw new \DomainException($message, 0x03);
190 | }
191 | }
192 | }
193 | }
194 |
195 | /**
196 | * @param \ReflectionMethod $method
197 | * @param \ReflectionParameter $input
198 | * @throws \ReflectionException
199 | */
200 | private static function assertInputParameter(\ReflectionMethod $method, \ReflectionParameter $input): void
201 | {
202 | $type = $input->getType();
203 |
204 | // Parameter type cannot be omitted ("mixed")
205 | if ($type === null) {
206 | $message = \vsprintf(self::ERROR_PARAM_INPUT_TYPE, [
207 | $input->getName(),
208 | $method->getName(),
209 | Message::class,
210 | 'mixed'
211 | ]);
212 |
213 | throw new \DomainException($message, 0x04);
214 | }
215 |
216 | // Parameter type cannot be declared as singular non-named type
217 | if (! $type instanceof \ReflectionNamedType) {
218 | $message = \sprintf(self::ERROR_PARAM_UNION_TYPE, $input->getName(), $method->getName());
219 | throw new \DomainException($message, 0x05);
220 | }
221 |
222 | /** @psalm-suppress ArgumentTypeCoercion */
223 | $isProtobufMessageType = ! $type->isBuiltin()
224 | && (new \ReflectionClass($type->getName()))
225 | ->isSubclassOf(Message::class)
226 | ;
227 |
228 | if (! $isProtobufMessageType) {
229 | $message = \vsprintf(self::ERROR_PARAM_INPUT_TYPE, [
230 | $input->getName(),
231 | $method->getName(),
232 | Message::class,
233 | $type->getName(),
234 | ]);
235 | throw new \DomainException($message, 0x06);
236 | }
237 | }
238 |
239 | /**
240 | * @param \ReflectionMethod $method
241 | * @throws \ReflectionException
242 | */
243 | private static function assertOutputReturnType(\ReflectionMethod $method): void
244 | {
245 | $type = $method->getReturnType();
246 |
247 | // Return type cannot be omitted ("mixed")
248 | if ($type === null) {
249 | $message = \sprintf(self::ERROR_RETURN_TYPE, $method->getName(), Message::class, 'mixed');
250 | throw new \DomainException($message, 0x07);
251 | }
252 |
253 | // Return type cannot be declared as singular non-named type
254 | if (! $type instanceof \ReflectionNamedType) {
255 | $message = \sprintf(self::ERROR_RETURN_UNION_TYPE, $method->getName());
256 | throw new \DomainException($message, 0x08);
257 | }
258 |
259 | /** @psalm-suppress ArgumentTypeCoercion */
260 | $isProtobufMessageType = ! $type->isBuiltin()
261 | && (new \ReflectionClass($type->getName()))
262 | ->isSubclassOf(Message::class)
263 | ;
264 |
265 | if (! $isProtobufMessageType) {
266 | $message = \sprintf(self::ERROR_RETURN_TYPE, $method->getName(), Message::class, $type->getName());
267 | throw new \DomainException($message, 0x09);
268 | }
269 | }
270 |
271 | /**
272 | * @param \ReflectionMethod $method
273 | * @throws \ReflectionException
274 | * @throws \DomainException
275 | */
276 | private static function assertMethodSignature(\ReflectionMethod $method): void
277 | {
278 | // Check that there are only two parameters
279 | if ($method->getNumberOfParameters() !== 2) {
280 | $message = \sprintf(self::ERROR_PARAMS_COUNT, $method->getName(), $method->getNumberOfParameters());
281 | throw new \DomainException($message, 0x01);
282 | }
283 |
284 | /**
285 | * @var \ReflectionParameter $context
286 | * @var \ReflectionParameter $input
287 | */
288 | [$context, $input] = $method->getParameters();
289 |
290 | // The first parameter can only take a context object
291 | self::assertContextParameter($method, $context);
292 |
293 | // The second argument can only be a subtype of the Google\Protobuf\Internal\Message class
294 | self::assertInputParameter($method, $input);
295 |
296 | // The return type must be declared as a Google\Protobuf\Internal\Message class
297 | self::assertOutputReturnType($method);
298 | }
299 |
300 | /**
301 | * Creates a new {@see Method} object from a {@see \ReflectionMethod} object.
302 | *
303 | * @param \ReflectionMethod $method
304 | * @return Method
305 | */
306 | public static function parse(\ReflectionMethod $method): Method
307 | {
308 | try {
309 | self::assertMethodSignature($method);
310 | } catch (\Throwable $e) {
311 | $message = \sprintf(self::ERROR_INVALID_GRPC_METHOD, $method->getName());
312 | throw GRPCException::create($message, StatusCode::INTERNAL, $e);
313 | }
314 |
315 | [,$input] = $method->getParameters();
316 |
317 | /** @var \ReflectionNamedType $inputType */
318 | $inputType = $input->getType();
319 |
320 | /** @var \ReflectionNamedType $returnType */
321 | $returnType = $method->getReturnType();
322 |
323 | /** @psalm-suppress ArgumentTypeCoercion */
324 | return new self($method->getName(), $inputType->getName(), $returnType->getName());
325 | }
326 | }
327 |
--------------------------------------------------------------------------------
/src/ResponseHeaders.php:
--------------------------------------------------------------------------------
1 |
18 | */
19 | final class ResponseHeaders implements \IteratorAggregate, \Countable
20 | {
21 | /**
22 | * @var array
23 | */
24 | private $headers = [];
25 |
26 | /**
27 | * @param iterable $headers
28 | */
29 | public function __construct(iterable $headers = [])
30 | {
31 | foreach ($headers as $key => $value) {
32 | $this->set($key, $value);
33 | }
34 | }
35 |
36 | /**
37 | * @param string $key
38 | * @param string $value
39 | */
40 | public function set(string $key, string $value): void
41 | {
42 | $this->headers[$key] = $value;
43 | }
44 |
45 | /**
46 | * @param string $key
47 | * @param string|null $default
48 | * @return string|null
49 | */
50 | public function get(string $key, string $default = null): ?string
51 | {
52 | return $this->headers[$key] ?? $default;
53 | }
54 |
55 | /**
56 | * {@inheritDoc}
57 | */
58 | public function getIterator(): \Traversable
59 | {
60 | return new \ArrayIterator($this->headers);
61 | }
62 |
63 | /**
64 | * @return int
65 | */
66 | public function count(): int
67 | {
68 | return \count($this->headers);
69 | }
70 |
71 | /**
72 | * @return string
73 | * @throws \JsonException
74 | */
75 | public function packHeaders(): string
76 | {
77 | // If an empty array is serialized, it is cast to the string "[]"
78 | // instead of object string "{}"
79 | if ($this->headers === []) {
80 | return '{}';
81 | }
82 |
83 | return Json::encode($this->headers);
84 | }
85 | }
86 |
--------------------------------------------------------------------------------
/src/Server.php:
--------------------------------------------------------------------------------
1 | >
34 | * }
35 | */
36 | final class Server
37 | {
38 | /**
39 | * @var InvokerInterface
40 | */
41 | private $invoker;
42 |
43 | /**
44 | * @var ServiceWrapper[]
45 | */
46 | private $services = [];
47 |
48 | /**
49 | * @var ServerOptions
50 | */
51 | private $options;
52 |
53 | /**
54 | * @param InvokerInterface|null $invoker
55 | * @param ServerOptions $options
56 | */
57 | public function __construct(InvokerInterface $invoker = null, array $options = [])
58 | {
59 | $this->invoker = $invoker ?? new Invoker();
60 | $this->options = $options;
61 | }
62 |
63 | /**
64 | * Register new GRPC service.
65 | *
66 | * For example:
67 | *
68 | * $server->registerService(EchoServiceInterface::class, new EchoService());
69 | *
70 | *
71 | * @template T of ServiceInterface
72 | *
73 | * @param class-string $interface Generated service interface.
74 | * @param T $service Must implement interface.
75 | * @throws ServiceException
76 | */
77 | public function registerService(string $interface, ServiceInterface $service): void
78 | {
79 | $service = new ServiceWrapper($this->invoker, $interface, $service);
80 |
81 | $this->services[$service->getName()] = $service;
82 | }
83 |
84 | /**
85 | * @param string $body
86 | * @param ContextResponse $data
87 | * @return array{ 0: string, 1: string }
88 | * @throws \JsonException
89 | * @throws \Throwable
90 | */
91 | private function tick(string $body, array $data): array
92 | {
93 | $context = (new Context($data['context']))
94 | ->withValue(ResponseHeaders::class, new ResponseHeaders())
95 | ;
96 |
97 | $response = $this->invoke($data['service'], $data['method'], $context, $body);
98 |
99 | /** @var ResponseHeaders|null $responseHeaders */
100 | $responseHeaders = $context->getValue(ResponseHeaders::class);
101 | $responseHeadersString = $responseHeaders ? $responseHeaders->packHeaders() : '{}';
102 |
103 | return [$response, $responseHeadersString];
104 | }
105 |
106 | /**
107 | * @param Worker $worker
108 | * @param string $body
109 | * @param string $headers
110 | * @psalm-suppress InaccessibleMethod
111 | */
112 | private function workerSend(Worker $worker, string $body, string $headers): void
113 | {
114 | // RoadRunner 1.x
115 | if (\method_exists($worker, 'send')) {
116 | $worker->send($body, $headers);
117 |
118 | return;
119 | }
120 |
121 | // RoadRunner 2.x
122 | $worker->respond(new Payload($body, $headers));
123 | }
124 |
125 | /**
126 | * @param Worker $worker
127 | * @param string $message
128 | */
129 | private function workerError(Worker $worker, string $message): void
130 | {
131 | $worker->error($message);
132 | }
133 |
134 | /**
135 | * @param Worker $worker
136 | * @return array { 0: string, 1: string } | null
137 | *
138 | * @psalm-suppress UndefinedMethod
139 | * @psalm-suppress PossiblyUndefinedVariable
140 | */
141 | private function workerReceive(Worker $worker): ?array
142 | {
143 | /** @var string|\Stringable $body */
144 | $body = $worker->receive($ctx);
145 |
146 | if (empty($body) && empty($ctx)) {
147 | return null;
148 | }
149 |
150 | return [(string)$body, (string)$ctx];
151 | }
152 |
153 | /**
154 | * Serve GRPC over given RoadRunner worker.
155 | *
156 | * @param Worker $worker
157 | * @param callable|null $finalize
158 | */
159 | public function serve(Worker $worker, callable $finalize = null): void
160 | {
161 | while (true) {
162 | $request = $this->workerReceive($worker);
163 |
164 | if (! $request) {
165 | return;
166 | }
167 |
168 | [$body, $headers] = $request;
169 |
170 | try {
171 | /** @var ContextResponse $context */
172 | $context = Json::decode((string)$headers);
173 |
174 | [$answerBody, $answerHeaders] = $this->tick((string)$body, $context);
175 |
176 | $this->workerSend($worker, $answerBody, $answerHeaders);
177 | } catch (GRPCExceptionInterface $e) {
178 | $this->workerError($worker, $this->packError($e));
179 | } catch (\Throwable $e) {
180 | $this->workerError($worker, $this->isDebugMode() ? (string)$e : $e->getMessage());
181 | } finally {
182 | if ($finalize !== null) {
183 | isset($e) ? $finalize($e) : $finalize();
184 | }
185 | }
186 | }
187 | }
188 |
189 | /**
190 | * Invoke service method with binary payload and return the response.
191 | *
192 | * @param string $service
193 | * @param string $method
194 | * @param ContextInterface $context
195 | * @param string $body
196 | * @return string
197 | * @throws GRPCException
198 | */
199 | protected function invoke(string $service, string $method, ContextInterface $context, string $body): string
200 | {
201 | if (! isset($this->services[$service])) {
202 | throw NotFoundException::create("Service `{$service}` not found.", StatusCode::NOT_FOUND);
203 | }
204 |
205 | return $this->services[$service]->invoke($method, $context, $body);
206 | }
207 |
208 | /**
209 | * Packs exception message and code into one string.
210 | *
211 | * Internal agreement:
212 | *
213 | * Details will be sent as serialized google.protobuf.Any messages after
214 | * code and exception message separated with |:| delimiter.
215 | *
216 | * @param GRPCExceptionInterface $e
217 | * @return string
218 | */
219 | private function packError(GRPCExceptionInterface $e): string
220 | {
221 | $data = [$e->getCode(), $e->getMessage()];
222 |
223 | foreach ($e->getDetails() as $detail) {
224 | $anyMessage = new Any();
225 |
226 | $anyMessage->pack($detail);
227 |
228 | $data[] = $anyMessage->serializeToString();
229 | }
230 |
231 | return \implode('|:|', $data);
232 | }
233 |
234 | /**
235 | * If server runs in debug mode
236 | *
237 | * @return bool
238 | */
239 | private function isDebugMode(): bool
240 | {
241 | $debug = false;
242 |
243 | if (isset($this->options['debug'])) {
244 | $debug = \filter_var($this->options['debug'], \FILTER_VALIDATE_BOOLEAN);
245 | }
246 |
247 | return $debug;
248 | }
249 | }
250 |
--------------------------------------------------------------------------------
/src/ServiceInterface.php:
--------------------------------------------------------------------------------
1 | invoker = $invoker;
44 |
45 | $this->configure($interface, $service);
46 | }
47 |
48 | /**
49 | * @return string
50 | */
51 | public function getName(): string
52 | {
53 | return $this->name;
54 | }
55 |
56 | /**
57 | * @return ServiceInterface
58 | */
59 | public function getService(): ServiceInterface
60 | {
61 | return $this->service;
62 | }
63 |
64 | /**
65 | * @return array
66 | */
67 | public function getMethods(): array
68 | {
69 | return array_values($this->methods);
70 | }
71 |
72 | /**
73 | * @param string $method
74 | * @param ContextInterface $context
75 | * @param string|null $input
76 | * @return string
77 | * @throws NotFoundException
78 | * @throws InvokeException
79 | */
80 | public function invoke(string $method, ContextInterface $context, ?string $input): string
81 | {
82 | if (! isset($this->methods[$method])) {
83 | throw NotFoundException::create("Method `{$method}` not found in service `{$this->name}`.");
84 | }
85 |
86 | return $this->invoker->invoke($this->service, $this->methods[$method], $context, $input);
87 | }
88 |
89 | /**
90 | * Configure service name and methods.
91 | *
92 | * @param class-string $interface
93 | * @param ServiceInterface $service
94 | * @throws ServiceException
95 | */
96 | protected function configure(string $interface, ServiceInterface $service): void
97 | {
98 | try {
99 | $reflection = new \ReflectionClass($interface);
100 |
101 | if (! $reflection->hasConstant('NAME')) {
102 | $message = "Invalid service interface `{$interface}`, constant `NAME` not found.";
103 | throw ServiceException::create($message);
104 | }
105 |
106 | $name = $reflection->getConstant('NAME');
107 |
108 | if (! \is_string($name)) {
109 | $message = "Constant `NAME` of service interface `{$interface}` must be a type of string";
110 | throw ServiceException::create($message);
111 | }
112 |
113 | $this->name = $name;
114 | } catch (\ReflectionException $e) {
115 | $message = "Invalid service interface `{$interface}`.";
116 | throw ServiceException::create($message, StatusCode::INTERNAL, $e);
117 | }
118 |
119 | if (! $service instanceof $interface) {
120 | throw ServiceException::create("Service handler does not implement `{$interface}`.");
121 | }
122 |
123 | $this->service = $service;
124 |
125 | // list of all available methods and their object types
126 | $this->methods = $this->fetchMethods($service);
127 | }
128 |
129 | /**
130 | * @param ServiceInterface $service
131 | * @return array
132 | */
133 | protected function fetchMethods(ServiceInterface $service): array
134 | {
135 | $reflection = new \ReflectionObject($service);
136 |
137 | $methods = [];
138 | foreach ($reflection->getMethods(\ReflectionMethod::IS_PUBLIC) as $method) {
139 | if (Method::match($method)) {
140 | $methods[$method->getName()] = Method::parse($method);
141 | }
142 | }
143 |
144 | return $methods;
145 | }
146 | }
147 |
--------------------------------------------------------------------------------
/src/StatusCode.php:
--------------------------------------------------------------------------------
1 |