Mastering Go: From Beginner to Expert
- Description
- Curriculum
- FAQ
- Reviews
“Mastering Go: From Beginner to Advanced” is a comprehensive course designed to take you on a journey from a beginner to an advanced Go developer. Whether you are new to programming or have experience in other languages, this course will equip you with the knowledge and skills needed to become proficient in Go and build robust applications.
Introduction to Go and Basic Syntax
Introduction to Go and its benefits
- Basic syntax and data types
- Variables and constants
- Control flow: if/else, for, and switch statements
- Functions and function signatures
Arrays, Slices, and Maps
- Arrays and slices
- Maps and their uses
- Using arrays, slices, and maps in real-world examples
Pointers and Structs
- Pointers and memory management
- Structs and their uses
Concurrency and Go Routines
- Introduction to concurrency in Go
- Go routines and channels
- Using Go routines and channels to create concurrent applications
Web Development with Go
- Introduction to web development with Go
- Building web servers and handling HTTP requests
- Creating RESTful web services
Testing in Go
Overview of testing in Go
- Writing unit tests
- Using the testing package
- Best practices for testing Go code
Advanced Go Features
- Interfaces and their uses
- Embedding types
- Advanced concurrency patterns
- Error handling in Go
Project work
- Final project or capstone that allows learners to apply their newfound knowledge to build a complete application.
- Implementing the tests for the final project
Review and wrap up
- Review of material covered
- Next steps for continuing to learn and improve their Go skills
-
1Introduction & Course OverviewVideo lesson
Introduction to Go and Basic Syntax
Introduction to Go and its benefits
Basic syntax and data types
Variables and constants
Control flow: if/else, for, and switch statements
Functions and function signatures
Arrays, Slices, and Maps
Arrays and slices
Maps and their uses
Using arrays, slices, and maps in real-world examples
Pointers and Structs
Pointers and memory management
Structs and their uses
Concurrency and Go Routines
Introduction to concurrency in Go
Go routines and channels
Using Go routines and channels to create concurrent applications
Web Development with Go
Introduction to web development with Go
Building web servers and handling HTTP requests
Creating RESTful web services
Testing in Go
Overview of testing in Go
Writing unit tests
Using the testing package
Best practices for testing Go code
Advanced Go Features
Interfaces and their uses
Embedding types
Advanced concurrency patterns
Error handling in Go
Project work
Final project or capstone that allows learners to apply their newfound knowledge to build a complete application.
Implementing the tests for the final project
Review and wrap up
Review of material covered
Next steps for continuing to learn and improve their Go skills
-
2Build environment setupVideo lesson
Installation Setup - Windows
Go to https://go.dev/doc/install
1. Download the MSI file
2. Open the MSI file you downloaded and follow the prompts to install Go.
The installer will install Go to Program Files or Program Files (x86) by default. You can change the location as needed. After installing, you will need to close and reopen any open command prompts so that changes to the environment made by the installer are reflected in the command prompt.
3. Verify that you've installed Go.
•In Windows, click the Start menu.
•In the menu's search box, type cmd, then press the Enter Enter
•In the Command Prompt window that appears, type the following command:
$ go version
•Confirm that the command prints the installed version of Go.
Installation Setup - Mac
Go to https://go.dev/doc/install
1. Download the package file
2. Open the package file you downloaded and follow the prompts to install Go.
The package installs the Go distribution to /usr/local/go. The package should put the /usr/local/go/bin directory in your PATH environment variable. You may need to restart any open Terminal sessions for the change to take effect.
3. Verify that you've installed Go by opening a command prompt and typing the following command:
$ go version
4. Confirm that the command prints the installed version of Go.
Installation Setup - Linux
Go to https://go.dev/doc/install
1. Download the package file
2. Remove any previous Go installation by deleting the /usr/local/go folder (if it exists), then extract the archive you just downloaded into /usr/local, creating a fresh Go tree in /usr/local/go:
$ rm -rf /usr/local/go && tar -C /usr/local -xzf go1.20.1.linux-amd64.tar.gz
(You may need to run the command as root or through sudo).
Do not untar the archive into an existing /usr/local/go tree. This is known to produce broken Go installations.
3. Add /usr/local/go/bin to the PATH environment variable.
You can do this by adding the following line to your $HOME/.profile or /etc/profile (for a system-wide installation):
export PATH=$PATH:/usr/local/go/bin
Note: Changes made to a profile file may not apply until the next time you log into your computer. To apply the changes immediately, just run the shell commands directly or execute them from the profile using a command such as source $HOME/.profile.
4. Verify that you've installed Go by opening a command prompt and typing the following command:
$ go version
5. Confirm that the command prints the installed version of Go.
-
3Why Go/GolangVideo lesson
Hardware limitations:
Moore’s law is failing –
-The number of transistors on a microchip doubles every two years, though the cost of computers is halved.
-Growth of microprocessors is exponential.
Single-thread performance and the frequency of the processor remained steady for almost a decade
If you are thinking that adding more transistors is the solution, then that is wrong.
This is because at a smaller scale, some quantum properties start to emerge (like tunneling) and because it costs more to put more transistors
Thoughts -
-Manufacturers started adding more and more cores to the processor.
-Introduced hyper-threading
-Added more cache to the processor to increase the performance
Its limitations -
-Adding more cores to the processor has its cost too.
-We cannot add more and more cache to the processor to increase performance as cache have physical limits: the bigger the cache, the slower it gets.
If can't rely on the hardware improvements, the only option is to write more efficient software to increase the performance
-
4Introduction to Go and Basic SyntaxVideo lesson
Introduction to Go and its Benefits
Go, also known as Golang, is a programming language developed by Google. It is designed to be fast, efficient, and easy to use
Benefits of Go:
Simple and clean syntax, which makes it easy to understand and learn
Built-in support for concurrency, which allows for the creation of concurrent and parallel programs
Strong standard library with a wide range of functionality
Great performance and scalability
Used by many well-known companies such as Uber, Netflix, and Dropbox, which makes it relevant for students to learn about real-world use cases
Go can be used for a variety of programming tasks, including web development, system programming, and network services. It is also a popular choice for building microservices and high-performance network services.
Go is a relatively new language, but its popularity is increasing, and it has a large and growing community. It's a great choice to learn a language that is in demand and widely used in the industry.
Basic syntax and data types
Basic Syntax:
Go starts with the package and with its name
Go uses the keyword import to import packages
Go uses the keyword func to define functions
Go uses curly braces {} to define the beginning and end of a block of code
Go uses the keyword var to define variables
Go uses the keyword return to return a value from a function
Data Types:
Go has several built-in data types, including:
Numbers: int, float64, complex128
Boolean: bool
Strings: string
Arrays: [size]type
Slices: []type
Maps: map[keyType]valueType
Structs: struct{ field1 type1, field2 type2, ... }
Go also supports type inference, which means that the compiler can automatically determine the type of a variable based on the value assigned to it.
Go supports pointers which allow storing of the address of a variable in memory, this allows for better performance in certain cases and can be used to pass variables by reference
Go also supports interfaces that allow to the definition of a set of methods that a type must implement, and allows working with different types in a common way.
Go also supports user-defined types, which allows programmers to create custom types based on existing types.
-
5Data Types & Basic VariablesVideo lesson
Learning about Golang data types and the creation of the variable
Valid variable names:
var speed int = 1
var Speed int = 2
var _speed int = 3
var 속도 int = 4
Invalid variable names
var 3speed int
var !speed int
var spe!ed int
var var int
var for int
-
6Variables and ConstantsVideo lesson
Go - Variables
A variable in Go is a storage location that holds a value of a specific type. Variables are declared using the var keyword, followed by the variable name, and the type of the variable.
1. Declare a single variable and initialize
2. You can also declare multiple variables at once
3. You can also declare multiple variables of different types
4. You can also leave the type and initialize the variable with its value
Go - Constants
Constants are declared like variables but with the const keyword.
Constants can be character, string, boolean, or numeric values.
Constants cannot be declared using the:= syntax.
1. Using the const keyword
2. Using shorthand notation
3. Declaring multiple constants in a single statement
4. It's also possible to use iota to declare a set of related constants, these are good for enumerations
-
7Control flow - If Else and SwitchVideo lesson
Control flow: if/else
if/else statement: The if statement is used to execute a block of code if a certain condition is true. The else statement is used to execute a block of code if the condition is false.
1. Basic if statement: The basic if statement is used to execute a block of code based on the value of a Boolean expression.
2. If statement with initialization: The if statement can also, have an initialization statement executed before evaluating the Boolean expression
3. If statement with short-circuit evaluation: The if a statement can use the short-circuit evaluation to evaluate the second Boolean expression only if the first Boolean expression is true.
4. If statement with multiple conditions: The if statement can evaluate multiple Boolean expressions separated by logical operators (&& or ||).
5. If statement with optional else if and else blocks: The if statement can have multiple else if blocks and an optional else block.
Control flow: Switch statement
The switch statement is used to execute a block of code based on the value of an expression. It is similar to the switch statement in other programming languages, but Go's switch statement has some unique features.
1. Basic switch: The basic switch statement is used to evaluate a variable or an expression and execute a block of code based on the value of the variable.
2. Switch with multiple expressions: The switch statement can also evaluate multiple expressions separated by commas.
3. Switch with initializer: The switch statement can also have an initializer statement executed before evaluating
the expressions.
4. Type switch: The switch statement can e used to evaluate the type of interface value.
5. Switch with fallthrough: The fallthrough statement can be used in a switch statement to execute the statements of the next case block as well, regardless of the case expression.
-
8Control flow - LoopsVideo lesson
Control flow: for
for loop: The for loop is used to execute a block of code repeatedly while a certain condition is true.
1. for loop syntax:
The for loop in Go is similar to the for loop in other programming languages, with the added flexibility of being able to omit any or all of the loop control statements.
initialization: This is an optional statement that is executed before the first iteration of the loop.
condition: This is the condition that is checked before each iteration of the loop. If it evaluates to false, the loop terminates.
post: This is an optional statement that is executed at the end of each iteration of the loop.
2. range loop syntax:
The range loop in Go is used to iterate over arrays, slices, maps, and channels.
index: This is the index of the current element in the collection.
value: This is the value of the current element in the collection.
collection: This is the array, slice, map, or channel over which the loop is iterating.
3. goto loop syntax:
The goto statement in Go allows you to jump to a specific label in your code. This can be used to create a loop-like construct in Go.
-
9Functions and function signaturesVideo lesson
Functions and function signatures
In Go, functions are blocks of code that can be executed by calling them by their name. They can take zero or more parameters and return zero or more values.
This function takes two parameters, both of which are integers, and returns an integer
Functions can also return multiple values
You can also use the _ to ignore some of the returned values
Functions can also have named return values
It's also possible to use variadic functions, a function that can take a variable number of arguments. You can use the ... to indicate that a function can take a variable number of arguments of a certain type
-
10QuestionsQuiz
-
11ArraysVideo lesson
Arrays, Slices, and Maps
Arrays, slices, and maps are all data structures commonly used in Go programming language. They each have their own unique properties and use cases.
Arrays are a fixed-size, indexed collection of elements of the same type. They are useful for situations where a specific number of items need to be stored and accessed by their index. However, they cannot be resized once they are created and they do not support the built-in functionality for adding or removing elements.
Slices, on the other hand, are a dynamic, resizable version of arrays. They are built on top of arrays and provide a way to work with a segment of an array. Slices can be created from arrays, or created directly and they support built-in functionality for adding or removing elements.
Maps are a collection of key-value pairs, where each key must be unique. They are useful for situations where data needs to be stored and accessed by a unique key. Go's built-in map type is implemented as a hash table and provides efficient O(1) lookups, insertions, and deletions.
In terms of concurrency, Go has the sync package which provides the sync.Map which allows maps to be safely used in concurrent environments, allowing multiple goroutines to access and modify the map without explicit locking. -
12SlicesVideo lesson
Slices
In Go, a slice is a dynamically-sized, flexible view of the elements of an array. Slices are built on top of arrays and provide a convenient way to work with a range of elements within an array
Here's the syntax for creating a slice:
var slice []type
Alternatively, you can create a slice using the make() function:
slice := make([]type, length, capacity)
Creating a slice
var slice[]int // create an empty slice of int
slice = make([]int, 3, 5) // create a slice with length 3 and capacity 5
Accessing elements
newSlice := []int{1, 2, 3}
fmt.Println(newSlice[0]) // prints 1
fmt.Println(newSlice[1:]) // prints [2 3]
Modifying elements
newSlice[0] = 4 // modifies the first element to be 4
Appending elements
newSlice = append(newSlice, 5) // appends 5 to the end of the slice
Copying slices
copySlice := make([]int, len(newSlice))
copy(copySlice, newSlice) // copies the contents of newSlice into copySlice
One of the key advantages of slices is their dynamic nature, unlike arrays which have a fixed size, slices can grow or shrink as needed. This makes them a great choice for cases where the size of the data set is not known in advance or when it needs to be modified frequently.
Slices also support a variety of built-in functions and methods, such as append(), copy(), len(), cap(), and range, that make working with them convenient and efficient.
One of the most common use cases of slices is to pass them as arguments to functions. Slices are passed by reference, which means that any changes made to a slice inside a function are reflected outside the function as well. This can be useful for avoiding unnecessary copying of data when working with large datasets.
-
13MapsVideo lesson
Maps
Golang maps are an important data structure in the Go programming language that allows developers to store and retrieve key-value pairs.
There are several reasons why maps are important in Golang:
Efficient data retrieval: Maps in Golang are implemented using a hash table, which makes data retrieval fast and efficient. This is especially important when dealing with large amounts of data, where quick access to specific data points can greatly improve performance.
Flexible keys and values: Golang maps allow developers to use a wide variety of data types as both keys and values. This means that maps can be used to store and retrieve data in a highly customizable way, without requiring developers to conform to strict data type restrictions.
Easy to use: Golang maps have a simple and intuitive syntax, making them easy to use and understand for developers at all experience levels. This simplicity also makes maps highly readable and maintainable, even in large and complex codebases.
Concurrency support: Golang maps include built-in support for concurrency, which means that multiple goroutines can safely access and modify a map at the same time. This makes maps a powerful tool for building highly concurrent applications like web servers or distributed systems.
-
14Summary of Arrays, Slices, and MapsVideo lesson
Using arrays, slices, and maps in real-world examples
Arrays:
In a weather forecasting application, an array can be used to store the temperature readings for the next 7 days. The array can be indexed by day, allowing the application to easily retrieve the temperature forecast for a specific day.
In a game, an array can be used to store the position of all the objects in the game. The array can be indexed by the object ID, allowing the game to easily retrieve the position of a specific object and update it.
Slices:
In a social media application, a slice can be used to store a user's list of friends. The slice can be used to easily retrieve the list of friends, add new friends, or remove existing friends.
In an e-commerce application, a slice can be used to store the list of items in a customer's shopping cart. The slice can be used to easily retrieve the list of items, add new items, or remove existing items.
Maps:
In a web application, a map can be used to store the session information for each user. A unique session ID can be used as the key, and the value can include information such as the user's ID, login time, and other data. This allows for efficient lookups of session information based on the session ID.
In a transportation application, a map can be used to store the location of all the buses and their routes. A unique bus ID can be used as the key, and the value can include information such as the bus's current location, next stop, and the route it is taking.
This allows for efficient lookups of bus information based on the bus ID.
-
15Arrays, Slices and Maps QuestionsQuiz
-
16Pointers and memory managementVideo lesson
Go’s automatic memory management
Go has an automatic memory management system known as garbage collection. The garbage collector periodically frees the memory that is no longer needed by the program. This frees developers from the task of manually freeing memory and reduces the risk of memory leaks, which are a common source of bugs in other programming languages
In Go, memory is allocated using the built-in "new" function, which returns a pointer to a newly created zero-valued object of a specified type. When the object is no longer needed, the garbage collector reclaims its memory. The garbage collector determines when an object is no longer needed based on a simple reachability rule: an object is reachable if there is a path from a reachable pointer to the object
This automatic memory management system makes it easier for developers to write correct and efficient Go code. It also enables Go programs to run with low memory overhead and minimal CPU utilization, making it suitable for running large-scale applications
-
17Pointers and StructsVideo lesson
Pointers and Structs
In Go, a pointer is a variable that stores the memory address of another variable. Pointers are useful for situations where you need to pass a large amount of data to a function or need to change the value of a variable from another scope.
Structs are a way to define a new data type in Go, which is composed of a collection of fields. Structs are useful for grouping related data together and can be used to create complex data structures.
-
18Pointers & Structs QuestionsQuiz
-
19Concurrency, Goroutines and ChannelsVideo lesson
Concurrency is a key feature of Go, and it's designed to make it easy for developers to write efficient and scalable applications that can take advantage of multiple CPU cores
Goroutines: Goroutines are lightweight threads of execution in Go. They are created using the go keyword, and they run in the same address space as the main function. Goroutines are used to run multiple functions concurrently, and they can be used to write concurrent programs that are scalable and efficient.
Channels: Channels are a type of communication mechanism in Go that allow Goroutines to communicate with each other. They are used to send and receive values between Goroutines, and they provide a way to synchronize Goroutines and control access to shared resources.
Select statement: The select statement is a way to multiplex communication on multiple channels in Go. It allows Goroutines to wait on multiple channels and execute the first one that is ready. The select statement is used to implement complex communication patterns between Goroutines.
WaitGroups: WaitGroups are used to wait for multiple Goroutines to finish. They provide a way to synchronize Goroutines and ensure that all Goroutines have been completed before the program exits.
Mutexes: Mutexes are a type of synchronization mechanism in Go that are used to control access to shared resources. They provide a way to ensure that only one Goroutine can access a resource at a time, and they are used to avoid race conditions and other concurrency-related issues.
-
20Create concurrent applicationsVideo lesson
Using Go routines and WaitGroups to create concurrent applications
WaitGroup, provided in the sync package, allows a program to wait for specified goroutines. These are sync mechanisms in Golang that block the execution of a program until goroutines in the WaitGroup completely execute
-
21QuestionsQuiz
-
22Web Development with GoVideo lesson
Web development with Go
Building server-side web applications is an important aspect of web development, as it involves creating the back-end of a web application that interacts with the client-side (front-end) to deliver a seamless user experience.
Go is an ideal language for building server-side web applications due to its several features that make it well-suited for this type of development. Some of the reasons why Go is ideal for building server-side web applications are:
•Fast Performance: Go is known for its fast performance, which is crucial for building high-performance server-side applications that need to handle a large number of requests and process a large amount of data.
•Concurrency Support: Go provides built-in support for concurrency, which is crucial for building server-side applications that need to handle multiple requests simultaneously. This means that Go can handle multiple requests at the same time, making it an ideal language for building scalable and efficient server-side applications.
•Simplicity: Go is designed to be simple, easy to learn, and easy to use. This makes it an ideal language for building server-side applications, as developers can quickly get up and running with Go, without having to spend a lot of time learning complex concepts.
•Standard Library: Go has a rich standard library that provides a lot of functionality out-of-the-box. This includes support for handling HTTP requests, handling databases, and more, making it easier to build server-side applications in Go.
-
23QuestionsQuiz
-
25Advanced Go FeaturesVideo lesson
Advanced Go Features
Advanced features in Go include:
1.Concurrency: Go has strong support for concurrency through goroutines and channels, making it easy to write efficient and scalable programs.
2.Interfaces: Go uses interfaces to provide abstraction and polymorphism. An interface defines a set of methods that a type must implement to be considered "implementing" the interface.
3.Reflection: Go has a reflection package that allows developers to inspect and manipulate the runtime behavior of Go programs, including types, values, and variables.
4.Type embedding: Go supports type embedding, which allows developers to include the fields and methods of one type as part of another type, creating a sort of inheritance.
5.First-class functions: Go supports first-class functions, which can be assigned to variables, passed as arguments, and returned as values from other functions.
6.Context Package: Go provides a context package that makes it easy to carry deadlines, cancellations, and other request-scoped values across API boundaries and between processes.
These features make Go a powerful and versatile language, suitable for a wide range of applications, from web development to system programming.
-
26Interfaces and their usesVideo lesson
Interfaces and their uses
In the Go programming language, an interface is a type that specifies a set of methods that a concrete type must implement in order to be considered an instance of that interface. Interfaces provide a way to define a contract between different parts of a program, allowing them to communicate and work together in a flexible and decoupled way.
Here are some common uses of interfaces in Go:
Abstraction and decoupling: Interfaces allow you to define abstract types that can be implemented by different concrete types, without requiring those types to be tightly coupled to each other. This promotes modular, reusable code that is easier to reason about and maintain.
Polymorphism: By defining a common interface that multiple types implement, you can write code that can work with any of those types without knowing their specific implementation details. This makes it easier to write generic code that can handle a variety of inputs and outputs
Mocking and testing: Interfaces can be used to define mock objects that simulate the behavior of real objects during testing, allowing you to isolate and test individual parts of your code in a controlled environment.
Extensibility: Interfaces provide a way to extend the behavior of a type without modifying its implementation. By defining a new interface that extends an existing interface, you can add new methods that build on top of the existing behavior, without breaking existing code.
Dependency injection: Interfaces can be used to inject dependencies into functions or objects, allowing you to swap out implementations at runtime. This can be useful for testing, or for creating pluggable architectures where different modules can be swapped in and out without requiring changes to the core code
Interfaces are a powerful Go tool that enables flexible, decoupled, and extensible code. By using interfaces to define contracts between different parts of your program, you can create modular, maintainable, and scalable code that is easy to reason about and test
-
27Embedding typesVideo lesson
Embedding types
In Go, there are several types of embeddings that can be used to extend the functionality of a struct or interface.
Here are some examples of embedding types in Go:
Struct embedding: Struct embedding is the process of including one struct type inside another struct type. This is a way to compose a new struct from existing ones.
Pointer embedding: Pointer embedding is a technique for embedding a type as a pointer instead of as a value. This is useful when you want to modify the embedded type in place.
Interface embedding: Interface embedding is similar to struct embedding, but it is used with interfaces. This is a way to combine the behavior of multiple interfaces into a single interface
Anonymous embedding: Anonymous embedding is a shorthand syntax for struct and interface embedding. Instead of explicitly naming the embedded type, you can use an anonymous field
-
28Advanced concurrency patternsVideo lesson
Advanced concurrency patterns
Worker pools:
A worker pool is a common concurrency pattern used for executing a large number of tasks concurrently. In a worker pool, a group of workers is created, and tasks are assigned to them. Once a worker completes a task, it requests another task from a task queue. This pattern is useful for applications that require parallel processing of large sets of independent tasks.
Futures and Promises:
A future is an object that represents the result of a concurrent operation that has not yet completed. A promise is a placeholder for the value that the future will hold once the operation completes. Futures and promises are commonly used to implement asynchronous programming models, allowing the main thread of an application to continue executing while a long-running operation is performed in the background.
Cancellation:
Cancellation is the ability to terminate a concurrent operation before it completes. It is useful in situations where a long-running operation needs to be cancelled due to user input or external factors.
Select statements:
A select statement is a powerful Go feature that allows you to wait for multiple channel operations to complete simultaneously. This pattern is useful for building reactive and event-driven systems.
Rate limiting:
Rate limiting is the process of controlling the rate at which an operation is executed. It is useful for preventing resource exhaustion and regulating network traffic.
Fan-out/fan-in:
Fan-out/Fan-in is a common pattern in concurrent programming, where a single task is divided into multiple sub-tasks that can be executed concurrently (fan-out) and the results are then combined (fan-in) to produce the final output.
Context package:
A context package is a powerful tool in Go for managing cancellations and timeouts across multiple goroutines. It provides a way to propagate a signal across a context tree and cancel all goroutines associated with that context.
Select with timeout:
Select with timeout is a pattern that allows us to wait for multiple channels simultaneously and exit early if a timeout occurs. This pattern is useful when dealing with external systems that may be slow or unresponsive, and we want to avoid blocking indefinitely.
Pipeline:
A pipeline is a pattern for breaking down a complex task into smaller stages and connecting them with channels. This pattern is useful for building efficient data processing pipelines that can handle large volumes of data.
-
29ReflectionVideo lesson
Reflection
In Go, reflection is a powerful mechanism that allows you to examine and manipulate the runtime structure of types, values, and functions. Reflection is particularly useful when dealing with unknown types at compile time or when writing generic code that needs to work with a wide variety of types.
-
30First-class functionsVideo lesson
First-class functions
In Golang, functions are first-class citizens, which means that they can be treated like any other value such as strings, integers, or structs. This means that functions can be passed as arguments to other functions, returned from functions, and stored in variables.
-
31Context PackageVideo lesson
Context Package
The context package in Golang provides a way to carry request-scoped values, cancellation signals, and deadlines across API boundaries and between processes. It is often used in networking and server-side applications where a request may need to be canceled or timed out.
-
32Error handling in GoVideo lesson
Error handling in Go
1. Checking for specific errors
2. Returning errors
3. Handling multiple errors
4. Defining custom errors
-
33QuestionsQuiz
External Links May Contain Affiliate Links read more