├── .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 | [![Latest Stable Version](https://poser.pugx.org/spiral/php-grpc/version)](https://packagist.org/packages/spiral/php-grpc) 7 | [![GoDoc](https://godoc.org/github.com/spiral/php-grpc?status.svg)](https://godoc.org/github.com/spiral/php-grpc) 8 | [![Tests](https://github.com/spiral/roadrunner-plugins/workflows/Linux/badge.svg)](https://github.com/spiral/roadrunner-plugins/actions) 9 | [![Linters](https://github.com/spiral/roadrunner-plugins/workflows/Linters/badge.svg)](https://github.com/spiral/roadrunner-plugins/actions) 10 | [![Go Report Card](https://goreportcard.com/badge/github.com/spiral/php-grpc)](https://goreportcard.com/report/github.com/spiral/php-grpc) 11 | [![Total alerts](https://img.shields.io/lgtm/alerts/g/spiral/php-grpc.svg?logo=lgtm&logoWidth=18)](https://lgtm.com/projects/g/spiral/php-grpc/alerts/) 12 | [![Codecov](https://codecov.io/gh/spiral/php-grpc/branch/master/graph/badge.svg)](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 |